@payconductor/houndsman 0.12.0 → 0.14.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 (119) hide show
  1. package/README.md +23 -2
  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/doctor-command.ts +22 -0
  6. package/src/cli/commands/findings-command.ts +11 -1
  7. package/src/cli/commands/hunt-command.ts +479 -0
  8. package/src/cli/commands/review-command.ts +58 -2
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +20 -3
  11. package/src/cli/index.ts +20 -3
  12. package/src/cli/progress.ts +50 -0
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/ai-sdk-model-provider.ts +39 -0
  15. package/src/core/ai/features/brief-enricher.ts +1 -1
  16. package/src/core/ai/features/business-analyzer.ts +61 -0
  17. package/src/core/ai/features/business-summarizer.ts +44 -0
  18. package/src/core/ai/index.ts +14 -1
  19. package/src/core/ai/prompts/brief.ts +33 -7
  20. package/src/core/ai/prompts/business.ts +76 -0
  21. package/src/core/ai/provider-capabilities.ts +12 -0
  22. package/src/core/ai/schemas.ts +70 -2
  23. package/src/core/ai/types.ts +2 -0
  24. package/src/core/ai/usage-recorder.ts +53 -1
  25. package/src/core/brief.ts +246 -12
  26. package/src/core/builtins.ts +108 -0
  27. package/src/core/business-summary.ts +583 -0
  28. package/src/core/commands/compare.ts +233 -0
  29. package/src/core/commands/context.ts +6 -2
  30. package/src/core/commands/findings.ts +38 -30
  31. package/src/core/commands/review.ts +6 -0
  32. package/src/core/commands/scaffold.ts +38 -0
  33. package/src/core/commands/status.ts +9 -4
  34. package/src/core/compare.ts +243 -0
  35. package/src/core/concurrency.ts +30 -0
  36. package/src/core/config.ts +57 -1
  37. package/src/core/defaults.ts +63 -2
  38. package/src/core/domain/budget.ts +62 -4
  39. package/src/core/domain/classification.ts +12 -2
  40. package/src/core/engine/carryover.ts +4 -0
  41. package/src/core/engine/finalize-pipeline.ts +347 -0
  42. package/src/core/engine/prepare-pipeline.ts +1294 -0
  43. package/src/core/engine/review-context.ts +51 -0
  44. package/src/core/engine/review-options.ts +47 -0
  45. package/src/core/engine/review-scope.ts +106 -26
  46. package/src/core/engine/review-session.ts +18 -16
  47. package/src/core/engine/review-workspace.ts +14 -0
  48. package/src/core/engine/run-artifacts.ts +196 -0
  49. package/src/core/engine/run-format.ts +4 -0
  50. package/src/core/env.ts +14 -0
  51. package/src/core/findings-state.ts +13 -1
  52. package/src/core/github-review.ts +5 -2
  53. package/src/core/hunt/hunt-runner.ts +418 -0
  54. package/src/core/hunt/index.ts +61 -0
  55. package/src/core/hunt/maki-install.ts +331 -0
  56. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  57. package/src/core/hunt/maki-model.ts +101 -0
  58. package/src/core/hunt/maki-prompt.ts +83 -0
  59. package/src/core/hunt/maki-release.ts +104 -0
  60. package/src/core/hunt/maki-runner.ts +276 -0
  61. package/src/core/hunt/pool.ts +8 -0
  62. package/src/core/logging/index.ts +7 -0
  63. package/src/core/logging/logger.ts +19 -0
  64. package/src/core/logging/progress.ts +366 -0
  65. package/src/core/orchestrator.ts +33 -1180
  66. package/src/core/paths.ts +6 -3
  67. package/src/core/pr-context.ts +2 -4
  68. package/src/core/prepare-report.ts +61 -0
  69. package/src/core/project-config.ts +46 -1
  70. package/src/core/propagation.ts +127 -0
  71. package/src/core/registry-check.ts +60 -21
  72. package/src/core/renderers/manifest-writer.ts +13 -0
  73. package/src/core/renderers/resume-renderer.ts +151 -11
  74. package/src/core/report-builder.ts +108 -278
  75. package/src/core/storage/file/file-storage.ts +7 -3
  76. package/src/core/storage/sqlite/mappers.ts +10 -0
  77. package/src/core/storage/sqlite/schema.ts +104 -23
  78. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  79. package/src/core/storage/types.ts +17 -4
  80. package/src/core/storage/work-area.ts +59 -1
  81. package/src/core/types.ts +31 -0
  82. package/src/core/watchdog-registry.ts +21 -3
  83. package/src/index.ts +78 -4
  84. package/src/server/client/ArtifactsPanel.tsx +4 -2
  85. package/src/server/client/FindingCard.tsx +21 -1
  86. package/src/server/client/RunsSection.tsx +5 -0
  87. package/src/server/client/utils/constants.ts +21 -11
  88. package/src/server/client/utils/markdown.ts +1 -1
  89. package/src/server/client/utils/types.ts +14 -2
  90. package/src/server/run-repository.ts +35 -14
  91. package/src/server/styles.css +2 -0
  92. package/src/skill/OUTPUT-FORMAT.md +36 -10
  93. package/src/skill/README.md +30 -14
  94. package/src/skill/SETUP.md +22 -1
  95. package/src/skill/SKILL.md +17 -4
  96. package/src/skill/example-resume.md +43 -0
  97. package/src/skill/hunt.md +181 -58
  98. package/src/tests/__snapshots__/baseline.test.ts.snap +206 -178
  99. package/src/tests/ai-core.test.ts +84 -1
  100. package/src/tests/ai-features.test.ts +95 -4
  101. package/src/tests/baseline.test.ts +29 -121
  102. package/src/tests/brief-scope.test.ts +250 -0
  103. package/src/tests/business-summary.test.ts +258 -0
  104. package/src/tests/cli.test.ts +1 -0
  105. package/src/tests/compare.test.ts +233 -0
  106. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  107. package/src/tests/hunt-cli.test.ts +185 -0
  108. package/src/tests/hunt-install.test.ts +153 -0
  109. package/src/tests/hunt-maki.test.ts +525 -0
  110. package/src/tests/markdown.test.ts +3 -3
  111. package/src/tests/progress.test.ts +145 -0
  112. package/src/tests/propagation.test.ts +119 -0
  113. package/src/tests/review-resume.test.ts +60 -1
  114. package/src/tests/review-scope.test.ts +90 -19
  115. package/src/tests/review-scripts.test.ts +106 -218
  116. package/src/tests/server.test.ts +7 -4
  117. package/src/tests/storage.test.ts +194 -9
  118. package/src/core/renderers/report-renderer.ts +0 -66
  119. package/src/skill/example-review.md +0 -66
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Modelo de intenção (`business-summary.json`) e política de severidade dos
3
+ * findings de lógica/domínio.
4
+ */
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { AstAnalysis } from "../core/ast/types";
8
+ import { defaultRenderBrief } from "../core/brief";
9
+ import { GENERAL_WATCHDOG_ID, generalWatchdog } from "../core/builtins";
10
+ import {
11
+ buildBusinessBrief,
12
+ buildBusinessSummary,
13
+ parseBusinessSummary,
14
+ } from "../core/business-summary";
15
+ import { normalizeFinding } from "../core/report-builder";
16
+ import { findWatchdog } from "../core/watchdog-registry";
17
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
18
+
19
+ const facts: AstAnalysis["facts"] = {
20
+ language: "typescript",
21
+ symbols: [],
22
+ imports: [],
23
+ exports: [],
24
+ decorators: [],
25
+ hasSyntaxErrors: false,
26
+ };
27
+
28
+ const delta = {
29
+ addedSymbols: [
30
+ {
31
+ name: "Contrato",
32
+ kind: "interface" as const,
33
+ exported: true,
34
+ startLine: 1,
35
+ endLine: 3,
36
+ },
37
+ {
38
+ name: "executar",
39
+ kind: "function" as const,
40
+ exported: true,
41
+ startLine: 5,
42
+ endLine: 9,
43
+ },
44
+ ],
45
+ removedSymbols: [],
46
+ modifiedSymbols: [],
47
+ changedSignatures: [],
48
+ addedImports: [],
49
+ removedImports: [],
50
+ changedExports: [],
51
+ };
52
+
53
+ const classification = {
54
+ files: {
55
+ "src/alpha/a.ts": { status: "modified" as const },
56
+ "src/beta/b.ts": { status: "modified" as const },
57
+ "src/alpha/old.ts": { status: "deleted" as const },
58
+ },
59
+ churn: {
60
+ "src/alpha/a.ts": { additions: 10, deletions: 2 },
61
+ "src/beta/b.ts": { additions: 1, deletions: 0 },
62
+ },
63
+ watchdogs: {
64
+ alpha: ["src/alpha/a.ts"],
65
+ beta: ["src/beta/b.ts"],
66
+ [GENERAL_WATCHDOG_ID]: ["src/alpha/a.ts", "src/beta/b.ts"],
67
+ },
68
+ };
69
+
70
+ const domains = [
71
+ { id: "alpha", name: "Alpha" },
72
+ { id: "beta", name: "Beta" },
73
+ { id: GENERAL_WATCHDOG_ID, name: "Lógica e domínio (fluxo)" },
74
+ ];
75
+
76
+ describe("buildBusinessSummary", () => {
77
+ test("deriva fluxos por domínio (sem o general) e contratos do AST", () => {
78
+ const summary = buildBusinessSummary({
79
+ classification,
80
+ domains,
81
+ ast: new Map([["src/alpha/a.ts", { facts, delta }]]),
82
+ headSha: "abc123",
83
+ context: { objective: "Entregar o split" },
84
+ });
85
+
86
+ expect(summary.source).toBe("deterministic");
87
+ expect(summary.intent).toBe("Entregar o split");
88
+ expect(summary.headSha).toBe("abc123");
89
+ expect(summary.flows.map((flow) => flow.name)).toEqual(["Alpha", "Beta"]);
90
+ expect(summary.flows[0]?.files).toEqual(["src/alpha/a.ts"]);
91
+ expect(summary.flows[0]?.expected).toContain("Contrato");
92
+ expect(summary.contracts).toEqual([
93
+ {
94
+ name: "Contrato",
95
+ sides: ["src/alpha/a.ts"],
96
+ expectation:
97
+ "Declaração `interface` nova — verificar se todos os lados do contrato a honram.",
98
+ },
99
+ ]);
100
+ });
101
+
102
+ test("sem contexto e sem AST, mantém o esqueleto vazio (nunca inventa)", () => {
103
+ const summary = buildBusinessSummary({ classification, domains });
104
+ expect(summary.intent).toBeUndefined();
105
+ expect(summary.contracts).toEqual([]);
106
+ expect(summary.invariants).toEqual([]);
107
+ expect(summary.flows).toHaveLength(2);
108
+ });
109
+ });
110
+
111
+ describe("parseBusinessSummary", () => {
112
+ test("normaliza listas e preserva a fonte", () => {
113
+ const summary = parseBusinessSummary(
114
+ {
115
+ source: "reviewer",
116
+ intent: " Entregar Y ",
117
+ flows: [{ name: "Renovação", trigger: "job", files: ["src/a.ts"] }],
118
+ invariants: ["split sempre presente"],
119
+ contracts: [{ name: "AutomaticPix", sides: ["asaas", "processor"] }],
120
+ states: [{ name: "authorization", transitions: ["ativa → cancelada"] }],
121
+ },
122
+ "deterministic",
123
+ );
124
+ expect(summary?.source).toBe("reviewer");
125
+ expect(summary?.intent).toBe("Entregar Y");
126
+ expect(summary?.flows[0]?.name).toBe("Renovação");
127
+ expect(summary?.invariants).toEqual(["split sempre presente"]);
128
+ });
129
+
130
+ test("rejeita documento sem conteúdo útil", () => {
131
+ expect(parseBusinessSummary({})).toBeNull();
132
+ expect(parseBusinessSummary(null)).toBeNull();
133
+ expect(parseBusinessSummary({ flows: [{ name: "" }] })).toBeNull();
134
+ });
135
+
136
+ test("corta itens acima do limite e ignora entradas inválidas", () => {
137
+ const summary = parseBusinessSummary({
138
+ intent: "x",
139
+ flows: [
140
+ { name: "ok" },
141
+ { name: "" },
142
+ { name: "outro", files: ["a", 1, "b"] },
143
+ ],
144
+ });
145
+ expect(summary?.flows.map((flow) => flow.name)).toEqual(["ok", "outro"]);
146
+ expect(summary?.flows[1]?.files).toEqual(["a", "b"]);
147
+ });
148
+ });
149
+
150
+ describe("buildBusinessBrief", () => {
151
+ test("aponta o output e inclui o schema e o modelo determinístico", () => {
152
+ const summary = buildBusinessSummary({ classification, domains });
153
+ const brief = buildBusinessBrief({
154
+ projectName: "Fake",
155
+ branch: "main",
156
+ headSha: "abc123",
157
+ outputPath: "/tmp/work/review/business-summary.json",
158
+ business: summary,
159
+ files: [
160
+ {
161
+ path: "src/alpha/a.ts",
162
+ status: "modified",
163
+ ranges: [{ start: 3, end: 9 }],
164
+ symbols: ["Contrato"],
165
+ },
166
+ ],
167
+ docs: ["AGENTS.md"],
168
+ context: { objective: "Entregar o split" },
169
+ });
170
+ expect(brief).toContain("/tmp/work/review/business-summary.json");
171
+ expect(brief).toContain('"invariants"');
172
+ expect(brief).toContain("Modelo determinístico");
173
+ expect(brief).toContain("src/alpha/a.ts");
174
+ expect(brief).toContain("AGENTS.md");
175
+ expect(brief).toContain('source: "reviewer"');
176
+ });
177
+ });
178
+
179
+ describe("política de severidade (lógica/domínio)", () => {
180
+ const base = {
181
+ file: "src/alpha/a.ts",
182
+ line: 3,
183
+ message: "gap",
184
+ action: "corrigir",
185
+ };
186
+
187
+ test("blocking sem cenário+evidência vira attention", () => {
188
+ const warnings: string[] = [];
189
+ const finding = normalizeFinding(
190
+ {
191
+ ...base,
192
+ rule: "Domínio: renovação perde o split",
193
+ severity: "blocking",
194
+ },
195
+ (message) => warnings.push(message),
196
+ );
197
+ expect(finding.basis).toBe("domain");
198
+ expect(finding.severity).toBe("attention");
199
+ expect(warnings).toHaveLength(1);
200
+ });
201
+
202
+ test("com cenário e evidência, blocking é preservado", () => {
203
+ const finding = normalizeFinding({
204
+ ...base,
205
+ rule: "Lógica: subscription sem authorize",
206
+ severity: "blocking",
207
+ scenario: "renovação de assinatura Pix",
208
+ evidence: "this.sendAutomaticPixPayment(data)",
209
+ });
210
+ expect(finding.basis).toBe("logic");
211
+ expect(finding.severity).toBe("blocking");
212
+ });
213
+
214
+ test("regra explícita mantém a severidade declarada", () => {
215
+ const finding = normalizeFinding({
216
+ ...base,
217
+ rule: "Nunca usar `any`.",
218
+ severity: "blocking",
219
+ });
220
+ expect(finding.basis).toBe("rule");
221
+ expect(finding.severity).toBe("blocking");
222
+ });
223
+ });
224
+
225
+ describe("watchdog general", () => {
226
+ test("resolvido como built-in mesmo sem config", () => {
227
+ const definition = findWatchdog(fakeHoundsmanConfig, GENERAL_WATCHDOG_ID);
228
+ expect(definition?.name).toContain("Lógica");
229
+ expect(definition?.allowEmergentFindings).toBe(true);
230
+ expect(definition?.fallback).toBe(false);
231
+ });
232
+
233
+ test("brief inclui intenção, diff e obrigação de output", () => {
234
+ const summary = buildBusinessSummary({ classification, domains });
235
+ const markdown = defaultRenderBrief(
236
+ {
237
+ watchdogId: GENERAL_WATCHDOG_ID,
238
+ files: ["src/alpha/a.ts"],
239
+ statuses: { "src/alpha/a.ts": { status: "modified" as const } },
240
+ deleted: [],
241
+ branch: "main",
242
+ outputPath: "/tmp/findings/general.json",
243
+ outputFormatPath: "OUTPUT-FORMAT.md",
244
+ docs: [],
245
+ business: summary,
246
+ businessSummaryPath: "/tmp/business-summary.json",
247
+ snippets: [{ file: "src/alpha/a.ts", snippet: "@@ -1 +1 @@\n-a\n+b" }],
248
+ flowReview: true,
249
+ },
250
+ generalWatchdog,
251
+ ).join("\n");
252
+ expect(markdown).toContain("## Modelo de intenção (negócio)");
253
+ expect(markdown).toContain("## Trechos alterados (diff)");
254
+ expect(markdown).toContain("## Obrigação de output (fluxo)");
255
+ expect(markdown).toContain("/tmp/business-summary.json");
256
+ expect(markdown).toContain("scenario");
257
+ });
258
+ });
@@ -192,6 +192,7 @@ describe("cli smoke — imports dinâmicos", () => {
192
192
  const result = runCli(project.cwd, ["doctor", "--no-ping"]);
193
193
  expect(result.code).toBe(0);
194
194
  expect(result.stdout).toContain("Validando");
195
+ expect(result.stdout).toContain("maki (hunt local)");
195
196
  } finally {
196
197
  project.cleanup();
197
198
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Comparação A/B e identidade das variantes.
3
+ *
4
+ * Cobre o slug da run (URL/backend de arquivos), o diff de findings entre dois
5
+ * snapshots e o parse de `--variant` no comando default.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { runCompare } from "../core/commands/compare";
13
+ import { parseReviewArgs } from "../core/commands/review";
14
+ import {
15
+ buildComparison,
16
+ type ComparisonFinding,
17
+ countBySeverity,
18
+ type ReviewSnapshot,
19
+ renderComparison,
20
+ } from "../core/compare";
21
+ import type { FindingsStateFile } from "../core/findings-state";
22
+ import { reviewSlug, sanitizeVariant } from "../core/report-builder";
23
+ import { buildRepoIdentity } from "../core/storage/repo-identity";
24
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
25
+
26
+ function finding(
27
+ fingerprint: string,
28
+ partial: Partial<ComparisonFinding> = {},
29
+ ): ComparisonFinding {
30
+ return {
31
+ fingerprint,
32
+ rule: "regra",
33
+ severity: "attention",
34
+ file: "src/a.ts",
35
+ line: 10,
36
+ message: "mensagem",
37
+ watchdogs: ["alpha"],
38
+ ...partial,
39
+ };
40
+ }
41
+
42
+ function snapshot(
43
+ label: string,
44
+ findings: ComparisonFinding[],
45
+ variant?: string,
46
+ ): ReviewSnapshot {
47
+ return {
48
+ label,
49
+ slug: reviewSlug("feature/x", variant),
50
+ branch: "feature/x",
51
+ variant,
52
+ headSha: "deadbeef",
53
+ findings,
54
+ bySeverity: countBySeverity(findings),
55
+ coverage: { watchdogs: 2, files: 3, overflow: 0 },
56
+ durationMs: 60_000,
57
+ };
58
+ }
59
+
60
+ describe("reviewSlug / sanitizeVariant", () => {
61
+ test("run principal usa o slug da branch", () => {
62
+ expect(reviewSlug("feature/add-webhook")).toBe("feature-add-webhook");
63
+ expect(reviewSlug("feature/x", " ")).toBe("feature-x");
64
+ });
65
+
66
+ test("variante ganha sufixo legível + hash estável", () => {
67
+ const baseline = reviewSlug("feature/x", "Baseline A");
68
+ const optimized = reviewSlug("feature/x", "Baseline B");
69
+ expect(baseline).toStartWith("feature-x--baseline-a-");
70
+ expect(baseline).toBe(reviewSlug("feature/x", "Baseline A"));
71
+ expect(baseline).not.toBe(optimized);
72
+ expect(sanitizeVariant(" A/B ")).toBe("a-b");
73
+ });
74
+ });
75
+
76
+ describe("buildComparison", () => {
77
+ const a = snapshot("A", [
78
+ finding("f1", { severity: "attention", file: "src/a.ts" }),
79
+ finding("f2", { severity: "suggestion", file: "src/b.ts", line: 2 }),
80
+ ]);
81
+ const b = snapshot(
82
+ "B",
83
+ [
84
+ finding("f1", { severity: "blocking", file: "src/a.ts" }),
85
+ finding("f3", { severity: "blocking", file: "src/c.ts", line: 5 }),
86
+ ],
87
+ "otimizado",
88
+ );
89
+
90
+ test("separa novos, sumidos e mudança de severidade", () => {
91
+ const comparison = buildComparison(a, b);
92
+ expect(comparison.onlyB.map((item) => item.fingerprint)).toEqual(["f3"]);
93
+ expect(comparison.onlyA.map((item) => item.fingerprint)).toEqual(["f2"]);
94
+ expect(comparison.common).toHaveLength(1);
95
+ expect(comparison.severityChanged).toHaveLength(1);
96
+ expect(comparison.severityChanged[0].a.severity).toBe("attention");
97
+ expect(comparison.severityChanged[0].b.severity).toBe("blocking");
98
+ });
99
+
100
+ test("conta por severidade", () => {
101
+ expect(a.bySeverity).toEqual({
102
+ blocking: 0,
103
+ attention: 1,
104
+ suggestion: 1,
105
+ nit: 0,
106
+ });
107
+ expect(b.bySeverity).toEqual({
108
+ blocking: 2,
109
+ attention: 0,
110
+ suggestion: 0,
111
+ nit: 0,
112
+ });
113
+ });
114
+
115
+ test("renderiza a tabela e as listas", () => {
116
+ const markdown = renderComparison(buildComparison(a, b));
117
+ expect(markdown).toContain("# Comparação A/B — `feature/x`");
118
+ expect(markdown).toContain("| Findings (presentes) | 2 | 2 | 0 |");
119
+ expect(markdown).toContain("## Só em B (novos) (1)");
120
+ expect(markdown).toContain("## Só em A (não apareceram em B) (1)");
121
+ expect(markdown).toContain("## Severidade mudou (1)");
122
+ expect(markdown).toContain("## Só em B");
123
+ });
124
+ });
125
+
126
+ describe("--variant", () => {
127
+ test("é lido pelo parse do comando default", () => {
128
+ const options = parseReviewArgs([
129
+ "--branch",
130
+ "feature/x",
131
+ "--variant",
132
+ " baseline ",
133
+ ]);
134
+ expect(options.variant).toBe("baseline");
135
+ });
136
+
137
+ describe("runCompare", () => {
138
+ test("lê as duas runs e grava o relatório na run B", () => {
139
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-compare-"));
140
+ const storage = new SQLiteStorageProvider(join(dir, "houndsman.db"));
141
+ try {
142
+ const repo = buildRepoIdentity(null, dir);
143
+ const main = storage.getOrCreateReview({
144
+ repo,
145
+ branch: "feature/x",
146
+ slug: "feature-x",
147
+ });
148
+ const variant = storage.getOrCreateReview({
149
+ repo,
150
+ branch: "feature/x",
151
+ variant: "otimizado",
152
+ slug: "feature-x--otimizado-1a2b3c",
153
+ });
154
+ const state = (
155
+ findings: FindingsStateFile["findings"],
156
+ ): FindingsStateFile => ({
157
+ version: 2,
158
+ branch: "feature/x",
159
+ slug: "feature-x",
160
+ updatedAt: "2026-01-01T00:00:00.000Z",
161
+ order: Object.keys(findings),
162
+ findings,
163
+ });
164
+ const baseFinding = {
165
+ id: "ab12cd34ef",
166
+ fingerprint: "fp-1",
167
+ watchdogIds: ["alpha"],
168
+ rule: "regra",
169
+ severity: "attention" as const,
170
+ file: "src/a.ts",
171
+ line: 10,
172
+ message: "mensagem",
173
+ action: "ação",
174
+ anchor: null,
175
+ count: 1,
176
+ present: true,
177
+ decision: "pending" as const,
178
+ execState: "pending" as const,
179
+ createdAt: "2026-01-01T00:00:00.000Z",
180
+ updatedAt: "2026-01-01T00:00:00.000Z",
181
+ history: [],
182
+ };
183
+ storage.writeFindings(main.id, state({ ab12cd34ef: baseFinding }));
184
+ storage.writeFindings(
185
+ variant.id,
186
+ state({
187
+ ab12cd34ef: baseFinding,
188
+ ff00aa11bb: {
189
+ ...baseFinding,
190
+ id: "ff00aa11bb",
191
+ fingerprint: "fp-2",
192
+ rule: "outra",
193
+ },
194
+ }),
195
+ );
196
+ storage.writeArtifact(main.id, {
197
+ name: "manifest.json",
198
+ kind: "manifest",
199
+ content: JSON.stringify({
200
+ watchdogs: [{ files: ["src/a.ts"], overflow: 0 }],
201
+ }),
202
+ });
203
+ storage.writeArtifact(variant.id, {
204
+ name: "prepare-report.json",
205
+ kind: "prepare",
206
+ content: JSON.stringify({ durationMs: 1_000, stages: [] }),
207
+ });
208
+
209
+ const result = runCompare(storage, repo, {
210
+ branch: "feature/x",
211
+ variantB: "otimizado",
212
+ });
213
+ expect(result.b.id).toBe(variant.id);
214
+ expect(result.markdown).toContain("Só em B (novos) (1)");
215
+ expect(
216
+ storage.readArtifact(variant.id, result.artifacts.markdown)?.kind,
217
+ ).toBe("compare");
218
+ expect(
219
+ storage.readArtifact(variant.id, result.artifacts.json)?.content,
220
+ ).toContain("onlyB");
221
+ } finally {
222
+ storage.close();
223
+ rmSync(dir, { recursive: true, force: true });
224
+ }
225
+ });
226
+ });
227
+
228
+ test("vazio é rejeitado", () => {
229
+ expect(() => parseReviewArgs(["--variant", " "])).toThrow(
230
+ "--variant não pode ser vazio",
231
+ );
232
+ });
233
+ });
@@ -30,6 +30,7 @@ export class FakeModelProvider implements ModelProvider {
30
30
  constructor(
31
31
  private readonly handler: FakeHandler,
32
32
  modelId = "fake-model",
33
+ private readonly finishReason = "stop",
33
34
  ) {
34
35
  this.modelId = modelId;
35
36
  }
@@ -41,7 +42,7 @@ export class FakeModelProvider implements ModelProvider {
41
42
  return {
42
43
  output: output as T,
43
44
  usage: fakeUsage(),
44
- finishReason: "stop",
45
+ finishReason: this.finishReason,
45
46
  };
46
47
  }
47
48
  }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Smoke test do comando `hunt` (harness local).
3
+ *
4
+ * Não executa o maki real: cobre help, doctor e os erros acionáveis antes do
5
+ * prepare. A skill `/houndsman hunt` continua coberta pelos testes da camada.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ const CLI = join(import.meta.dir, "..", "cli", "index.ts");
14
+
15
+ const CONFIG = (hunt = "") => `export default {
16
+ projectName: "Hunt Smoke",
17
+ storage: { provider: "sqlite", path: "./houndsman.db" },
18
+ watchdogs: {
19
+ alpha: {
20
+ id: "alpha",
21
+ name: "Alpha",
22
+ description: "Watchdog de smoke.",
23
+ scope: "src/**",
24
+ classify: [{ value: "^src/" }],
25
+ rules: [{ severity: "attention", text: "Evitar any." }],
26
+ contextFiles: [],
27
+ },
28
+ },
29
+ exclusions: [],
30
+ docMappings: [],
31
+ ${hunt}
32
+ };
33
+ `;
34
+
35
+ type Project = { cwd: string; home: string; cleanup: () => void };
36
+
37
+ function makeProject(hunt = ""): Project {
38
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-hunt-cli-"));
39
+ const home = mkdtempSync(join(tmpdir(), "houndsman-hunt-home-"));
40
+ writeFileSync(join(cwd, "houndsman.config.ts"), CONFIG(hunt));
41
+ return {
42
+ cwd,
43
+ home,
44
+ cleanup: () => {
45
+ rmSync(cwd, { recursive: true, force: true });
46
+ rmSync(home, { recursive: true, force: true });
47
+ },
48
+ };
49
+ }
50
+
51
+ type CliResult = { code: number; stdout: string; stderr: string };
52
+
53
+ function runCli(
54
+ cwd: string,
55
+ args: string[],
56
+ env: Record<string, string> = {},
57
+ ): CliResult {
58
+ const proc = Bun.spawnSync(["bun", CLI, ...args], {
59
+ cwd,
60
+ env: { ...process.env, NO_COLOR: "1", ...env },
61
+ stdout: "pipe",
62
+ stderr: "pipe",
63
+ });
64
+ return {
65
+ code: proc.exitCode ?? -1,
66
+ stdout: proc.stdout.toString(),
67
+ stderr: proc.stderr.toString(),
68
+ };
69
+ }
70
+
71
+ describe("hunt — help e diagnóstico", () => {
72
+ test("--help distingue a skill do comando CLI", () => {
73
+ const result = runCli(process.cwd(), ["hunt", "--help"]);
74
+ expect(result.code).toBe(0);
75
+ expect(result.stdout).toContain("/houndsman hunt");
76
+ expect(result.stdout).toContain("houndsman hunt install");
77
+ expect(result.stdout).toContain("--allow-partial");
78
+ });
79
+
80
+ test("doctor não falha sem maki e trata o harness como opcional", () => {
81
+ const project = makeProject();
82
+ try {
83
+ const result = runCli(project.cwd, ["hunt", "doctor"], {
84
+ HOUNDSMAN_HOME: project.home,
85
+ HOUNDSMAN_MAKI_PATH: join(project.cwd, "maki-inexistente"),
86
+ });
87
+ expect(result.code).toBe(0);
88
+ expect(result.stdout).toContain("Harness local");
89
+ expect(result.stdout).toContain("modelo:");
90
+ expect(result.stdout).toContain("harness:");
91
+ } finally {
92
+ project.cleanup();
93
+ }
94
+ });
95
+
96
+ test("doctor funciona sem projeto configurado (diagnóstico global)", () => {
97
+ const cwd = mkdtempSync(join(tmpdir(), "houndsman-hunt-nocfg-"));
98
+ const home = mkdtempSync(join(tmpdir(), "houndsman-hunt-home-"));
99
+ try {
100
+ const result = runCli(cwd, ["hunt", "doctor"], {
101
+ HOUNDSMAN_HOME: home,
102
+ HOUNDSMAN_MAKI_PATH: join(cwd, "maki-inexistente"),
103
+ });
104
+ expect(result.code).toBe(0);
105
+ expect(result.stdout).toContain("Harness local");
106
+ } finally {
107
+ rmSync(cwd, { recursive: true, force: true });
108
+ rmSync(home, { recursive: true, force: true });
109
+ }
110
+ });
111
+ });
112
+
113
+ describe("hunt — erros antes do prepare", () => {
114
+ test("sem binário, orienta a instalar (e não toca na review)", () => {
115
+ const project = makeProject();
116
+ try {
117
+ const result = runCli(project.cwd, ["hunt", "--branch", "main"], {
118
+ HOUNDSMAN_HOME: project.home,
119
+ HOUNDSMAN_MAKI_PATH: join(project.cwd, "maki-inexistente"),
120
+ HOUNDSMAN_DEEP_SEEK_API_KEY: "sk-test",
121
+ });
122
+ expect(result.code).toBe(1);
123
+ expect(result.stderr).toContain("hunt install");
124
+ } finally {
125
+ project.cleanup();
126
+ }
127
+ });
128
+
129
+ test("sem credencial, aponta a env correta", () => {
130
+ const project = makeProject();
131
+ try {
132
+ const env = { ...process.env };
133
+ delete env.HOUNDSMAN_DEEP_SEEK_API_KEY;
134
+ const proc = Bun.spawnSync(["bun", CLI, "hunt", "--branch", "main"], {
135
+ cwd: project.cwd,
136
+ env: {
137
+ ...env,
138
+ NO_COLOR: "1",
139
+ HOUNDSMAN_HOME: project.home,
140
+ HOUNDSMAN_MAKI_PATH: join(project.cwd, "maki-inexistente"),
141
+ },
142
+ stdout: "pipe",
143
+ stderr: "pipe",
144
+ });
145
+ expect(proc.exitCode).toBe(1);
146
+ expect(proc.stderr.toString()).toContain("HOUNDSMAN_DEEP_SEEK_API_KEY");
147
+ } finally {
148
+ project.cleanup();
149
+ }
150
+ });
151
+
152
+ test("respeita hunt.enabled: false", () => {
153
+ const project = makeProject("hunt: { enabled: false },");
154
+ try {
155
+ const result = runCli(project.cwd, ["hunt"], {
156
+ HOUNDSMAN_HOME: project.home,
157
+ HOUNDSMAN_DEEP_SEEK_API_KEY: "sk-test",
158
+ });
159
+ expect(result.code).toBe(1);
160
+ expect(result.stderr).toContain("desligado");
161
+ } finally {
162
+ project.cleanup();
163
+ }
164
+ });
165
+
166
+ test("rejeita --finalize (o hunt já finaliza)", () => {
167
+ const project = makeProject();
168
+ try {
169
+ const result = runCli(project.cwd, ["hunt", "--finalize"], {
170
+ HOUNDSMAN_HOME: project.home,
171
+ });
172
+ expect(result.code).toBe(1);
173
+ expect(result.stderr).toContain("já finaliza");
174
+ } finally {
175
+ project.cleanup();
176
+ }
177
+ });
178
+
179
+ test("subcomando desconhecido mostra o help", () => {
180
+ const result = runCli(process.cwd(), ["hunt", "foo"]);
181
+ expect(result.code).toBe(1);
182
+ expect(result.stderr).toContain("desconhecido");
183
+ expect(result.stdout).toContain("houndsman hunt install");
184
+ });
185
+ });