@payconductor/houndsman 0.13.0 → 0.16.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 (116) 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/golden-command.ts +92 -0
  7. package/src/cli/commands/hunt-command.ts +105 -4
  8. package/src/cli/commands/review-command.ts +38 -0
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +6 -1
  11. package/src/cli/index.ts +5 -1
  12. package/src/cli/progress.ts +7 -2
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/features/brief-enricher.ts +1 -1
  15. package/src/core/ai/features/business-analyzer.ts +61 -0
  16. package/src/core/ai/features/business-summarizer.ts +44 -0
  17. package/src/core/ai/index.ts +8 -0
  18. package/src/core/ai/prompts/brief.ts +33 -7
  19. package/src/core/ai/prompts/business.ts +76 -0
  20. package/src/core/ai/provider-capabilities.ts +12 -0
  21. package/src/core/ai/schemas.ts +70 -2
  22. package/src/core/ai/usage-recorder.ts +53 -1
  23. package/src/core/brief.ts +278 -16
  24. package/src/core/builtins.ts +108 -0
  25. package/src/core/business-summary.ts +583 -0
  26. package/src/core/commands/compare.ts +266 -0
  27. package/src/core/commands/context.ts +6 -2
  28. package/src/core/commands/findings.ts +38 -30
  29. package/src/core/commands/golden.ts +176 -0
  30. package/src/core/commands/review.ts +6 -0
  31. package/src/core/commands/scaffold.ts +29 -0
  32. package/src/core/commands/status.ts +9 -4
  33. package/src/core/compare.ts +310 -0
  34. package/src/core/concurrency.ts +30 -0
  35. package/src/core/config.ts +41 -3
  36. package/src/core/defaults.ts +50 -4
  37. package/src/core/domain/budget.ts +62 -4
  38. package/src/core/domain/classification.ts +12 -2
  39. package/src/core/engine/carryover.ts +4 -0
  40. package/src/core/engine/finalize-pipeline.ts +371 -0
  41. package/src/core/engine/prepare-pipeline.ts +1294 -0
  42. package/src/core/engine/review-context.ts +51 -0
  43. package/src/core/engine/review-options.ts +47 -0
  44. package/src/core/engine/review-scope.ts +14 -1
  45. package/src/core/engine/review-session.ts +11 -13
  46. package/src/core/engine/run-artifacts.ts +196 -0
  47. package/src/core/engine/run-format.ts +4 -0
  48. package/src/core/findings-state.ts +22 -2
  49. package/src/core/github-review.ts +5 -2
  50. package/src/core/golden.ts +346 -0
  51. package/src/core/hunt/hunt-runner.ts +621 -34
  52. package/src/core/hunt/index.ts +40 -1
  53. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  54. package/src/core/hunt/maki-prompt.ts +298 -5
  55. package/src/core/hunt/maki-runner.ts +93 -16
  56. package/src/core/hunt/pool.ts +4 -26
  57. package/src/core/hunt/review-checks.ts +460 -0
  58. package/src/core/logging/index.ts +6 -1
  59. package/src/core/logging/progress.ts +85 -1
  60. package/src/core/orchestrator.ts +33 -1289
  61. package/src/core/paths.ts +6 -3
  62. package/src/core/pr-context.ts +2 -4
  63. package/src/core/prepare-report.ts +61 -0
  64. package/src/core/project-config.ts +18 -1
  65. package/src/core/propagation.ts +127 -0
  66. package/src/core/registry-check.ts +90 -27
  67. package/src/core/renderers/manifest-writer.ts +13 -0
  68. package/src/core/renderers/resume-renderer.ts +151 -11
  69. package/src/core/report-builder.ts +116 -278
  70. package/src/core/severity.ts +245 -0
  71. package/src/core/storage/file/file-storage.ts +7 -3
  72. package/src/core/storage/sqlite/mappers.ts +10 -0
  73. package/src/core/storage/sqlite/schema.ts +104 -23
  74. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  75. package/src/core/storage/types.ts +17 -4
  76. package/src/core/storage/work-area.ts +59 -1
  77. package/src/core/types.ts +31 -0
  78. package/src/core/watchdog-registry.ts +21 -3
  79. package/src/index.ts +51 -4
  80. package/src/server/client/ArtifactsPanel.tsx +4 -2
  81. package/src/server/client/FindingCard.tsx +21 -1
  82. package/src/server/client/RunsSection.tsx +5 -0
  83. package/src/server/client/utils/constants.ts +21 -11
  84. package/src/server/client/utils/markdown.ts +1 -1
  85. package/src/server/client/utils/types.ts +14 -2
  86. package/src/server/run-repository.ts +35 -14
  87. package/src/server/styles.css +2 -0
  88. package/src/skill/OUTPUT-FORMAT.md +55 -10
  89. package/src/skill/README.md +19 -14
  90. package/src/skill/SETUP.md +1 -0
  91. package/src/skill/SKILL.md +13 -4
  92. package/src/skill/example-resume.md +43 -0
  93. package/src/skill/hunt.md +205 -58
  94. package/src/skill/train.md +3 -0
  95. package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
  96. package/src/tests/ai-core.test.ts +46 -0
  97. package/src/tests/ai-features.test.ts +95 -4
  98. package/src/tests/baseline.test.ts +29 -121
  99. package/src/tests/brief-scope.test.ts +250 -0
  100. package/src/tests/business-summary.test.ts +258 -0
  101. package/src/tests/compare.test.ts +256 -0
  102. package/src/tests/findings-state.test.ts +82 -0
  103. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  104. package/src/tests/golden.test.ts +195 -0
  105. package/src/tests/hunt-maki.test.ts +710 -2
  106. package/src/tests/markdown.test.ts +3 -3
  107. package/src/tests/progress.test.ts +29 -1
  108. package/src/tests/propagation.test.ts +119 -0
  109. package/src/tests/review-resume.test.ts +60 -1
  110. package/src/tests/review-scope.test.ts +38 -29
  111. package/src/tests/review-scripts.test.ts +125 -218
  112. package/src/tests/server.test.ts +7 -4
  113. package/src/tests/severity.test.ts +235 -0
  114. package/src/tests/storage.test.ts +194 -9
  115. package/src/core/renderers/report-renderer.ts +0 -66
  116. package/src/skill/example-review.md +0 -66
@@ -1,1311 +1,55 @@
1
1
  /**
2
- * Orquestração do houndsman (biblioteca): preparar (classificação + briefs +
3
- * manifesto + Jev) e finalizar (findings → dedupe → relatório).
2
+ * Fachada da orquestração do houndsman (biblioteca).
3
+ *
4
+ * O trabalho vive nas pipelines (`PreparePipeline`/`FinalizePipeline`), que
5
+ * recebem o `ReviewContext` no construtor. Aqui ficam apenas os tipos, os
6
+ * defaults e as fachadas usadas pelo `ReviewSession` e pelos testes.
4
7
  *
5
8
  * Genérico: recebe o `HoundsmanConfig` por parâmetro (injeção explícita).
6
9
  * Entrypoints: `cli/index.ts` (`houndsman`). Aqui não há `process.argv`.
7
- *
8
- * Persistência:
9
- * storage reviews/findings/artefatos (SQLite por default)
10
- * <home>/work/<review> staging dos subagentes
11
- * briefs/<id>.md instruções prontas para cada sub-agente
12
- * findings/<id>.json saída dos sub-agentes (schema skill/OUTPUT-FORMAT.md)
13
- * context*.json/md contexto da PR (Passo 0)
14
- *
15
- * Artefatos persistidos: classification, candidates, manifest, review.md,
16
- * review.<watchdog>.md, context e context-summary.
17
10
  */
18
11
 
19
- import { existsSync } from "node:fs";
20
- import { join } from "node:path";
21
- import type { AiClient } from "./ai/ai-client";
22
- import { AiBriefEnricher } from "./ai/features/brief-enricher";
23
- import { AiClassifier } from "./ai/features/classifier";
24
- import { AiContextSummarizer } from "./ai/features/context-summarizer";
25
- import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
26
- import { AiTechnicalSummarizer } from "./ai/features/technical-summarizer";
27
- import type { BriefPlan } from "./ai/schemas";
28
- import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
29
- import type { AstService } from "./ast/service";
30
- import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
31
- import { summarizeScope } from "./changed-scope";
32
- import {
33
- type HoundsmanConfig,
34
- resolveJevConcurrency,
35
- resolveMaxFilesPerBrief,
36
- } from "./config";
37
- import type { DiffSource } from "./diff-source";
38
- import { discoverDocs } from "./doc-discoverer";
39
- import { Classification } from "./domain/classification";
40
- import { Watchdog } from "./domain/watchdog";
41
- import { mergeCarryover } from "./engine/carryover";
42
- import {
43
- ReviewScopeResolver,
44
- type RunClassification,
45
- type ScopeProgress,
46
- } from "./engine/review-scope";
47
- import { ENV_NAMES } from "./env";
48
- import type { BudgetSelection, ClassificationResult } from "./file-classifier";
49
- import { buildStateFromFindings, fileReader } from "./findings-state";
50
- import { syncResolvedThreads } from "./github-review";
51
- import {
52
- type JevCandidate,
53
- type JevCandidatesFile,
54
- JevCandidatesStore,
55
- JevClient,
56
- JevDetector,
57
- parseCandidates,
58
- serializeCandidates,
59
- } from "./jev";
12
+ import type { HoundsmanConfig } from "./config";
13
+ import { FinalizePipeline, type FinalizeResult } from "./engine/finalize-pipeline";
60
14
  import {
61
- displayPath,
62
- OUTPUT_FORMAT_PATH,
63
- REVIEW_FILE,
64
- REVIEW_RESUME_FILE,
65
- TECHNICAL_SUMMARY_FILE,
66
- } from "./paths";
67
- import { ProjectConfig } from "./project-config";
68
- import { BriefRenderer } from "./renderers/brief-renderer";
69
- import {
70
- type ManifestAi,
71
- type ManifestAst,
72
- ManifestWriter,
73
- } from "./renderers/manifest-writer";
74
- import { ReportRenderer } from "./renderers/report-renderer";
75
- import { renderReviewResume } from "./renderers/resume-renderer";
76
- import {
77
- type ContextSummary,
78
- type DomainReport,
79
- dedupeFindings,
80
- parseContextSummary,
81
- sanitizeBranch,
82
- type WatchdogReport,
83
- } from "./report-builder";
84
- import { writeRunStatus } from "./run-status";
85
- import { systemClock } from "./services/clock";
15
+ type PrepareDeps,
16
+ PreparePipeline,
17
+ type PrepareResult,
18
+ } from "./engine/prepare-pipeline";
19
+ import type { ReviewContext } from "./engine/review-context";
20
+ import type { ReviewOptions } from "./engine/review-options";
86
21
  import { GitRepository } from "./services/git-repository";
87
- import type { ReviewRecord, StorageProvider } from "./storage/types";
88
- import { ReviewWorkArea } from "./storage/work-area";
89
- import {
90
- buildTechnicalSummary,
91
- parseTechnicalSummary,
92
- type TechnicalSummary,
93
- } from "./technical-summary";
94
- import type { WatchdogMeta } from "./types";
95
- import { findWatchdog } from "./watchdog-registry";
96
-
97
- export type ReviewOptions = {
98
- base: string;
99
- head: string;
100
- branch?: string;
101
- finalize: boolean;
102
- failOnBlocking: boolean;
103
- clean: boolean;
104
- noJev: boolean;
105
- /** Desativa a análise estrutural (AST) nesta run. */
106
- noAst: boolean;
107
- /** Força review completo (ignora o modo incremental). */
108
- full: boolean;
109
- /** Desativa as features de IA do fluxo normal. */
110
- noAi: boolean;
111
- /** PR de origem (resolvida via `--pr`). */
112
- pr?: number;
113
- /** Worktree informado via flag (`--worktree`). */
114
- worktree?: string;
115
- /** Worktree resolvido (absoluto; ausente = clone principal). */
116
- worktreePath?: string;
117
- };
118
-
119
- /** Valores default das opções (o CLI sobrepõe com as flags). */
120
- export function defaultOptions(): ReviewOptions {
121
- return {
122
- base: "main",
123
- head: "HEAD",
124
- finalize: false,
125
- failOnBlocking: false,
126
- clean: false,
127
- noJev: false,
128
- noAst: false,
129
- full: false,
130
- noAi: false,
131
- };
132
- }
133
-
134
- /** Workspace onde os arquivos da review são lidos. */
135
- export type ReviewWorkspaceContext = {
136
- /** Raiz operacional (worktree ou clone principal). */
137
- root?: string;
138
- /** Raiz do clone principal (identidade/config). */
139
- mainRoot?: string;
140
- git?: GitRepository;
141
- };
142
-
143
- /** Contexto de uma run: storage + review + staging + raiz operacional. */
144
- export type ReviewContext = {
145
- storage: StorageProvider;
146
- review: ReviewRecord;
147
- work: ReviewWorkArea;
148
- root: string;
149
- mainRoot: string;
150
- git: GitRepository;
151
- };
152
-
153
- /** Cria o contexto de uma review existente. */
154
- export function reviewContext(
155
- storage: StorageProvider,
156
- review: ReviewRecord,
157
- workspace: ReviewWorkspaceContext = {},
158
- ): ReviewContext {
159
- const root = workspace.root ?? process.cwd();
160
- return {
161
- storage,
162
- review,
163
- work: new ReviewWorkArea(review),
164
- root,
165
- mainRoot: workspace.mainRoot ?? root,
166
- git: workspace.git ?? new GitRepository(),
167
- };
168
- }
169
-
170
- /**
171
- * Schema do output: relativo à raiz da review quando a camada existe; no
172
- * worktree sem camada, cai para o asset embarcado (caminho absoluto legível).
173
- */
174
- function outputFormatPathFor(ctx: ReviewContext): string {
175
- if (existsSync(join(ctx.root, OUTPUT_FORMAT_PATH))) return OUTPUT_FORMAT_PATH;
176
- return ctx.review.worktreePath ? assetPath(OUTPUT_FORMAT_FILE) : OUTPUT_FORMAT_PATH;
177
- }
178
-
179
- // ---------------------------------------------------------------------------
180
- // Escopo da revisão (completo vs incremental)
181
- // ---------------------------------------------------------------------------
182
-
183
- /** SHA curto para logs (7 chars, como o git). */
184
- function shortSha(sha: string): string {
185
- return sha.slice(0, 7);
186
- }
187
-
188
- /** Aviso no topo do `review.md` quando a revisão foi incremental (senão vazio). */
189
- function incrementalNote(classification: RunClassification): string {
190
- if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
191
- const carried = classification.carryover?.length ?? 0;
192
- return (
193
- `> **Revisão incremental** — desde \`${shortSha(classification.reviewedFrom)}\`; ` +
194
- `${Object.keys(classification.files).length} arquivo(s) alterado(s), ` +
195
- `${carried} finding(s) carregado(s) do review anterior.\n\n`
196
- );
197
- }
198
-
199
- // ---------------------------------------------------------------------------
200
- // Orçamento (triage de PRs grandes)
201
- // ---------------------------------------------------------------------------
202
-
203
- /** Aplica o orçamento aos arquivos de um watchdog. */
204
- function budgetFor(
205
- classification: ClassificationResult,
206
- id: string,
207
- maxFiles: number,
208
- ): BudgetSelection {
209
- return Classification.from(classification).budgetFor(id, maxFiles);
210
- }
211
-
212
- /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
213
- function writeManifest(
214
- ctx: ReviewContext,
215
- config: HoundsmanConfig,
216
- opts: ReviewOptions,
217
- classification: RunClassification,
218
- activeIds: string[],
219
- pending: string[],
220
- jev: JevCandidatesFile,
221
- ast?: ManifestAst,
222
- ai?: ManifestAi,
223
- ): void {
224
- new ManifestWriter(
225
- ctx.storage,
226
- ctx.review,
227
- ctx.work,
228
- new ProjectConfig(config),
229
- opts,
230
- ).write(classification, activeIds, pending, jev, ast, ai);
231
- }
232
22
 
233
- // ---------------------------------------------------------------------------
234
- // Briefs
235
- // ---------------------------------------------------------------------------
23
+ export type { FinalizeResult } from "./engine/finalize-pipeline";
24
+ export type {
25
+ PrepareDeps,
26
+ PrepareProgress,
27
+ PrepareProgressEvent,
28
+ PrepareResult,
29
+ PrepareStage,
30
+ } from "./engine/prepare-pipeline";
31
+ export type { ReviewContext, ReviewWorkspaceContext } from "./engine/review-context";
32
+ export { reviewContext } from "./engine/review-context";
33
+ export type { ReviewOptions } from "./engine/review-options";
34
+ export { defaultOptions } from "./engine/review-options";
236
35
 
237
- function writeBriefs(
238
- activeIds: string[],
239
- classification: RunClassification,
36
+ /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
37
+ export function prepare(
240
38
  ctx: ReviewContext,
241
- branch: string,
242
- context: ContextSummary | null,
243
- candidates: JevCandidate[],
244
39
  config: HoundsmanConfig,
245
- astAnalyses?: ReadonlyMap<string, AstAnalysis>,
246
- astImpact?: AstImpact,
247
- aiPlans?: ReadonlyMap<string, BriefPlan>,
248
- ): void {
249
- const work = ctx.work;
250
- const docs = discoverDocs(Object.keys(classification.files), config);
251
- const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
252
- const renderer = new BriefRenderer();
253
- for (const id of activeIds) {
254
- const def = findWatchdog(config, id);
255
- if (!def) continue;
256
- const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
257
- const astFiles: AstFileBrief[] = selected.flatMap((file) => {
258
- const analysis = astAnalyses?.get(file);
259
- if (!analysis?.delta) return [];
260
- return [
261
- {
262
- file,
263
- delta: analysis.delta,
264
- importers: astImpact?.importers[file],
265
- },
266
- ];
267
- });
268
- const brief = renderer.render(new Watchdog(def), {
269
- watchdogId: id,
270
- files: selected,
271
- statuses: classification.files,
272
- churn: classification.churn,
273
- overflow,
274
- deleted: classification.deleted,
275
- branch,
276
- outputPath: work.findingFile(id),
277
- outputFormatPath: outputFormatPathFor(ctx),
278
- worktree: ctx.review.worktreePath,
279
- docs,
280
- context,
281
- candidates: candidates.filter((c) => c.watchdogId === id),
282
- ast: astFiles,
283
- aiPlan: aiPlans?.get(id),
284
- changedScope: classification.changedScope,
285
- });
286
- const content = `${brief}\n`;
287
- work.writeBrief(id, content);
288
- // Auditoria: o brief também fica no storage (o staging é limpo no finalize).
289
- ctx.storage.writeArtifact(ctx.review.id, {
290
- name: `briefs/${id}.md`,
291
- kind: "brief",
292
- content,
293
- });
294
- }
295
- }
296
-
297
- // ---------------------------------------------------------------------------
298
- // Passo 1 — preparar
299
- // ---------------------------------------------------------------------------
300
-
301
- /**
302
- * Resolve os candidatos Jev (Passo 1.5). Sempre grava `candidates.json`.
303
- * Falha-aberto: sem chave / erro / `--no-jev` → artefato `available:false`.
304
- */
305
- async function resolveCandidates(
306
40
  opts: ReviewOptions,
307
- classification: ClassificationResult,
308
- ctx: ReviewContext,
309
- config: HoundsmanConfig,
310
- diff: DiffSource,
311
- ai?: AiClient,
312
- ): Promise<JevCandidatesFile> {
313
- const save = (data: JevCandidatesFile): JevCandidatesFile => {
314
- ctx.storage.writeArtifact(ctx.review.id, {
315
- name: "candidates.json",
316
- kind: "candidates",
317
- content: serializeCandidates(data),
318
- });
319
- return data;
320
- };
321
-
322
- // Pré-análise por IA (opcional) tem precedência sobre o Jev.
323
- if (ai && !opts.noAi) {
324
- const analyzer = new AiPreAnalyzer(ai);
325
- if (analyzer.enabled) {
326
- const candidates: JevCandidate[] = [];
327
- for (const [id, files] of Object.entries(classification.watchdogs)) {
328
- const def = findWatchdog(config, id);
329
- if (!def) continue;
330
- const inputs = files.slice(0, 5).flatMap((file) => {
331
- // Só o trecho alterado: candidato fora do diff vira ruído de código
332
- // pré-existente no brief do watchdog.
333
- const snippet = diff.byFile.get(file);
334
- return snippet ? [{ file, snippet }] : [];
335
- });
336
- candidates.push(
337
- ...(await analyzer.analyze({
338
- watchdogId: id,
339
- name: def.name,
340
- rules: def.rules,
341
- files: inputs,
342
- })),
343
- );
344
- }
345
- return save({
346
- available: true,
347
- model: ai.modelId,
348
- threshold: JevCandidatesStore.THRESHOLD,
349
- analyzedFiles: Object.values(classification.watchdogs).flat().length,
350
- candidates,
351
- });
352
- }
353
- }
354
-
355
- if (opts.noJev) {
356
- return save(JevCandidatesStore.unavailable("desativado (--no-jev)"));
357
- }
358
-
359
- const client = new JevClient();
360
- if (!client.isConfigured()) {
361
- return save(
362
- JevCandidatesStore.unavailable(`${ENV_NAMES.typeSafeApiKey} não configurada`),
363
- );
364
- }
365
-
366
- try {
367
- const detector = new JevDetector(
368
- client,
369
- config.watchdogs as Record<string, WatchdogMeta>,
370
- ctx.root,
371
- );
372
- const result = await detector.detect(classification, opts.base, opts.head, {
373
- diff,
374
- concurrency: resolveJevConcurrency(config),
375
- });
376
- return save({
377
- available: true,
378
- model: result.model,
379
- threshold: JevCandidatesStore.THRESHOLD,
380
- analyzedFiles: result.analyzedFiles,
381
- candidates: result.candidates,
382
- skipped: result.skipped.length > 0 ? result.skipped : undefined,
383
- });
384
- } catch (error) {
385
- return save(
386
- JevCandidatesStore.unavailable(
387
- error instanceof Error ? error.message : String(error),
388
- ),
389
- );
390
- }
391
- }
392
-
393
- function jevSummary(jev: JevCandidatesFile): string[] {
394
- if (!jev.available) return [`Jev: pulado (${jev.reason}).`];
395
- const skipped = jev.skipped?.length ?? 0;
396
- return [
397
- `Jev: ${jev.candidates.length} candidato(s) em ${jev.analyzedFiles ?? 0} arquivo(s)` +
398
- (skipped > 0 ? `, ${skipped} com falha.` : "."),
399
- ];
400
- }
401
-
402
- function discoveryMessages(
403
- activeIds: string[],
404
- classification: ClassificationResult,
405
- briefsDir: string,
406
- ): string[] {
407
- const messages = [
408
- `Discovery: ${activeIds.length} watchdog(s) ativado(s), ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
409
- `Briefs: ${displayPath(briefsDir)}/`,
410
- ];
411
- for (const file of classification.deleted) {
412
- messages.push(`Deletado (breaking change): ${file}`);
413
- }
414
- return messages;
415
- }
416
-
417
- /** Resumo do escopo — lembra que só as faixas alteradas são revisáveis. */
418
- function changedScopeMessage(classification: RunClassification): string[] {
419
- const scope = classification.changedScope;
420
- if (!scope) return [];
421
- const totals = summarizeScope(scope);
422
- return [
423
- `Escopo: ${totals.files} arquivo(s), ${totals.symbols} símbolo(s) em ${totals.ranges} faixa(s) alterada(s).`,
424
- ];
425
- }
426
-
427
- /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
428
- function budgetMessage(
429
- classification: ClassificationResult,
430
- config: HoundsmanConfig,
431
- ): string[] {
432
- const maxFiles = resolveMaxFilesPerBrief(config);
433
- let overflow = 0;
434
- for (const id of Object.keys(classification.watchdogs)) {
435
- overflow += budgetFor(classification, id, maxFiles).overflow.length;
436
- }
437
- if (overflow === 0) return [];
438
- return [
439
- `Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
440
- ];
441
- }
442
-
443
- /** Resultado do prepare — mensagens para o CLI imprimir. */
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
- }
522
-
523
- /**
524
- * Classificação fallback por IA (opcional): atribui arquivos não classificados
525
- * a watchdogs conhecidos. Fail-open — falha mantém `unclassified`.
526
- */
527
- async function applyAiClassification(
528
- classification: RunClassification,
529
- config: HoundsmanConfig,
530
- ai: AiClient | undefined,
531
- opts: ReviewOptions,
532
- ): Promise<void> {
533
- if (!ai || opts.noAi || classification.unclassified.length === 0) return;
534
- const classifier = new AiClassifier(ai);
535
- if (!classifier.enabled) return;
536
- const watchdogs = config.watchdogIds.flatMap((id) => {
537
- const def = config.watchdogs[id];
538
- return def ? [{ id, name: def.name, scope: def.scope }] : [];
539
- });
540
- const assignments = await classifier.classify(classification.unclassified, watchdogs);
541
- if (assignments.size === 0) return;
542
- for (const [file, id] of assignments) {
543
- const list = classification.watchdogs[id] ?? [];
544
- if (!list.includes(file)) list.push(file);
545
- classification.watchdogs[id] = list;
546
- classification.stats[id] = list.length;
547
- }
548
- classification.unclassified = classification.unclassified.filter(
549
- (file) => !assignments.has(file),
550
- );
551
- }
552
-
553
- /**
554
- * Resumo do contexto da PR: usa o do subagente ou, na ausência, a IA
555
- * (`AiContextSummarizer`). Grava o artefato e o staging.
556
- */
557
- async function resolveContextSummary(
558
- ctx: ReviewContext,
559
- ai: AiClient | undefined,
560
- opts: ReviewOptions,
561
- ): Promise<ContextSummary | null> {
562
- const existing = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
563
- if (existing) return existing;
564
- if (!ai || opts.noAi) return null;
565
- const raw = ctx.work.readContextRaw();
566
- if (!raw) return null;
567
- const summary = await new AiContextSummarizer(ai).summarize(raw);
568
- if (!summary) return null;
569
- ctx.work.writeContextSummary(summary);
570
- ctx.storage.writeArtifact(ctx.review.id, {
571
- name: "context-summary.json",
572
- kind: "context-summary",
573
- content: `${JSON.stringify(summary, null, 2)}\n`,
574
- });
575
- return summary;
576
- }
577
-
578
- /**
579
- * Pontos técnicos estruturais: IA quando disponível e, sempre, um fallback
580
- * determinístico a partir da classificação/AST. Persistido para auditoria —
581
- * o `finalize` apenas o consome.
582
- */
583
- async function resolveTechnicalSummary(
584
- ctx: ReviewContext,
585
- ai: AiClient | undefined,
586
- opts: ReviewOptions,
587
- input: {
588
- branch: string;
589
- classification: RunClassification;
590
- context: ContextSummary | null;
591
- ast?: ReadonlyMap<string, AstAnalysis>;
592
- impact?: AstImpact;
593
- domains: readonly string[];
594
- },
595
- ): Promise<TechnicalSummary> {
596
- const fallback = buildTechnicalSummary({
597
- classification: input.classification,
598
- ast: input.ast,
599
- impact: input.impact,
600
- headSha: input.classification.headSha,
601
- });
602
- const summary =
603
- ai && !opts.noAi
604
- ? ((await new AiTechnicalSummarizer(ai).summarize({
605
- branch: input.branch,
606
- context: input.context,
607
- classification: input.classification,
608
- ast: input.ast,
609
- impact: input.impact,
610
- headSha: input.classification.headSha,
611
- domains: input.domains,
612
- })) ?? fallback)
613
- : fallback;
614
- ctx.storage.writeArtifact(ctx.review.id, {
615
- name: TECHNICAL_SUMMARY_FILE,
616
- kind: "technical",
617
- content: `${JSON.stringify(summary, null, 2)}\n`,
618
- });
619
- return summary;
620
- }
621
-
622
- /**
623
- * Planos de revisão por watchdog (uma chamada por brief, concorrência
624
- * limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
625
- */
626
- async function buildAiPlans(
627
- activeIds: string[],
628
- classification: ClassificationResult,
629
- config: HoundsmanConfig,
630
- context: ContextSummary | null,
631
- astAnalyses: ReadonlyMap<string, AstAnalysis> | undefined,
632
- astImpact: AstImpact | undefined,
633
- ai: AiClient | undefined,
634
- opts: ReviewOptions,
635
- ): Promise<Map<string, BriefPlan>> {
636
- const plans = new Map<string, BriefPlan>();
637
- if (!ai || opts.noAi) return plans;
638
- const enricher = new AiBriefEnricher(ai);
639
- if (!enricher.enabled) return plans;
640
-
641
- const docs = discoverDocs(Object.keys(classification.files), config);
642
- const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
643
- const inputs = [];
644
- for (const id of activeIds) {
645
- const def = findWatchdog(config, id);
646
- if (!def) continue;
647
- const { selected } = budgetFor(classification, id, maxFilesPerBrief);
648
- const astFiles: AstFileBrief[] = selected.flatMap((file) => {
649
- const analysis = astAnalyses?.get(file);
650
- if (!analysis?.delta) return [];
651
- return [
652
- { file, delta: analysis.delta, importers: astImpact?.importers[file] },
653
- ];
654
- });
655
- inputs.push({
656
- watchdogId: id,
657
- name: def.name,
658
- description: def.description,
659
- scope: def.scope,
660
- rules: def.rules,
661
- files: selected,
662
- churn: classification.churn,
663
- context,
664
- ast: astFiles,
665
- docs,
666
- });
667
- }
668
- return enricher.enrichAll(inputs);
669
- }
670
-
671
- /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
672
- export async function prepare(
673
- opts: ReviewOptions,
674
- branch: string,
675
- config: HoundsmanConfig,
676
- ctx: ReviewContext,
677
- ast?: AstService,
678
- ai?: AiClient,
679
- onProgress?: PrepareProgress,
41
+ deps: PrepareDeps = {},
680
42
  ): Promise<PrepareResult> {
681
- const messages: string[] = [];
682
- ctx.work.ensure();
683
- if (opts.clean) ctx.work.cleanFindings();
684
- ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
685
-
686
- // Marca o commit revisado e decide entre completo, incremental ou no-op.
687
- const headSha = ctx.git.resolveSha(opts.head);
688
- const previous = ctx.storage.readFindings(ctx.review.id);
689
- const scope = await new ReviewScopeResolver(
690
- opts,
691
- config,
692
- opts.noAst ? undefined : ast,
693
- ctx.git,
694
- scopeProgress(onProgress),
695
- ).resolve(previous, headSha);
696
-
697
- // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
698
- if (scope.kind === "noop") {
699
- const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
700
- // Ciclo em andamento continua válido (os findings já despachados devem
701
- // ser finalizados); fora isso, o relatório anterior é a resposta final.
702
- const pendingCycle = status === "reviewing";
703
- if (!pendingCycle) {
704
- writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
705
- }
706
- messages.push(
707
- `Nenhuma alteração desde a última revisão (${shortSha(scope.reviewedFrom)}).`,
708
- pendingCycle
709
- ? "Nada novo a preparar — o ciclo em andamento continua; rode `--finalize` quando os findings estiverem gravados."
710
- : "Nada a revisar — o relatório anterior foi mantido. Use `--full` para re-revisar.",
711
- );
712
- return { messages, activeIds: [] };
713
- }
714
-
715
- const { classification, diff } = scope;
716
-
717
- // Run recém-criada e sem nenhum arquivo alterado: não persiste uma "run
718
- // vazia" na lista. Acontece quando o comando roda no clone principal sem
719
- // `--pr`/`--worktree` (ex.: `houndsman` na `main` logo após revisar uma PR).
720
- // Run com artefatos anteriores (contexto, revisão) continua existindo.
721
- if (Object.keys(classification.files).length === 0) {
722
- const fresh = ctx.storage.listArtifacts(ctx.review.id).length === 0;
723
- if (fresh) {
724
- ctx.work.clean();
725
- ctx.storage.deleteReview(ctx.review.id);
726
- messages.push(
727
- `Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
728
- );
729
- return { messages, activeIds: [] };
730
- }
731
- }
732
-
733
- // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
734
- writeRunStatus(ctx.storage, ctx.review.id, {
735
- status: "reviewing",
736
- resetStartedAt: true,
737
- });
738
-
739
- await applyAiClassification(classification, config, ai, opts);
740
- ctx.storage.writeArtifact(ctx.review.id, {
741
- name: "classification.json",
742
- kind: "classification",
743
- content: `${JSON.stringify(classification, null, 2)}\n`,
744
- });
745
-
746
- const astAnalyses = scope.ast;
747
- const astImpact =
748
- astAnalyses && ast
749
- ? await runStage(onProgress, "impact", () => ast.impact(astAnalyses))
750
- : undefined;
751
- if (astAnalyses && ast?.enabled) {
752
- const summary = {
753
- enabled: true,
754
- files: Object.fromEntries(
755
- [...astAnalyses].map(([path, analysis]) => [
756
- path,
757
- {
758
- facts: analysis.facts,
759
- delta: analysis.delta,
760
- deleted: analysis.deleted,
761
- },
762
- ]),
763
- ),
764
- };
765
- ctx.storage.writeArtifact(ctx.review.id, {
766
- name: "ast-summary.json",
767
- kind: "ast",
768
- content: `${JSON.stringify(summary, null, 2)}\n`,
769
- });
770
- if (astImpact) {
771
- ctx.storage.writeArtifact(ctx.review.id, {
772
- name: "ast-impact.json",
773
- kind: "ast-impact",
774
- content: `${JSON.stringify(astImpact, null, 2)}\n`,
775
- });
776
- }
777
- }
778
-
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
- );
786
-
787
- const activeIds = Object.keys(classification.watchdogs);
788
- if (Object.keys(classification.files).length === 0) {
789
- writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
790
- messages.push(
791
- classification.mode === "incremental"
792
- ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
793
- : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
794
- );
795
- return { messages, activeIds: [] };
796
- }
797
- if (activeIds.length === 0) {
798
- writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
799
- messages.push(
800
- "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
801
- );
802
- return { messages, activeIds: [] };
803
- }
804
- if (classification.mode === "incremental") {
805
- messages.push(
806
- `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.`,
807
- );
808
- }
809
-
810
- persistContext(ctx);
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
- );
826
-
827
- const manifestAst: ManifestAst | undefined = astAnalyses
828
- ? {
829
- enabled: ast?.enabled ?? false,
830
- languages: [
831
- ...new Set(
832
- [...astAnalyses.values()].map(
833
- (analysis) => analysis.facts.language,
834
- ),
835
- ),
836
- ].sort(),
837
- files: astAnalyses.size,
838
- impact: {
839
- affectedFiles: astImpact?.affectedFiles.length ?? 0,
840
- importers: Object.keys(astImpact?.importers ?? {}).length,
841
- exportedChanges: astImpact?.exportedChanges.length ?? 0,
842
- },
843
- }
844
- : undefined;
845
-
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
- ),
858
- );
859
- if (aiPlans.size > 0) {
860
- ctx.storage.writeArtifact(ctx.review.id, {
861
- name: "ai-briefs.json",
862
- kind: "ai-brief",
863
- content: `${JSON.stringify(
864
- { plans: Object.fromEntries(aiPlans) },
865
- null,
866
- 2,
867
- )}\n`,
868
- });
869
- }
870
- const aiUsage = ai?.recorder.total;
871
- const manifestAi: ManifestAi | undefined =
872
- ai && !opts.noAi && (aiPlans.size > 0 || (aiUsage?.calls ?? 0) > 0)
873
- ? {
874
- enabled: true,
875
- provider: ai.projectConfig.aiProvider,
876
- model: ai.modelId,
877
- briefPlans: aiPlans.size,
878
- usage: aiUsage ?? {
879
- inputTokens: 0,
880
- outputTokens: 0,
881
- totalTokens: 0,
882
- calls: 0,
883
- },
884
- }
885
- : undefined;
886
-
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
- ),
900
- );
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
- ),
913
- );
914
- messages.push(
915
- ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
916
- ...budgetMessage(classification, config),
917
- ...changedScopeMessage(classification),
918
- ...jevSummary(jev),
919
- );
920
- if (astAnalyses) {
921
- messages.push(
922
- `AST: ${astAnalyses.size} arquivo(s) analisado(s), ` +
923
- `${astImpact?.affectedFiles.length ?? 0} impactado(s).`,
924
- );
925
- } else {
926
- messages.push("AST: desabilitado ou sem grammar compatível.");
927
- }
928
-
929
- if (context) {
930
- messages.push("Contexto da PR resumido e injetado nos briefs.");
931
- } else {
932
- messages.push(
933
- "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
934
- );
935
- }
936
- if (aiPlans.size > 0) {
937
- messages.push(
938
- `IA: plano de revisão em ${aiPlans.size} brief(s) (sugestão, não substitui regras).`,
939
- );
940
- }
941
- if (aiUsage && aiUsage.calls > 0) {
942
- messages.push(
943
- `IA: ${aiUsage.calls} chamada(s), ${aiUsage.totalTokens} token(s).`,
944
- );
945
- ai?.recorder.write(ctx.storage, ctx.review.id);
946
- }
947
-
948
- messages.push(
949
- `\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
950
- "Quando os findings estiverem gravados: bun run houndsman --finalize",
951
- );
952
-
953
- return {
954
- messages,
955
- activeIds,
956
- briefsDir: ctx.work.briefsDir,
957
- reviewId: ctx.review.id,
958
- };
959
- }
960
- /** Persiste o contexto da PR (bruto + resumo) como artefatos, quando existirem. */
961
- function persistContext(ctx: ReviewContext): void {
962
- const raw = ctx.work.readContextRaw();
963
- if (raw !== null) {
964
- ctx.storage.writeArtifact(ctx.review.id, {
965
- name: "context.json",
966
- kind: "context",
967
- content: raw,
968
- });
969
- }
970
- const summary = ctx.work.readContextSummaryRaw();
971
- if (summary !== null) {
972
- ctx.storage.writeArtifact(ctx.review.id, {
973
- name: "context-summary.json",
974
- kind: "context-summary",
975
- content: summary,
976
- });
977
- }
978
- const brief = ctx.work.readContextBriefRaw();
979
- if (brief !== null) {
980
- ctx.storage.writeArtifact(ctx.review.id, {
981
- name: "context-brief.md",
982
- kind: "context-brief",
983
- content: brief,
984
- });
985
- }
986
- }
987
-
988
- // ---------------------------------------------------------------------------
989
- // Passo 2 — finalizar
990
- // ---------------------------------------------------------------------------
991
-
992
- /** Reaproveita `ast`/`ai` do manifesto anterior (o finalize não os recalcula). */
993
- function readManifestExtras(ctx: ReviewContext): {
994
- ast?: ManifestAst;
995
- ai?: ManifestAi;
996
- } {
997
- const artifact = ctx.storage.readArtifact(ctx.review.id, "manifest.json");
998
- if (!artifact) return {};
999
- try {
1000
- const parsed = JSON.parse(artifact.content) as {
1001
- ast?: ManifestAst;
1002
- ai?: ManifestAi;
1003
- };
1004
- return { ast: parsed.ast, ai: parsed.ai };
1005
- } catch {
1006
- return {};
1007
- }
1008
- }
1009
-
1010
- /** Lê o `classification.json` persistido pelo prepare (lança se ausente). */
1011
- function readClassification(ctx: ReviewContext): RunClassification {
1012
- const artifact = ctx.storage.readArtifact(ctx.review.id, "classification.json");
1013
- if (!artifact) {
1014
- throw new Error(
1015
- `Não encontrei a classificação da review. Rode \`bun run houndsman\` (preparar) primeiro.`,
1016
- );
1017
- }
1018
- return JSON.parse(artifact.content) as RunClassification;
1019
- }
1020
-
1021
- /** Lê os candidatos Jev persistidos (unavailable se ausente/inválido). */
1022
- function readCandidates(ctx: ReviewContext, config: HoundsmanConfig): JevCandidatesFile {
1023
- const artifact = ctx.storage.readArtifact(ctx.review.id, "candidates.json");
1024
- if (!artifact) return JevCandidatesStore.unavailable("candidates.json ausente");
1025
- try {
1026
- return (
1027
- parseCandidates(JSON.parse(artifact.content), config.watchdogIds) ??
1028
- JevCandidatesStore.unavailable("candidates.json inválido")
1029
- );
1030
- } catch {
1031
- return JevCandidatesStore.unavailable("candidates.json inválido");
1032
- }
1033
- }
1034
-
1035
- /** Resumo do contexto persistido (usado quando o staging já foi limpo). */
1036
- function readPersistedContextSummary(ctx: ReviewContext): ContextSummary | null {
1037
- const artifact = ctx.storage.readArtifact(ctx.review.id, "context-summary.json");
1038
- if (!artifact) return null;
1039
- try {
1040
- return parseContextSummary(JSON.parse(artifact.content));
1041
- } catch {
1042
- return null;
1043
- }
1044
- }
1045
-
1046
- /** JSON persistido, ou `null` quando ausente/inválido. */
1047
- function readArtifactJson<T>(ctx: ReviewContext, name: string): T | null {
1048
- const artifact = ctx.storage.readArtifact(ctx.review.id, name);
1049
- if (!artifact) return null;
1050
- try {
1051
- return JSON.parse(artifact.content) as T;
1052
- } catch {
1053
- return null;
1054
- }
1055
- }
1056
-
1057
- /** Pontos técnicos persistidos pelo `prepare`; `null` quando ausentes. */
1058
- function readPersistedTechnicalSummary(ctx: ReviewContext): TechnicalSummary | null {
1059
- const raw = readArtifactJson<unknown>(ctx, TECHNICAL_SUMMARY_FILE);
1060
- return raw ? parseTechnicalSummary(raw) : null;
1061
- }
1062
-
1063
- /** Fallback para runs antigas: reconstrói os destaques dos artefatos de AST. */
1064
- function buildTechnicalFallback(
1065
- ctx: ReviewContext,
1066
- classification: RunClassification,
1067
- ): TechnicalSummary {
1068
- const ast = readArtifactJson<{ files?: Record<string, AstAnalysis> }>(
1069
- ctx,
1070
- "ast-summary.json",
1071
- );
1072
- return buildTechnicalSummary({
1073
- classification,
1074
- ast: ast?.files ? new Map(Object.entries(ast.files)) : undefined,
1075
- impact: readArtifactJson<AstImpact>(ctx, "ast-impact.json") ?? undefined,
1076
- headSha: classification.headSha,
1077
- });
1078
- }
1079
-
1080
- /**
1081
- * Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
1082
- * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
1083
- */
1084
- function writeDomainReports(
1085
- ctx: ReviewContext,
1086
- branch: string,
1087
- reports: WatchdogReport[],
1088
- classification: ClassificationResult,
1089
- context: ContextSummary | null,
1090
- nameOf: (id: string) => string,
1091
- ): DomainReport[] {
1092
- const domains: DomainReport[] = [];
1093
- const renderer = new ReportRenderer(nameOf);
1094
- for (const report of reports) {
1095
- const findings = dedupeFindings([report]);
1096
- if (findings.length === 0) continue;
1097
- const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
1098
- const expected = classification.watchdogs[report.watchdogId] ?? [];
1099
- ctx.storage.writeArtifact(ctx.review.id, {
1100
- name: file,
1101
- kind: "domain-review",
1102
- content: `${renderer.renderDomain(
1103
- branch,
1104
- report,
1105
- findings,
1106
- expected.length,
1107
- context,
1108
- )}\n`,
1109
- });
1110
- domains.push({
1111
- id: report.watchdogId,
1112
- name: nameOf(report.watchdogId),
1113
- file,
1114
- findings: findings.length,
1115
- reviewedFiles: report.reviewedFiles.length,
1116
- expectedFiles: expected.length,
1117
- });
1118
- }
1119
- return domains;
43
+ return new PreparePipeline(ctx, config, opts, deps).run();
1120
44
  }
1121
45
 
1122
- export type FinalizeResult = {
1123
- reportPath: string;
1124
- blockers: number;
1125
- attention: number;
1126
- messages: string[];
1127
- };
1128
-
1129
- /**
1130
- * Passo 2 — consolida os findings em review.md.
1131
- * Lança se houver findings pendentes (o CLI decide o exit code).
1132
- */
46
+ /** Passo 2 — consolida os findings no resumo final. */
1133
47
  export function finalize(
1134
- opts: ReviewOptions,
1135
- branch: string,
1136
- config: HoundsmanConfig,
1137
48
  ctx: ReviewContext,
49
+ config: HoundsmanConfig,
50
+ opts: ReviewOptions,
1138
51
  ): FinalizeResult {
1139
- // `--finalize` só consolida um ciclo preparado (status `reviewing`). Sem esta
1140
- // guarda, um prepare no-op seguido de `--finalize` reescreveria o relatório
1141
- // anterior com a classificação antiga (perdendo os relatórios por domínio).
1142
- // O `ctx.review` é um snapshot: relê o status atual do storage.
1143
- const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
1144
- if (status !== "reviewing") {
1145
- const state = ctx.storage.readFindings(ctx.review.id);
1146
- const present = Object.values(state?.findings ?? {}).filter(
1147
- (finding) => finding.present,
1148
- );
1149
- return {
1150
- reportPath: ctx.storage.location(ctx.review.id),
1151
- blockers: present.filter((f) => f.severity === "blocking").length,
1152
- attention: present.filter((f) => f.severity === "attention").length,
1153
- messages: [
1154
- `Nada a finalizar: nenhum ciclo de revisão pendente (status: ${status ?? "sem status"}).`,
1155
- "Se nada mudou desde a última revisão, o relatório anterior foi mantido; use `--full` para re-revisar.",
1156
- ],
1157
- };
1158
- }
1159
-
1160
- const classification = readClassification(ctx);
1161
- const activeIds = Object.keys(classification.watchdogs);
1162
-
1163
- const messages: string[] = [];
1164
- const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
1165
- messages.push(...warnings);
1166
- const jev = readCandidates(ctx, config);
1167
- const extras = readManifestExtras(ctx);
1168
- writeManifest(
1169
- ctx,
1170
- config,
1171
- opts,
1172
- classification,
1173
- activeIds,
1174
- pending,
1175
- jev,
1176
- extras.ast,
1177
- extras.ai,
1178
- );
1179
-
1180
- if (pending.length > 0) {
1181
- throw new Error(
1182
- `Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
1183
- `Cada watchdog deve gravar ${displayPath(ctx.work.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
1184
- );
1185
- }
1186
-
1187
- const now = systemClock();
1188
- // Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
1189
- const merged = mergeCarryover(
1190
- dedupeFindings(reports),
1191
- classification.carryover ?? [],
1192
- );
1193
- if (classification.mode === "incremental") {
1194
- messages.push(
1195
- `Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
1196
- );
1197
- }
1198
-
1199
- // Estado de triagem: preserva o que já existia (casando por fingerprint) e
1200
- // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
1201
- // recalculada por `houndsman findings sync` após edições. O commit revisado
1202
- // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
1203
- const state = buildStateFromFindings(merged, {
1204
- branch,
1205
- readFile: fileReader(ctx.root),
1206
- previous: ctx.storage.readFindings(ctx.review.id),
1207
- reviewedPaths: new Set([
1208
- ...reports.flatMap((report) => report.reviewedFiles),
1209
- // Arquivo deletado: o achado deixou de existir junto com ele.
1210
- ...classification.deleted,
1211
- ]),
1212
- now,
1213
- headSha: classification.headSha,
1214
- reviewed: classification.headSha
1215
- ? { headSha: classification.headSha, at: now }
1216
- : undefined,
1217
- });
1218
-
1219
- // Findings corrigidos entre revisões (ausentes + done) têm a conversa do PR
1220
- // resolvida automaticamente; as já resolvidas no GitHub são puladas. Só
1221
- // toca o `gh` quando há vínculo remoto pendente (fail-open).
1222
- const threadIds = Object.values(state.findings)
1223
- .filter(
1224
- (finding) =>
1225
- !finding.present &&
1226
- finding.execState === "done" &&
1227
- finding.github &&
1228
- finding.github.kind !== "comment" &&
1229
- !finding.github.resolvedAt,
1230
- )
1231
- .map((finding) => finding.id);
1232
- if (threadIds.length > 0) {
1233
- const sync = syncResolvedThreads({ state, ids: threadIds, now });
1234
- if (!sync.available) {
1235
- messages.push(`GitHub: threads não sincronizadas (${sync.reason}).`);
1236
- } else if (sync.checked > 0) {
1237
- const failures =
1238
- sync.failed.length > 0 ? `, ${sync.failed.length} falha(s)` : "";
1239
- messages.push(
1240
- `GitHub: ${sync.resolved.length} thread(s) resolvida(s), ${sync.skipped.length} já resolvida(s)/pulada(s)${failures}.`,
1241
- );
1242
- }
1243
- }
1244
-
1245
- ctx.storage.writeFindings(ctx.review.id, state);
1246
- ctx.storage.updateReview(ctx.review.id, {
1247
- base: opts.base,
1248
- head: opts.head,
1249
- headSha: classification.headSha,
1250
- });
1251
- writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
1252
-
1253
- persistContext(ctx);
1254
- const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
1255
- const technical =
1256
- readPersistedTechnicalSummary(ctx) ?? buildTechnicalFallback(ctx, classification);
1257
- const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
1258
- const domains = writeDomainReports(
1259
- ctx,
1260
- branch,
1261
- reports,
1262
- classification,
1263
- context,
1264
- nameOf,
1265
- );
1266
- const markdown = new ReportRenderer(nameOf).render(
1267
- branch,
1268
- reports,
1269
- merged,
1270
- { classified: classification.watchdogs },
1271
- context,
1272
- jev.available ? jev.candidates : undefined,
1273
- domains,
1274
- );
1275
- ctx.storage.writeArtifact(ctx.review.id, {
1276
- name: REVIEW_FILE,
1277
- kind: "review",
1278
- content: `${incrementalNote(classification)}${markdown}\n`,
1279
- });
1280
- // Resumo final para leitura humana — não participa de nenhuma etapa seguinte.
1281
- ctx.storage.writeArtifact(ctx.review.id, {
1282
- name: REVIEW_RESUME_FILE,
1283
- kind: "resume",
1284
- content: renderReviewResume({
1285
- branch,
1286
- date: now.slice(0, 10),
1287
- context,
1288
- classification,
1289
- findings: merged,
1290
- technical,
1291
- }),
1292
- });
1293
-
1294
- const location = ctx.storage.location(ctx.review.id);
1295
- messages.push(
1296
- `Triagem: ${location} — ${Object.keys(state.findings).length} finding(s).`,
1297
- );
1298
-
1299
- const blockers = merged.filter((f) => f.severity === "blocking").length;
1300
- const attention = merged.filter((f) => f.severity === "attention").length;
1301
- messages.push(
1302
- `Relatório: ${location} — ${blockers} bloqueante(s), ${attention} atenção.`,
1303
- );
1304
-
1305
- // O staging já cumpriu o papel (briefs/findings/contexto estão no storage).
1306
- ctx.work.clean();
1307
-
1308
- return { reportPath: location, blockers, attention, messages };
52
+ return new FinalizePipeline(ctx, config, opts).run();
1309
53
  }
1310
54
 
1311
55
  /** Resolve a branch (opção explícita ou a atual do git). */