@payconductor/houndsman 0.13.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 (106) 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/hunt-command.ts +20 -3
  7. package/src/cli/commands/review-command.ts +38 -0
  8. package/src/cli/commands/status-command.ts +3 -2
  9. package/src/cli/help.ts +3 -1
  10. package/src/cli/index.ts +3 -1
  11. package/src/cli/progress.ts +7 -2
  12. package/src/core/ai/ai-client.ts +38 -2
  13. package/src/core/ai/features/brief-enricher.ts +1 -1
  14. package/src/core/ai/features/business-analyzer.ts +61 -0
  15. package/src/core/ai/features/business-summarizer.ts +44 -0
  16. package/src/core/ai/index.ts +8 -0
  17. package/src/core/ai/prompts/brief.ts +33 -7
  18. package/src/core/ai/prompts/business.ts +76 -0
  19. package/src/core/ai/provider-capabilities.ts +12 -0
  20. package/src/core/ai/schemas.ts +70 -2
  21. package/src/core/ai/usage-recorder.ts +53 -1
  22. package/src/core/brief.ts +246 -12
  23. package/src/core/builtins.ts +108 -0
  24. package/src/core/business-summary.ts +583 -0
  25. package/src/core/commands/compare.ts +233 -0
  26. package/src/core/commands/context.ts +6 -2
  27. package/src/core/commands/findings.ts +38 -30
  28. package/src/core/commands/review.ts +6 -0
  29. package/src/core/commands/scaffold.ts +27 -0
  30. package/src/core/commands/status.ts +9 -4
  31. package/src/core/compare.ts +243 -0
  32. package/src/core/concurrency.ts +30 -0
  33. package/src/core/config.ts +31 -3
  34. package/src/core/defaults.ts +50 -4
  35. package/src/core/domain/budget.ts +62 -4
  36. package/src/core/domain/classification.ts +12 -2
  37. package/src/core/engine/carryover.ts +4 -0
  38. package/src/core/engine/finalize-pipeline.ts +347 -0
  39. package/src/core/engine/prepare-pipeline.ts +1294 -0
  40. package/src/core/engine/review-context.ts +51 -0
  41. package/src/core/engine/review-options.ts +47 -0
  42. package/src/core/engine/review-scope.ts +14 -1
  43. package/src/core/engine/review-session.ts +11 -13
  44. package/src/core/engine/run-artifacts.ts +196 -0
  45. package/src/core/engine/run-format.ts +4 -0
  46. package/src/core/findings-state.ts +13 -1
  47. package/src/core/github-review.ts +5 -2
  48. package/src/core/hunt/hunt-runner.ts +90 -0
  49. package/src/core/hunt/index.ts +2 -0
  50. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  51. package/src/core/hunt/maki-prompt.ts +13 -1
  52. package/src/core/hunt/pool.ts +4 -26
  53. package/src/core/logging/index.ts +6 -1
  54. package/src/core/logging/progress.ts +85 -1
  55. package/src/core/orchestrator.ts +33 -1289
  56. package/src/core/paths.ts +6 -3
  57. package/src/core/pr-context.ts +2 -4
  58. package/src/core/prepare-report.ts +61 -0
  59. package/src/core/project-config.ts +8 -1
  60. package/src/core/propagation.ts +127 -0
  61. package/src/core/registry-check.ts +60 -21
  62. package/src/core/renderers/manifest-writer.ts +13 -0
  63. package/src/core/renderers/resume-renderer.ts +151 -11
  64. package/src/core/report-builder.ts +108 -278
  65. package/src/core/storage/file/file-storage.ts +7 -3
  66. package/src/core/storage/sqlite/mappers.ts +10 -0
  67. package/src/core/storage/sqlite/schema.ts +104 -23
  68. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  69. package/src/core/storage/types.ts +16 -4
  70. package/src/core/storage/work-area.ts +59 -1
  71. package/src/core/types.ts +31 -0
  72. package/src/core/watchdog-registry.ts +21 -3
  73. package/src/index.ts +29 -4
  74. package/src/server/client/ArtifactsPanel.tsx +4 -2
  75. package/src/server/client/FindingCard.tsx +21 -1
  76. package/src/server/client/RunsSection.tsx +5 -0
  77. package/src/server/client/utils/constants.ts +21 -11
  78. package/src/server/client/utils/markdown.ts +1 -1
  79. package/src/server/client/utils/types.ts +14 -2
  80. package/src/server/run-repository.ts +35 -14
  81. package/src/server/styles.css +2 -0
  82. package/src/skill/OUTPUT-FORMAT.md +36 -10
  83. package/src/skill/README.md +18 -14
  84. package/src/skill/SETUP.md +1 -0
  85. package/src/skill/SKILL.md +13 -4
  86. package/src/skill/example-resume.md +43 -0
  87. package/src/skill/hunt.md +145 -58
  88. package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
  89. package/src/tests/ai-core.test.ts +46 -0
  90. package/src/tests/ai-features.test.ts +95 -4
  91. package/src/tests/baseline.test.ts +29 -121
  92. package/src/tests/brief-scope.test.ts +250 -0
  93. package/src/tests/business-summary.test.ts +258 -0
  94. package/src/tests/compare.test.ts +233 -0
  95. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  96. package/src/tests/hunt-maki.test.ts +58 -0
  97. package/src/tests/markdown.test.ts +3 -3
  98. package/src/tests/progress.test.ts +29 -1
  99. package/src/tests/propagation.test.ts +119 -0
  100. package/src/tests/review-resume.test.ts +60 -1
  101. package/src/tests/review-scope.test.ts +38 -29
  102. package/src/tests/review-scripts.test.ts +106 -218
  103. package/src/tests/server.test.ts +7 -4
  104. package/src/tests/storage.test.ts +194 -9
  105. package/src/core/renderers/report-renderer.ts +0 -66
  106. 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
+ });
@@ -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
  }
@@ -244,6 +244,64 @@ describe("HuntRunner", () => {
244
244
  }
245
245
  });
246
246
 
247
+ test("pré-passe de intenção grava o modelo antes dos watchdogs", async () => {
248
+ const staging = makeWork(["alpha"]);
249
+ staging.work.writeBusinessBrief("# Brief de intenção");
250
+ try {
251
+ let intentCalls = 0;
252
+ const runner = new HuntRunner({
253
+ work: staging.work,
254
+ root: staging.root,
255
+ projectName: "Proj",
256
+ model: "deepseek/deepseek-v4-pro",
257
+ apiKeyEnv: "DEEPSEEK_API_KEY",
258
+ apiKey: "sk",
259
+ concurrency: 1,
260
+ timeoutMs: 10_000,
261
+ retries: 0,
262
+ runner: {
263
+ run: async (request) => {
264
+ if (request.prompt.includes("passo de intenção")) {
265
+ intentCalls++;
266
+ return {
267
+ ok: true,
268
+ text: JSON.stringify({
269
+ intent: "Entregar o split",
270
+ flows: [
271
+ { name: "Renovação", files: ["src/alpha/a.ts"] },
272
+ ],
273
+ invariants: ["split sempre presente"],
274
+ contracts: [],
275
+ states: [],
276
+ }),
277
+ metrics: { durationMs: 5 },
278
+ stderr: "",
279
+ exitCode: 0,
280
+ };
281
+ }
282
+ return {
283
+ ok: true,
284
+ text: reportJson("alpha"),
285
+ metrics: { durationMs: 5 },
286
+ stderr: "",
287
+ exitCode: 0,
288
+ };
289
+ },
290
+ },
291
+ });
292
+
293
+ const report = await runner.run(["alpha"]);
294
+ expect(intentCalls).toBe(1);
295
+ expect(report.business).toBe("reviewer");
296
+ const summary = staging.work.readBusinessSummary();
297
+ expect(summary?.source).toBe("reviewer");
298
+ expect(summary?.intent).toBe("Entregar o split");
299
+ expect(summary?.invariants).toEqual(["split sempre presente"]);
300
+ } finally {
301
+ staging.cleanup();
302
+ }
303
+ });
304
+
247
305
  test("repete uma vez quando a saída não tem JSON válido", async () => {
248
306
  const staging = makeWork(["alpha"]);
249
307
  try {
@@ -116,11 +116,11 @@ describe("parseInline", () => {
116
116
  });
117
117
 
118
118
  test("destaca caminhos de arquivo no texto corrido", () => {
119
- expect(parseInline("veja src/foo/bar.ts e review.md")).toEqual([
119
+ expect(parseInline("veja src/foo/bar.ts e manifest.json")).toEqual([
120
120
  { type: "text", text: "veja " },
121
121
  { type: "path", text: "src/foo/bar.ts" },
122
122
  { type: "text", text: " e " },
123
- { type: "path", text: "review.md" },
123
+ { type: "path", text: "manifest.json" },
124
124
  ]);
125
125
  });
126
126
  });
@@ -128,7 +128,7 @@ describe("parseInline", () => {
128
128
  describe("isFilePath", () => {
129
129
  test("reconhece arquivos e caminhos, sem confundir texto comum", () => {
130
130
  expect(isFilePath("src/foo/bar.ts")).toBe(true);
131
- expect(isFilePath("review.md")).toBe(true);
131
+ expect(isFilePath("manifest.json")).toBe(true);
132
132
  expect(isFilePath("./scripts/ci.sh")).toBe(true);
133
133
  expect(isFilePath("M/D")).toBe(false);
134
134
  expect(isFilePath("duas palavras.ts")).toBe(false);
@@ -6,7 +6,13 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import { formatDuration, Logger, PhaseReporter, ProgressBoard } from "../core/logging";
9
+ import {
10
+ formatDuration,
11
+ Logger,
12
+ ParallelPhaseReporter,
13
+ PhaseReporter,
14
+ ProgressBoard,
15
+ } from "../core/logging";
10
16
 
11
17
  function memoryStream(): { stream: NodeJS.WritableStream; text: () => string } {
12
18
  let content = "";
@@ -64,6 +70,28 @@ describe("PhaseReporter", () => {
64
70
  });
65
71
  });
66
72
 
73
+ describe("ParallelPhaseReporter", () => {
74
+ test("em pipe aceita fases concorrentes e loga cada uma", () => {
75
+ const { logger, out } = loggerFor(false);
76
+ const reporter = new ParallelPhaseReporter(logger);
77
+ reporter.begin("context", "Resumindo o contexto");
78
+ reporter.begin("business", "Reconstruindo a intenção");
79
+ reporter.end("context", "Contexto resumido", 50);
80
+ reporter.end("business", "Intenção reconstruída", 80);
81
+ reporter.stop();
82
+ const text = out.text();
83
+ expect(text).toContain("→ Resumindo o contexto");
84
+ expect(text).toContain("→ Reconstruindo a intenção");
85
+ expect(text).toContain("✔ Contexto resumido (50ms)");
86
+ expect(text).toContain("✔ Intenção reconstruída (80ms)");
87
+ });
88
+
89
+ test("stop sem fases ativas não lança", () => {
90
+ const { logger } = loggerFor(false);
91
+ expect(() => new ParallelPhaseReporter(logger).stop()).not.toThrow();
92
+ });
93
+ });
94
+
67
95
  describe("ProgressBoard", () => {
68
96
  test("em pipe loga início/fim e não usa ANSI", () => {
69
97
  const { logger, out, err } = loggerFor(false);