@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,282 @@
1
+ /**
2
+ * Progresso visual do CLI (infraestrutura).
3
+ *
4
+ * `PhaseReporter` acompanha fases sequenciais (spinner em TTY; `→`/`✔` em
5
+ * pipe). `ProgressBoard` mantém tarefas concorrentes com loader e tempo
6
+ * decorrido — é o que o harness local usa por watchdog.
7
+ *
8
+ * Progresso é diagnóstico: nenhum método lança.
9
+ */
10
+
11
+ import type { LoggerLike, SpinnerLike } from "./logger";
12
+
13
+ /** Formata uma duração em ms (`820ms`, `12.3s`, `2m05s`). */
14
+ export function formatDuration(ms: number): string {
15
+ const safe = Math.max(0, Math.round(ms));
16
+ if (safe < 1_000) return `${safe}ms`;
17
+ const seconds = safe / 1_000;
18
+ if (seconds < 10) return `${seconds.toFixed(1)}s`;
19
+ if (seconds < 60) return `${Math.round(seconds)}s`;
20
+ const minutes = Math.floor(seconds / 60);
21
+ const rest = Math.round(seconds % 60);
22
+ return `${minutes}m${String(rest).padStart(2, "0")}s`;
23
+ }
24
+
25
+ /** Relógio curto no formato `m:ss` (usado no loader). */
26
+ function clock(ms: number): string {
27
+ const total = Math.max(0, Math.round(ms / 1_000));
28
+ const minutes = Math.floor(total / 60);
29
+ return `${minutes}:${String(total % 60).padStart(2, "0")}`;
30
+ }
31
+
32
+ /** Relógio de uma fase: spinner no TTY, linhas `→`/`✔` em pipe. */
33
+ export class PhaseReporter {
34
+ private readonly logger: LoggerLike;
35
+ private active: { stage: string; spinner: SpinnerLike } | null = null;
36
+
37
+ constructor(logger: LoggerLike) {
38
+ this.logger = logger;
39
+ }
40
+
41
+ /** Abre a fase (substituindo qualquer loader anterior). */
42
+ begin(stage: string, message: string): void {
43
+ try {
44
+ this.stop();
45
+ const spinner = this.logger.spinner(message);
46
+ if (spinner) {
47
+ spinner.start();
48
+ this.active = { stage, spinner };
49
+ return;
50
+ }
51
+ this.logger.step(message);
52
+ } catch {
53
+ // Progresso nunca derruba a revisão.
54
+ }
55
+ }
56
+
57
+ /** Fecha a fase com a duração medida. */
58
+ end(stage: string, message: string, durationMs: number): void {
59
+ try {
60
+ const text = `${message} (${formatDuration(durationMs)})`;
61
+ if (this.active?.stage === stage) {
62
+ const { spinner } = this.active;
63
+ this.active = null;
64
+ spinner.succeed(text);
65
+ return;
66
+ }
67
+ this.logger.success(text);
68
+ } catch {
69
+ // Progresso nunca derruba a revisão.
70
+ }
71
+ }
72
+
73
+ /** Encerra um loader pendente (sem mensagem de sucesso). */
74
+ stop(): void {
75
+ if (!this.active) return;
76
+ const { spinner } = this.active;
77
+ this.active = null;
78
+ try {
79
+ spinner.stop();
80
+ } catch {
81
+ // Progresso nunca derruba a revisão.
82
+ }
83
+ }
84
+ }
85
+
86
+ /** Tarefa concorrente exibida pelo board. */
87
+ type BoardTask = {
88
+ id: string;
89
+ detail: string;
90
+ startedMs: number;
91
+ };
92
+
93
+ export type ProgressBoardOptions = {
94
+ logger: LoggerLike;
95
+ /** Intervalo de repintura do loader em TTY (ms). */
96
+ frameMs?: number;
97
+ /** Intervalo do heartbeat textual fora de TTY (ms). */
98
+ heartbeatMs?: number;
99
+ };
100
+
101
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
102
+
103
+ /**
104
+ * Board de tarefas concorrentes (watchdogs do `hunt`).
105
+ *
106
+ * Em TTY repinta um bloco com loader + tempo decorrido por tarefa. Fora de
107
+ * TTY mantém as linhas de início/fim e emite um heartbeat periódico com quem
108
+ * ainda está rodando — o log do CI continua mostrando progresso.
109
+ */
110
+ export class ProgressBoard {
111
+ private readonly logger: LoggerLike;
112
+ private readonly frameMs: number;
113
+ private readonly heartbeatMs: number;
114
+ private readonly tasks = new Map<string, BoardTask>();
115
+ private timer: ReturnType<typeof setInterval> | null = null;
116
+ private rendered = 0;
117
+ private frame = 0;
118
+ private done = 0;
119
+ private failed = 0;
120
+ private total = 0;
121
+ private lastHeartbeat = Date.now();
122
+
123
+ constructor(options: ProgressBoardOptions) {
124
+ this.logger = options.logger;
125
+ this.frameMs = options.frameMs ?? 200;
126
+ this.heartbeatMs = options.heartbeatMs ?? 30_000;
127
+ }
128
+
129
+ /** Define o total de tarefas do lote (rodapé do bloco). */
130
+ start(total: number): void {
131
+ this.total = Math.max(0, total);
132
+ }
133
+
134
+ /** Registra uma tarefa em execução. */
135
+ add(id: string, detail = "em execução"): void {
136
+ try {
137
+ this.tasks.set(id, { id, detail, startedMs: Date.now() });
138
+ if (!this.logger.interactive) this.logger.bullet(`${id}: ${detail}…`);
139
+ this.ensureTimer();
140
+ this.paint();
141
+ } catch {
142
+ // Progresso nunca derruba a revisão.
143
+ }
144
+ }
145
+
146
+ /** Atualiza o detalhe de uma tarefa (ex.: tentativa atual). */
147
+ update(id: string, detail: string): void {
148
+ const task = this.tasks.get(id);
149
+ if (!task) return;
150
+ task.detail = detail;
151
+ this.paint();
152
+ }
153
+
154
+ /** Atualiza o detalhe e avisa em pipe (tentativas/retries). */
155
+ retry(id: string, detail: string, message: string): void {
156
+ try {
157
+ this.update(id, detail);
158
+ if (!this.logger.interactive) this.logger.bullet(message);
159
+ } catch {
160
+ // Progresso nunca derruba a revisão.
161
+ }
162
+ }
163
+
164
+ /** Conclui uma tarefa com a mensagem final. */
165
+ succeed(id: string, message: string): void {
166
+ try {
167
+ if (this.tasks.delete(id)) this.done++;
168
+ if (!this.logger.interactive) {
169
+ this.logger.success(message);
170
+ return;
171
+ }
172
+ this.clear();
173
+ this.logger.success(message);
174
+ this.paint();
175
+ } catch {
176
+ // Progresso nunca derruba a revisão.
177
+ }
178
+ }
179
+
180
+ /** Falha uma tarefa com a mensagem final. */
181
+ fail(id: string, message: string): void {
182
+ try {
183
+ if (this.tasks.delete(id)) this.failed++;
184
+ if (!this.logger.interactive) {
185
+ this.logger.warn(message);
186
+ return;
187
+ }
188
+ this.clear();
189
+ this.logger.warn(message);
190
+ this.paint();
191
+ } catch {
192
+ // Progresso nunca derruba a revisão.
193
+ }
194
+ }
195
+
196
+ /** Encerra o board (timer + bloco renderizado). */
197
+ stop(): void {
198
+ this.stopTimer();
199
+ this.clear();
200
+ }
201
+
202
+ private ensureTimer(): void {
203
+ if (this.timer) return;
204
+ const period = this.logger.interactive ? this.frameMs : this.heartbeatMs;
205
+ this.timer = setInterval(() => this.tick(), period);
206
+ // Nunca segura o processo vivo por causa de um loader.
207
+ (this.timer as unknown as { unref?: () => void }).unref?.();
208
+ }
209
+
210
+ private stopTimer(): void {
211
+ if (!this.timer) return;
212
+ clearInterval(this.timer);
213
+ this.timer = null;
214
+ }
215
+
216
+ private tick(): void {
217
+ if (this.tasks.size === 0) {
218
+ this.stopTimer();
219
+ return;
220
+ }
221
+ if (this.logger.interactive) {
222
+ this.frame++;
223
+ this.paint();
224
+ return;
225
+ }
226
+ if (Date.now() - this.lastHeartbeat < this.heartbeatMs) return;
227
+ this.lastHeartbeat = Date.now();
228
+ try {
229
+ const running = [...this.tasks.values()]
230
+ .map((task) => `${task.id} (${clock(Date.now() - task.startedMs)})`)
231
+ .join(", ");
232
+ this.logger.bullet(`${this.tasks.size} tarefa(s) em execução: ${running}`);
233
+ } catch {
234
+ // Progresso nunca derruba a revisão.
235
+ }
236
+ }
237
+
238
+ /** Linhas do bloco (ativas + rodapé). */
239
+ private lines(): string[] {
240
+ const now = Date.now();
241
+ const active = [...this.tasks.values()];
242
+ const width = Math.max(...active.map((task) => task.id.length), 0);
243
+ const spinner = FRAMES[this.frame % FRAMES.length];
244
+ const rows = active.map(
245
+ (task) =>
246
+ ` ${spinner} ${task.id.padEnd(width)} ${task.detail} · ${clock(now - task.startedMs)}`,
247
+ );
248
+ const finished = this.done + this.failed;
249
+ const failures = this.failed > 0 ? ` · ${this.failed} falha(s)` : "";
250
+ rows.push(
251
+ ` ${finished}/${this.total} concluído(s)${failures} · ${active.length} em execução`,
252
+ );
253
+ return rows;
254
+ }
255
+
256
+ /** Repinta o bloco no lugar (movendo o cursor para cima). */
257
+ private paint(): void {
258
+ if (!this.logger.interactive) return;
259
+ const lines = this.lines();
260
+ const stream = this.logger.stream;
261
+ const total = Math.max(this.rendered, lines.length);
262
+ if (this.rendered > 0) stream.write(`\u001b[${this.rendered}A`);
263
+ for (let index = 0; index < total; index++) {
264
+ stream.write(`\u001b[2K${lines[index] ?? ""}\n`);
265
+ }
266
+ const extra = total - lines.length;
267
+ if (extra > 0) stream.write(`\u001b[${extra}A`);
268
+ this.rendered = lines.length;
269
+ }
270
+
271
+ /** Apaga o bloco renderizado (deixa o cursor no início dele). */
272
+ private clear(): void {
273
+ if (!this.logger.interactive || this.rendered === 0) return;
274
+ const stream = this.logger.stream;
275
+ stream.write(`\u001b[${this.rendered}A`);
276
+ for (let index = 0; index < this.rendered; index++) {
277
+ stream.write("\u001b[2K\n");
278
+ }
279
+ stream.write(`\u001b[${this.rendered}A`);
280
+ this.rendered = 0;
281
+ }
282
+ }
@@ -39,7 +39,11 @@ import { discoverDocs } from "./doc-discoverer";
39
39
  import { Classification } from "./domain/classification";
40
40
  import { Watchdog } from "./domain/watchdog";
41
41
  import { mergeCarryover } from "./engine/carryover";
42
- import { ReviewScopeResolver, type RunClassification } from "./engine/review-scope";
42
+ import {
43
+ ReviewScopeResolver,
44
+ type RunClassification,
45
+ type ScopeProgress,
46
+ } from "./engine/review-scope";
43
47
  import { ENV_NAMES } from "./env";
44
48
  import type { BudgetSelection, ClassificationResult } from "./file-classifier";
45
49
  import { buildStateFromFindings, fileReader } from "./findings-state";
@@ -437,7 +441,84 @@ function budgetMessage(
437
441
  }
438
442
 
439
443
  /** Resultado do prepare — mensagens para o CLI imprimir. */
440
- export type PrepareResult = { messages: string[] };
444
+ export type PrepareResult = {
445
+ messages: string[];
446
+ /** Watchdogs ativos neste ciclo (vazio em no-op/sem arquivos). */
447
+ activeIds: string[];
448
+ /** Diretório dos briefs do ciclo (ausente quando não há briefs). */
449
+ briefsDir?: string;
450
+ /** Review do ciclo (ausente quando nada foi preparado). */
451
+ reviewId?: string;
452
+ };
453
+
454
+ /** Etapas do prepare reportadas ao CLI (o core não escreve saída). */
455
+ export type PrepareStage =
456
+ | "diff"
457
+ | "ast"
458
+ | "classify"
459
+ | "impact"
460
+ | "jev"
461
+ | "context"
462
+ | "technical"
463
+ | "ai-plans"
464
+ | "briefs"
465
+ | "manifest";
466
+
467
+ /** Evento de progresso de uma etapa do prepare. */
468
+ export type PrepareProgressEvent =
469
+ | { type: "start"; stage: PrepareStage }
470
+ | { type: "done"; stage: PrepareStage; durationMs: number };
471
+
472
+ /** Callback de progresso do prepare (falhas do callback são ignoradas). */
473
+ export type PrepareProgress = (event: PrepareProgressEvent) => void;
474
+
475
+ /** Encaminha o progresso do escopo como etapa do prepare. */
476
+ function scopeProgress(onProgress?: PrepareProgress): ScopeProgress | undefined {
477
+ if (!onProgress) return undefined;
478
+ return (event) => emitProgress(onProgress, event);
479
+ }
480
+
481
+ /** Emite um evento de progresso sem deixar o callback derrubar a revisão. */
482
+ function emitProgress(
483
+ onProgress: PrepareProgress | undefined,
484
+ event: PrepareProgressEvent,
485
+ ): void {
486
+ try {
487
+ onProgress?.(event);
488
+ } catch {
489
+ // Progresso nunca derruba a revisão.
490
+ }
491
+ }
492
+
493
+ /** Executa uma etapa reportando início/fim (sync ou async). */
494
+ async function runStage<T>(
495
+ onProgress: PrepareProgress | undefined,
496
+ stage: PrepareStage,
497
+ run: () => Promise<T> | T,
498
+ ): Promise<T> {
499
+ if (!onProgress) return run();
500
+ const started = Date.now();
501
+ emitProgress(onProgress, { type: "start", stage });
502
+ try {
503
+ return await run();
504
+ } finally {
505
+ emitProgress(onProgress, {
506
+ type: "done",
507
+ stage,
508
+ durationMs: Date.now() - started,
509
+ });
510
+ }
511
+ }
512
+
513
+ /** Como `runStage`, mas só reporta quando a etapa realmente vai rodar. */
514
+ async function runOptionalStage<T>(
515
+ onProgress: PrepareProgress | undefined,
516
+ enabled: boolean,
517
+ stage: PrepareStage,
518
+ run: () => Promise<T> | T,
519
+ ): Promise<T> {
520
+ return enabled ? runStage(onProgress, stage, run) : run();
521
+ }
441
522
 
442
523
  /**
443
524
  * Classificação fallback por IA (opcional): atribui arquivos não classificados
@@ -595,6 +676,7 @@ export async function prepare(
595
676
  ctx: ReviewContext,
596
677
  ast?: AstService,
597
678
  ai?: AiClient,
679
+ onProgress?: PrepareProgress,
598
680
  ): Promise<PrepareResult> {
599
681
  const messages: string[] = [];
600
682
  ctx.work.ensure();
@@ -609,6 +691,7 @@ export async function prepare(
609
691
  config,
610
692
  opts.noAst ? undefined : ast,
611
693
  ctx.git,
694
+ scopeProgress(onProgress),
612
695
  ).resolve(previous, headSha);
613
696
 
614
697
  // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
@@ -626,7 +709,7 @@ export async function prepare(
626
709
  ? "Nada novo a preparar — o ciclo em andamento continua; rode `--finalize` quando os findings estiverem gravados."
627
710
  : "Nada a revisar — o relatório anterior foi mantido. Use `--full` para re-revisar.",
628
711
  );
629
- return { messages };
712
+ return { messages, activeIds: [] };
630
713
  }
631
714
 
632
715
  const { classification, diff } = scope;
@@ -643,7 +726,7 @@ export async function prepare(
643
726
  messages.push(
644
727
  `Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
645
728
  );
646
- return { messages };
729
+ return { messages, activeIds: [] };
647
730
  }
648
731
  }
649
732
 
@@ -661,7 +744,10 @@ export async function prepare(
661
744
  });
662
745
 
663
746
  const astAnalyses = scope.ast;
664
- const astImpact = astAnalyses && ast ? await ast.impact(astAnalyses) : undefined;
747
+ const astImpact =
748
+ astAnalyses && ast
749
+ ? await runStage(onProgress, "impact", () => ast.impact(astAnalyses))
750
+ : undefined;
665
751
  if (astAnalyses && ast?.enabled) {
666
752
  const summary = {
667
753
  enabled: true,
@@ -690,7 +776,13 @@ export async function prepare(
690
776
  }
691
777
  }
692
778
 
693
- const jev = await resolveCandidates(opts, classification, ctx, config, diff, ai);
779
+ const jevWillRun = Boolean(
780
+ (ai && !opts.noAi && new AiPreAnalyzer(ai).enabled) ||
781
+ (!opts.noJev && new JevClient().isConfigured()),
782
+ );
783
+ const jev = await runOptionalStage(onProgress, jevWillRun, "jev", () =>
784
+ resolveCandidates(opts, classification, ctx, config, diff, ai),
785
+ );
694
786
 
695
787
  const activeIds = Object.keys(classification.watchdogs);
696
788
  if (Object.keys(classification.files).length === 0) {
@@ -700,14 +792,14 @@ export async function prepare(
700
792
  ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
701
793
  : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
702
794
  );
703
- return { messages };
795
+ return { messages, activeIds: [] };
704
796
  }
705
797
  if (activeIds.length === 0) {
706
798
  writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
707
799
  messages.push(
708
800
  "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
709
801
  );
710
- return { messages };
802
+ return { messages, activeIds: [] };
711
803
  }
712
804
  if (classification.mode === "incremental") {
713
805
  messages.push(
@@ -716,15 +808,21 @@ export async function prepare(
716
808
  }
717
809
 
718
810
  persistContext(ctx);
719
- const context = await resolveContextSummary(ctx, ai, opts);
720
- await resolveTechnicalSummary(ctx, ai, opts, {
721
- branch,
722
- classification,
723
- context,
724
- ast: astAnalyses,
725
- impact: astImpact,
726
- domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
727
- });
811
+ const aiContext = ai && !opts.noAi ? new AiContextSummarizer(ai).enabled : false;
812
+ const context = await runOptionalStage(onProgress, aiContext, "context", () =>
813
+ resolveContextSummary(ctx, ai, opts),
814
+ );
815
+ const aiTechnical = ai && !opts.noAi ? new AiTechnicalSummarizer(ai).enabled : false;
816
+ await runOptionalStage(onProgress, aiTechnical, "technical", () =>
817
+ resolveTechnicalSummary(ctx, ai, opts, {
818
+ branch,
819
+ classification,
820
+ context,
821
+ ast: astAnalyses,
822
+ impact: astImpact,
823
+ domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
824
+ }),
825
+ );
728
826
 
729
827
  const manifestAst: ManifestAst | undefined = astAnalyses
730
828
  ? {
@@ -745,15 +843,18 @@ export async function prepare(
745
843
  }
746
844
  : undefined;
747
845
 
748
- const aiPlans = await buildAiPlans(
749
- activeIds,
750
- classification,
751
- config,
752
- context,
753
- astAnalyses,
754
- astImpact,
755
- ai,
756
- opts,
846
+ const aiBriefs = ai && !opts.noAi ? new AiBriefEnricher(ai).enabled : false;
847
+ const aiPlans = await runOptionalStage(onProgress, aiBriefs, "ai-plans", () =>
848
+ buildAiPlans(
849
+ activeIds,
850
+ classification,
851
+ config,
852
+ context,
853
+ astAnalyses,
854
+ astImpact,
855
+ ai,
856
+ opts,
857
+ ),
757
858
  );
758
859
  if (aiPlans.size > 0) {
759
860
  ctx.storage.writeArtifact(ctx.review.id, {
@@ -783,30 +884,33 @@ export async function prepare(
783
884
  }
784
885
  : undefined;
785
886
 
786
- writeBriefs(
787
- activeIds,
788
- classification,
789
- ctx,
790
- branch,
791
- context,
792
- jev.candidates,
793
- config,
794
- astAnalyses,
795
- astImpact,
796
- aiPlans,
887
+ await runStage(onProgress, "briefs", () =>
888
+ writeBriefs(
889
+ activeIds,
890
+ classification,
891
+ ctx,
892
+ branch,
893
+ context,
894
+ jev.candidates,
895
+ config,
896
+ astAnalyses,
897
+ astImpact,
898
+ aiPlans,
899
+ ),
797
900
  );
798
- writeManifest(
799
- ctx,
800
- config,
801
- opts,
802
- classification,
803
- activeIds,
804
- activeIds,
805
- jev,
806
- manifestAst,
807
- manifestAi,
901
+ await runStage(onProgress, "manifest", () =>
902
+ writeManifest(
903
+ ctx,
904
+ config,
905
+ opts,
906
+ classification,
907
+ activeIds,
908
+ activeIds,
909
+ jev,
910
+ manifestAst,
911
+ manifestAi,
912
+ ),
808
913
  );
809
-
810
914
  messages.push(
811
915
  ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
812
916
  ...budgetMessage(classification, config),
@@ -846,7 +950,12 @@ export async function prepare(
846
950
  "Quando os findings estiverem gravados: bun run houndsman --finalize",
847
951
  );
848
952
 
849
- return { messages };
953
+ return {
954
+ messages,
955
+ activeIds,
956
+ briefsDir: ctx.work.briefsDir,
957
+ reviewId: ctx.review.id,
958
+ };
850
959
  }
851
960
  /** Persiste o contexto da PR (bruto + resumo) como artefatos, quando existirem. */
852
961
  function persistContext(ctx: ReviewContext): void {
@@ -13,6 +13,7 @@ import type {
13
13
  HoundsmanAiFeatures,
14
14
  HoundsmanAst,
15
15
  HoundsmanConfig,
16
+ HoundsmanHunt,
16
17
  HoundsmanStorage,
17
18
  HoundsmanWorktrees,
18
19
  } from "./config";
@@ -25,6 +26,9 @@ import {
25
26
  DEFAULT_ALWAYS_INCLUDE_DOCS,
26
27
  DEFAULT_AST_MAX_FILE_BYTES,
27
28
  DEFAULT_AST_MAX_IMPACT_FILES,
29
+ DEFAULT_HUNT_CONCURRENCY,
30
+ DEFAULT_HUNT_RETRIES,
31
+ DEFAULT_HUNT_TIMEOUT_MS,
28
32
  DEFAULT_JEV_CONCURRENCY,
29
33
  DEFAULT_MAX_FILES_PER_BRIEF,
30
34
  DEFAULT_WORKTREES_LOCATION,
@@ -162,6 +166,40 @@ export class ProjectConfig<TId extends string = string> {
162
166
  const override = this.ai.watchdogs?.[watchdogId]?.model?.trim();
163
167
  return override && override.length > 0 ? override : this.aiModel;
164
168
  }
169
+
170
+ /** Config do harness local (objeto vazio quando ausente). */
171
+ get hunt(): HoundsmanHunt {
172
+ return this.raw.hunt ?? {};
173
+ }
174
+
175
+ /** Harness local habilitado (default: ligado; sem binário é fail-closed no comando). */
176
+ get huntEnabled(): boolean {
177
+ return this.hunt.enabled !== false;
178
+ }
179
+
180
+ /** Processos maki simultâneos. */
181
+ get huntConcurrency(): number {
182
+ return positive(this.hunt.concurrency, DEFAULT_HUNT_CONCURRENCY);
183
+ }
184
+
185
+ /** Timeout por watchdog (ms). */
186
+ get huntTimeoutMs(): number {
187
+ return positive(this.hunt.timeoutMs, DEFAULT_HUNT_TIMEOUT_MS);
188
+ }
189
+
190
+ /** Retentativas extras (0 desliga; valores negativos caem no default). */
191
+ get huntRetries(): number {
192
+ const value = this.hunt.retries;
193
+ return value !== undefined && Number.isInteger(value) && value >= 0
194
+ ? value
195
+ : DEFAULT_HUNT_RETRIES;
196
+ }
197
+
198
+ /** Modelo base do harness local (sem prefixo de provider). */
199
+ get huntModel(): string {
200
+ const value = this.hunt.model?.trim();
201
+ return value && value.length > 0 ? value : this.aiModel;
202
+ }
165
203
  }
166
204
 
167
205
  /** Inteiro positivo ou o default. */
@@ -110,6 +110,7 @@ export type ArtifactKind =
110
110
  | "ai"
111
111
  | "ai-usage"
112
112
  | "ai-brief"
113
+ | "hunt"
113
114
  | "other";
114
115
 
115
116
  /** Artefato persistido de uma review. */