@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.
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/commands/compare-command.ts +112 -0
- package/src/cli/commands/context-command.ts +1 -0
- package/src/cli/commands/findings-command.ts +11 -1
- package/src/cli/commands/hunt-command.ts +20 -3
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/index.ts +3 -1
- package/src/cli/progress.ts +7 -2
- package/src/core/ai/ai-client.ts +38 -2
- package/src/core/ai/features/brief-enricher.ts +1 -1
- package/src/core/ai/features/business-analyzer.ts +61 -0
- package/src/core/ai/features/business-summarizer.ts +44 -0
- package/src/core/ai/index.ts +8 -0
- package/src/core/ai/prompts/brief.ts +33 -7
- package/src/core/ai/prompts/business.ts +76 -0
- package/src/core/ai/provider-capabilities.ts +12 -0
- package/src/core/ai/schemas.ts +70 -2
- package/src/core/ai/usage-recorder.ts +53 -1
- package/src/core/brief.ts +246 -12
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +233 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +27 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +243 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +31 -3
- package/src/core/defaults.ts +50 -4
- package/src/core/domain/budget.ts +62 -4
- package/src/core/domain/classification.ts +12 -2
- package/src/core/engine/carryover.ts +4 -0
- package/src/core/engine/finalize-pipeline.ts +347 -0
- package/src/core/engine/prepare-pipeline.ts +1294 -0
- package/src/core/engine/review-context.ts +51 -0
- package/src/core/engine/review-options.ts +47 -0
- package/src/core/engine/review-scope.ts +14 -1
- package/src/core/engine/review-session.ts +11 -13
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/findings-state.ts +13 -1
- package/src/core/github-review.ts +5 -2
- package/src/core/hunt/hunt-runner.ts +90 -0
- package/src/core/hunt/index.ts +2 -0
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +13 -1
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/logging/index.ts +6 -1
- package/src/core/logging/progress.ts +85 -1
- package/src/core/orchestrator.ts +33 -1289
- package/src/core/paths.ts +6 -3
- package/src/core/pr-context.ts +2 -4
- package/src/core/prepare-report.ts +61 -0
- package/src/core/project-config.ts +8 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +60 -21
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +108 -278
- package/src/core/storage/file/file-storage.ts +7 -3
- package/src/core/storage/sqlite/mappers.ts +10 -0
- package/src/core/storage/sqlite/schema.ts +104 -23
- package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
- package/src/core/storage/types.ts +16 -4
- package/src/core/storage/work-area.ts +59 -1
- package/src/core/types.ts +31 -0
- package/src/core/watchdog-registry.ts +21 -3
- package/src/index.ts +29 -4
- package/src/server/client/ArtifactsPanel.tsx +4 -2
- package/src/server/client/FindingCard.tsx +21 -1
- package/src/server/client/RunsSection.tsx +5 -0
- package/src/server/client/utils/constants.ts +21 -11
- package/src/server/client/utils/markdown.ts +1 -1
- package/src/server/client/utils/types.ts +14 -2
- package/src/server/run-repository.ts +35 -14
- package/src/server/styles.css +2 -0
- package/src/skill/OUTPUT-FORMAT.md +36 -10
- package/src/skill/README.md +18 -14
- package/src/skill/SETUP.md +1 -0
- package/src/skill/SKILL.md +13 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +145 -58
- package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
- package/src/tests/ai-core.test.ts +46 -0
- package/src/tests/ai-features.test.ts +95 -4
- package/src/tests/baseline.test.ts +29 -121
- package/src/tests/brief-scope.test.ts +250 -0
- package/src/tests/business-summary.test.ts +258 -0
- package/src/tests/compare.test.ts +233 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/hunt-maki.test.ts +58 -0
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +29 -1
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +38 -29
- package/src/tests/review-scripts.test.ts +106 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/storage.test.ts +194 -9
- package/src/core/renderers/report-renderer.ts +0 -66
- 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
|
+
}
|