@payconductor/houndsman 0.11.0 → 0.13.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 (140) hide show
  1. package/README.md +14 -13
  2. package/package.json +1 -1
  3. package/src/cli/commands/doctor-command.ts +22 -0
  4. package/src/cli/commands/hunt-command.ts +440 -29
  5. package/src/cli/commands/review-command.ts +20 -2
  6. package/src/cli/help.ts +16 -8
  7. package/src/cli/index.ts +15 -2
  8. package/src/cli/progress.ts +45 -0
  9. package/src/core/ai/ai-client.ts +3 -7
  10. package/src/core/ai/ai-sdk-model-provider.ts +43 -24
  11. package/src/core/ai/features/brief-enricher.ts +0 -1
  12. package/src/core/ai/features/classifier.ts +0 -1
  13. package/src/core/ai/features/context-summarizer.ts +0 -1
  14. package/src/core/ai/features/pre-analyzer.ts +0 -1
  15. package/src/core/ai/features/technical-summarizer.ts +0 -1
  16. package/src/core/ai/features/verifier.ts +0 -1
  17. package/src/core/ai/index.ts +6 -4
  18. package/src/core/ai/prompts/pre-analysis.ts +3 -2
  19. package/src/core/ai/schemas.ts +1 -23
  20. package/src/core/ai/types.ts +4 -15
  21. package/src/core/ai/usage-budget.ts +1 -1
  22. package/src/core/ast/diff.ts +37 -2
  23. package/src/core/ast/service.ts +1 -1
  24. package/src/core/brief.ts +54 -5
  25. package/src/core/changed-scope.ts +207 -0
  26. package/src/core/commands/scaffold.ts +12 -18
  27. package/src/core/config.ts +30 -15
  28. package/src/core/defaults.ts +14 -5
  29. package/src/core/engine/review-scope.ts +131 -17
  30. package/src/core/engine/review-session.ts +7 -3
  31. package/src/core/engine/review-workspace.ts +14 -0
  32. package/src/core/env.ts +14 -0
  33. package/src/core/hunt/hunt-runner.ts +328 -0
  34. package/src/core/hunt/index.ts +59 -0
  35. package/src/core/hunt/maki-install.ts +331 -0
  36. package/src/core/hunt/maki-model.ts +101 -0
  37. package/src/core/hunt/maki-prompt.ts +71 -0
  38. package/src/core/hunt/maki-release.ts +104 -0
  39. package/src/core/hunt/maki-runner.ts +276 -0
  40. package/src/core/hunt/pool.ts +30 -0
  41. package/src/core/jev/jev-detector.ts +7 -2
  42. package/src/core/logging/index.ts +3 -1
  43. package/src/core/logging/logger.ts +22 -3
  44. package/src/core/logging/progress.ts +282 -0
  45. package/src/core/orchestrator.ts +173 -59
  46. package/src/core/project-config.ts +34 -24
  47. package/src/core/registry-check.ts +2 -12
  48. package/src/core/rule-text.ts +3 -55
  49. package/src/core/services/git-repository.ts +8 -0
  50. package/src/core/storage/types.ts +1 -1
  51. package/src/core/types.ts +3 -14
  52. package/src/index.ts +74 -3
  53. package/src/server/client/utils/constants.ts +3 -6
  54. package/src/server/client/utils/types.ts +0 -1
  55. package/src/skill/OUTPUT-FORMAT.md +5 -0
  56. package/src/skill/README.md +14 -1
  57. package/src/skill/SETUP.md +22 -2
  58. package/src/skill/SKILL.md +4 -0
  59. package/src/skill/fix.md +1 -1
  60. package/src/skill/hunt.md +38 -6
  61. package/src/tests/__snapshots__/baseline.test.ts.snap +69 -32
  62. package/src/tests/ai-core.test.ts +38 -1
  63. package/src/tests/changed-scope.test.ts +197 -0
  64. package/src/tests/cli.test.ts +1 -0
  65. package/src/tests/fallback.test.ts +1 -96
  66. package/src/tests/fixtures/fake-model-provider.ts +0 -1
  67. package/src/tests/hunt-cli.test.ts +185 -0
  68. package/src/tests/hunt-install.test.ts +153 -0
  69. package/src/tests/hunt-maki.test.ts +467 -0
  70. package/src/tests/progress.test.ts +117 -0
  71. package/src/tests/review-scope.test.ts +95 -0
  72. package/src/tests/worktree.test.ts +1 -3
  73. package/src/harness/README.md +0 -15
  74. package/src/harness/agent/agent-context.ts +0 -33
  75. package/src/harness/agent/agent-errors.ts +0 -30
  76. package/src/harness/agent/agent-factory.ts +0 -46
  77. package/src/harness/agent/agent-reporter.ts +0 -34
  78. package/src/harness/agent/agent-result.ts +0 -16
  79. package/src/harness/agent/agent-runner.ts +0 -32
  80. package/src/harness/agent/agent-scheduler.ts +0 -37
  81. package/src/harness/agent/review-agent.ts +0 -401
  82. package/src/harness/config/harness-config.ts +0 -89
  83. package/src/harness/hunt/hunt-harness.ts +0 -83
  84. package/src/harness/hunt/hunt-options.ts +0 -92
  85. package/src/harness/hunt/hunt-result.ts +0 -17
  86. package/src/harness/hunt/hunt-session.ts +0 -359
  87. package/src/harness/hunt/review-planner.ts +0 -98
  88. package/src/harness/index.ts +0 -36
  89. package/src/harness/investigation/agent-state.ts +0 -134
  90. package/src/harness/investigation/evidence-verifier.ts +0 -129
  91. package/src/harness/investigation/evidence.ts +0 -110
  92. package/src/harness/investigation/finding-critic.ts +0 -96
  93. package/src/harness/investigation/investigation-planner.ts +0 -118
  94. package/src/harness/investigation/investigation-task.ts +0 -10
  95. package/src/harness/investigation/review-journal.ts +0 -87
  96. package/src/harness/investigation/rule-analyzer.ts +0 -178
  97. package/src/harness/investigation/rule-signals.ts +0 -110
  98. package/src/harness/prompts/agent-schemas.ts +0 -57
  99. package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
  100. package/src/harness/prompts/states/conclude-prompt.ts +0 -16
  101. package/src/harness/prompts/states/investigate-prompt.ts +0 -37
  102. package/src/harness/prompts/states/orient-prompt.ts +0 -15
  103. package/src/harness/prompts/states/verify-prompt.ts +0 -22
  104. package/src/harness/tools/agent-tool.ts +0 -102
  105. package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
  106. package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
  107. package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
  108. package/src/harness/tools/builtin/find-references-tool.ts +0 -66
  109. package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
  110. package/src/harness/tools/builtin/helpers.ts +0 -115
  111. package/src/harness/tools/builtin/impact-tool.ts +0 -48
  112. package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
  113. package/src/harness/tools/builtin/index.ts +0 -59
  114. package/src/harness/tools/builtin/list-files-tool.ts +0 -85
  115. package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
  116. package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
  117. package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
  118. package/src/harness/tools/builtin/search-code-tool.ts +0 -65
  119. package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
  120. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
  121. package/src/harness/tools/path-guard.ts +0 -90
  122. package/src/harness/tools/result-limiter.ts +0 -49
  123. package/src/harness/tools/secret-redactor.ts +0 -54
  124. package/src/harness/tools/tool-cache.ts +0 -50
  125. package/src/harness/tools/tool-call-budget.ts +0 -60
  126. package/src/harness/tools/tool-context.ts +0 -5
  127. package/src/harness/tools/tool-middleware.ts +0 -105
  128. package/src/harness/tools/tool-policy.ts +0 -51
  129. package/src/harness/tools/tool-registry.ts +0 -112
  130. package/src/harness/tools/tool-result.ts +0 -15
  131. package/src/harness/tools/toolbox.ts +0 -36
  132. package/src/harness/validation/finding-validator.ts +0 -119
  133. package/src/tests/fixtures/fake-agent-tools.ts +0 -69
  134. package/src/tests/fixtures/fake-investigation.ts +0 -64
  135. package/src/tests/harness-agent.test.ts +0 -141
  136. package/src/tests/harness-investigation.test.ts +0 -206
  137. package/src/tests/harness-tools.test.ts +0 -148
  138. package/src/tests/hunt-command.test.ts +0 -212
  139. package/src/tests/rule-analyzer.test.ts +0 -61
  140. package/src/tests/tool-contracts.test.ts +0 -82
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Harness local — prompt, parser de saída, runner e política de retry.
3
+ *
4
+ * Testa o `core/hunt` sem processo real: o `MakiProcessRunner` é injetado.
5
+ * A execução via `Bun.spawn` é coberta em plataformas POSIX nos testes abaixo.
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { defineHoundsmanConfig } from "../core/config";
13
+ import { type HuntEvent, HuntRunner } from "../core/hunt/hunt-runner";
14
+ import { requireMakiModel, resolveMakiModel } from "../core/hunt/maki-model";
15
+ import { renderHuntPrompt } from "../core/hunt/maki-prompt";
16
+ import {
17
+ extractJsonObject,
18
+ MAKI_DISALLOWED_TOOLS,
19
+ makiArgs,
20
+ parseMakiOutput,
21
+ SpawnMakiRunner,
22
+ } from "../core/hunt/maki-runner";
23
+ import { ProjectConfig } from "../core/project-config";
24
+ import type { ReviewRecord } from "../core/storage/types";
25
+ import { ReviewWorkArea } from "../core/storage/work-area";
26
+ import { defineWatchdog } from "../core/types";
27
+
28
+ const config = defineHoundsmanConfig({
29
+ projectName: "Hunt Test",
30
+ watchdogs: {
31
+ alpha: defineWatchdog({
32
+ id: "alpha",
33
+ name: "Alpha",
34
+ description: "alpha",
35
+ scope: "src/alpha/**",
36
+ classify: [{ value: "^src/alpha/" }],
37
+ rules: [{ severity: "attention", text: "Evitar any." }],
38
+ contextFiles: [],
39
+ }),
40
+ beta: defineWatchdog({
41
+ id: "beta",
42
+ name: "Beta",
43
+ description: "beta",
44
+ scope: "src/beta/**",
45
+ classify: [{ value: "^src/beta/" }],
46
+ rules: [{ severity: "attention", text: "Evitar console.log." }],
47
+ contextFiles: [],
48
+ }),
49
+ },
50
+ exclusions: [],
51
+ docMappings: [],
52
+ });
53
+
54
+ const project = new ProjectConfig(config);
55
+
56
+ /** Review fake (o staging só precisa de id/branch). */
57
+ function fakeReview(root: string): ReviewRecord {
58
+ const now = "2026-01-01T00:00:00.000Z";
59
+ return {
60
+ id: "review-hunt",
61
+ repoKey: "repo",
62
+ repoRoot: root,
63
+ branch: "main",
64
+ slug: "main",
65
+ status: "reviewing",
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ };
69
+ }
70
+
71
+ /** Staging temporário com briefs prontos. */
72
+ function makeWork(ids: string[]): {
73
+ root: string;
74
+ work: ReviewWorkArea;
75
+ cleanup: () => void;
76
+ } {
77
+ const root = mkdtempSync(join(tmpdir(), "houndsman-hunt-"));
78
+ const work = new ReviewWorkArea(fakeReview(root), root);
79
+ work.ensure();
80
+ for (const id of ids) work.writeBrief(id, `brief de ${id}`);
81
+ return { root, work, cleanup: () => rmSync(root, { recursive: true, force: true }) };
82
+ }
83
+
84
+ function reportJson(watchdogId: string, findings = 1): string {
85
+ return JSON.stringify({
86
+ watchdogId,
87
+ reviewedFiles: [`src/${watchdogId}/a.ts`],
88
+ skipped: [],
89
+ findings: Array.from({ length: findings }, (_, index) => ({
90
+ rule: "Evitar any",
91
+ severity: "attention",
92
+ file: `src/${watchdogId}/a.ts`,
93
+ line: index + 1,
94
+ message: "any detectado",
95
+ action: "tipar",
96
+ })),
97
+ });
98
+ }
99
+
100
+ describe("maki — contrato de execução", () => {
101
+ test("args são headless e negam escrita/bash/rede sem yolo/trust", () => {
102
+ const args = makiArgs("deepseek/deepseek-v4-pro");
103
+ expect(args).toEqual([
104
+ "-p",
105
+ "--output-format",
106
+ "json",
107
+ "--model",
108
+ "deepseek/deepseek-v4-pro",
109
+ "--disallowed-tools",
110
+ MAKI_DISALLOWED_TOOLS,
111
+ ]);
112
+ expect(args).not.toContain("--yolo");
113
+ expect(args).not.toContain("--trust");
114
+ for (const tool of ["bash", "write", "edit", "task", "webfetch"]) {
115
+ expect(MAKI_DISALLOWED_TOOLS.split(",")).toContain(tool);
116
+ }
117
+ });
118
+
119
+ test("prompt inclui brief e proíbe escrita", () => {
120
+ const prompt = renderHuntPrompt({
121
+ projectName: "Proj",
122
+ watchdogId: "alpha",
123
+ brief: "# Brief alpha",
124
+ });
125
+ expect(prompt).toContain("# Brief alpha");
126
+ expect(prompt).toContain("não escreva arquivos");
127
+ expect(prompt).toContain('"watchdogId": "alpha"');
128
+ });
129
+
130
+ test("extrai JSON puro, cercado por markdown ou texto", () => {
131
+ const payload = { watchdogId: "alpha", findings: [] };
132
+ expect(extractJsonObject(JSON.stringify(payload))).toEqual(payload);
133
+ expect(
134
+ extractJsonObject(`\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``),
135
+ ).toEqual(payload);
136
+ expect(extractJsonObject(`Aqui está:\n${JSON.stringify(payload)}\nfim`)).toEqual(
137
+ payload,
138
+ );
139
+ expect(extractJsonObject("sem json")).toBeNull();
140
+ });
141
+
142
+ test("parseia o JSON do maki (linha única e multi-linha)", () => {
143
+ const single = '{"type":"result","result":"ok","session_id":"s1"}';
144
+ expect(parseMakiOutput(single)?.result).toBe("ok");
145
+
146
+ const pretty = JSON.stringify(
147
+ { type: "result", result: "ok", total_cost_usd: 0.5 },
148
+ null,
149
+ 2,
150
+ );
151
+ expect(parseMakiOutput(pretty)?.total_cost_usd).toBe(0.5);
152
+ expect(parseMakiOutput(`log\n${single}`)?.session_id).toBe("s1");
153
+ expect(parseMakiOutput("nada")).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe("maki — modelo e credencial", () => {
158
+ const original = {
159
+ deepseek: process.env.HOUNDSMAN_DEEP_SEEK_API_KEY,
160
+ openai: process.env.HOUNDSMAN_OPENAI_API_KEY,
161
+ };
162
+
163
+ afterEach(() => {
164
+ if (original.deepseek === undefined)
165
+ delete process.env.HOUNDSMAN_DEEP_SEEK_API_KEY;
166
+ else process.env.HOUNDSMAN_DEEP_SEEK_API_KEY = original.deepseek;
167
+ if (original.openai === undefined) delete process.env.HOUNDSMAN_OPENAI_API_KEY;
168
+ else process.env.HOUNDSMAN_OPENAI_API_KEY = original.openai;
169
+ });
170
+
171
+ test("deriva deepseek/<model> e injeta DEEPSEEK_API_KEY", () => {
172
+ process.env.HOUNDSMAN_DEEP_SEEK_API_KEY = "sk-deepseek";
173
+ const model = requireMakiModel(project);
174
+ expect(model.spec).toBe("deepseek/deepseek-v4-pro");
175
+ expect(model.apiKeyEnv).toBe("DEEPSEEK_API_KEY");
176
+ expect(model.apiKey).toBe("sk-deepseek");
177
+ });
178
+
179
+ test("prefixo reconhecido troca provider e credencial", () => {
180
+ process.env.HOUNDSMAN_OPENAI_API_KEY = "sk-openai";
181
+ const model = resolveMakiModel(project, "openai/gpt-5");
182
+ expect(model.spec).toBe("openai/gpt-5");
183
+ expect(model.provider).toBe("openai");
184
+ expect(model.apiKeyEnv).toBe("OPENAI_API_KEY");
185
+ expect(model.apiKey).toBe("sk-openai");
186
+ });
187
+
188
+ test("sem chave, requireMakiModel falha com a env correta", () => {
189
+ delete process.env.HOUNDSMAN_DEEP_SEEK_API_KEY;
190
+ expect(() => requireMakiModel(project)).toThrow("HOUNDSMAN_DEEP_SEEK_API_KEY");
191
+ });
192
+ });
193
+
194
+ describe("HuntRunner", () => {
195
+ test("grava findings e agrega métricas", async () => {
196
+ const staging = makeWork(["alpha", "beta"]);
197
+ try {
198
+ const runner = new HuntRunner({
199
+ work: staging.work,
200
+ root: staging.root,
201
+ projectName: "Proj",
202
+ model: "deepseek/deepseek-v4-pro",
203
+ apiKeyEnv: "DEEPSEEK_API_KEY",
204
+ apiKey: "sk",
205
+ concurrency: 2,
206
+ timeoutMs: 10_000,
207
+ retries: 1,
208
+ runner: {
209
+ run: async (request) => {
210
+ const id = request.prompt.includes("brief de alpha")
211
+ ? "alpha"
212
+ : "beta";
213
+ return {
214
+ ok: true,
215
+ text: reportJson(id),
216
+ metrics: {
217
+ durationMs: 10,
218
+ costUsd: 0.01,
219
+ sessionId: `s-${id}`,
220
+ numTurns: 2,
221
+ usage: {
222
+ totalTokens: 100,
223
+ inputTokens: 60,
224
+ outputTokens: 40,
225
+ },
226
+ },
227
+ stderr: "",
228
+ exitCode: 0,
229
+ };
230
+ },
231
+ },
232
+ });
233
+
234
+ const report = await runner.run(["alpha", "beta"]);
235
+ expect(report.totals.watchdogs).toBe(2);
236
+ expect(report.totals.done).toBe(2);
237
+ expect(report.totals.failed).toBe(0);
238
+ expect(report.totals.findings).toBe(2);
239
+ expect(report.totals.costUsd).toBeCloseTo(0.02);
240
+ expect(report.totals.totalTokens).toBe(200);
241
+ expect(staging.work.readFindings(["alpha", "beta"]).pending).toEqual([]);
242
+ } finally {
243
+ staging.cleanup();
244
+ }
245
+ });
246
+
247
+ test("repete uma vez quando a saída não tem JSON válido", async () => {
248
+ const staging = makeWork(["alpha"]);
249
+ try {
250
+ const prompts: string[] = [];
251
+ let calls = 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: 1,
262
+ runner: {
263
+ run: async (request) => {
264
+ prompts.push(request.prompt);
265
+ calls++;
266
+ return calls === 1
267
+ ? {
268
+ ok: true,
269
+ text: "não é json",
270
+ metrics: { durationMs: 5 },
271
+ stderr: "",
272
+ exitCode: 0,
273
+ }
274
+ : {
275
+ ok: true,
276
+ text: reportJson("alpha"),
277
+ metrics: { durationMs: 5 },
278
+ stderr: "",
279
+ exitCode: 0,
280
+ };
281
+ },
282
+ },
283
+ });
284
+
285
+ const report = await runner.run(["alpha"]);
286
+ expect(calls).toBe(2);
287
+ expect(report.watchdogs[0].attempts).toBe(2);
288
+ expect(report.watchdogs[0].status).toBe("done");
289
+ expect(prompts[1]).toContain("Correção obrigatória");
290
+ } finally {
291
+ staging.cleanup();
292
+ }
293
+ });
294
+
295
+ test("emite evento de retry antes da segunda tentativa", async () => {
296
+ const staging = makeWork(["alpha"]);
297
+ try {
298
+ const events: HuntEvent[] = [];
299
+ let calls = 0;
300
+ const runner = new HuntRunner({
301
+ work: staging.work,
302
+ root: staging.root,
303
+ projectName: "Proj",
304
+ model: "deepseek/deepseek-v4-pro",
305
+ apiKeyEnv: "DEEPSEEK_API_KEY",
306
+ apiKey: "sk",
307
+ concurrency: 1,
308
+ timeoutMs: 10_000,
309
+ retries: 1,
310
+ onEvent: (event) => events.push(event),
311
+ runner: {
312
+ run: async () => {
313
+ calls++;
314
+ return calls === 1
315
+ ? {
316
+ ok: true,
317
+ text: "não é json",
318
+ metrics: { durationMs: 5 },
319
+ stderr: "",
320
+ exitCode: 0,
321
+ }
322
+ : {
323
+ ok: true,
324
+ text: reportJson("alpha"),
325
+ metrics: { durationMs: 5 },
326
+ stderr: "",
327
+ exitCode: 0,
328
+ };
329
+ },
330
+ },
331
+ });
332
+
333
+ await runner.run(["alpha"]);
334
+ const retry = events.find((event) => event.type === "retry");
335
+ expect(retry).toMatchObject({
336
+ type: "retry",
337
+ watchdogId: "alpha",
338
+ attempt: 1,
339
+ maxAttempts: 2,
340
+ });
341
+ expect(events.some((event) => event.type === "done")).toBe(true);
342
+ } finally {
343
+ staging.cleanup();
344
+ }
345
+ });
346
+
347
+ test("falha após os retries e não grava findings", async () => {
348
+ const staging = makeWork(["alpha"]);
349
+ try {
350
+ const runner = new HuntRunner({
351
+ work: staging.work,
352
+ root: staging.root,
353
+ projectName: "Proj",
354
+ model: "deepseek/deepseek-v4-pro",
355
+ apiKeyEnv: "DEEPSEEK_API_KEY",
356
+ apiKey: "sk",
357
+ concurrency: 1,
358
+ timeoutMs: 10_000,
359
+ retries: 1,
360
+ runner: {
361
+ run: async () => ({
362
+ ok: true,
363
+ text: "{}",
364
+ metrics: { durationMs: 1 },
365
+ stderr: "",
366
+ exitCode: 0,
367
+ }),
368
+ },
369
+ });
370
+
371
+ const report = await runner.run(["alpha"]);
372
+ expect(report.watchdogs[0].status).toBe("failed");
373
+ expect(report.watchdogs[0].error).toContain("watchdogId");
374
+ expect(existsSync(staging.work.findingFile("alpha"))).toBe(false);
375
+ } finally {
376
+ staging.cleanup();
377
+ }
378
+ });
379
+
380
+ test("watchdogId divergente é rejeitado", async () => {
381
+ const staging = makeWork(["alpha"]);
382
+ try {
383
+ const runner = new HuntRunner({
384
+ work: staging.work,
385
+ root: staging.root,
386
+ projectName: "Proj",
387
+ model: "deepseek/deepseek-v4-pro",
388
+ apiKeyEnv: "DEEPSEEK_API_KEY",
389
+ apiKey: "sk",
390
+ concurrency: 1,
391
+ timeoutMs: 10_000,
392
+ retries: 0,
393
+ runner: {
394
+ run: async () => ({
395
+ ok: true,
396
+ text: reportJson("beta"),
397
+ metrics: { durationMs: 1 },
398
+ stderr: "",
399
+ exitCode: 0,
400
+ }),
401
+ },
402
+ });
403
+
404
+ const report = await runner.run(["alpha"]);
405
+ expect(report.watchdogs[0].status).toBe("failed");
406
+ expect(report.watchdogs[0].error).toContain("não corresponde");
407
+ } finally {
408
+ staging.cleanup();
409
+ }
410
+ });
411
+ });
412
+
413
+ describe("SpawnMakiRunner (POSIX)", () => {
414
+ const dirs: string[] = [];
415
+
416
+ afterEach(() => {
417
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
418
+ });
419
+
420
+ test.skipIf(process.platform === "win32")(
421
+ "lê o JSON do stdout e respeita o timeout",
422
+ async () => {
423
+ const dir = mkdtempSync(join(tmpdir(), "houndsman-maki-bin-"));
424
+ dirs.push(dir);
425
+ const binary = join(dir, "maki");
426
+ writeFileSync(
427
+ binary,
428
+ [
429
+ "#!/bin/sh",
430
+ "cat > /dev/null",
431
+ `printf '%s\\n' '{"type":"result","result":"{\\"findings\\":[]}","session_id":"s1","total_cost_usd":0.01,"usage":{"total_tokens":10}}'`,
432
+ ].join("\n"),
433
+ );
434
+ chmodSync(binary, 0o755);
435
+
436
+ const runner = new SpawnMakiRunner(binary);
437
+ const result = await runner.run({
438
+ prompt: "brief",
439
+ cwd: dir,
440
+ model: "deepseek/deepseek-v4-pro",
441
+ apiKeyEnv: "DEEPSEEK_API_KEY",
442
+ apiKey: "sk",
443
+ timeoutMs: 5_000,
444
+ });
445
+ expect(result.ok).toBe(true);
446
+ if (result.ok) {
447
+ expect(result.text).toContain('"findings"');
448
+ expect(result.metrics.sessionId).toBe("s1");
449
+ expect(result.metrics.costUsd).toBe(0.01);
450
+ }
451
+
452
+ const slow = join(dir, "maki-slow");
453
+ writeFileSync(slow, "#!/bin/sh\nsleep 10\n");
454
+ chmodSync(slow, 0o755);
455
+ const timedOut = await new SpawnMakiRunner(slow).run({
456
+ prompt: "brief",
457
+ cwd: dir,
458
+ model: "deepseek/deepseek-v4-pro",
459
+ apiKeyEnv: "DEEPSEEK_API_KEY",
460
+ apiKey: "sk",
461
+ timeoutMs: 100,
462
+ });
463
+ expect(timedOut.ok).toBe(false);
464
+ if (!timedOut.ok) expect(timedOut.timedOut).toBe(true);
465
+ },
466
+ );
467
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Progresso do CLI — formatação, fases sequenciais e board concorrente.
3
+ *
4
+ * O board em TTY pinta um bloco com loader; fora de TTY mantém as linhas de
5
+ * início/fim e um heartbeat periódico.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { formatDuration, Logger, PhaseReporter, ProgressBoard } from "../core/logging";
10
+
11
+ function memoryStream(): { stream: NodeJS.WritableStream; text: () => string } {
12
+ let content = "";
13
+ const stream = {
14
+ write(chunk: string) {
15
+ content += chunk;
16
+ return true;
17
+ },
18
+ isTTY: false,
19
+ } as unknown as NodeJS.WritableStream;
20
+ return { stream, text: () => content };
21
+ }
22
+
23
+ function loggerFor(tty: boolean): {
24
+ logger: Logger;
25
+ out: ReturnType<typeof memoryStream>;
26
+ err: ReturnType<typeof memoryStream>;
27
+ } {
28
+ const out = memoryStream();
29
+ const err = memoryStream();
30
+ const logger = new Logger({
31
+ stdout: out.stream,
32
+ stderr: err.stream,
33
+ color: false,
34
+ isTTY: tty,
35
+ });
36
+ return { logger, out, err };
37
+ }
38
+
39
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
40
+
41
+ describe("formatDuration", () => {
42
+ test("ms, segundos e minutos", () => {
43
+ expect(formatDuration(820)).toBe("820ms");
44
+ expect(formatDuration(3_400)).toBe("3.4s");
45
+ expect(formatDuration(42_000)).toBe("42s");
46
+ expect(formatDuration(125_000)).toBe("2m05s");
47
+ });
48
+ });
49
+
50
+ describe("PhaseReporter", () => {
51
+ test("em pipe imprime início e fim com a duração", () => {
52
+ const { logger, out } = loggerFor(false);
53
+ const reporter = new PhaseReporter(logger);
54
+ reporter.begin("diff", "Lendo o diff");
55
+ expect(out.text()).toContain("→ Lendo o diff");
56
+ reporter.end("diff", "Diff lido", 1_200);
57
+ expect(out.text()).toContain("✔ Diff lido (1.2s)");
58
+ });
59
+
60
+ test("fase sem início ainda reporta o fim", () => {
61
+ const { logger, out } = loggerFor(false);
62
+ new PhaseReporter(logger).end("ast", "AST analisada", 40);
63
+ expect(out.text()).toContain("AST analisada (40ms)");
64
+ });
65
+ });
66
+
67
+ describe("ProgressBoard", () => {
68
+ test("em pipe loga início/fim e não usa ANSI", () => {
69
+ const { logger, out, err } = loggerFor(false);
70
+ const board = new ProgressBoard({ logger, heartbeatMs: 10_000 });
71
+ board.start(2);
72
+ board.add("alpha", "revisando");
73
+ board.add("beta", "revisando");
74
+ board.succeed("alpha", "alpha — 2 finding(s) (12s)");
75
+ board.fail("beta", "beta falhou");
76
+ board.stop();
77
+ const text = out.text();
78
+ expect(text).toContain("alpha: revisando…");
79
+ expect(text).toContain("alpha — 2 finding(s) (12s)");
80
+ expect(err.text()).toContain("beta falhou");
81
+ expect(`${text}${err.text()}`).not.toContain("\u001b[");
82
+ });
83
+
84
+ test("em pipe emite heartbeat enquanto há tarefa em execução", async () => {
85
+ const { logger, out } = loggerFor(false);
86
+ const board = new ProgressBoard({ logger, heartbeatMs: 5 });
87
+ board.start(1);
88
+ board.add("alpha", "revisando");
89
+ await sleep(40);
90
+ board.stop();
91
+ expect(out.text()).toContain("1 tarefa(s) em execução: alpha");
92
+ });
93
+
94
+ test("em TTY pinta o bloco com loader e apaga ao concluir", () => {
95
+ const { logger, out } = loggerFor(true);
96
+ const board = new ProgressBoard({ logger, frameMs: 1_000 });
97
+ board.start(1);
98
+ board.add("alpha", "revisando");
99
+ const painted = out.text();
100
+ expect(painted).toContain("\u001b[2K");
101
+ expect(painted).toContain("alpha");
102
+ expect(painted).toContain("0/1 concluído(s)");
103
+ board.succeed("alpha", "alpha — 1 finding(s) (5s)");
104
+ board.stop();
105
+ expect(out.text()).toContain("✔ alpha — 1 finding(s) (5s)");
106
+ });
107
+
108
+ test("retry atualiza o detalhe sem derrubar o board", () => {
109
+ const { logger, out } = loggerFor(false);
110
+ const board = new ProgressBoard({ logger });
111
+ board.start(1);
112
+ board.add("alpha", "revisando");
113
+ board.retry("alpha", "tentativa 2/2", "alpha: repetindo (2/2)");
114
+ board.stop();
115
+ expect(out.text()).toContain("alpha: repetindo (2/2)");
116
+ });
117
+ });
@@ -9,8 +9,10 @@ import { describe, expect, test } from "bun:test";
9
9
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
+ import { AstService } from "../core/ast/service";
12
13
  import { ReviewScopeResolver, type ScopeOptions } from "../core/engine/review-scope";
13
14
  import type { FindingState, FindingsStateFile } from "../core/findings-state";
15
+ import type { PrepareProgressEvent } from "../core/orchestrator";
14
16
  import { defaultOptions, finalize, prepare, reviewContext } from "../core/orchestrator";
15
17
  import { resolveRepoIdentity } from "../core/storage/repo-identity";
16
18
  import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
@@ -138,6 +140,35 @@ describe("ReviewScopeResolver", () => {
138
140
  if (scope.kind !== "review") throw new Error("esperava review");
139
141
  expect(scope.classification.mode).toBe("full");
140
142
  expect(scope.classification.headSha).toBe(repo.head);
143
+ expect(scope.classification.changedScope?.["src/alpha/a.ts"]).toEqual({
144
+ ranges: [{ start: 1, end: 1 }],
145
+ symbols: [],
146
+ source: "hunks",
147
+ });
148
+ } finally {
149
+ process.chdir(originalCwd);
150
+ repo.cleanup();
151
+ }
152
+ });
153
+
154
+ test("escopo usa o símbolo AST quando disponível", async () => {
155
+ const repo = makeRepo();
156
+ const originalCwd = process.cwd();
157
+ try {
158
+ process.chdir(repo.cwd);
159
+ const ast = new AstService(fakeHoundsmanConfig);
160
+ const scope = await new ReviewScopeResolver(
161
+ options({ base: repo.base, full: true }),
162
+ fakeHoundsmanConfig,
163
+ ast,
164
+ ).resolve(null, repo.head);
165
+
166
+ expect(scope.kind).toBe("review");
167
+ if (scope.kind !== "review") throw new Error("esperava review");
168
+ const file = scope.classification.changedScope?.["src/alpha/a.ts"];
169
+ expect(file?.source).toBe("ast");
170
+ expect(file?.symbols.map((symbol) => symbol.name)).toEqual(["a"]);
171
+ expect(file?.ranges).toEqual([{ start: 1, end: 1 }]);
141
172
  } finally {
142
173
  process.chdir(originalCwd);
143
174
  repo.cleanup();
@@ -163,6 +194,9 @@ describe("ReviewScopeResolver", () => {
163
194
  expect(scope.classification.mode).toBe("incremental");
164
195
  expect(scope.classification.reviewedFrom).toBe(repo.base);
165
196
  expect(Object.keys(scope.classification.files)).toEqual(["src/alpha/a.ts"]);
197
+ expect(scope.classification.changedScope?.["src/alpha/a.ts"]?.ranges).toEqual(
198
+ [{ start: 1, end: 1 }],
199
+ );
166
200
  expect(scope.classification.carryover?.map((f) => f.file)).toEqual([
167
201
  "src/alpha/keep.ts",
168
202
  ]);
@@ -367,3 +401,64 @@ describe("prepare sem alterações", () => {
367
401
  }
368
402
  }, 30_000);
369
403
  });
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Progresso do prepare
407
+ // ---------------------------------------------------------------------------
408
+
409
+ describe("prepare — progresso", () => {
410
+ test("emite início/fim das etapas que realmente rodam", async () => {
411
+ const repo = makeRepo();
412
+ const originalCwd = process.cwd();
413
+ const originalHome = process.env.HOUNDSMAN_HOME;
414
+ let storage: SQLiteStorageProvider | null = null;
415
+
416
+ try {
417
+ process.env.HOUNDSMAN_HOME = join(repo.cwd, ".home");
418
+ process.chdir(repo.cwd);
419
+
420
+ storage = new SQLiteStorageProvider(join(repo.cwd, "houndsman.db"));
421
+ const record = storage.getOrCreateReview({
422
+ repo: resolveRepoIdentity(),
423
+ branch: "main",
424
+ slug: "main",
425
+ });
426
+ const opts = {
427
+ ...defaultOptions(),
428
+ base: repo.base,
429
+ head: "HEAD",
430
+ noJev: true,
431
+ noAst: true,
432
+ };
433
+
434
+ const events: PrepareProgressEvent[] = [];
435
+ await prepare(
436
+ opts,
437
+ "main",
438
+ fakeHoundsmanConfig,
439
+ reviewContext(storage, record),
440
+ undefined,
441
+ undefined,
442
+ (event) => events.push(event),
443
+ );
444
+
445
+ const starts = events
446
+ .filter((event) => event.type === "start")
447
+ .map((event) => event.stage);
448
+ expect(starts).toEqual(["diff", "classify", "briefs", "manifest"]);
449
+ const done = events.filter((event) => event.type === "done");
450
+ expect(done.map((event) => event.stage)).toEqual(starts);
451
+ for (const event of done) {
452
+ if (event.type === "done") {
453
+ expect(event.durationMs).toBeGreaterThanOrEqual(0);
454
+ }
455
+ }
456
+ } finally {
457
+ storage?.close();
458
+ process.chdir(originalCwd);
459
+ if (originalHome === undefined) delete process.env.HOUNDSMAN_HOME;
460
+ else process.env.HOUNDSMAN_HOME = originalHome;
461
+ repo.cleanup();
462
+ }
463
+ }, 30_000);
464
+ });