@payconductor/houndsman 0.8.0 → 0.10.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/core/ai/ai-client.ts +21 -0
  3. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  4. package/src/core/ai/features/technical-summarizer.ts +43 -0
  5. package/src/core/ai/index.ts +4 -1
  6. package/src/core/ai/prompts/context.ts +3 -3
  7. package/src/core/ai/prompts/technical.ts +30 -0
  8. package/src/core/ai/provider-capabilities.ts +45 -0
  9. package/src/core/ai/schemas.ts +19 -3
  10. package/src/core/ai/types.ts +3 -1
  11. package/src/core/brief.ts +25 -4
  12. package/src/core/commands/scaffold.ts +5 -0
  13. package/src/core/config.ts +17 -1
  14. package/src/core/git-runner.ts +8 -1
  15. package/src/core/orchestrator.ts +114 -2
  16. package/src/core/paths.ts +3 -0
  17. package/src/core/pr-context.ts +11 -6
  18. package/src/core/project-config.ts +9 -1
  19. package/src/core/renderers/resume-renderer.ts +17 -34
  20. package/src/core/report-builder.ts +6 -6
  21. package/src/core/services/worktree-manager.ts +29 -5
  22. package/src/core/storage/types.ts +1 -0
  23. package/src/core/technical-summary.ts +398 -0
  24. package/src/harness/agent/agent-factory.ts +1 -0
  25. package/src/harness/agent/review-agent.ts +11 -1
  26. package/src/harness/config/harness-config.ts +6 -1
  27. package/src/index.ts +2 -0
  28. package/src/server/client/App.tsx +2 -1
  29. package/src/server/client/PrsSection.tsx +296 -0
  30. package/src/server/client/ResumePanel.tsx +10 -13
  31. package/src/server/client/RunsSection.tsx +73 -0
  32. package/src/server/client/Sidebar.tsx +171 -413
  33. package/src/server/client/sidebar-sections.tsx +73 -0
  34. package/src/server/client/utils/constants.ts +7 -2
  35. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  36. package/src/server/client/utils/types.ts +1 -0
  37. package/src/server/index.ts +23 -409
  38. package/src/server/styles.css +454 -0
  39. package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
  40. package/src/tests/ai-core.test.ts +71 -0
  41. package/src/tests/ai-features.test.ts +36 -0
  42. package/src/tests/baseline.test.ts +4 -1
  43. package/src/tests/harness-agent.test.ts +19 -0
  44. package/src/tests/review-resume.test.ts +36 -8
  45. package/src/tests/review-scope.test.ts +67 -0
  46. package/src/tests/review-scripts.test.ts +2 -2
  47. package/src/tests/server.test.ts +16 -1
  48. package/src/tests/sidebar-sections.test.ts +94 -0
  49. package/src/tests/technical-summary.test.ts +176 -0
  50. package/src/tests/worktree.test.ts +39 -4
  51. package/assets/logo-legacy.png +0 -0
@@ -229,13 +229,17 @@ export function renderContextBrief(
229
229
 
230
230
  lines.push("## Regras", "");
231
231
  lines.push(
232
- "- **Seja curto** — o resumo abre o relatório e entra em **todos** os briefs.",
232
+ "- O resumo abre o relatório e o resumo final; nos briefs ele é recortado.",
233
233
  );
234
234
  lines.push("- Baseie-se **somente** no `context.json` — não invente intenção.");
235
- lines.push("- `objective`: 1 frase (≤ 200 caracteres) sobre o que a PR entrega.");
236
- lines.push("- `motivation`: 1 frase (≤ 240 caracteres) sobre o porquê.");
237
235
  lines.push(
238
- "- `changeSummary`: no máximo 4 bullets (≤ 160 caracteres cada), focados em comportamento — não liste arquivos.",
236
+ "- `objective`: até 2 frases (≤ 400 caracteres) sobre o que a PR entrega.",
237
+ );
238
+ lines.push(
239
+ "- `motivation`: até 3 frases (≤ 480 caracteres) sobre o porquê e o impacto.",
240
+ );
241
+ lines.push(
242
+ "- `changeSummary`: no máximo 6 bullets (≤ 240 caracteres cada), focados em comportamento — não liste arquivos.",
239
243
  );
240
244
  lines.push(
241
245
  "- NÃO copie a descrição da PR: sintetize. Copie `source` do contexto bruto.",
@@ -253,7 +257,8 @@ const CONTEXT_SUMMARY_TEMPLATE = {
253
257
  base: "main",
254
258
  head: "feature/x",
255
259
  },
256
- objective: "O que a PR entrega, em 1 frase.",
257
- motivation: "Por que a mudança é necessária, em 1-2 frases.",
260
+ objective: "O que a PR entrega, em 1-2 frases.",
261
+ motivation:
262
+ "Por que a mudança é necessária, com o problema e o impacto (até 3 frases).",
258
263
  changeSummary: ["Bullet de comportamento 1", "Bullet de comportamento 2"],
259
264
  };
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type {
10
10
  AiProviderKind,
11
+ AiProviderOptions,
11
12
  HoundsmanAi,
12
13
  HoundsmanAiFeatures,
13
14
  HoundsmanAst,
@@ -138,6 +139,12 @@ export class ProjectConfig<TId extends string = string> {
138
139
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
139
140
  }
140
141
 
142
+ /** Opções do AI SDK por provider (undefined quando ausentes/vazias). */
143
+ get aiProviderOptions(): AiProviderOptions | undefined {
144
+ const value = this.ai.providerOptions;
145
+ return value && Object.keys(value).length > 0 ? value : undefined;
146
+ }
147
+
141
148
  get aiTimeoutMs(): number {
142
149
  return positive(this.ai.timeoutMs, DEFAULT_AI_TIMEOUT_MS);
143
150
  }
@@ -152,12 +159,13 @@ export class ProjectConfig<TId extends string = string> {
152
159
  return value !== undefined && value > 0 ? value : undefined;
153
160
  }
154
161
 
155
- /** Features resolvidas com defaults (`context`/`brief` ligadas). */
162
+ /** Features resolvidas com defaults (`context`/`brief`/`technical` ligadas). */
156
163
  get aiFeatures(): Required<HoundsmanAiFeatures> {
157
164
  const features = this.ai.features ?? {};
158
165
  return {
159
166
  context: features.context !== false,
160
167
  brief: features.brief !== false,
168
+ technical: features.technical !== false,
161
169
  classification: features.classification === true,
162
170
  preAnalysis: features.preAnalysis === true,
163
171
  verify: features.verify === true,
@@ -3,19 +3,16 @@
3
3
  *
4
4
  * Artefato somente-leitura para humanos: não entra em briefs, não é lido pelos
5
5
  * watchdogs e não participa de nenhuma etapa seguinte. Une o contexto de
6
- * negócio da PR aos números técnicos diretos da revisão, com limite de
6
+ * negócio da PR aos pontos técnicos estruturais da revisão, com limite de
7
7
  * `RESUME_MAX_CHARS`.
8
8
  */
9
9
 
10
- import type {
11
- ContextSummary,
12
- MergedFinding,
13
- WatchdogNameResolver,
14
- } from "../report-builder";
10
+ import type { ContextSummary, MergedFinding } from "../report-builder";
15
11
  import { systemClock } from "../services/clock";
12
+ import type { TechnicalSummary } from "../technical-summary";
16
13
 
17
14
  /** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
18
- export const RESUME_MAX_CHARS = 2500;
15
+ export const RESUME_MAX_CHARS = 5000;
19
16
 
20
17
  /** Dados de classificação usados no resumo. */
21
18
  export type ResumeClassification = {
@@ -30,11 +27,10 @@ export type ReviewResumeInput = {
30
27
  context?: ContextSummary | null;
31
28
  classification: ResumeClassification;
32
29
  findings: readonly MergedFinding[];
33
- nameOf?: WatchdogNameResolver;
30
+ /** Pontos técnicos estruturais (não achados). */
31
+ technical?: TechnicalSummary | null;
34
32
  };
35
33
 
36
- const identityName: WatchdogNameResolver = (id) => id;
37
-
38
34
  const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
39
35
  blocking: "❌ Mudanças necessárias",
40
36
  attention: "⚠️ Aprovado com ressalvas",
@@ -80,36 +76,24 @@ function businessSection(input: ReviewResumeInput): string[] {
80
76
  if (context.objective) lines.push(context.objective, "");
81
77
  if (context.motivation) lines.push(`**Por quê:** ${context.motivation}`, "");
82
78
  if (context.changeSummary && context.changeSummary.length > 0) {
83
- for (const item of context.changeSummary.slice(0, 4)) {
84
- lines.push(`- ${truncate(item, 180)}`);
79
+ for (const item of context.changeSummary.slice(0, 6)) {
80
+ lines.push(`- ${truncate(item, 240)}`);
85
81
  }
86
82
  lines.push("");
87
83
  }
88
84
  return lines;
89
85
  }
90
86
 
91
- /** Achados que exigem ação, em formato direto (máximo 5). */
92
- function technicalSection(
93
- findings: readonly MergedFinding[],
94
- nameOf: WatchdogNameResolver,
95
- ): string[] {
96
- const relevant = findings
97
- .filter(
98
- (finding) =>
99
- finding.severity === "blocking" || finding.severity === "attention",
100
- )
101
- .slice(0, 5);
87
+ /** Pontos técnicos estruturais (módulos, contratos, impacto) — não achados. */
88
+ function technicalSection(summary: TechnicalSummary | null | undefined): string[] {
102
89
  const lines = ["## Pontos técnicos", ""];
103
- if (relevant.length === 0) {
104
- lines.push("- Nenhum bloqueio ou ponto de atenção.", "");
90
+ if (!summary || (!summary.overview && summary.highlights.length === 0)) {
91
+ lines.push("Sem mudanças estruturais detectadas nesta revisão.", "");
105
92
  return lines;
106
93
  }
107
- for (const finding of relevant) {
108
- const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
109
- const watchdogs = finding.watchdogs.map((id) => nameOf(id)).join(", ");
110
- lines.push(
111
- `- \`${location}\` — ${truncate(finding.message, 130)}${watchdogs ? ` _(${watchdogs})_` : ""}`,
112
- );
94
+ if (summary.overview) lines.push(summary.overview, "");
95
+ for (const highlight of summary.highlights) {
96
+ lines.push(`- **${highlight.area}** — ${truncate(highlight.detail, 300)}`);
113
97
  }
114
98
  lines.push("");
115
99
  return lines;
@@ -125,10 +109,9 @@ function clamp(markdown: string, max: number): string {
125
109
 
126
110
  /**
127
111
  * Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
128
- * nasce do contexto já disponível e dos findings consolidados.
112
+ * nasce do contexto já disponível e dos pontos técnicos persistidos.
129
113
  */
130
114
  export function renderReviewResume(input: ReviewResumeInput): string {
131
- const nameOf = input.nameOf ?? identityName;
132
115
  const blockers = input.findings.filter((f) => f.severity === "blocking").length;
133
116
  const attention = input.findings.filter((f) => f.severity === "attention").length;
134
117
  const status =
@@ -144,7 +127,7 @@ export function renderReviewResume(input: ReviewResumeInput): string {
144
127
  `**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
145
128
  "",
146
129
  ...businessSection(input),
147
- ...technicalSection(input.findings, nameOf),
130
+ ...technicalSection(input.technical),
148
131
  ];
149
132
 
150
133
  return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
@@ -63,14 +63,14 @@ export type ContextSummary = {
63
63
  const VALID_SEVERITIES = new Set<string>(SEVERITY_ORDER);
64
64
 
65
65
  /**
66
- * Limites do resumo da PR: mantém o relatório e os briefs enxutos — evita
67
- * confundir o watchdog humano e estourar o contexto da IA watchdog.
66
+ * Limites do resumo da PR: o contexto abre o relatório e o resumo final, mas
67
+ * não pode inflar os briefs — a injeção nos briefs é recortada em `brief.ts`.
68
68
  */
69
69
  const CONTEXT_LIMITS = {
70
- objective: 200,
71
- motivation: 240,
72
- changeSummaryItem: 160,
73
- changeSummaryItems: 4,
70
+ objective: 400,
71
+ motivation: 480,
72
+ changeSummaryItem: 240,
73
+ changeSummaryItems: 6,
74
74
  } as const;
75
75
 
76
76
  /** Corta `value` em `max` caracteres, sinalizando com reticências. */
@@ -4,7 +4,12 @@
4
4
  * Um worktree por branch revisada, criado em **detached HEAD** no commit da PR.
5
5
  * Detached evita o conflito "branch já checada" com o clone principal e é o
6
6
  * modo correto para revisão (somente leitura). Vive fora do projeto, em
7
- * `<HOUNDSMAN_HOME>/worktrees/<repoSlug>/<branchSlug>`.
7
+ * `<baseDir>/<repoSlug>/<branchSlug>`.
8
+ *
9
+ * Os dois segmentos são `<trecho-legível>-<hash8>`: o trecho legível é
10
+ * truncado porque o checkout de uma PR herda a profundidade dos arquivos do
11
+ * projeto, e o Windows (MAX_PATH = 260) estoura "Filename too long" com
12
+ * repo+branch longos. O hash mantém o nome determinístico e único.
8
13
  */
9
14
 
10
15
  import { createHash } from "node:crypto";
@@ -64,6 +69,22 @@ export type WorktreeManagerOptions = {
64
69
  baseDir?: string;
65
70
  };
66
71
 
72
+ /** Limite do trecho legível no diretório do repositório. */
73
+ const REPO_SLUG_MAX = 24;
74
+ /** Limite do trecho legível no diretório da branch. */
75
+ const BRANCH_SLUG_MAX = 20;
76
+
77
+ /**
78
+ * `<trecho legível truncado>-<hash8>`: curto o bastante para o MAX_PATH do
79
+ * Windows e estável para o mesmo par (repositório, branch).
80
+ */
81
+ function hashName(key: string, readable: string, max: number, fallback: string): string {
82
+ const hash = createHash("sha1").update(key).digest("hex").slice(0, 8);
83
+ // Ponto/traço no fim do nome é descartado pelo Windows; corte antes do hash.
84
+ const head = readable.slice(0, max).replace(/[-._]+$/, "");
85
+ return `${head || fallback}-${hash}`;
86
+ }
87
+
67
88
  /** Slug estável e legível para o diretório do repositório. */
68
89
  export function repoSlug(repoKey: string): string {
69
90
  const clean = repoKey
@@ -71,8 +92,12 @@ export function repoSlug(repoKey: string): string {
71
92
  .replace(/[^a-zA-Z0-9._-]+/g, "-")
72
93
  .replace(/^-+|-+$/g, "")
73
94
  .toLowerCase();
74
- const hash = createHash("sha1").update(repoKey).digest("hex").slice(0, 8);
75
- return `${clean.slice(-60) || "repo"}-${hash}`;
95
+ return hashName(repoKey, clean.slice(-REPO_SLUG_MAX), REPO_SLUG_MAX, "repo");
96
+ }
97
+
98
+ /** Slug estável e curto para o diretório de uma branch. */
99
+ export function branchSlug(branch: string): string {
100
+ return hashName(branch, sanitizeBranch(branch), BRANCH_SLUG_MAX, "branch");
76
101
  }
77
102
 
78
103
  /** Normaliza um path para comparação (Windows é case-insensitive). */
@@ -154,8 +179,7 @@ export class WorktreeManager {
154
179
 
155
180
  /** Caminho determinístico do worktree de uma branch. */
156
181
  pathFor(branch: string): string {
157
- const slug = sanitizeBranch(branch) || "branch";
158
- return join(this.rootDir, slug);
182
+ return join(this.rootDir, branchSlug(branch));
159
183
  }
160
184
 
161
185
  /** Todos os worktrees registrados no repositório. */
@@ -102,6 +102,7 @@ export type ArtifactKind =
102
102
  | "finding-report"
103
103
  | "review"
104
104
  | "resume"
105
+ | "technical"
105
106
  | "domain-review"
106
107
  | "verify"
107
108
  | "ast"
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Pontos técnicos estruturais do resumo final (`technical-summary.json`).
3
+ *
4
+ * Descrevem a arquitetura da mudança (módulos, contratos, implementações,
5
+ * dependências e impacto) — nunca os achados da revisão. A IA apenas refina a
6
+ * redação; sem ela, a heurística abaixo é o resultado final.
7
+ */
8
+
9
+ import type { AstAnalysis, AstImpact, AstSymbol, AstSymbolKind } from "./ast/types";
10
+ import type { ClassificationResult } from "./file-classifier";
11
+
12
+ /** Destaque técnico exibido como um bullet do resumo. */
13
+ export type TechnicalHighlight = {
14
+ /** Rótulo curto (ex.: "Contratos", "Implementações"). */
15
+ area: string;
16
+ /** Detalhe em uma linha, com paths/símbolos. */
17
+ detail: string;
18
+ };
19
+
20
+ /** Pontos técnicos de uma revisão. */
21
+ export type TechnicalSummary = {
22
+ /** `ai` quando o texto veio do modelo; `deterministic` no fallback. */
23
+ source: "ai" | "deterministic";
24
+ /** Commit coberto — permite saber a que revisão o resumo se refere. */
25
+ headSha?: string;
26
+ /** Parágrafo curto de abertura (opcional). */
27
+ overview?: string;
28
+ highlights: TechnicalHighlight[];
29
+ };
30
+
31
+ /** Entrada mínima para montar os destaques. */
32
+ export type TechnicalInput = {
33
+ classification: Pick<ClassificationResult, "files" | "churn">;
34
+ ast?: ReadonlyMap<string, AstAnalysis>;
35
+ impact?: AstImpact;
36
+ headSha?: string;
37
+ };
38
+
39
+ /** Entrada do payload enviado à IA (inclui contexto de negócio e domínios). */
40
+ export type TechnicalPromptInput = TechnicalInput & {
41
+ branch: string;
42
+ context?: {
43
+ objective?: string;
44
+ motivation?: string;
45
+ changeSummary?: string[];
46
+ } | null;
47
+ domains?: readonly string[];
48
+ };
49
+
50
+ const MAX_HIGHLIGHTS = 6;
51
+ const MAX_DETAIL = 240;
52
+
53
+ /** Corta o texto em `max` caracteres, sinalizando com reticências. */
54
+ function truncate(value: string, max: number): string {
55
+ const text = value.trim().replace(/\s+/g, " ");
56
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
57
+ }
58
+
59
+ /** Diretório do arquivo (`(raiz)` para paths sem pasta). */
60
+ function moduleOf(path: string): string {
61
+ const slash = path.lastIndexOf("/");
62
+ return slash > 0 ? path.slice(0, slash) : "(raiz)";
63
+ }
64
+
65
+ /** `true` para pacotes externos (fora do projeto e de aliases locais). */
66
+ function isExternal(source: string): boolean {
67
+ return (
68
+ !source.startsWith(".") &&
69
+ !source.startsWith("/") &&
70
+ !source.startsWith("#") &&
71
+ !source.startsWith("@/")
72
+ );
73
+ }
74
+
75
+ /** `true` para símbolos que representam contratos públicos. */
76
+ function isContract(kind: AstSymbolKind): boolean {
77
+ return kind === "interface" || kind === "type" || kind === "enum";
78
+ }
79
+
80
+ type SymbolRef = { symbol: AstSymbol; file: string };
81
+
82
+ /** Lista curta de símbolos com o arquivo de origem. */
83
+ function symbolDetail(refs: SymbolRef[], max = 4): string {
84
+ const seen = new Set<string>();
85
+ const parts: string[] = [];
86
+ for (const ref of refs) {
87
+ const key = `${ref.file}|${ref.symbol.name}`;
88
+ if (seen.has(key)) continue;
89
+ seen.add(key);
90
+ parts.push(`\`${ref.symbol.name}\` (${ref.file})`);
91
+ if (parts.length === max) break;
92
+ }
93
+ const total = new Set(refs.map((ref) => `${ref.file}|${ref.symbol.name}`)).size;
94
+ return `${parts.join(", ")}${total > parts.length ? ` +${total - parts.length}` : ""}`;
95
+ }
96
+
97
+ type SignatureRef = {
98
+ name: string;
99
+ before: string;
100
+ after: string;
101
+ file: string;
102
+ };
103
+
104
+ /** Assinaturas alteradas, com o `antes → depois`. */
105
+ function signatureDetail(changes: SignatureRef[], max = 3): string {
106
+ return changes
107
+ .slice(0, max)
108
+ .map(
109
+ (change) =>
110
+ `\`${change.name}\` (${truncate(change.before, 60)} → ${truncate(change.after, 60)})`,
111
+ )
112
+ .join(", ");
113
+ }
114
+
115
+ type ExportRef = {
116
+ name: string;
117
+ change: "added" | "removed";
118
+ file: string;
119
+ };
120
+
121
+ type PromptSymbolRef = { name: string; kind: string; file: string };
122
+
123
+ /** Exports adicionados/removidos, com o arquivo. */
124
+ function exportDetail(changes: ExportRef[], max = 4): string {
125
+ return changes
126
+ .slice(0, max)
127
+ .map((change) => `\`${change.name}\` (${change.change}, ${change.file})`)
128
+ .join(", ");
129
+ }
130
+
131
+ /** Agrupa paths por diretório, do maior para o menor grupo. */
132
+ function groupByModule(paths: string[]): [string, number][] {
133
+ const counts = new Map<string, number>();
134
+ for (const path of paths) {
135
+ const dir = moduleOf(path);
136
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
137
+ }
138
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
139
+ }
140
+
141
+ /**
142
+ * Monta os destaques determinísticos. Prioriza símbolos do AST; sem fatos
143
+ * estruturais, agrupa os arquivos novos por diretório. Nunca lança.
144
+ */
145
+ export function buildTechnicalSummary(input: TechnicalInput): TechnicalSummary {
146
+ const highlights: TechnicalHighlight[] = [];
147
+ const files = input.classification.files;
148
+ const added = Object.entries(files)
149
+ .filter(([, file]) => file.status === "added")
150
+ .map(([path]) => path);
151
+ const deleted = Object.entries(files)
152
+ .filter(([, file]) => file.status === "deleted")
153
+ .map(([path]) => path);
154
+
155
+ const contracts: SymbolRef[] = [];
156
+ const implementations: SymbolRef[] = [];
157
+ const functions: SymbolRef[] = [];
158
+ const signatures: SignatureRef[] = [];
159
+ const exportsChanged: ExportRef[] = [];
160
+ const imports = new Set<string>();
161
+
162
+ if (input.ast) {
163
+ for (const [file, analysis] of input.ast) {
164
+ const delta = analysis.delta;
165
+ if (!delta) continue;
166
+ for (const symbol of delta.addedSymbols) {
167
+ if (isContract(symbol.kind)) contracts.push({ symbol, file });
168
+ else if (symbol.kind === "class") implementations.push({ symbol, file });
169
+ else if (symbol.kind === "function") functions.push({ symbol, file });
170
+ }
171
+ for (const change of delta.changedSignatures) {
172
+ if (!change.exported) continue;
173
+ signatures.push({
174
+ name: change.name,
175
+ before: change.before,
176
+ after: change.after,
177
+ file,
178
+ });
179
+ }
180
+ for (const change of delta.changedExports) {
181
+ exportsChanged.push({
182
+ name: change.name,
183
+ change: change.change,
184
+ file,
185
+ });
186
+ }
187
+ for (const source of delta.addedImports) {
188
+ if (isExternal(source)) imports.add(source);
189
+ }
190
+ }
191
+ }
192
+
193
+ if (contracts.length > 0) {
194
+ highlights.push({ area: "Contratos", detail: symbolDetail(contracts) });
195
+ }
196
+ if (implementations.length > 0) {
197
+ highlights.push({
198
+ area: "Implementações",
199
+ detail: symbolDetail(implementations),
200
+ });
201
+ }
202
+ if (functions.length > 0 && contracts.length === 0 && implementations.length === 0) {
203
+ highlights.push({ area: "Funções", detail: symbolDetail(functions) });
204
+ }
205
+ if (signatures.length > 0) {
206
+ highlights.push({
207
+ area: "Assinaturas alteradas",
208
+ detail: signatureDetail(signatures),
209
+ });
210
+ }
211
+ if (exportsChanged.length > 0) {
212
+ highlights.push({
213
+ area: "Exports alterados",
214
+ detail: exportDetail(exportsChanged),
215
+ });
216
+ }
217
+ if (imports.size > 0) {
218
+ highlights.push({
219
+ area: "Novas dependências",
220
+ detail: [...imports]
221
+ .slice(0, 6)
222
+ .map((source) => `\`${source}\``)
223
+ .join(", "),
224
+ });
225
+ }
226
+
227
+ // Sem fatos do AST, a estrutura visível é o conjunto de diretórios novos.
228
+ if (highlights.length === 0) {
229
+ for (const [dir, count] of groupByModule(added).slice(0, 3)) {
230
+ highlights.push({
231
+ area: "Estrutura",
232
+ detail: `Novo diretório \`${dir}\` com ${count} arquivo(s).`,
233
+ });
234
+ }
235
+ }
236
+
237
+ if (deleted.length > 0 && highlights.length < MAX_HIGHLIGHTS) {
238
+ highlights.push({
239
+ area: "Remoções",
240
+ detail: `${deleted
241
+ .slice(0, 4)
242
+ .map((path) => `\`${path}\``)
243
+ .join(", ")}${deleted.length > 4 ? ` +${deleted.length - 4}` : ""}`,
244
+ });
245
+ }
246
+
247
+ if (
248
+ input.impact &&
249
+ input.impact.affectedFiles.length > 0 &&
250
+ highlights.length < MAX_HIGHLIGHTS
251
+ ) {
252
+ const exported = input.impact.exportedChanges.length;
253
+ highlights.push({
254
+ area: "Impacto",
255
+ detail:
256
+ `${input.impact.affectedFiles.length} arquivo(s) importam os alterados` +
257
+ (exported > 0 ? `; ${exported} export(s) público(s) alterado(s)` : "") +
258
+ ".",
259
+ });
260
+ }
261
+
262
+ return {
263
+ source: "deterministic",
264
+ headSha: input.headSha,
265
+ highlights: highlights.slice(0, MAX_HIGHLIGHTS).map((highlight) => ({
266
+ area: truncate(highlight.area, 80),
267
+ detail: truncate(highlight.detail, MAX_DETAIL),
268
+ })),
269
+ };
270
+ }
271
+
272
+ /** Payload compacto enviado ao modelo (nunca arquivos inteiros). */
273
+ export function buildTechnicalPromptPayload(
274
+ input: TechnicalPromptInput,
275
+ ): Record<string, unknown> {
276
+ const files = Object.entries(input.classification.files)
277
+ .map(([path, file]) => ({
278
+ path,
279
+ status: file.status,
280
+ additions: input.classification.churn[path]?.additions ?? 0,
281
+ deletions: input.classification.churn[path]?.deletions ?? 0,
282
+ }))
283
+ .sort(
284
+ (a, b) =>
285
+ b.additions + b.deletions - (a.additions + a.deletions) ||
286
+ a.path.localeCompare(b.path),
287
+ )
288
+ .slice(0, 80);
289
+
290
+ const contracts: PromptSymbolRef[] = [];
291
+ const implementations: PromptSymbolRef[] = [];
292
+ const signatures: SignatureRef[] = [];
293
+ const exportsChanged: ExportRef[] = [];
294
+ const imports = new Set<string>();
295
+ const deletedFiles: string[] = [];
296
+
297
+ for (const [path, file] of Object.entries(input.classification.files)) {
298
+ if (file.status === "deleted") deletedFiles.push(path);
299
+ }
300
+
301
+ if (input.ast) {
302
+ for (const [file, analysis] of input.ast) {
303
+ const delta = analysis.delta;
304
+ if (!delta) continue;
305
+ for (const symbol of delta.addedSymbols) {
306
+ if (isContract(symbol.kind)) {
307
+ contracts.push({ name: symbol.name, kind: symbol.kind, file });
308
+ } else if (symbol.kind === "class") {
309
+ implementations.push({ name: symbol.name, kind: symbol.kind, file });
310
+ }
311
+ }
312
+ for (const change of delta.changedSignatures) {
313
+ if (change.exported) {
314
+ signatures.push({
315
+ name: change.name,
316
+ before: change.before,
317
+ after: change.after,
318
+ file,
319
+ });
320
+ }
321
+ }
322
+ for (const change of delta.changedExports) {
323
+ exportsChanged.push({
324
+ name: change.name,
325
+ change: change.change,
326
+ file,
327
+ });
328
+ }
329
+ for (const source of delta.addedImports) {
330
+ if (isExternal(source)) imports.add(source);
331
+ }
332
+ }
333
+ }
334
+
335
+ return {
336
+ branch: input.branch,
337
+ headSha: input.headSha,
338
+ business: input.context
339
+ ? {
340
+ objective: input.context.objective,
341
+ motivation: input.context.motivation,
342
+ changeSummary: input.context.changeSummary,
343
+ }
344
+ : undefined,
345
+ domains: input.domains ?? [],
346
+ files,
347
+ structure: {
348
+ contracts: contracts.slice(0, 40),
349
+ implementations: implementations.slice(0, 40),
350
+ changedSignatures: signatures.slice(0, 20),
351
+ changedExports: exportsChanged.slice(0, 20),
352
+ addedImports: [...imports].slice(0, 20),
353
+ deletedFiles: deletedFiles.slice(0, 20),
354
+ },
355
+ impact: input.impact
356
+ ? {
357
+ affectedFiles: input.impact.affectedFiles.length,
358
+ importers: Object.keys(input.impact.importers).length,
359
+ exportedChanges: input.impact.exportedChanges.length,
360
+ }
361
+ : undefined,
362
+ };
363
+ }
364
+
365
+ /** Valida/normaliza o `technical-summary.json`; null quando vazio/inválido. */
366
+ export function parseTechnicalSummary(raw: unknown): TechnicalSummary | null {
367
+ if (typeof raw !== "object" || raw === null) return null;
368
+ const obj = raw as Record<string, unknown>;
369
+
370
+ const highlights: TechnicalHighlight[] = [];
371
+ if (Array.isArray(obj.highlights)) {
372
+ for (const item of obj.highlights) {
373
+ if (typeof item !== "object" || item === null) continue;
374
+ const entry = item as Record<string, unknown>;
375
+ const area = typeof entry.area === "string" ? entry.area.trim() : "";
376
+ const detail = typeof entry.detail === "string" ? entry.detail.trim() : "";
377
+ if (!area || !detail) continue;
378
+ highlights.push({
379
+ area: truncate(area, 80),
380
+ detail: truncate(detail, MAX_DETAIL),
381
+ });
382
+ if (highlights.length === MAX_HIGHLIGHTS) break;
383
+ }
384
+ }
385
+
386
+ const overview =
387
+ typeof obj.overview === "string" && obj.overview.trim().length > 0
388
+ ? truncate(obj.overview, 600)
389
+ : undefined;
390
+ if (highlights.length === 0 && !overview) return null;
391
+
392
+ return {
393
+ source: obj.source === "deterministic" ? "deterministic" : "ai",
394
+ headSha: typeof obj.headSha === "string" ? obj.headSha : undefined,
395
+ overview,
396
+ highlights,
397
+ };
398
+ }
@@ -37,6 +37,7 @@ export class AgentFactory {
37
37
  reporter: this.deps.reporter,
38
38
  maxOutputTokens: config.maxOutputTokensFor(context.watchdog.id),
39
39
  temperature: config.temperature,
40
+ providerOptions: config.providerOptions,
40
41
  timeoutMs: config.timeoutMs,
41
42
  ast: this.deps.ast,
42
43
  readFile: this.deps.readFile,