@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.
@@ -6,20 +6,47 @@
6
6
  */
7
7
 
8
8
  import { afterEach, describe, expect, test } from "bun:test";
9
- import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import {
10
+ chmodSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
10
17
  import { tmpdir } from "node:os";
11
18
  import { join } from "node:path";
12
19
  import { defineHoundsmanConfig } from "../core/config";
13
20
  import { type HuntEvent, HuntRunner } from "../core/hunt/hunt-runner";
14
21
  import { requireMakiModel, resolveMakiModel } from "../core/hunt/maki-model";
15
- import { renderHuntPrompt } from "../core/hunt/maki-prompt";
22
+ import {
23
+ renderAdversarialPrompt,
24
+ renderBookkeepingPrompt,
25
+ renderChecklistSuffix,
26
+ renderHuntPrompt,
27
+ renderResumeSuffix,
28
+ } from "../core/hunt/maki-prompt";
16
29
  import {
17
30
  extractJsonObject,
18
31
  MAKI_DISALLOWED_TOOLS,
32
+ MAKI_REVIEW_SYSTEM_PROMPT,
19
33
  makiArgs,
20
34
  parseMakiOutput,
21
35
  SpawnMakiRunner,
22
36
  } from "../core/hunt/maki-runner";
37
+ import {
38
+ briefRules,
39
+ businessScore,
40
+ chooseBusinessSummary,
41
+ expectedReviewItems,
42
+ mergeFindings,
43
+ missingReviewItems,
44
+ missingRuleChecks,
45
+ parseReviewChecks,
46
+ parseRuleChecks,
47
+ summarizeRuleChecks,
48
+ withInferredRule,
49
+ } from "../core/hunt/review-checks";
23
50
  import { ProjectConfig } from "../core/project-config";
24
51
  import type { ReviewRecord } from "../core/storage/types";
25
52
  import { ReviewWorkArea } from "../core/storage/work-area";
@@ -77,6 +104,12 @@ function makeWork(ids: string[]): {
77
104
  const root = mkdtempSync(join(tmpdir(), "houndsman-hunt-"));
78
105
  const work = new ReviewWorkArea(fakeReview(root), root);
79
106
  work.ensure();
107
+ // Arquivos reais: o checklist agora só aceita `at` que existe em disco.
108
+ const files = new Set(["src/alpha/a.ts", ...ids.map((id) => `src/${id}/a.ts`)]);
109
+ for (const file of files) {
110
+ mkdirSync(join(root, "src", file.split("/")[1]), { recursive: true });
111
+ writeFileSync(join(root, file), "linha 1\nlinha 2\nlinha 3\n", "utf8");
112
+ }
80
113
  for (const id of ids) work.writeBrief(id, `brief de ${id}`);
81
114
  return { root, work, cleanup: () => rmSync(root, { recursive: true, force: true }) };
82
115
  }
@@ -108,9 +141,12 @@ describe("maki — contrato de execução", () => {
108
141
  "deepseek/deepseek-v4-pro",
109
142
  "--disallowed-tools",
110
143
  MAKI_DISALLOWED_TOOLS,
144
+ "--append-system-prompt",
145
+ MAKI_REVIEW_SYSTEM_PROMPT,
111
146
  ]);
112
147
  expect(args).not.toContain("--yolo");
113
148
  expect(args).not.toContain("--trust");
149
+ expect(MAKI_REVIEW_SYSTEM_PROMPT).toContain("Profundidade vence brevidade");
114
150
  for (const tool of ["bash", "write", "edit", "task", "webfetch"]) {
115
151
  expect(MAKI_DISALLOWED_TOOLS.split(",")).toContain(tool);
116
152
  }
@@ -127,6 +163,16 @@ describe("maki — contrato de execução", () => {
127
163
  expect(prompt).toContain('"watchdogId": "alpha"');
128
164
  });
129
165
 
166
+ test("resume entra nos args do maki e o sufixo é compacto", () => {
167
+ expect(makiArgs("deepseek/deepseek-v4-pro", "sess-1")).toContain("--resume");
168
+ expect(makiArgs("deepseek/deepseek-v4-pro", "sess-1")).toContain("sess-1");
169
+ expect(makiArgs("deepseek/deepseek-v4-pro")).not.toContain("--resume");
170
+ const suffix = renderResumeSuffix("sem JSON", "alpha");
171
+ expect(suffix).toContain("sessão retomada");
172
+ expect(suffix).toContain('"watchdogId": "alpha"');
173
+ expect(suffix).not.toContain("## Brief");
174
+ });
175
+
130
176
  test("extrai JSON puro, cercado por markdown ou texto", () => {
131
177
  const payload = { watchdogId: "alpha", findings: [] };
132
178
  expect(extractJsonObject(JSON.stringify(payload))).toEqual(payload);
@@ -139,6 +185,21 @@ describe("maki — contrato de execução", () => {
139
185
  expect(extractJsonObject("sem json")).toBeNull();
140
186
  });
141
187
 
188
+ test("extrai o último objeto quando o result concatena a narração do agente", () => {
189
+ const first = { businessSummary: { flows: [] }, note: "rascunho" };
190
+ const last = { watchdogId: "general", findings: [] };
191
+ const transcript = [
192
+ "Vou revisar o diff. {isso não é JSON}",
193
+ JSON.stringify(first),
194
+ "Agora tenho evidências. Segue o relatório:",
195
+ `\`\`\`json\n${JSON.stringify(last, null, 2)}\n\`\`\``,
196
+ ].join("\n");
197
+ expect(extractJsonObject(transcript)).toEqual(last);
198
+
199
+ const withoutFence = `narração com { e } no meio\n${JSON.stringify(first)}\nfinal:\n${JSON.stringify(last)}`;
200
+ expect(extractJsonObject(withoutFence)).toEqual(last);
201
+ });
202
+
142
203
  test("parseia o JSON do maki (linha única e multi-linha)", () => {
143
204
  const single = '{"type":"result","result":"ok","session_id":"s1"}';
144
205
  expect(parseMakiOutput(single)?.result).toBe("ok");
@@ -150,6 +211,7 @@ describe("maki — contrato de execução", () => {
150
211
  );
151
212
  expect(parseMakiOutput(pretty)?.total_cost_usd).toBe(0.5);
152
213
  expect(parseMakiOutput(`log\n${single}`)?.session_id).toBe("s1");
214
+ expect(parseMakiOutput(`log antes\n${pretty}\nlog depois`)?.result).toBe("ok");
153
215
  expect(parseMakiOutput("nada")).toBeNull();
154
216
  });
155
217
  });
@@ -468,6 +530,594 @@ describe("HuntRunner", () => {
468
530
  });
469
531
  });
470
532
 
533
+ /** Brief mínimo com a seção de arquivos do domínio (usada pelo checklist). */
534
+ function briefWithFiles(id: string, files: string[], rules: string[] = []): string {
535
+ return [
536
+ `# Brief — ${id}`,
537
+ "",
538
+ `## Arquivos do seu domínio (${files.length})`,
539
+ "",
540
+ ...files.map((file) => `- \`${file}\` (modified, +10/−0)`),
541
+ "",
542
+ ...(rules.length > 0
543
+ ? [
544
+ "## Regras (por severidade)",
545
+ "",
546
+ ...rules.map(
547
+ (rule, index) => `- ${index + 1}. ⚠️ **attention** — ${rule}`,
548
+ ),
549
+ "",
550
+ ]
551
+ : []),
552
+ "## Procedimento",
553
+ "",
554
+ ].join("\n");
555
+ }
556
+
557
+ describe("harness — checklist de intenção", () => {
558
+ const business = {
559
+ source: "ai" as const,
560
+ intent: "Entregar split",
561
+ flows: [
562
+ { name: "Criação de subconta", files: ["src/alpha/a.ts"] },
563
+ { name: "Fluxo beta", files: ["src/beta/b.ts"] },
564
+ ],
565
+ invariants: ["Split sempre presente"],
566
+ contracts: [],
567
+ states: [],
568
+ };
569
+
570
+ test("prompt declara protocolo, calibração e o campo checks", () => {
571
+ const prompt = renderHuntPrompt({
572
+ projectName: "Proj",
573
+ watchdogId: "alpha",
574
+ brief: "# Brief alpha",
575
+ });
576
+ expect(prompt).toContain("## Protocolo de revisão");
577
+ expect(prompt).toContain("AUTOVERIFICAÇÃO");
578
+ expect(prompt).toContain('"checks"');
579
+ expect(prompt).toContain("blocking`");
580
+ });
581
+
582
+ test("sufixo de checklist lista os itens pendentes", () => {
583
+ const suffix = renderChecklistSuffix(["Split sempre presente"]);
584
+ expect(suffix).toContain("Verificação obrigatória (checklist)");
585
+ expect(suffix).toContain("- Split sempre presente");
586
+ expect(suffix).toContain("relatório **completo**");
587
+ });
588
+
589
+ test("itens esperados recortam fluxos por domínio e mantêm invariantes", () => {
590
+ const brief = briefWithFiles("alpha", ["src/alpha/a.ts"]);
591
+ expect(expectedReviewItems(brief, business, "alpha")).toEqual([
592
+ "Criação de subconta",
593
+ "Split sempre presente",
594
+ ]);
595
+ expect(expectedReviewItems(brief, business, "general")).toEqual([
596
+ "Criação de subconta",
597
+ "Fluxo beta",
598
+ "Split sempre presente",
599
+ ]);
600
+ expect(expectedReviewItems(brief, null, "alpha")).toEqual([]);
601
+ });
602
+
603
+ test("checklist ignora acento/caixa e lê variações de veredito", () => {
604
+ const checks = parseReviewChecks({
605
+ checks: [
606
+ { item: "split SEMPRE presente", verdict: "OK", at: "a.ts:1" },
607
+ { item: "Outro item", verdict: "gap" },
608
+ "Item sem objeto",
609
+ ],
610
+ });
611
+ expect(checks[0]).toMatchObject({ verdict: "ok", at: "a.ts:1" });
612
+ expect(checks[1].verdict).toBe("gap");
613
+ expect(checks[2].verdict).toBe("n/a");
614
+ expect(missingReviewItems(["Split sempre presente"], checks)).toEqual([]);
615
+ expect(missingReviewItems(["Invariante ausente"], checks)).toEqual([
616
+ "Invariante ausente",
617
+ ]);
618
+ });
619
+
620
+ test("merge de findings preserva a maior severidade e deduplica", () => {
621
+ const suggestion = {
622
+ rule: "Evitar any",
623
+ severity: "suggestion" as const,
624
+ file: "src/alpha/a.ts",
625
+ line: 4,
626
+ message: "any",
627
+ action: "tipar",
628
+ };
629
+ const blocking = { ...suggestion, severity: "blocking" as const };
630
+ expect(mergeFindings([suggestion], [blocking])).toHaveLength(1);
631
+ expect(mergeFindings([suggestion], [blocking])[0].severity).toBe("blocking");
632
+ expect(mergeFindings([suggestion], [suggestion])).toHaveLength(1);
633
+ });
634
+
635
+ test("lê as regras numeradas do brief e valida a cobertura", () => {
636
+ const brief = briefWithFiles(
637
+ "alpha",
638
+ ["src/alpha/a.ts"],
639
+ ["Evitar any.", "Promover: validar a entrada."],
640
+ );
641
+ const rules = briefRules(brief);
642
+ expect(rules).toEqual([
643
+ { id: 1, severity: "attention", text: "Evitar any." },
644
+ { id: 2, severity: "attention", text: "Promover: validar a entrada." },
645
+ ]);
646
+ const checks = parseRuleChecks({
647
+ rules: [{ id: 1, verdict: "ok", at: "src/alpha/a.ts:1" }, "2: gap"],
648
+ });
649
+ expect(checks).toMatchObject([
650
+ { id: 1, verdict: "ok" },
651
+ { id: 2, verdict: "gap" },
652
+ ]);
653
+ expect(missingRuleChecks(rules, checks)).toEqual([]);
654
+ // Verdicto sem `at` não é prova: só a regra 1 comprova localização.
655
+ expect(summarizeRuleChecks(rules, checks)).toEqual({
656
+ expected: 2,
657
+ verified: 1,
658
+ missing: 0,
659
+ notApplicable: 0,
660
+ gaps: 1,
661
+ });
662
+ });
663
+
664
+ test("checklist dispara por regra não verificada e retoma a sessão", async () => {
665
+ const staging = makeWork(["alpha"]);
666
+ staging.work.writeBrief(
667
+ "alpha",
668
+ briefWithFiles(
669
+ "alpha",
670
+ ["src/alpha/a.ts"],
671
+ ["Evitar any.", "Evitar console.log."],
672
+ ),
673
+ );
674
+ const requests: { prompt: string; resume?: string }[] = [];
675
+ try {
676
+ let calls = 0;
677
+ const runner = new HuntRunner({
678
+ work: staging.work,
679
+ root: staging.root,
680
+ projectName: "Proj",
681
+ model: "deepseek/deepseek-v4-pro",
682
+ apiKeyEnv: "DEEPSEEK_API_KEY",
683
+ apiKey: "sk",
684
+ concurrency: 1,
685
+ timeoutMs: 10_000,
686
+ retries: 0,
687
+ runner: {
688
+ run: async (request) => {
689
+ requests.push({ prompt: request.prompt, resume: request.resume });
690
+ calls++;
691
+ return calls === 1
692
+ ? {
693
+ ok: true,
694
+ text: reportJson("alpha"),
695
+ metrics: {
696
+ durationMs: 5,
697
+ sessionId: "sess-1",
698
+ costUsd: 0.01,
699
+ },
700
+ stderr: "",
701
+ exitCode: 0,
702
+ }
703
+ : {
704
+ ok: true,
705
+ text: JSON.stringify({
706
+ watchdogId: "alpha",
707
+ reviewedFiles: ["src/alpha/a.ts"],
708
+ skipped: [],
709
+ rules: [
710
+ {
711
+ id: 1,
712
+ verdict: "ok",
713
+ at: "src/alpha/a.ts:1",
714
+ },
715
+ {
716
+ id: 2,
717
+ verdict: "ok",
718
+ at: "src/alpha/a.ts:2",
719
+ },
720
+ ],
721
+ checks: [],
722
+ findings: [],
723
+ }),
724
+ metrics: {
725
+ durationMs: 5,
726
+ sessionId: "sess-1",
727
+ costUsd: 0.005,
728
+ },
729
+ stderr: "",
730
+ exitCode: 0,
731
+ };
732
+ },
733
+ },
734
+ });
735
+
736
+ const report = await runner.run(["alpha"]);
737
+ expect(requests).toHaveLength(2);
738
+ expect(requests[1].resume).toBe("sess-1");
739
+ expect(requests[1].prompt).toContain("Regras não verificadas");
740
+ expect(requests[1].prompt).toContain("1, 2");
741
+ expect(report.watchdogs[0].findings).toBe(1);
742
+ expect(report.watchdogs[0].rules).toEqual({
743
+ expected: 2,
744
+ verified: 2,
745
+ missing: 0,
746
+ notApplicable: 0,
747
+ gaps: 0,
748
+ });
749
+ expect(report.watchdogs[0].checklist?.costUsd).toBeCloseTo(0.005);
750
+ expect(report.watchdogs[0].checklist?.numTurns).toBe(0);
751
+ } finally {
752
+ staging.cleanup();
753
+ }
754
+ });
755
+
756
+ test("segunda opinião do general roda em sessão nova com os achados já cobertos", async () => {
757
+ const staging = makeWork(["general"]);
758
+ staging.work.writeBrief("general", briefWithFiles("general", ["src/alpha/a.ts"]));
759
+ const requests: { prompt: string; resume?: string }[] = [];
760
+ try {
761
+ let calls = 0;
762
+ const runner = new HuntRunner({
763
+ work: staging.work,
764
+ root: staging.root,
765
+ projectName: "Proj",
766
+ model: "deepseek/deepseek-v4-pro",
767
+ apiKeyEnv: "DEEPSEEK_API_KEY",
768
+ apiKey: "sk",
769
+ concurrency: 1,
770
+ timeoutMs: 10_000,
771
+ retries: 0,
772
+ runner: {
773
+ run: async (request) => {
774
+ requests.push({ prompt: request.prompt, resume: request.resume });
775
+ calls++;
776
+ // 1 = fluxo, 2 = bookkeeping (só vereditos), 3 = adversarial.
777
+ const text =
778
+ calls === 2
779
+ ? JSON.stringify({
780
+ watchdogId: "general",
781
+ reviewedFiles: ["src/general/a.ts"],
782
+ skipped: [],
783
+ rules: [],
784
+ checks: [],
785
+ findings: [],
786
+ })
787
+ : reportJson("general", calls === 1 ? 1 : 2);
788
+ return {
789
+ ok: true,
790
+ text,
791
+ metrics: {
792
+ durationMs: 5,
793
+ sessionId: `sess-${calls}`,
794
+ costUsd: 0.02,
795
+ numTurns: 3,
796
+ },
797
+ stderr: "",
798
+ exitCode: 0,
799
+ };
800
+ },
801
+ },
802
+ });
803
+
804
+ const report = await runner.run(["general"]);
805
+ expect(requests).toHaveLength(3);
806
+ // Passada base: fluxo puro, sem bookkeeping no schema.
807
+ expect(requests[0].prompt).toContain("Seu produto é **achado**");
808
+ expect(requests[0].prompt).not.toContain('"checks"');
809
+ // Bookkeeping: sessão nova, tarefa estreita.
810
+ expect(requests[1].prompt).toContain("verificador de checklist");
811
+ expect(requests[1].resume).toBeUndefined();
812
+ // Adversarial: sessão nova, com os achados já cobertos.
813
+ expect(requests[2].prompt).toContain("Classes a atacar");
814
+ expect(requests[2].prompt).toContain("Já cobertos");
815
+ expect(requests[2].resume).toBeUndefined();
816
+ expect(report.watchdogs[0].findings).toBe(2);
817
+ expect(report.watchdogs[0].secondOpinion).toMatchObject({
818
+ status: "done",
819
+ findings: 1,
820
+ });
821
+ expect(report.watchdogs[0].secondOpinion?.costUsd).toBeCloseTo(0.02);
822
+ expect(report.watchdogs[0].secondOpinion?.numTurns).toBe(3);
823
+ } finally {
824
+ staging.cleanup();
825
+ }
826
+ });
827
+
828
+ test("prompt adversarial em sessão nova cita as cinco classes e não pede bookkeeping", () => {
829
+ const prompt = renderAdversarialPrompt({
830
+ projectName: "Proj",
831
+ watchdogId: "general",
832
+ brief: "# Brief general",
833
+ candidates: [
834
+ {
835
+ rule: "Evitar any",
836
+ severity: "attention",
837
+ file: "a.ts",
838
+ line: 1,
839
+ message: "m",
840
+ },
841
+ ],
842
+ });
843
+ for (const term of [
844
+ "Credencial e contexto compartilhado",
845
+ "Capacidade anunciada",
846
+ "Identificador persistido",
847
+ "Idempotência e transição de estado",
848
+ "Contrato entre lados",
849
+ ]) {
850
+ expect(prompt).toContain(term);
851
+ }
852
+ expect(prompt).toContain("sessão nova");
853
+ expect(prompt).toContain("Já cobertos");
854
+ expect(prompt).not.toContain('"checks"');
855
+ // Não pode herdar o pedido de "relatório completo" da passada em sessão.
856
+ expect(prompt).not.toContain("findings anteriores + novos");
857
+ });
858
+
859
+ test("prompt de bookkeeping pede só vereditos com `at`", () => {
860
+ const prompt = renderBookkeepingPrompt({
861
+ projectName: "Proj",
862
+ watchdogId: "general",
863
+ brief: "# Brief general",
864
+ items: ["Criação de subconta"],
865
+ ruleIds: [1],
866
+ });
867
+ expect(prompt).toContain("- Criação de subconta");
868
+ expect(prompt).toContain('"findings"');
869
+ expect(prompt).toContain("`findings` deve ser sempre `[]`");
870
+ });
871
+
872
+ test("rule vazio é inferido com o prefixo do basis", () => {
873
+ const report = withInferredRule({
874
+ watchdogId: "alpha",
875
+ reviewedFiles: [],
876
+ skipped: [],
877
+ findings: [
878
+ {
879
+ rule: "",
880
+ severity: "attention",
881
+ file: "src/alpha/a.ts",
882
+ line: 1,
883
+ message: "Split ausente na criação. Detalhe adicional.",
884
+ action: "enviar split",
885
+ basis: "domain",
886
+ },
887
+ {
888
+ rule: " ",
889
+ severity: "attention",
890
+ file: "src/alpha/a.ts",
891
+ line: 2,
892
+ message: "Outro problema",
893
+ action: "corrigir",
894
+ },
895
+ ],
896
+ });
897
+ expect(report.findings[0].rule).toBe("Domínio: Split ausente na criação");
898
+ expect(report.findings[1].rule).toBe("Lógica: Outro problema");
899
+ });
900
+
901
+ test("retry de conteúdo retoma a sessão e envia só o sufixo", async () => {
902
+ const staging = makeWork(["alpha"]);
903
+ try {
904
+ const requests: { prompt: string; resume?: string }[] = [];
905
+ let calls = 0;
906
+ const runner = new HuntRunner({
907
+ work: staging.work,
908
+ root: staging.root,
909
+ projectName: "Proj",
910
+ model: "deepseek/deepseek-v4-pro",
911
+ apiKeyEnv: "DEEPSEEK_API_KEY",
912
+ apiKey: "sk",
913
+ concurrency: 1,
914
+ timeoutMs: 10_000,
915
+ retries: 1,
916
+ runner: {
917
+ run: async (request) => {
918
+ requests.push({ prompt: request.prompt, resume: request.resume });
919
+ calls++;
920
+ return calls === 1
921
+ ? {
922
+ ok: true,
923
+ text: "narração sem JSON",
924
+ metrics: { durationMs: 5, sessionId: "sess-1" },
925
+ stderr: "",
926
+ exitCode: 0,
927
+ }
928
+ : {
929
+ ok: true,
930
+ text: reportJson("alpha"),
931
+ metrics: { durationMs: 5, sessionId: "sess-1" },
932
+ stderr: "",
933
+ exitCode: 0,
934
+ };
935
+ },
936
+ },
937
+ });
938
+
939
+ const report = await runner.run(["alpha"]);
940
+ expect(report.watchdogs[0].status).toBe("done");
941
+ expect(requests[1].resume).toBe("sess-1");
942
+ expect(requests[1].prompt).toContain("sessão retomada");
943
+ expect(requests[1].prompt).not.toContain("brief de alpha");
944
+ } finally {
945
+ staging.cleanup();
946
+ }
947
+ });
948
+
949
+ test("modelo de intenção mais fraco não substitui o da IA", () => {
950
+ const weak = {
951
+ ...business,
952
+ source: "reviewer" as const,
953
+ flows: [],
954
+ invariants: ["Split sempre presente"],
955
+ };
956
+ expect(businessScore(weak)).toBeLessThan(businessScore(business));
957
+ const kept = chooseBusinessSummary(business, weak);
958
+ expect(kept.accepted).toBe(false);
959
+ expect(kept.summary).toBe(business);
960
+
961
+ const deterministic = { ...business, source: "deterministic" as const };
962
+ expect(chooseBusinessSummary(deterministic, weak).accepted).toBe(true);
963
+ expect(chooseBusinessSummary(null, weak).accepted).toBe(true);
964
+ });
965
+
966
+ test("runner repete o brief para cobrir itens não verificados e une achados", async () => {
967
+ const staging = makeWork(["alpha"]);
968
+ staging.work.writeBrief("alpha", briefWithFiles("alpha", ["src/alpha/a.ts"]));
969
+ staging.work.writeBusinessSummary(business);
970
+ const prompts: string[] = [];
971
+ try {
972
+ const runner = new HuntRunner({
973
+ work: staging.work,
974
+ root: staging.root,
975
+ projectName: "Proj",
976
+ model: "deepseek/deepseek-v4-pro",
977
+ apiKeyEnv: "DEEPSEEK_API_KEY",
978
+ apiKey: "sk",
979
+ concurrency: 1,
980
+ timeoutMs: 10_000,
981
+ retries: 0,
982
+ runner: {
983
+ run: async (request) => {
984
+ prompts.push(request.prompt);
985
+ if (prompts.length === 1) {
986
+ return {
987
+ ok: true,
988
+ text: reportJson("alpha"),
989
+ metrics: { durationMs: 5 },
990
+ stderr: "",
991
+ exitCode: 0,
992
+ };
993
+ }
994
+ return {
995
+ ok: true,
996
+ text: JSON.stringify({
997
+ watchdogId: "alpha",
998
+ reviewedFiles: ["src/alpha/a.ts"],
999
+ skipped: [],
1000
+ checks: [
1001
+ {
1002
+ item: "Criação de subconta",
1003
+ verdict: "ok",
1004
+ at: "src/alpha/a.ts:1",
1005
+ },
1006
+ {
1007
+ item: "Split sempre presente",
1008
+ verdict: "gap",
1009
+ at: "src/alpha/a.ts:2",
1010
+ },
1011
+ ],
1012
+ findings: [
1013
+ {
1014
+ rule: "Evitar any",
1015
+ severity: "attention",
1016
+ file: "src/alpha/a.ts",
1017
+ line: 2,
1018
+ message: "split ausente",
1019
+ action: "enviar split",
1020
+ },
1021
+ ],
1022
+ }),
1023
+ metrics: { durationMs: 5 },
1024
+ stderr: "",
1025
+ exitCode: 0,
1026
+ };
1027
+ },
1028
+ },
1029
+ });
1030
+
1031
+ const report = await runner.run(["alpha"]);
1032
+ expect(prompts).toHaveLength(2);
1033
+ expect(prompts[1]).toContain("Verificação obrigatória (checklist)");
1034
+ expect(prompts[1]).toContain("Criação de subconta");
1035
+ expect(report.watchdogs[0].findings).toBe(2);
1036
+ expect(report.watchdogs[0].checks).toEqual({
1037
+ expected: 2,
1038
+ verified: 2,
1039
+ missing: 0,
1040
+ notApplicable: 0,
1041
+ gaps: 1,
1042
+ });
1043
+ } finally {
1044
+ staging.cleanup();
1045
+ }
1046
+ });
1047
+
1048
+ test("businessSummary do general só substitui um modelo mais fraco", async () => {
1049
+ const staging = makeWork(["general"]);
1050
+ staging.work.writeBrief("general", briefWithFiles("general", ["src/alpha/a.ts"]));
1051
+ staging.work.writeBusinessSummary(business);
1052
+ try {
1053
+ const runner = new HuntRunner({
1054
+ work: staging.work,
1055
+ root: staging.root,
1056
+ projectName: "Proj",
1057
+ model: "deepseek/deepseek-v4-pro",
1058
+ apiKeyEnv: "DEEPSEEK_API_KEY",
1059
+ apiKey: "sk",
1060
+ concurrency: 1,
1061
+ timeoutMs: 10_000,
1062
+ retries: 0,
1063
+ runner: {
1064
+ run: async () => ({
1065
+ ok: true,
1066
+ text: JSON.stringify({
1067
+ watchdogId: "general",
1068
+ reviewedFiles: ["src/alpha/a.ts"],
1069
+ skipped: [],
1070
+ checks: [
1071
+ {
1072
+ item: "Criação de subconta",
1073
+ verdict: "ok",
1074
+ at: "src/alpha/a.ts:1",
1075
+ },
1076
+ { item: "Fluxo beta", verdict: "n/a" },
1077
+ {
1078
+ item: "Split sempre presente",
1079
+ verdict: "ok",
1080
+ at: "src/alpha/a.ts:3",
1081
+ },
1082
+ ],
1083
+ findings: [],
1084
+ businessSummary: {
1085
+ invariants: ["Split sempre presente"],
1086
+ flows: [],
1087
+ contracts: [],
1088
+ states: [],
1089
+ },
1090
+ }),
1091
+ metrics: { durationMs: 5 },
1092
+ stderr: "",
1093
+ exitCode: 0,
1094
+ }),
1095
+ },
1096
+ });
1097
+
1098
+ const report = await runner.run(["general"]);
1099
+ expect(report.watchdogs[0].business).toMatchObject({
1100
+ accepted: false,
1101
+ source: "ai",
1102
+ });
1103
+ expect(report.business).toBe("ai");
1104
+ const summary = staging.work.readBusinessSummary();
1105
+ expect(summary?.source).toBe("ai");
1106
+ expect(summary?.flows).toHaveLength(2);
1107
+ // `n/a` é veredito, não prova: 2 itens comprovados + 1 não aplicável.
1108
+ expect(report.watchdogs[0].checks).toEqual({
1109
+ expected: 3,
1110
+ verified: 2,
1111
+ missing: 0,
1112
+ notApplicable: 1,
1113
+ gaps: 0,
1114
+ });
1115
+ } finally {
1116
+ staging.cleanup();
1117
+ }
1118
+ });
1119
+ });
1120
+
471
1121
  describe("SpawnMakiRunner (POSIX)", () => {
472
1122
  const dirs: string[] = [];
473
1123