@payconductor/houndsman 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/hunt-command.ts +20 -3
  7. package/src/cli/commands/review-command.ts +38 -0
  8. package/src/cli/commands/status-command.ts +3 -2
  9. package/src/cli/help.ts +3 -1
  10. package/src/cli/index.ts +3 -1
  11. package/src/cli/progress.ts +7 -2
  12. package/src/core/ai/ai-client.ts +38 -2
  13. package/src/core/ai/features/brief-enricher.ts +1 -1
  14. package/src/core/ai/features/business-analyzer.ts +61 -0
  15. package/src/core/ai/features/business-summarizer.ts +44 -0
  16. package/src/core/ai/index.ts +8 -0
  17. package/src/core/ai/prompts/brief.ts +33 -7
  18. package/src/core/ai/prompts/business.ts +76 -0
  19. package/src/core/ai/provider-capabilities.ts +12 -0
  20. package/src/core/ai/schemas.ts +70 -2
  21. package/src/core/ai/usage-recorder.ts +53 -1
  22. package/src/core/brief.ts +246 -12
  23. package/src/core/builtins.ts +108 -0
  24. package/src/core/business-summary.ts +583 -0
  25. package/src/core/commands/compare.ts +233 -0
  26. package/src/core/commands/context.ts +6 -2
  27. package/src/core/commands/findings.ts +38 -30
  28. package/src/core/commands/review.ts +6 -0
  29. package/src/core/commands/scaffold.ts +27 -0
  30. package/src/core/commands/status.ts +9 -4
  31. package/src/core/compare.ts +243 -0
  32. package/src/core/concurrency.ts +30 -0
  33. package/src/core/config.ts +31 -3
  34. package/src/core/defaults.ts +50 -4
  35. package/src/core/domain/budget.ts +62 -4
  36. package/src/core/domain/classification.ts +12 -2
  37. package/src/core/engine/carryover.ts +4 -0
  38. package/src/core/engine/finalize-pipeline.ts +347 -0
  39. package/src/core/engine/prepare-pipeline.ts +1294 -0
  40. package/src/core/engine/review-context.ts +51 -0
  41. package/src/core/engine/review-options.ts +47 -0
  42. package/src/core/engine/review-scope.ts +14 -1
  43. package/src/core/engine/review-session.ts +11 -13
  44. package/src/core/engine/run-artifacts.ts +196 -0
  45. package/src/core/engine/run-format.ts +4 -0
  46. package/src/core/findings-state.ts +13 -1
  47. package/src/core/github-review.ts +5 -2
  48. package/src/core/hunt/hunt-runner.ts +90 -0
  49. package/src/core/hunt/index.ts +2 -0
  50. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  51. package/src/core/hunt/maki-prompt.ts +13 -1
  52. package/src/core/hunt/pool.ts +4 -26
  53. package/src/core/logging/index.ts +6 -1
  54. package/src/core/logging/progress.ts +85 -1
  55. package/src/core/orchestrator.ts +33 -1289
  56. package/src/core/paths.ts +6 -3
  57. package/src/core/pr-context.ts +2 -4
  58. package/src/core/prepare-report.ts +61 -0
  59. package/src/core/project-config.ts +8 -1
  60. package/src/core/propagation.ts +127 -0
  61. package/src/core/registry-check.ts +60 -21
  62. package/src/core/renderers/manifest-writer.ts +13 -0
  63. package/src/core/renderers/resume-renderer.ts +151 -11
  64. package/src/core/report-builder.ts +108 -278
  65. package/src/core/storage/file/file-storage.ts +7 -3
  66. package/src/core/storage/sqlite/mappers.ts +10 -0
  67. package/src/core/storage/sqlite/schema.ts +104 -23
  68. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  69. package/src/core/storage/types.ts +16 -4
  70. package/src/core/storage/work-area.ts +59 -1
  71. package/src/core/types.ts +31 -0
  72. package/src/core/watchdog-registry.ts +21 -3
  73. package/src/index.ts +29 -4
  74. package/src/server/client/ArtifactsPanel.tsx +4 -2
  75. package/src/server/client/FindingCard.tsx +21 -1
  76. package/src/server/client/RunsSection.tsx +5 -0
  77. package/src/server/client/utils/constants.ts +21 -11
  78. package/src/server/client/utils/markdown.ts +1 -1
  79. package/src/server/client/utils/types.ts +14 -2
  80. package/src/server/run-repository.ts +35 -14
  81. package/src/server/styles.css +2 -0
  82. package/src/skill/OUTPUT-FORMAT.md +36 -10
  83. package/src/skill/README.md +18 -14
  84. package/src/skill/SETUP.md +1 -0
  85. package/src/skill/SKILL.md +13 -4
  86. package/src/skill/example-resume.md +43 -0
  87. package/src/skill/hunt.md +145 -58
  88. package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
  89. package/src/tests/ai-core.test.ts +46 -0
  90. package/src/tests/ai-features.test.ts +95 -4
  91. package/src/tests/baseline.test.ts +29 -121
  92. package/src/tests/brief-scope.test.ts +250 -0
  93. package/src/tests/business-summary.test.ts +258 -0
  94. package/src/tests/compare.test.ts +233 -0
  95. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  96. package/src/tests/hunt-maki.test.ts +58 -0
  97. package/src/tests/markdown.test.ts +3 -3
  98. package/src/tests/progress.test.ts +29 -1
  99. package/src/tests/propagation.test.ts +119 -0
  100. package/src/tests/review-resume.test.ts +60 -1
  101. package/src/tests/review-scope.test.ts +38 -29
  102. package/src/tests/review-scripts.test.ts +106 -218
  103. package/src/tests/server.test.ts +7 -4
  104. package/src/tests/storage.test.ts +194 -9
  105. package/src/core/renderers/report-renderer.ts +0 -66
  106. package/src/skill/example-review.md +0 -66
@@ -0,0 +1,1294 @@
1
+ /**
2
+ * Pipeline do prepare (Passo 1): escopo → classificação → contexto/intenção →
3
+ * briefs → manifesto.
4
+ *
5
+ * O contexto da run entra no construtor; não há estado ambiente. Duas pipelines
6
+ * concorrentes no mesmo processo continuam isoladas.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type { AiClient } from "../ai/ai-client";
12
+ import { AiBriefEnricher } from "../ai/features/brief-enricher";
13
+ import { AiBusinessAnalyzer } from "../ai/features/business-analyzer";
14
+ import { AiBusinessSummarizer } from "../ai/features/business-summarizer";
15
+ import { AiClassifier } from "../ai/features/classifier";
16
+ import { AiContextSummarizer } from "../ai/features/context-summarizer";
17
+ import { AiPreAnalyzer } from "../ai/features/pre-analyzer";
18
+ import { AiTechnicalSummarizer } from "../ai/features/technical-summarizer";
19
+ import type { BriefPlan } from "../ai/schemas";
20
+ import { assetPath, OUTPUT_FORMAT_FILE } from "../assets";
21
+ import type { AstService } from "../ast/service";
22
+ import type { AstAnalysis, AstFileBrief, AstImpact } from "../ast/types";
23
+ import { GENERAL_WATCHDOG_ID } from "../builtins";
24
+ import {
25
+ type BusinessInput,
26
+ type BusinessSummary,
27
+ buildBusinessBrief,
28
+ buildBusinessSummary,
29
+ summarizeBusiness,
30
+ } from "../business-summary";
31
+ import { summarizeScope } from "../changed-scope";
32
+ import { mapPool } from "../concurrency";
33
+ import {
34
+ type HoundsmanConfig,
35
+ type HoundsmanPriority,
36
+ resolveJevConcurrency,
37
+ resolveMaxFilesPerBrief,
38
+ } from "../config";
39
+ import {
40
+ BRIEF_SNIPPET_FLOOR_RATIO,
41
+ BRIEF_SNIPPET_MIN_PER_FILE,
42
+ DEFAULT_BRIEF_SNIPPET_CHARS,
43
+ DEFAULT_BRIEF_SNIPPET_CHARS_PER_FILE,
44
+ GENERAL_BRIEF_SNIPPET_CHARS,
45
+ GENERAL_BRIEF_SNIPPET_CHARS_PER_FILE,
46
+ GENERAL_FLOW_MAX_FILES,
47
+ GENERAL_FLOW_MIN_FILES,
48
+ GENERAL_FLOW_MIN_LINES,
49
+ GENERAL_FLOW_SNIPPET_CHARS_PER_FILE,
50
+ } from "../defaults";
51
+ import type { DiffSource } from "../diff-source";
52
+ import { discoverDocs } from "../doc-discoverer";
53
+ import { sortByRisk } from "../domain/budget";
54
+ import { Classification } from "../domain/classification";
55
+ import { Watchdog } from "../domain/watchdog";
56
+ import { ENV_NAMES } from "../env";
57
+ import type { BudgetSelection, ClassificationResult } from "../file-classifier";
58
+ import {
59
+ type JevCandidate,
60
+ type JevCandidatesFile,
61
+ JevCandidatesStore,
62
+ JevClient,
63
+ JevDetector,
64
+ serializeCandidates,
65
+ } from "../jev";
66
+ import {
67
+ BUSINESS_BRIEF_FILE,
68
+ BUSINESS_SUMMARY_FILE,
69
+ displayPath,
70
+ OUTPUT_FORMAT_PATH,
71
+ TECHNICAL_SUMMARY_FILE,
72
+ } from "../paths";
73
+ import { type PrepareStageTiming, renderPrepareReport } from "../prepare-report";
74
+ import { ProjectConfig } from "../project-config";
75
+ import { detectPropagation, detectPropagationGaps } from "../propagation";
76
+ import { BriefRenderer } from "../renderers/brief-renderer";
77
+ import {
78
+ type ManifestAi,
79
+ type ManifestAst,
80
+ type ManifestBusiness,
81
+ ManifestWriter,
82
+ } from "../renderers/manifest-writer";
83
+ import type { ContextSummary } from "../report-builder";
84
+ import { writeRunStatus } from "../run-status";
85
+ import { systemClock } from "../services/clock";
86
+ import { buildTechnicalSummary, type TechnicalSummary } from "../technical-summary";
87
+ import type { WatchdogMeta } from "../types";
88
+ import { findWatchdog } from "../watchdog-registry";
89
+ import type { ReviewContext } from "./review-context";
90
+ import type { ReviewOptions } from "./review-options";
91
+ import {
92
+ ReviewScopeResolver,
93
+ type RunClassification,
94
+ type ScopeProgress,
95
+ } from "./review-scope";
96
+ import { RunArtifacts } from "./run-artifacts";
97
+ import { shortSha } from "./run-format";
98
+
99
+ /** Resultado do prepare — mensagens para o CLI imprimir. */
100
+ export type PrepareResult = {
101
+ messages: string[];
102
+ /** Watchdogs ativos neste ciclo (vazio em no-op/sem arquivos). */
103
+ activeIds: string[];
104
+ /** Diretório dos briefs do ciclo (ausente quando não há briefs). */
105
+ briefsDir?: string;
106
+ /** Review do ciclo (ausente quando nada foi preparado). */
107
+ reviewId?: string;
108
+ };
109
+
110
+ /** Etapas do prepare reportadas ao CLI (o core não escreve saída). */
111
+ export type PrepareStage =
112
+ | "diff"
113
+ | "ast"
114
+ | "classify"
115
+ | "impact"
116
+ | "jev"
117
+ | "context"
118
+ | "business"
119
+ | "technical"
120
+ | "ai-plans"
121
+ | "briefs"
122
+ | "manifest";
123
+
124
+ /** Evento de progresso de uma etapa do prepare. */
125
+ export type PrepareProgressEvent =
126
+ | { type: "start"; stage: PrepareStage }
127
+ | { type: "done"; stage: PrepareStage; durationMs: number };
128
+
129
+ /** Callback de progresso do prepare (falhas do callback são ignoradas). */
130
+ export type PrepareProgress = (event: PrepareProgressEvent) => void;
131
+
132
+ /** Dependências opcionais do prepare (injetadas pelo engine/testes). */
133
+ export type PrepareDeps = {
134
+ ast?: AstService;
135
+ ai?: AiClient;
136
+ onProgress?: PrepareProgress;
137
+ };
138
+
139
+ /** Marcador de corte no trecho (o subagente sabe que falta código). */
140
+ const SNIPPET_TRUNCATED_MARKER = "… [trecho truncado — abra o arquivo para o restante]";
141
+
142
+ /**
143
+ * Corta o patch preservando linhas inteiras e sinalizando o corte.
144
+ *
145
+ * Cortar no meio de um hunk entregava diff inválido como se estivesse completo
146
+ * — o subagente concluía sobre código que não viu.
147
+ */
148
+ export function truncatePatch(text: string, limit: number): string {
149
+ if (text.length <= limit) return text;
150
+ const room = Math.max(0, limit - SNIPPET_TRUNCATED_MARKER.length - 1);
151
+ const head = text.slice(0, room);
152
+ const cut = head.lastIndexOf("\n");
153
+ return `${(cut > 0 ? head.slice(0, cut) : head).trimEnd()}\n${SNIPPET_TRUNCATED_MARKER}`;
154
+ }
155
+
156
+ /**
157
+ * Trechos do diff para o brief, distribuídos entre os arquivos.
158
+ *
159
+ * Metade do teto vai em partes iguais (todo arquivo revisável tem contexto); o
160
+ * resto completa os maiores primeiro, na ordem recebida (já ordenada por
161
+ * churn). Sem o rateio, os primeiros arquivos consumiam o teto e o subagente
162
+ * abria os demais à mão — justamente o que o brief evita.
163
+ */
164
+ export function buildBriefSnippets(
165
+ files: readonly string[],
166
+ patch: ReadonlyMap<string, string> | undefined,
167
+ options: {
168
+ total?: number;
169
+ perFile?: number;
170
+ floorRatio?: number;
171
+ minPerFile?: number;
172
+ } = {},
173
+ ): { file: string; snippet: string }[] {
174
+ if (!patch) return [];
175
+ const total = options.total ?? DEFAULT_BRIEF_SNIPPET_CHARS;
176
+ const perFile = options.perFile ?? DEFAULT_BRIEF_SNIPPET_CHARS_PER_FILE;
177
+ const floorRatio = options.floorRatio ?? BRIEF_SNIPPET_FLOOR_RATIO;
178
+ const minPerFile = options.minPerFile ?? BRIEF_SNIPPET_MIN_PER_FILE;
179
+
180
+ const entries = files.flatMap((file) => {
181
+ const text = patch.get(file);
182
+ return text ? [{ file, text, take: 0 }] : [];
183
+ });
184
+ if (entries.length === 0) return [];
185
+
186
+ const floorBudget = Math.min(total, Math.floor(total * floorRatio));
187
+ const share = Math.max(
188
+ minPerFile,
189
+ Math.min(perFile, Math.floor(floorBudget / entries.length)),
190
+ );
191
+ let used = 0;
192
+ for (const entry of entries) {
193
+ entry.take = Math.min(share, entry.text.length);
194
+ used += entry.take;
195
+ }
196
+
197
+ let remaining = Math.max(0, total - used);
198
+ for (const entry of entries) {
199
+ if (remaining <= 0) break;
200
+ const room = Math.min(
201
+ perFile - entry.take,
202
+ entry.text.length - entry.take,
203
+ remaining,
204
+ );
205
+ if (room <= 0) continue;
206
+ entry.take += room;
207
+ remaining -= room;
208
+ }
209
+
210
+ return entries.map((entry) => ({
211
+ file: entry.file,
212
+ snippet: truncatePatch(entry.text, entry.take),
213
+ }));
214
+ }
215
+
216
+ /** Aplica o orçamento aos arquivos de um watchdog. */
217
+ function budgetFor(
218
+ classification: ClassificationResult,
219
+ id: string,
220
+ maxFiles: number,
221
+ priorities?: readonly HoundsmanPriority[],
222
+ ): BudgetSelection {
223
+ return Classification.from(classification).budgetFor(id, maxFiles, priorities);
224
+ }
225
+
226
+ /** Churn total (linhas alteradas) da PR. */
227
+ function changedLines(classification: Pick<ClassificationResult, "churn">): number {
228
+ return Object.values(classification.churn).reduce(
229
+ (sum, item) => sum + (item?.additions ?? 0) + (item?.deletions ?? 0),
230
+ 0,
231
+ );
232
+ }
233
+
234
+ /**
235
+ * `true` quando a PR é grande o bastante para o `general` revisar por fluxo.
236
+ *
237
+ * Amostrar 40 arquivos com ~800 chars cada não sustenta revisão de lógica:
238
+ * o recorte por fluxo troca cobertura por profundidade nos arquivos que
239
+ * participam dos fluxos do modelo de intenção.
240
+ */
241
+ export function shouldSliceGeneral(
242
+ classification: Pick<ClassificationResult, "watchdogs" | "churn">,
243
+ ): boolean {
244
+ const files = (classification.watchdogs[GENERAL_WATCHDOG_ID] ?? []).length;
245
+ return (
246
+ files > GENERAL_FLOW_MIN_FILES ||
247
+ changedLines(classification) >= GENERAL_FLOW_MIN_LINES
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Arquivos do recorte de fluxo: os citados pelos fluxos do modelo de intenção
253
+ * primeiro, completando pelos de maior **risco** (churn + prioridade). O resto
254
+ * vai para `overflow` (declarado em `skipped`).
255
+ */
256
+ export function generalFlowFiles(
257
+ classification: Pick<ClassificationResult, "watchdogs" | "churn">,
258
+ business: BusinessSummary | null | undefined,
259
+ maxFiles: number,
260
+ priorities?: readonly HoundsmanPriority[],
261
+ ): { selected: string[]; overflow: string[] } {
262
+ const all = classification.watchdogs[GENERAL_WATCHDOG_ID] ?? [];
263
+ const normalize = (value: string): string =>
264
+ value.replace(/\\/g, "/").replace(/^\.\//, "");
265
+ const own = new Map(all.map((file) => [normalize(file), file]));
266
+ const flowFiles: string[] = [];
267
+ for (const flow of business?.flows ?? []) {
268
+ for (const file of flow.files ?? []) {
269
+ const match = own.get(normalize(file));
270
+ if (match && !flowFiles.includes(match)) flowFiles.push(match);
271
+ }
272
+ }
273
+ const byChurn = sortByRisk(all, classification.churn, priorities);
274
+ const selected: string[] = [];
275
+ const seen = new Set<string>();
276
+ for (const file of [...flowFiles, ...byChurn]) {
277
+ if (selected.length >= maxFiles) break;
278
+ if (seen.has(file)) continue;
279
+ seen.add(file);
280
+ selected.push(file);
281
+ }
282
+ return { selected, overflow: all.filter((file) => !seen.has(file)) };
283
+ }
284
+
285
+ function jevSummary(jev: JevCandidatesFile): string[] {
286
+ if (!jev.available) return [`Jev: pulado (${jev.reason}).`];
287
+ const skipped = jev.skipped?.length ?? 0;
288
+ return [
289
+ `Jev: ${jev.candidates.length} candidato(s) em ${jev.analyzedFiles ?? 0} arquivo(s)` +
290
+ (skipped > 0 ? `, ${skipped} com falha.` : "."),
291
+ ];
292
+ }
293
+
294
+ /** Lista curta das features que falharam (para a mensagem do CLI). */
295
+ function summarizeFailures(
296
+ failures: readonly { feature: string; error: string }[],
297
+ ): string {
298
+ const byFeature = new Map<string, string>();
299
+ for (const failure of failures) {
300
+ if (!byFeature.has(failure.feature)) {
301
+ byFeature.set(failure.feature, failure.error.slice(0, 120));
302
+ }
303
+ }
304
+ return [...byFeature].map(([feature, error]) => `${feature} (${error})`).join("; ");
305
+ }
306
+
307
+ function discoveryMessages(
308
+ activeIds: string[],
309
+ classification: ClassificationResult,
310
+ briefsDir: string,
311
+ ): string[] {
312
+ const hasGeneral = activeIds.includes(GENERAL_WATCHDOG_ID);
313
+ const domains = activeIds.filter((id) => id !== GENERAL_WATCHDOG_ID).length;
314
+ const label = hasGeneral
315
+ ? `${domains} watchdog(s) de domínio + revisor general`
316
+ : `${domains} watchdog(s)`;
317
+ const messages = [
318
+ `Discovery: ${label}, ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
319
+ `Briefs: ${displayPath(briefsDir)}/`,
320
+ ];
321
+ for (const file of classification.deleted) {
322
+ messages.push(`Deletado (breaking change): ${file}`);
323
+ }
324
+ return messages;
325
+ }
326
+
327
+ /** Resumo do escopo — lembra que só as faixas alteradas são revisáveis. */
328
+ function changedScopeMessage(classification: RunClassification): string[] {
329
+ const scope = classification.changedScope;
330
+ if (!scope) return [];
331
+ const totals = summarizeScope(scope);
332
+ return [
333
+ `Escopo: ${totals.files} arquivo(s), ${totals.symbols} símbolo(s) em ${totals.ranges} faixa(s) alterada(s).`,
334
+ ];
335
+ }
336
+
337
+ /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
338
+ function budgetMessage(
339
+ classification: ClassificationResult,
340
+ config: HoundsmanConfig,
341
+ ): string[] {
342
+ const maxFiles = resolveMaxFilesPerBrief(config);
343
+ let overflow = 0;
344
+ for (const id of Object.keys(classification.watchdogs)) {
345
+ overflow += budgetFor(classification, id, maxFiles, config.priorities ?? [])
346
+ .overflow.length;
347
+ }
348
+ if (overflow === 0) return [];
349
+ return [
350
+ `Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor risco; registrar em skipped).`,
351
+ ];
352
+ }
353
+
354
+ /** Encaminha o progresso do escopo como etapa do prepare. */
355
+ function scopeProgress(onProgress?: PrepareProgress): ScopeProgress | undefined {
356
+ if (!onProgress) return undefined;
357
+ return (event) => emitProgress(onProgress, event);
358
+ }
359
+
360
+ /**
361
+ * Aviso de sobreposição de escopo: o mesmo arquivo casando em vários domínios é
362
+ * revisado uma vez por domínio (recortes diferentes). Explica a cobertura >100%
363
+ * que um watchdog pode declarar e o custo multiplicado em PRs grandes.
364
+ */
365
+ export function overlapMessage(
366
+ classification: Pick<ClassificationResult, "watchdogs">,
367
+ ): string[] {
368
+ const counts = new Map<string, number>();
369
+ for (const files of Object.values(classification.watchdogs)) {
370
+ for (const file of files) counts.set(file, (counts.get(file) ?? 0) + 1);
371
+ }
372
+ const shared = [...counts.values()].filter((count) => count > 1);
373
+ if (shared.length === 0) return [];
374
+ const max = Math.max(...shared);
375
+ return [
376
+ `Escopo: ${shared.length} arquivo(s) em mais de um domínio (máx. ${max}) — cada domínio revisa o arquivo com o seu recorte.`,
377
+ ];
378
+ }
379
+
380
+ /** Emite um evento de progresso sem deixar o callback derrubar a revisão. */
381
+ function emitProgress(
382
+ onProgress: PrepareProgress | undefined,
383
+ event: PrepareProgressEvent,
384
+ ): void {
385
+ try {
386
+ onProgress?.(event);
387
+ } catch {
388
+ // Progresso nunca derruba a revisão.
389
+ }
390
+ }
391
+
392
+ /** Executa uma etapa reportando início/fim (sync ou async). */
393
+ async function runStage<T>(
394
+ onProgress: PrepareProgress | undefined,
395
+ stage: PrepareStage,
396
+ run: () => Promise<T> | T,
397
+ ): Promise<T> {
398
+ if (!onProgress) return run();
399
+ const started = Date.now();
400
+ emitProgress(onProgress, { type: "start", stage });
401
+ try {
402
+ return await run();
403
+ } finally {
404
+ emitProgress(onProgress, {
405
+ type: "done",
406
+ stage,
407
+ durationMs: Date.now() - started,
408
+ });
409
+ }
410
+ }
411
+
412
+ /** Como `runStage`, mas só reporta quando a etapa realmente vai rodar. */
413
+ async function runOptionalStage<T>(
414
+ onProgress: PrepareProgress | undefined,
415
+ enabled: boolean,
416
+ stage: PrepareStage,
417
+ run: () => Promise<T> | T,
418
+ ): Promise<T> {
419
+ return enabled ? runStage(onProgress, stage, run) : run();
420
+ }
421
+
422
+ /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
423
+ export class PreparePipeline {
424
+ private readonly artifacts: RunArtifacts;
425
+
426
+ constructor(
427
+ private readonly ctx: ReviewContext,
428
+ private readonly config: HoundsmanConfig,
429
+ private readonly opts: ReviewOptions,
430
+ private readonly deps: PrepareDeps = {},
431
+ ) {
432
+ this.artifacts = new RunArtifacts(ctx.storage, ctx.review, ctx.work);
433
+ }
434
+
435
+ async run(): Promise<PrepareResult> {
436
+ const { ctx, config, opts } = this;
437
+ const { ast, ai, onProgress } = this.deps;
438
+ const branch = ctx.review.branch;
439
+ const messages: string[] = [];
440
+ const startedAt = systemClock();
441
+ const startedMs = Date.now();
442
+ const timings: PrepareStageTiming[] = [];
443
+ // Coleta durações para o `prepare-report.json` sem acoplar o `runStage` ao
444
+ // artefato; etapas concorrentes chegam fora de ordem e são registradas como
445
+ // vieram.
446
+ const timedProgress: PrepareProgress = (event) => {
447
+ if (event.type === "done") {
448
+ timings.push({ stage: event.stage, durationMs: event.durationMs });
449
+ }
450
+ emitProgress(onProgress, event);
451
+ };
452
+ ctx.work.ensure();
453
+ if (opts.clean) ctx.work.cleanFindings();
454
+ ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
455
+
456
+ // Marca o commit revisado e decide entre completo, incremental ou no-op.
457
+ const headSha = ctx.git.resolveSha(opts.head);
458
+ const previous = ctx.storage.readFindings(ctx.review.id);
459
+ const scope = await new ReviewScopeResolver(
460
+ opts,
461
+ config,
462
+ opts.noAst ? undefined : ast,
463
+ ctx.git,
464
+ scopeProgress(timedProgress),
465
+ ).resolve(previous, headSha);
466
+
467
+ // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
468
+ if (scope.kind === "noop") {
469
+ const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
470
+ // Ciclo em andamento continua válido (os findings já despachados devem
471
+ // ser finalizados); fora isso, o relatório anterior é a resposta final.
472
+ const pendingCycle = status === "reviewing";
473
+ if (!pendingCycle) {
474
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
475
+ }
476
+ messages.push(
477
+ `Nenhuma alteração desde a última revisão (${shortSha(scope.reviewedFrom)}).`,
478
+ pendingCycle
479
+ ? "Nada novo a preparar — o ciclo em andamento continua; rode `--finalize` quando os findings estiverem gravados."
480
+ : "Nada a revisar — o resumo anterior foi mantido. Use `--full` para re-revisar.",
481
+ );
482
+ return { messages, activeIds: [] };
483
+ }
484
+
485
+ const { classification, diff, patch } = scope;
486
+
487
+ // Run recém-criada e sem nenhum arquivo alterado: não persiste uma "run
488
+ // vazia" na lista. Acontece quando o comando roda no clone principal sem
489
+ // `--pr`/`--worktree` (ex.: `houndsman` na `main` logo após revisar uma PR).
490
+ // Run com artefatos anteriores (contexto, revisão) continua existindo.
491
+ if (Object.keys(classification.files).length === 0) {
492
+ const fresh = ctx.storage.listArtifacts(ctx.review.id).length === 0;
493
+ if (fresh) {
494
+ ctx.work.clean();
495
+ ctx.storage.deleteReview(ctx.review.id);
496
+ messages.push(
497
+ `Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
498
+ );
499
+ return { messages, activeIds: [] };
500
+ }
501
+ }
502
+
503
+ // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
504
+ writeRunStatus(ctx.storage, ctx.review.id, {
505
+ status: "reviewing",
506
+ resetStartedAt: true,
507
+ });
508
+
509
+ await this.applyAiClassification(classification);
510
+ // Revisor de lógica obrigatório: recebe todos os arquivos alterados,
511
+ // inclusive os não classificados. Id reservado — não é configurável.
512
+ if (Object.keys(classification.files).length > 0) {
513
+ const files = Object.keys(classification.files).filter(
514
+ (file) => classification.files[file]?.status !== "deleted",
515
+ );
516
+ if (files.length > 0) {
517
+ classification.watchdogs[GENERAL_WATCHDOG_ID] = files;
518
+ classification.stats[GENERAL_WATCHDOG_ID] = files.length;
519
+ }
520
+ }
521
+ ctx.storage.writeArtifact(ctx.review.id, {
522
+ name: "classification.json",
523
+ kind: "classification",
524
+ content: `${JSON.stringify(classification, null, 2)}\n`,
525
+ });
526
+
527
+ const astAnalyses = scope.ast;
528
+ const astImpact =
529
+ astAnalyses && ast
530
+ ? await runStage(timedProgress, "impact", () => ast.impact(astAnalyses))
531
+ : undefined;
532
+ if (astAnalyses && ast?.enabled) {
533
+ const summary = {
534
+ enabled: true,
535
+ files: Object.fromEntries(
536
+ [...astAnalyses].map(([path, analysis]) => [
537
+ path,
538
+ {
539
+ facts: analysis.facts,
540
+ delta: analysis.delta,
541
+ deleted: analysis.deleted,
542
+ },
543
+ ]),
544
+ ),
545
+ };
546
+ ctx.storage.writeArtifact(ctx.review.id, {
547
+ name: "ast-summary.json",
548
+ kind: "ast",
549
+ content: `${JSON.stringify(summary, null, 2)}\n`,
550
+ });
551
+ if (astImpact) {
552
+ ctx.storage.writeArtifact(ctx.review.id, {
553
+ name: "ast-impact.json",
554
+ kind: "ast-impact",
555
+ content: `${JSON.stringify(astImpact, null, 2)}\n`,
556
+ });
557
+ }
558
+ }
559
+
560
+ const jevWillRun = Boolean(
561
+ (ai && !opts.noAi && new AiPreAnalyzer(ai).enabled) ||
562
+ (!opts.noJev && new JevClient().isConfigured()),
563
+ );
564
+
565
+ const activeIds = Object.keys(classification.watchdogs);
566
+ if (Object.keys(classification.files).length === 0) {
567
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
568
+ messages.push(
569
+ classification.mode === "incremental"
570
+ ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
571
+ : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
572
+ );
573
+ return { messages, activeIds: [] };
574
+ }
575
+ if (activeIds.length === 0) {
576
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
577
+ messages.push(
578
+ "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
579
+ );
580
+ return { messages, activeIds: [] };
581
+ }
582
+ if (classification.mode === "incremental") {
583
+ messages.push(
584
+ `Modo incremental: ${Object.keys(classification.files).length} arquivo(s) alterado(s) desde ${shortSha(classification.reviewedFrom ?? "")}; ${classification.carryover?.length ?? 0} finding(s) carregado(s) do review anterior.`,
585
+ );
586
+ }
587
+
588
+ this.artifacts.persistContext();
589
+ const aiContext = ai && !opts.noAi ? new AiContextSummarizer(ai).enabled : false;
590
+ // Contexto e pré-análise não dependem um do outro: rodam juntos. Antes, a
591
+ // pré-análise por IA (uma chamada por watchdog) era serial e dominava o
592
+ // prepare em PRs com muitos domínios.
593
+ const [context, jev] = await Promise.all([
594
+ runOptionalStage(timedProgress, aiContext, "context", () =>
595
+ this.resolveContextSummary(),
596
+ ),
597
+ runOptionalStage(timedProgress, jevWillRun, "jev", () =>
598
+ this.resolveCandidates(classification, diff),
599
+ ),
600
+ ]);
601
+
602
+ // Intenção da PR — sempre gerada (determinística) e enriquecida pela IA
603
+ // quando disponível. O brief do passo fica no staging para o subagente ou
604
+ // para o pré-passe do `houndsman hunt`.
605
+ const businessDomains = activeIds
606
+ .filter((id) => id !== GENERAL_WATCHDOG_ID)
607
+ .map((id) => ({ id, name: findWatchdog(config, id)?.name ?? id }));
608
+ const businessInput = {
609
+ classification,
610
+ context,
611
+ ast: astAnalyses,
612
+ impact: astImpact,
613
+ changedScope: classification.changedScope,
614
+ headSha: classification.headSha,
615
+ snippets: diff.byFile,
616
+ domains: businessDomains,
617
+ };
618
+ const business = await runStage(timedProgress, "business", () =>
619
+ this.resolveBusinessSummary(businessInput),
620
+ );
621
+
622
+ const aiTechnical =
623
+ ai && !opts.noAi ? new AiTechnicalSummarizer(ai).enabled : false;
624
+ const aiBriefs = ai && !opts.noAi ? new AiBriefEnricher(ai).enabled : false;
625
+ // Independentes entre si: hipóteses do `general`, resumo técnico e planos
626
+ // dos briefs. Rodam juntos depois que a intenção está pronta.
627
+ const [, , aiPlans] = await Promise.all([
628
+ this.appendBusinessCandidates(jev, business, businessInput),
629
+ runOptionalStage(timedProgress, aiTechnical, "technical", () =>
630
+ this.resolveTechnicalSummary({
631
+ branch,
632
+ classification,
633
+ context,
634
+ ast: astAnalyses,
635
+ impact: astImpact,
636
+ domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
637
+ }),
638
+ ),
639
+ runOptionalStage(timedProgress, aiBriefs, "ai-plans", () =>
640
+ this.buildAiPlans(
641
+ activeIds,
642
+ classification,
643
+ context,
644
+ astAnalyses,
645
+ astImpact,
646
+ business,
647
+ patch,
648
+ ),
649
+ ),
650
+ ]);
651
+
652
+ const manifestAst: ManifestAst | undefined = astAnalyses
653
+ ? {
654
+ enabled: ast?.enabled ?? false,
655
+ languages: [
656
+ ...new Set(
657
+ [...astAnalyses.values()].map(
658
+ (analysis) => analysis.facts.language,
659
+ ),
660
+ ),
661
+ ].sort(),
662
+ files: astAnalyses.size,
663
+ impact: {
664
+ affectedFiles: astImpact?.affectedFiles.length ?? 0,
665
+ importers: Object.keys(astImpact?.importers ?? {}).length,
666
+ exportedChanges: astImpact?.exportedChanges.length ?? 0,
667
+ },
668
+ }
669
+ : undefined;
670
+
671
+ if (aiPlans.size > 0) {
672
+ ctx.storage.writeArtifact(ctx.review.id, {
673
+ name: "ai-briefs.json",
674
+ kind: "ai-brief",
675
+ content: `${JSON.stringify(
676
+ { plans: Object.fromEntries(aiPlans) },
677
+ null,
678
+ 2,
679
+ )}\n`,
680
+ });
681
+ }
682
+ const aiUsage = ai?.recorder.total;
683
+ const manifestAi: ManifestAi | undefined =
684
+ ai && !opts.noAi && (aiPlans.size > 0 || (aiUsage?.calls ?? 0) > 0)
685
+ ? {
686
+ enabled: true,
687
+ provider: ai.projectConfig.aiProvider,
688
+ model: ai.modelId,
689
+ briefPlans: aiPlans.size,
690
+ usage: aiUsage ?? {
691
+ inputTokens: 0,
692
+ outputTokens: 0,
693
+ totalTokens: 0,
694
+ calls: 0,
695
+ },
696
+ }
697
+ : undefined;
698
+
699
+ const briefs = await runStage(timedProgress, "briefs", () =>
700
+ this.writeBriefs(
701
+ activeIds,
702
+ classification,
703
+ branch,
704
+ context,
705
+ jev.candidates,
706
+ astAnalyses,
707
+ astImpact,
708
+ aiPlans,
709
+ business,
710
+ patch,
711
+ ),
712
+ );
713
+ const manifestBusiness: ManifestBusiness = {
714
+ source: business.source,
715
+ model: business.source === "ai" ? ai?.modelId : undefined,
716
+ ...summarizeBusiness(business),
717
+ };
718
+ await runStage(timedProgress, "manifest", () =>
719
+ this.writeManifest(
720
+ classification,
721
+ activeIds,
722
+ activeIds,
723
+ jev,
724
+ manifestAst,
725
+ manifestAi,
726
+ manifestBusiness,
727
+ ),
728
+ );
729
+ messages.push(
730
+ ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
731
+ ...budgetMessage(classification, config),
732
+ ...changedScopeMessage(classification),
733
+ ...overlapMessage(classification),
734
+ ...jevSummary(jev),
735
+ );
736
+ if (astAnalyses) {
737
+ messages.push(
738
+ `AST: ${astAnalyses.size} arquivo(s) analisado(s), ` +
739
+ `${astImpact?.affectedFiles.length ?? 0} impactado(s).`,
740
+ );
741
+ } else {
742
+ messages.push("AST: desabilitado ou sem grammar compatível.");
743
+ }
744
+
745
+ if (context) {
746
+ messages.push("Contexto da PR resumido e injetado nos briefs.");
747
+ } else {
748
+ messages.push(
749
+ "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
750
+ );
751
+ }
752
+ const businessSummary = summarizeBusiness(business);
753
+ messages.push(
754
+ business.source === "ai"
755
+ ? `Intenção da PR reconstruída pela IA: ${businessSummary.flows} fluxo(s), ${businessSummary.invariants} invariante(s).`
756
+ : `Intenção da PR determinística (${businessSummary.flows} fluxo(s)) — enriquecida no passo de intenção (${displayPath(ctx.work.businessPaths.brief)}).`,
757
+ );
758
+ if (aiPlans.size > 0) {
759
+ messages.push(
760
+ `IA: plano de revisão em ${aiPlans.size} brief(s) (sugestão, não substitui regras).`,
761
+ );
762
+ }
763
+ if (briefs.generalSliced) {
764
+ messages.push(
765
+ `General: recorte por fluxo (${briefs.generalSliced.selected}/${briefs.generalSliced.total} arquivos) — os demais devem ser registrados em \`skipped\`.`,
766
+ );
767
+ }
768
+ if (ai && ((aiUsage?.calls ?? 0) > 0 || ai.recorder.attempts > 0)) {
769
+ const usage = ai.recorder.total;
770
+ const failures = ai.recorder.failures.length;
771
+ messages.push(
772
+ failures > 0
773
+ ? `IA: ${usage.calls}/${ai.recorder.attempts} chamada(s) ok, ${usage.totalTokens} token(s), ${failures} falha(s): ${summarizeFailures(ai.recorder.failures)}.`
774
+ : `IA: ${usage.calls} chamada(s), ${usage.totalTokens} token(s).`,
775
+ );
776
+ ai.recorder.write(ctx.storage, ctx.review.id);
777
+ }
778
+
779
+ // Duração por etapa (inclui as que rodaram em paralelo) para comparar runs.
780
+ ctx.storage.writeArtifact(ctx.review.id, {
781
+ name: "prepare-report.json",
782
+ kind: "prepare",
783
+ content: renderPrepareReport({
784
+ startedAt,
785
+ durationMs: Date.now() - startedMs,
786
+ stages: timings,
787
+ ai: ai?.recorder.summary,
788
+ }),
789
+ });
790
+
791
+ messages.push(
792
+ `\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
793
+ "Quando os findings estiverem gravados: bun run houndsman --finalize",
794
+ );
795
+
796
+ return {
797
+ messages,
798
+ activeIds,
799
+ briefsDir: ctx.work.briefsDir,
800
+ reviewId: ctx.review.id,
801
+ };
802
+ }
803
+
804
+ /**
805
+ * Schema do output: relativo à raiz da review quando a camada existe; no
806
+ * worktree sem camada, cai para o asset embarcado (caminho absoluto legível).
807
+ */
808
+ private outputFormatPathFor(): string {
809
+ if (existsSync(join(this.ctx.root, OUTPUT_FORMAT_PATH)))
810
+ return OUTPUT_FORMAT_PATH;
811
+ return this.ctx.review.worktreePath
812
+ ? assetPath(OUTPUT_FORMAT_FILE)
813
+ : OUTPUT_FORMAT_PATH;
814
+ }
815
+
816
+ /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
817
+ private writeManifest(
818
+ classification: RunClassification,
819
+ activeIds: string[],
820
+ pending: string[],
821
+ jev: JevCandidatesFile,
822
+ ast?: ManifestAst,
823
+ ai?: ManifestAi,
824
+ business?: ManifestBusiness,
825
+ ): void {
826
+ new ManifestWriter(
827
+ this.ctx.storage,
828
+ this.ctx.review,
829
+ this.ctx.work,
830
+ new ProjectConfig(this.config),
831
+ this.opts,
832
+ ).write(classification, activeIds, pending, jev, ast, ai, business);
833
+ }
834
+
835
+ private writeBriefs(
836
+ activeIds: string[],
837
+ classification: RunClassification,
838
+ branch: string,
839
+ context: ContextSummary | null,
840
+ candidates: JevCandidate[],
841
+ astAnalyses?: ReadonlyMap<string, AstAnalysis>,
842
+ astImpact?: AstImpact,
843
+ aiPlans?: ReadonlyMap<string, BriefPlan>,
844
+ business?: BusinessSummary,
845
+ patch?: ReadonlyMap<string, string>,
846
+ ): { generalSliced?: { selected: number; total: number } } {
847
+ const { ctx, config } = this;
848
+ const work = ctx.work;
849
+ const docs = discoverDocs(Object.keys(classification.files), config);
850
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
851
+ const renderer = new BriefRenderer();
852
+ const summary: { generalSliced?: { selected: number; total: number } } = {};
853
+ for (const id of activeIds) {
854
+ const def = findWatchdog(config, id);
855
+ if (!def) continue;
856
+ const isGeneral = id === GENERAL_WATCHDOG_ID;
857
+ const sliced = isGeneral && shouldSliceGeneral(classification);
858
+ const { selected, overflow } = sliced
859
+ ? generalFlowFiles(
860
+ classification,
861
+ business,
862
+ GENERAL_FLOW_MAX_FILES,
863
+ config.priorities,
864
+ )
865
+ : budgetFor(classification, id, maxFilesPerBrief, config.priorities);
866
+ if (sliced) {
867
+ summary.generalSliced = {
868
+ selected: selected.length,
869
+ total: (classification.watchdogs[id] ?? []).length,
870
+ };
871
+ }
872
+ const astFiles: AstFileBrief[] = selected.flatMap((file) => {
873
+ const analysis = astAnalyses?.get(file);
874
+ if (!analysis?.delta) return [];
875
+ return [
876
+ {
877
+ file,
878
+ delta: analysis.delta,
879
+ importers: astImpact?.importers[file],
880
+ },
881
+ ];
882
+ });
883
+ // Micro-alterações iguais: 1 representante + outliers em vez de N diffs.
884
+ const propagation = detectPropagation(
885
+ [...selected, ...overflow],
886
+ patch,
887
+ classification.churn,
888
+ );
889
+ // Contratos alterados com importadores fora do diff (possível lacuna).
890
+ const propagationGaps = detectPropagationGaps(
891
+ selected,
892
+ astAnalyses,
893
+ astImpact,
894
+ new Set(Object.keys(classification.files)),
895
+ );
896
+ const brief = renderer.render(new Watchdog(def), {
897
+ watchdogId: id,
898
+ files: selected,
899
+ statuses: classification.files,
900
+ churn: classification.churn,
901
+ overflow,
902
+ deleted: classification.deleted,
903
+ branch,
904
+ outputPath: work.findingFile(id),
905
+ outputFormatPath: this.outputFormatPathFor(),
906
+ worktree: ctx.review.worktreePath,
907
+ docs,
908
+ context,
909
+ candidates: candidates.filter((c) => c.watchdogId === id),
910
+ ast: astFiles,
911
+ aiPlan: aiPlans?.get(id),
912
+ business,
913
+ businessSummaryPath: business ? work.businessPaths.summary : undefined,
914
+ propagation,
915
+ propagationGaps,
916
+ // Todos os watchdogs recebem o diff: sem ele o subagente relê o arquivo
917
+ // inteiro só para enxergar as linhas alteradas. O `general` vê mais.
918
+ snippets: buildBriefSnippets(
919
+ selected,
920
+ patch,
921
+ isGeneral
922
+ ? {
923
+ total: GENERAL_BRIEF_SNIPPET_CHARS,
924
+ perFile: sliced
925
+ ? GENERAL_FLOW_SNIPPET_CHARS_PER_FILE
926
+ : GENERAL_BRIEF_SNIPPET_CHARS_PER_FILE,
927
+ }
928
+ : undefined,
929
+ ),
930
+ changedScope: classification.changedScope,
931
+ });
932
+ const content = `${brief}\n`;
933
+ work.writeBrief(id, content);
934
+ // Auditoria: o brief também fica no storage (o staging é limpo no finalize).
935
+ ctx.storage.writeArtifact(ctx.review.id, {
936
+ name: `briefs/${id}.md`,
937
+ kind: "brief",
938
+ content,
939
+ });
940
+ }
941
+ return summary;
942
+ }
943
+
944
+ /**
945
+ * Resolve os candidatos Jev (Passo 1.5). Sempre grava `candidates.json`.
946
+ * Falha-aberto: sem chave / erro / `--no-jev` → artefato `available:false`.
947
+ */
948
+ private async resolveCandidates(
949
+ classification: ClassificationResult,
950
+ diff: DiffSource,
951
+ ): Promise<JevCandidatesFile> {
952
+ const { ctx, config, opts } = this;
953
+ const ai = this.deps.ai;
954
+ const save = (data: JevCandidatesFile): JevCandidatesFile => {
955
+ ctx.storage.writeArtifact(ctx.review.id, {
956
+ name: "candidates.json",
957
+ kind: "candidates",
958
+ content: serializeCandidates(data),
959
+ });
960
+ return data;
961
+ };
962
+
963
+ // Pré-análise por IA (opcional) tem precedência sobre o Jev.
964
+ if (ai && !opts.noAi) {
965
+ const analyzer = new AiPreAnalyzer(ai);
966
+ if (analyzer.enabled) {
967
+ // Uma chamada por watchdog, concorrência limitada (antes era serial e
968
+ // dominava o prepare em PRs com muitos domínios).
969
+ const entries = Object.entries(classification.watchdogs).filter(
970
+ ([id]) => id !== GENERAL_WATCHDOG_ID,
971
+ );
972
+ const perWatchdog = await mapPool(
973
+ entries,
974
+ ai.projectConfig.aiConcurrency,
975
+ async ([id, files]) => {
976
+ const def = findWatchdog(config, id);
977
+ if (!def) return [];
978
+ const inputs = files.slice(0, 5).flatMap((file) => {
979
+ // Só o trecho alterado: candidato fora do diff vira ruído de código
980
+ // pré-existente no brief do watchdog.
981
+ const snippet = diff.byFile.get(file);
982
+ return snippet ? [{ file, snippet }] : [];
983
+ });
984
+ return analyzer.analyze({
985
+ watchdogId: id,
986
+ name: def.name,
987
+ rules: def.rules,
988
+ files: inputs,
989
+ });
990
+ },
991
+ );
992
+ return save({
993
+ available: true,
994
+ model: ai.modelId,
995
+ threshold: JevCandidatesStore.THRESHOLD,
996
+ analyzedFiles: Object.values(classification.watchdogs).flat().length,
997
+ candidates: perWatchdog.flat(),
998
+ });
999
+ }
1000
+ }
1001
+
1002
+ if (opts.noJev) {
1003
+ return save(JevCandidatesStore.unavailable("desativado (--no-jev)"));
1004
+ }
1005
+
1006
+ const client = new JevClient();
1007
+ if (!client.isConfigured()) {
1008
+ return save(
1009
+ JevCandidatesStore.unavailable(
1010
+ `${ENV_NAMES.typeSafeApiKey} não configurada`,
1011
+ ),
1012
+ );
1013
+ }
1014
+
1015
+ try {
1016
+ const detector = new JevDetector(
1017
+ client,
1018
+ config.watchdogs as Record<string, WatchdogMeta>,
1019
+ ctx.root,
1020
+ );
1021
+ const result = await detector.detect(classification, opts.base, opts.head, {
1022
+ diff,
1023
+ concurrency: resolveJevConcurrency(config),
1024
+ });
1025
+ return save({
1026
+ available: true,
1027
+ model: result.model,
1028
+ threshold: JevCandidatesStore.THRESHOLD,
1029
+ analyzedFiles: result.analyzedFiles,
1030
+ candidates: result.candidates,
1031
+ skipped: result.skipped.length > 0 ? result.skipped : undefined,
1032
+ });
1033
+ } catch (error) {
1034
+ return save(
1035
+ JevCandidatesStore.unavailable(
1036
+ error instanceof Error ? error.message : String(error),
1037
+ ),
1038
+ );
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Classificação fallback por IA (opcional): atribui arquivos não classificados
1044
+ * a watchdogs conhecidos. Fail-open — falha mantém `unclassified`.
1045
+ */
1046
+ private async applyAiClassification(
1047
+ classification: RunClassification,
1048
+ ): Promise<void> {
1049
+ const { config, opts } = this;
1050
+ const ai = this.deps.ai;
1051
+ if (!ai || opts.noAi || classification.unclassified.length === 0) return;
1052
+ const classifier = new AiClassifier(ai);
1053
+ if (!classifier.enabled) return;
1054
+ const watchdogs = config.watchdogIds.flatMap((id) => {
1055
+ const def = config.watchdogs[id];
1056
+ return def ? [{ id, name: def.name, scope: def.scope }] : [];
1057
+ });
1058
+ const assignments = await classifier.classify(
1059
+ classification.unclassified,
1060
+ watchdogs,
1061
+ );
1062
+ if (assignments.size === 0) return;
1063
+ for (const [file, id] of assignments) {
1064
+ const list = classification.watchdogs[id] ?? [];
1065
+ if (!list.includes(file)) list.push(file);
1066
+ classification.watchdogs[id] = list;
1067
+ classification.stats[id] = list.length;
1068
+ }
1069
+ classification.unclassified = classification.unclassified.filter(
1070
+ (file) => !assignments.has(file),
1071
+ );
1072
+ }
1073
+
1074
+ /**
1075
+ * Resumo do contexto da PR: usa o do subagente ou, na ausência, a IA
1076
+ * (`AiContextSummarizer`). Grava o artefato e o staging.
1077
+ */
1078
+ private async resolveContextSummary(): Promise<ContextSummary | null> {
1079
+ const { ctx, opts } = this;
1080
+ const ai = this.deps.ai;
1081
+ const existing = ctx.work.readContextSummary() ?? this.artifacts.contextSummary();
1082
+ if (existing) return existing;
1083
+ if (!ai || opts.noAi) return null;
1084
+ const raw = ctx.work.readContextRaw();
1085
+ if (!raw) return null;
1086
+ const summary = await new AiContextSummarizer(ai).summarize(raw);
1087
+ if (!summary) return null;
1088
+ ctx.work.writeContextSummary(summary);
1089
+ ctx.storage.writeArtifact(ctx.review.id, {
1090
+ name: "context-summary.json",
1091
+ kind: "context-summary",
1092
+ content: `${JSON.stringify(summary, null, 2)}\n`,
1093
+ });
1094
+ return summary;
1095
+ }
1096
+
1097
+ /**
1098
+ * Pontos técnicos estruturais: IA quando disponível e, sempre, um fallback
1099
+ * determinístico a partir da classificação/AST. Persistido para auditoria —
1100
+ * o `finalize` apenas o consome.
1101
+ */
1102
+ private async resolveTechnicalSummary(input: {
1103
+ branch: string;
1104
+ classification: RunClassification;
1105
+ context: ContextSummary | null;
1106
+ ast?: ReadonlyMap<string, AstAnalysis>;
1107
+ impact?: AstImpact;
1108
+ domains: readonly string[];
1109
+ }): Promise<TechnicalSummary> {
1110
+ const { ctx, opts } = this;
1111
+ const ai = this.deps.ai;
1112
+ const fallback = buildTechnicalSummary({
1113
+ classification: input.classification,
1114
+ ast: input.ast,
1115
+ impact: input.impact,
1116
+ headSha: input.classification.headSha,
1117
+ });
1118
+ const summary =
1119
+ ai && !opts.noAi
1120
+ ? ((await new AiTechnicalSummarizer(ai).summarize({
1121
+ branch: input.branch,
1122
+ context: input.context,
1123
+ classification: input.classification,
1124
+ ast: input.ast,
1125
+ impact: input.impact,
1126
+ headSha: input.classification.headSha,
1127
+ domains: input.domains,
1128
+ })) ?? fallback)
1129
+ : fallback;
1130
+ ctx.storage.writeArtifact(ctx.review.id, {
1131
+ name: TECHNICAL_SUMMARY_FILE,
1132
+ kind: "technical",
1133
+ content: `${JSON.stringify(summary, null, 2)}\n`,
1134
+ });
1135
+ return summary;
1136
+ }
1137
+
1138
+ /**
1139
+ * Modelo de intenção (Passo 1.4): determinístico sempre, IA quando disponível.
1140
+ * O brief do passo fica no staging para a skill (subagente) ou o `hunt` (maki).
1141
+ */
1142
+ private async resolveBusinessSummary(input: BusinessInput): Promise<BusinessSummary> {
1143
+ const { ctx, config, opts } = this;
1144
+ const ai = this.deps.ai;
1145
+ const fallback = buildBusinessSummary(input);
1146
+ const summary =
1147
+ ai && !opts.noAi
1148
+ ? ((await new AiBusinessSummarizer(ai).summarize(input)) ?? fallback)
1149
+ : fallback;
1150
+
1151
+ ctx.work.writeBusinessSummary(summary);
1152
+ ctx.storage.writeArtifact(ctx.review.id, {
1153
+ name: BUSINESS_SUMMARY_FILE,
1154
+ kind: "business",
1155
+ content: `${JSON.stringify(summary, null, 2)}\n`,
1156
+ });
1157
+
1158
+ const files = Object.entries(input.classification.files)
1159
+ .map(([path, file]) => {
1160
+ const analysis = input.ast?.get(path);
1161
+ const symbols = analysis?.delta
1162
+ ? [
1163
+ ...analysis.delta.addedSymbols,
1164
+ ...analysis.delta.modifiedSymbols,
1165
+ ].map((symbol) => symbol.name)
1166
+ : [];
1167
+ return {
1168
+ path,
1169
+ status: file.status,
1170
+ ranges: input.changedScope?.[path]?.ranges,
1171
+ symbols: [...new Set(symbols)].slice(0, 8),
1172
+ };
1173
+ })
1174
+ .sort(
1175
+ (a, b) =>
1176
+ (input.classification.churn[b.path]?.additions ?? 0) +
1177
+ (input.classification.churn[b.path]?.deletions ?? 0) -
1178
+ ((input.classification.churn[a.path]?.additions ?? 0) +
1179
+ (input.classification.churn[a.path]?.deletions ?? 0)),
1180
+ );
1181
+
1182
+ const docs = discoverDocs(Object.keys(input.classification.files), config);
1183
+ const brief = buildBusinessBrief({
1184
+ projectName: config.projectName,
1185
+ branch: ctx.review.branch,
1186
+ headSha: input.headSha,
1187
+ outputPath: ctx.work.businessPaths.summary,
1188
+ business: summary,
1189
+ files,
1190
+ docs,
1191
+ context: input.context,
1192
+ });
1193
+ ctx.work.writeBusinessBrief(brief.trimEnd());
1194
+ ctx.storage.writeArtifact(ctx.review.id, {
1195
+ name: BUSINESS_BRIEF_FILE,
1196
+ kind: "business-brief",
1197
+ content: `${brief.trimEnd()}\n`,
1198
+ });
1199
+
1200
+ return summary;
1201
+ }
1202
+
1203
+ /**
1204
+ * Hipóteses de lógica/domínio (opt-in por IA) — candidatos que o revisor
1205
+ * `general` confirma ou refuta. Nunca viram achado sozinhos.
1206
+ */
1207
+ private async appendBusinessCandidates(
1208
+ jev: JevCandidatesFile,
1209
+ business: BusinessSummary,
1210
+ input: BusinessInput,
1211
+ ): Promise<void> {
1212
+ const { ctx, opts } = this;
1213
+ const ai = this.deps.ai;
1214
+ if (!ai || opts.noAi) return;
1215
+ const analyzer = new AiBusinessAnalyzer(ai);
1216
+ if (!analyzer.enabled) return;
1217
+ const candidates = await analyzer.analyze({ business, payload: input });
1218
+ if (candidates.length === 0) return;
1219
+ jev.candidates.push(...candidates);
1220
+ jev.available = true;
1221
+ jev.model = jev.model ?? ai.modelId;
1222
+ jev.reason = undefined;
1223
+ jev.analyzedFiles = Math.max(
1224
+ jev.analyzedFiles ?? 0,
1225
+ Object.keys(input.classification.files).length,
1226
+ );
1227
+ ctx.storage.writeArtifact(ctx.review.id, {
1228
+ name: "candidates.json",
1229
+ kind: "candidates",
1230
+ content: serializeCandidates(jev),
1231
+ });
1232
+ }
1233
+
1234
+ /**
1235
+ * Planos de revisão por watchdog (uma chamada por brief, concorrência
1236
+ * limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
1237
+ */
1238
+ private async buildAiPlans(
1239
+ activeIds: string[],
1240
+ classification: ClassificationResult,
1241
+ context: ContextSummary | null,
1242
+ astAnalyses: ReadonlyMap<string, AstAnalysis> | undefined,
1243
+ astImpact: AstImpact | undefined,
1244
+ business?: BusinessSummary,
1245
+ patch?: ReadonlyMap<string, string>,
1246
+ ): Promise<Map<string, BriefPlan>> {
1247
+ const { config, opts } = this;
1248
+ const ai = this.deps.ai;
1249
+ const plans = new Map<string, BriefPlan>();
1250
+ if (!ai || opts.noAi) return plans;
1251
+ const enricher = new AiBriefEnricher(ai);
1252
+ if (!enricher.enabled) return plans;
1253
+
1254
+ const docs = discoverDocs(Object.keys(classification.files), config);
1255
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
1256
+ const inputs = [];
1257
+ for (const id of activeIds) {
1258
+ const def = findWatchdog(config, id);
1259
+ if (!def) continue;
1260
+ const { selected } = budgetFor(
1261
+ classification,
1262
+ id,
1263
+ maxFilesPerBrief,
1264
+ config.priorities,
1265
+ );
1266
+ const astFiles: AstFileBrief[] = selected.flatMap((file) => {
1267
+ const analysis = astAnalyses?.get(file);
1268
+ if (!analysis?.delta) return [];
1269
+ return [
1270
+ {
1271
+ file,
1272
+ delta: analysis.delta,
1273
+ importers: astImpact?.importers[file],
1274
+ },
1275
+ ];
1276
+ });
1277
+ inputs.push({
1278
+ watchdogId: id,
1279
+ name: def.name,
1280
+ description: def.description,
1281
+ scope: def.scope,
1282
+ rules: def.rules,
1283
+ files: selected,
1284
+ churn: classification.churn,
1285
+ context,
1286
+ ast: astFiles,
1287
+ docs,
1288
+ business,
1289
+ snippets: buildBriefSnippets(selected, patch),
1290
+ });
1291
+ }
1292
+ return enricher.enrichAll(inputs);
1293
+ }
1294
+ }