@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.
@@ -8,13 +8,21 @@
8
8
  */
9
9
 
10
10
  import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
11
12
  import { GENERAL_WATCHDOG_ID } from "../builtins";
12
13
  import { type BusinessSummary, parseBusinessSummary } from "../business-summary";
13
- import { parseWatchdogReport } from "../report-builder";
14
+ import { parseWatchdogReport, type WatchdogReport } from "../report-builder";
14
15
  import { systemClock } from "../services/clock";
15
16
  import type { ReviewWorkArea } from "../storage/work-area";
16
17
  import { renderHuntIntentPrompt, renderIntentRetrySuffix } from "./maki-intent-prompt";
17
- import { renderHuntPrompt, renderRetrySuffix } from "./maki-prompt";
18
+ import {
19
+ renderAdversarialPrompt,
20
+ renderBookkeepingPrompt,
21
+ renderChecklistSuffix,
22
+ renderHuntPrompt,
23
+ renderResumeSuffix,
24
+ renderRetrySuffix,
25
+ } from "./maki-prompt";
18
26
  import {
19
27
  extractJsonObject,
20
28
  type MakiProcessRunner,
@@ -22,7 +30,28 @@ import {
22
30
  type MakiUsage,
23
31
  } from "./maki-runner";
24
32
  import { mapPool } from "./pool";
25
-
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";
26
55
  /** Evento de progresso (CLI loga; core não escreve saída). */
27
56
  export type HuntEvent =
28
57
  | {
@@ -31,6 +60,21 @@ export type HuntEvent =
31
60
  source?: BusinessSummary["source"];
32
61
  error?: string;
33
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
+ }
34
78
  | { type: "start"; watchdogId: string }
35
79
  | {
36
80
  type: "retry";
@@ -68,6 +112,39 @@ export type HuntWatchdogResult = {
68
112
  costUsd?: number;
69
113
  usage?: MakiUsage;
70
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
+ };
71
148
  };
72
149
 
73
150
  /** Agregados do relatório. */
@@ -89,6 +166,8 @@ export type HuntReport = {
89
166
  model: string;
90
167
  /** Fonte do modelo de intenção usado na revisão. */
91
168
  business?: BusinessSummary["source"];
169
+ /** Pontuação do modelo de intenção efetivo (fluxos/invariantes/contratos). */
170
+ businessScore?: number;
92
171
  startedAt: string;
93
172
  finishedAt: string;
94
173
  durationMs: number;
@@ -110,6 +189,16 @@ export type HuntRunnerOptions = {
110
189
  concurrency: number;
111
190
  timeoutMs: number;
112
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;
113
202
  runner: MakiProcessRunner;
114
203
  onEvent?: (event: HuntEvent) => void;
115
204
  };
@@ -127,16 +216,21 @@ export class HuntRunner {
127
216
  const startedMs = Date.now();
128
217
  // Intenção primeiro: o revisor `general` e os demais watchdogs leem o
129
218
  // mesmo `business-summary.json` do staging.
130
- const business = await this.runIntent();
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();
131
223
  const watchdogs = await mapPool(activeIds, this.options.concurrency, (id) =>
132
- this.runOne(id),
224
+ this.runOne(id, businessModel),
133
225
  );
134
226
  const finishedAt = systemClock();
227
+ const effective = this.options.work.readBusinessSummary();
135
228
  return {
136
229
  version: 1,
137
230
  engine: "maki",
138
231
  model: this.options.model,
139
- business,
232
+ business: effective?.source ?? businessSource,
233
+ businessScore: effective ? businessScore(effective) : undefined,
140
234
  startedAt,
141
235
  finishedAt,
142
236
  durationMs: Date.now() - startedMs,
@@ -199,7 +293,25 @@ export class HuntRunner {
199
293
  lastError = "JSON fora do schema do modelo de intenção.";
200
294
  continue;
201
295
  }
202
- this.options.work.writeBusinessSummary(summary);
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);
203
315
  this.emit({ type: "intent", status: "done", source: "reviewer" });
204
316
  return "reviewer";
205
317
  }
@@ -208,7 +320,10 @@ export class HuntRunner {
208
320
  return existing?.source ?? "deterministic";
209
321
  }
210
322
 
211
- private async runOne(watchdogId: string): Promise<HuntWatchdogResult> {
323
+ private async runOne(
324
+ watchdogId: string,
325
+ business: BusinessSummary | null,
326
+ ): Promise<HuntWatchdogResult> {
212
327
  const briefPath = this.options.work.briefFile(watchdogId);
213
328
  let brief: string;
214
329
  try {
@@ -225,35 +340,305 @@ export class HuntRunner {
225
340
  return failedResult(watchdogId, error, 0, { durationMs: 0 });
226
341
  }
227
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";
228
348
  const basePrompt = renderHuntPrompt({
229
349
  projectName: this.options.projectName,
230
350
  watchdogId,
231
351
  brief,
352
+ bookkeeping: !isGeneral,
353
+ requestBusiness,
232
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);
233
361
 
234
362
  this.emit({ type: "start", watchdogId });
235
363
  let attempts = 0;
236
- let lastError = "";
237
364
  const accumulated: MakiRunMetrics = { durationMs: 0 };
238
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
+
239
614
  while (attempts <= this.options.retries) {
240
615
  attempts++;
616
+ const resume =
617
+ attempts === 1
618
+ ? options.resume
619
+ : canResume
620
+ ? metrics.sessionId
621
+ : undefined;
241
622
  const prompt =
242
623
  attempts === 1
243
624
  ? basePrompt
244
- : `${basePrompt}\n${renderRetrySuffix(lastError)}`;
625
+ : resume
626
+ ? renderResumeSuffix(lastError, watchdogId)
627
+ : `${basePrompt}\n${renderRetrySuffix(lastError)}`;
245
628
  const result = await this.options.runner.run({
246
629
  prompt,
247
630
  cwd: this.options.root,
248
631
  model: this.options.model,
249
632
  apiKeyEnv: this.options.apiKeyEnv,
250
633
  apiKey: this.options.apiKey,
634
+ resume,
251
635
  timeoutMs: this.options.timeoutMs,
252
636
  });
253
- mergeMetrics(accumulated, result.metrics);
637
+ mergeMetrics(metrics, result.metrics);
254
638
 
255
639
  if (!result.ok) {
256
640
  lastError = result.error;
641
+ canResume = false;
257
642
  this.emitRetry(watchdogId, attempts, lastError);
258
643
  continue;
259
644
  }
@@ -261,6 +646,7 @@ export class HuntRunner {
261
646
  const raw = extractJsonObject(result.text);
262
647
  if (raw === null) {
263
648
  lastError = "a resposta final não contém um objeto JSON.";
649
+ canResume = true;
264
650
  this.emitRetry(watchdogId, attempts, lastError);
265
651
  continue;
266
652
  }
@@ -271,57 +657,62 @@ export class HuntRunner {
271
657
  );
272
658
  if (!report) {
273
659
  lastError = warnings[0] ?? "JSON fora do schema do watchdog.";
660
+ canResume = true;
274
661
  this.emitRetry(watchdogId, attempts, lastError);
275
662
  continue;
276
663
  }
277
664
  if (report.watchdogId !== watchdogId) {
278
665
  lastError = `watchdogId '${report.watchdogId}' não corresponde a '${watchdogId}'.`;
666
+ canResume = true;
279
667
  this.emitRetry(watchdogId, attempts, lastError);
280
668
  continue;
281
669
  }
282
670
 
283
- // O revisor de fluxo devolve o modelo de intenção no JSON (maki não escreve).
284
- if (watchdogId === GENERAL_WATCHDOG_ID) {
285
- const summary = (raw as Record<string, unknown>).businessSummary;
286
- if (typeof summary === "object" && summary !== null) {
287
- const parsed = parseBusinessSummary(
288
- { ...(summary as Record<string, unknown>), source: "reviewer" },
289
- "reviewer",
290
- );
291
- if (parsed) this.options.work.writeBusinessSummary(parsed);
292
- }
293
- }
294
-
295
- this.options.work.writeFinding(
296
- watchdogId,
297
- `${JSON.stringify(report, null, 2)}\n`,
298
- );
299
- const done: HuntWatchdogResult = {
300
- watchdogId,
301
- status: "done",
302
- findings: report.findings.length,
303
- 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,
304
681
  attempts,
305
- durationMs: accumulated.durationMs,
306
- sessionId: accumulated.sessionId,
307
- numTurns: accumulated.numTurns,
308
- costUsd: accumulated.costUsd,
309
- usage: accumulated.usage,
682
+ metrics,
310
683
  };
311
- this.emit({
312
- type: "done",
313
- watchdogId,
314
- findings: done.findings,
315
- reviewedFiles: done.reviewedFiles,
316
- attempts,
317
- metrics: accumulated,
318
- });
319
- return done;
320
684
  }
321
685
 
322
- const error = lastError || "falha desconhecida.";
323
- this.emit({ type: "failed", watchdogId, error, attempts, metrics: accumulated });
324
- 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
+ };
325
716
  }
326
717
 
327
718
  private emit(event: HuntEvent): void {
@@ -345,6 +736,112 @@ export class HuntRunner {
345
736
  }
346
737
  }
347
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
+
348
845
  /** Soma métricas de várias tentativas no acumulado. */
349
846
  function mergeMetrics(target: MakiRunMetrics, source: MakiRunMetrics): void {
350
847
  target.durationMs += source.durationMs;