@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.
- package/README.md +17 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor-command.ts +22 -0
- package/src/cli/commands/hunt-command.ts +462 -0
- package/src/cli/commands/review-command.ts +20 -2
- package/src/cli/help.ts +17 -2
- package/src/cli/index.ts +17 -2
- package/src/cli/progress.ts +45 -0
- package/src/core/ai/ai-sdk-model-provider.ts +39 -0
- package/src/core/ai/index.ts +6 -1
- package/src/core/ai/types.ts +2 -0
- package/src/core/commands/scaffold.ts +11 -0
- package/src/core/config.ts +28 -0
- package/src/core/defaults.ts +15 -0
- package/src/core/engine/review-scope.ts +92 -25
- package/src/core/engine/review-session.ts +7 -3
- package/src/core/engine/review-workspace.ts +14 -0
- package/src/core/env.ts +14 -0
- package/src/core/hunt/hunt-runner.ts +328 -0
- package/src/core/hunt/index.ts +59 -0
- package/src/core/hunt/maki-install.ts +331 -0
- package/src/core/hunt/maki-model.ts +101 -0
- package/src/core/hunt/maki-prompt.ts +71 -0
- package/src/core/hunt/maki-release.ts +104 -0
- package/src/core/hunt/maki-runner.ts +276 -0
- package/src/core/hunt/pool.ts +30 -0
- package/src/core/logging/index.ts +2 -0
- package/src/core/logging/logger.ts +19 -0
- package/src/core/logging/progress.ts +282 -0
- package/src/core/orchestrator.ts +158 -49
- package/src/core/project-config.ts +38 -0
- package/src/core/storage/types.ts +1 -0
- package/src/index.ts +49 -0
- package/src/skill/README.md +12 -0
- package/src/skill/SETUP.md +21 -1
- package/src/skill/SKILL.md +4 -0
- package/src/skill/hunt.md +36 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +22 -3
- package/src/tests/ai-core.test.ts +38 -1
- package/src/tests/cli.test.ts +1 -0
- package/src/tests/hunt-cli.test.ts +185 -0
- package/src/tests/hunt-install.test.ts +153 -0
- package/src/tests/hunt-maki.test.ts +467 -0
- package/src/tests/progress.test.ts +117 -0
- 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
|
+
}
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -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 {
|
|
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 = {
|
|
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 =
|
|
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
|
|
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
|
|
720
|
-
await
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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 {
|
|
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. */
|