@payconductor/houndsman 0.12.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 (45) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/doctor-command.ts +22 -0
  4. package/src/cli/commands/hunt-command.ts +462 -0
  5. package/src/cli/commands/review-command.ts +20 -2
  6. package/src/cli/help.ts +17 -2
  7. package/src/cli/index.ts +17 -2
  8. package/src/cli/progress.ts +45 -0
  9. package/src/core/ai/ai-sdk-model-provider.ts +39 -0
  10. package/src/core/ai/index.ts +6 -1
  11. package/src/core/ai/types.ts +2 -0
  12. package/src/core/commands/scaffold.ts +11 -0
  13. package/src/core/config.ts +28 -0
  14. package/src/core/defaults.ts +15 -0
  15. package/src/core/engine/review-scope.ts +92 -25
  16. package/src/core/engine/review-session.ts +7 -3
  17. package/src/core/engine/review-workspace.ts +14 -0
  18. package/src/core/env.ts +14 -0
  19. package/src/core/hunt/hunt-runner.ts +328 -0
  20. package/src/core/hunt/index.ts +59 -0
  21. package/src/core/hunt/maki-install.ts +331 -0
  22. package/src/core/hunt/maki-model.ts +101 -0
  23. package/src/core/hunt/maki-prompt.ts +71 -0
  24. package/src/core/hunt/maki-release.ts +104 -0
  25. package/src/core/hunt/maki-runner.ts +276 -0
  26. package/src/core/hunt/pool.ts +30 -0
  27. package/src/core/logging/index.ts +2 -0
  28. package/src/core/logging/logger.ts +19 -0
  29. package/src/core/logging/progress.ts +282 -0
  30. package/src/core/orchestrator.ts +158 -49
  31. package/src/core/project-config.ts +38 -0
  32. package/src/core/storage/types.ts +1 -0
  33. package/src/index.ts +49 -0
  34. package/src/skill/README.md +12 -0
  35. package/src/skill/SETUP.md +21 -1
  36. package/src/skill/SKILL.md +4 -0
  37. package/src/skill/hunt.md +36 -0
  38. package/src/tests/__snapshots__/baseline.test.ts.snap +22 -3
  39. package/src/tests/ai-core.test.ts +38 -1
  40. package/src/tests/cli.test.ts +1 -0
  41. package/src/tests/hunt-cli.test.ts +185 -0
  42. package/src/tests/hunt-install.test.ts +153 -0
  43. package/src/tests/hunt-maki.test.ts +467 -0
  44. package/src/tests/progress.test.ts +117 -0
  45. package/src/tests/review-scope.test.ts +62 -0
@@ -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
+ });
@@ -12,6 +12,7 @@ import { join } from "node:path";
12
12
  import { AstService } from "../core/ast/service";
13
13
  import { ReviewScopeResolver, type ScopeOptions } from "../core/engine/review-scope";
14
14
  import type { FindingState, FindingsStateFile } from "../core/findings-state";
15
+ import type { PrepareProgressEvent } from "../core/orchestrator";
15
16
  import { defaultOptions, finalize, prepare, reviewContext } from "../core/orchestrator";
16
17
  import { resolveRepoIdentity } from "../core/storage/repo-identity";
17
18
  import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
@@ -400,3 +401,64 @@ describe("prepare sem alterações", () => {
400
401
  }
401
402
  }, 30_000);
402
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
+ });