@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payconductor/houndsman",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Orquestrador de revisão de Pull Requests: classifica o diff, despacha watchdogs especializados por domínio e consolida o relatório.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ZodType } from "zod";
10
+ import type { AiProviderOptions } from "../config";
10
11
  import type { ProjectConfig } from "../project-config";
11
12
  import { AiConfigurationError, errorMessage } from "./errors";
12
13
  import { ModelFactory } from "./model-factory";
@@ -33,6 +34,8 @@ export type ObjectRequest<T> = {
33
34
  maxSteps?: number;
34
35
  maxOutputTokens?: number;
35
36
  temperature?: number;
37
+ /** Opções do AI SDK por provider (mescladas com a config do projeto). */
38
+ providerOptions?: AiProviderOptions;
36
39
  signal?: AbortSignal;
37
40
  };
38
41
 
@@ -188,6 +191,10 @@ export class AiClient {
188
191
  const provider = await this.requireProvider(model);
189
192
  const response = await provider.generate({
190
193
  ...request,
194
+ providerOptions: mergeProviderOptions(
195
+ this.project.aiProviderOptions,
196
+ request.providerOptions,
197
+ ),
191
198
  signal: combineSignals(request.signal, this.project.aiTimeoutMs),
192
199
  });
193
200
  this.budget.record(response.usage);
@@ -204,6 +211,20 @@ export class AiClient {
204
211
  }
205
212
  }
206
213
 
214
+ /** Mescla opções por provider (o override do chamador vence chave a chave). */
215
+ function mergeProviderOptions(
216
+ base: AiProviderOptions | undefined,
217
+ override: AiProviderOptions | undefined,
218
+ ): AiProviderOptions | undefined {
219
+ if (!base) return override;
220
+ if (!override) return base;
221
+ const merged: AiProviderOptions = { ...base };
222
+ for (const [provider, values] of Object.entries(override)) {
223
+ merged[provider] = { ...base[provider], ...values };
224
+ }
225
+ return merged;
226
+ }
227
+
207
228
  /** Combina o sinal do chamador com o timeout configurado. */
208
229
  function combineSignals(
209
230
  signal: AbortSignal | undefined,
@@ -10,6 +10,7 @@ import type { LanguageModel, ToolSet } from "ai";
10
10
  import { generateText, Output, stepCountIs, tool } from "ai";
11
11
  import type { AiProviderKind } from "../config";
12
12
  import { AiProviderError } from "./errors";
13
+ import { temperatureApplies } from "./provider-capabilities";
13
14
  import {
14
15
  type AiTool,
15
16
  type AiUsage,
@@ -45,6 +46,47 @@ function numberOrZero(value: unknown): number {
45
46
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
46
47
  }
47
48
 
49
+ /** Argumentos aceitos por `generateText` (inclui `providerOptions` do SDK). */
50
+ type GenerateTextArgs = Parameters<typeof generateText>[0];
51
+
52
+ /**
53
+ * Argumentos de `generateText` derivados do pedido (puro — testável sem rede).
54
+ *
55
+ * `temperature` só é enviada quando o provider de fato a aplica: em thinking
56
+ * mode (DeepSeek V4 default) o AI SDK a descarta e emite warning por chamada.
57
+ */
58
+ export function buildGenerateTextArgs<T>(
59
+ model: LanguageModel,
60
+ request: ModelRequest<T>,
61
+ provider: AiProviderKind,
62
+ modelId: string,
63
+ ) {
64
+ const tools = request.tools ? toToolSet(request.tools) : undefined;
65
+ const hasTools = tools !== undefined && Object.keys(tools).length > 0;
66
+ const maxSteps = request.maxSteps ?? 1;
67
+ const providerOptions = request.providerOptions
68
+ ? (request.providerOptions as GenerateTextArgs["providerOptions"])
69
+ : undefined;
70
+ const temperature =
71
+ request.temperature !== undefined &&
72
+ temperatureApplies(provider, modelId, request.providerOptions)
73
+ ? request.temperature
74
+ : undefined;
75
+ return {
76
+ model,
77
+ system: request.instructions,
78
+ prompt: request.prompt,
79
+ ...(hasTools ? { tools, stopWhen: stepCountIs(maxSteps) } : {}),
80
+ ...(request.outputSchema
81
+ ? { output: Output.object({ schema: request.outputSchema }) }
82
+ : {}),
83
+ ...(request.maxOutputTokens ? { maxOutputTokens: request.maxOutputTokens } : {}),
84
+ ...(providerOptions ? { providerOptions } : {}),
85
+ ...(temperature !== undefined ? { temperature } : {}),
86
+ maxRetries: 0,
87
+ };
88
+ }
89
+
48
90
  /** Provider de modelo apoiado no AI SDK. */
49
91
  export class AiSdkModelProvider implements ModelProvider {
50
92
  constructor(
@@ -54,27 +96,10 @@ export class AiSdkModelProvider implements ModelProvider {
54
96
  ) {}
55
97
 
56
98
  async generate<T>(request: ModelRequest<T>): Promise<ModelResponse<T>> {
57
- const tools = request.tools ? toToolSet(request.tools) : undefined;
58
- const hasTools = tools !== undefined && Object.keys(tools).length > 0;
59
- const maxSteps = request.maxSteps ?? 1;
60
-
61
99
  try {
62
100
  const result = await generateText({
63
- model: this.model,
64
- system: request.instructions,
65
- prompt: request.prompt,
66
- ...(hasTools ? { tools, stopWhen: stepCountIs(maxSteps) } : {}),
67
- ...(request.outputSchema
68
- ? { output: Output.object({ schema: request.outputSchema }) }
69
- : {}),
70
- ...(request.maxOutputTokens
71
- ? { maxOutputTokens: request.maxOutputTokens }
72
- : {}),
73
- ...(request.temperature !== undefined
74
- ? { temperature: request.temperature }
75
- : {}),
101
+ ...buildGenerateTextArgs(this.model, request, this.name, this.modelId),
76
102
  ...(request.signal ? { abortSignal: request.signal } : {}),
77
- maxRetries: 0,
78
103
  });
79
104
 
80
105
  const output = request.outputSchema
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pontos técnicos estruturais via IA (fallback ao builder determinístico).
3
+ */
4
+
5
+ import {
6
+ buildTechnicalPromptPayload,
7
+ parseTechnicalSummary,
8
+ type TechnicalPromptInput,
9
+ type TechnicalSummary,
10
+ } from "../../technical-summary";
11
+ import type { AiClient } from "../ai-client";
12
+ import { technicalInstructions, technicalPrompt } from "../prompts/technical";
13
+ import { type TechnicalSummaryPlan, TechnicalSummarySchema } from "../schemas";
14
+
15
+ /** Resume a estrutura da mudança em destaques curtos. */
16
+ export class AiTechnicalSummarizer {
17
+ constructor(private readonly client: AiClient) {}
18
+
19
+ /** `true` quando a feature está ligada e há IA disponível. */
20
+ get enabled(): boolean {
21
+ return this.client.available && this.client.features.technical;
22
+ }
23
+
24
+ /** Falha-aberto: sem IA ou saída inválida → `null`. */
25
+ async summarize(input: TechnicalPromptInput): Promise<TechnicalSummary | null> {
26
+ if (!this.enabled) return null;
27
+ const result = await this.client.generateObject<TechnicalSummaryPlan>(
28
+ "technical",
29
+ {
30
+ instructions: technicalInstructions(),
31
+ prompt: technicalPrompt(buildTechnicalPromptPayload(input)),
32
+ outputSchema: TechnicalSummarySchema,
33
+ maxSteps: 1,
34
+ },
35
+ );
36
+ if (!result.ok) return null;
37
+ return parseTechnicalSummary({
38
+ ...result.output,
39
+ source: "ai",
40
+ headSha: input.headSha,
41
+ });
42
+ }
43
+ }
@@ -2,7 +2,7 @@
2
2
  * Barrel da camada de IA (`core/ai`).
3
3
  */
4
4
 
5
- export type { AiProviderKind } from "../config";
5
+ export type { AiProviderKind, AiProviderOptions } from "../config";
6
6
  export type {
7
7
  AiClientOptions,
8
8
  AiCredentialCheck,
@@ -21,6 +21,7 @@ export { AiBriefEnricher } from "./features/brief-enricher";
21
21
  export { AiClassifier } from "./features/classifier";
22
22
  export { AiContextSummarizer } from "./features/context-summarizer";
23
23
  export { AiPreAnalyzer } from "./features/pre-analyzer";
24
+ export { AiTechnicalSummarizer } from "./features/technical-summarizer";
24
25
  export { AiVerifier } from "./features/verifier";
25
26
  export { hasProviderCredential, ModelFactory } from "./model-factory";
26
27
  export {
@@ -35,6 +36,8 @@ export {
35
36
  type ContextSummaryPlan,
36
37
  ContextSummarySchema,
37
38
  SeveritySchema,
39
+ type TechnicalSummaryPlan,
40
+ TechnicalSummarySchema,
38
41
  VerifySchema,
39
42
  WatchdogReportSchema,
40
43
  } from "./schemas";
@@ -9,9 +9,9 @@ export function contextInstructions(): string {
9
9
  return buildSystem(
10
10
  "Você resume o contexto de uma Pull Request para revisores técnicos.",
11
11
  [
12
- "Objetivo em uma frase (máx. 200 caracteres).",
13
- "Motivação em uma frase (máx. 240 caracteres).",
14
- "Resumo das alterações em até 4 bullets (máx. 160 caracteres cada).",
12
+ "Objetivo em até 2 frases (máx. 400 caracteres).",
13
+ "Motivação em até 3 frases (máx. 480 caracteres), com o problema e o impacto esperado.",
14
+ "Resumo das alterações em até 6 bullets (máx. 240 caracteres cada), focado em comportamento, fluxos e impacto.",
15
15
  "Não invente informação ausente do contexto.",
16
16
  ],
17
17
  );
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Prompt dos pontos técnicos estruturais (`AiTechnicalSummarizer`).
3
+ */
4
+
5
+ import { buildSystem, clip } from "./base";
6
+
7
+ /** Instruções de sistema dos pontos técnicos. */
8
+ export function technicalInstructions(): string {
9
+ return buildSystem(
10
+ "Você descreve a estrutura técnica de uma Pull Request para revisores.",
11
+ [
12
+ "Foque em arquitetura e contratos: novos módulos/services, interfaces, providers, repositories, camadas, exports, dependências e impacto.",
13
+ "NÃO avalie qualidade, NÃO liste achados, bugs ou riscos de revisão.",
14
+ "NÃO repita a motivação de negócio: isso pertence ao resumo de negócio.",
15
+ "Use no máximo 6 destaques; `area` é um rótulo curto e `detail` cita paths/símbolos concretos.",
16
+ "Não invente módulos, símbolos ou dependências ausentes dos dados.",
17
+ ],
18
+ );
19
+ }
20
+
21
+ /** Prompt do usuário com o payload estrutural já compactado. */
22
+ export function technicalPrompt(payload: unknown): string {
23
+ return [
24
+ "Descreva os pontos técnicos estruturais da mudança abaixo.",
25
+ "",
26
+ "```json",
27
+ clip(JSON.stringify(payload), 14_000),
28
+ "```",
29
+ ].join("\n");
30
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Capacidades do provider de IA (puro).
3
+ *
4
+ * O AI SDK descarta `temperature`/`topP` e emite warning a cada chamada quando
5
+ * o DeepSeek opera em thinking mode — que é o default nos modelos V4. Enviar o
6
+ * parâmetro nesse caso não tem efeito: o adapter o omite.
7
+ */
8
+
9
+ import type { AiProviderKind, AiProviderOptions } from "../config";
10
+
11
+ /** `true` quando o modelo DeepSeek usa thinking sem `providerOptions`. */
12
+ function isDeepSeekV4Model(modelId: string): boolean {
13
+ return !/(^|[/.])deepseek-(?:chat|reasoner|v[1-3](?:\.\d+)?)(?=[-/:.]|$)/.test(
14
+ modelId,
15
+ );
16
+ }
17
+
18
+ /**
19
+ * `true` quando o DeepSeek vai operar em thinking mode.
20
+ *
21
+ * Espelha a resolução do `@ai-sdk/deepseek`: `thinking.type` explícito manda;
22
+ * sem ele, V4 (e o alias `deepseek-reasoner`) ligam thinking.
23
+ */
24
+ export function deepSeekThinkingEnabled(
25
+ modelId: string,
26
+ providerOptions: AiProviderOptions | undefined,
27
+ ): boolean {
28
+ const thinking = providerOptions?.deepseek?.thinking as
29
+ | Record<string, unknown>
30
+ | undefined;
31
+ const type = thinking?.type;
32
+ if (type === "disabled") return false;
33
+ if (type === "enabled" || type === "adaptive") return true;
34
+ return modelId === "deepseek-reasoner" || isDeepSeekV4Model(modelId);
35
+ }
36
+
37
+ /** `true` quando `temperature` chega ao provider (não é descartada). */
38
+ export function temperatureApplies(
39
+ provider: AiProviderKind,
40
+ modelId: string,
41
+ providerOptions?: AiProviderOptions,
42
+ ): boolean {
43
+ if (provider !== "deepseek") return true;
44
+ return !deepSeekThinkingEnabled(modelId, providerOptions);
45
+ }
@@ -22,12 +22,28 @@ export const ContextSummarySchema = z.object({
22
22
  head: z.string().optional(),
23
23
  })
24
24
  .optional(),
25
- objective: z.string().max(200).optional(),
26
- motivation: z.string().max(240).optional(),
27
- changeSummary: z.array(z.string().max(160)).max(4).optional(),
25
+ objective: z.string().max(400).optional(),
26
+ motivation: z.string().max(480).optional(),
27
+ changeSummary: z.array(z.string().max(240)).max(6).optional(),
28
28
  });
29
29
  export type ContextSummaryPlan = z.infer<typeof ContextSummarySchema>;
30
30
 
31
+ /** Pontos técnicos estruturais do resumo final (`technical-summary.json`). */
32
+ export const TechnicalSummarySchema = z.object({
33
+ /** Parágrafo curto de abertura (opcional). */
34
+ overview: z.string().max(600).optional(),
35
+ highlights: z
36
+ .array(
37
+ z.object({
38
+ area: z.string().min(1).max(80),
39
+ detail: z.string().min(1).max(400),
40
+ }),
41
+ )
42
+ .max(8)
43
+ .default([]),
44
+ });
45
+ export type TechnicalSummaryPlan = z.infer<typeof TechnicalSummarySchema>;
46
+
31
47
  /** Plano de revisão sugerido por IA para um brief. */
32
48
  export const BriefPlanSchema = z.object({
33
49
  focus: z.array(z.string()).max(5).default([]),
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ZodType, ZodTypeAny } from "zod";
9
- import type { AiProviderKind } from "../config";
9
+ import type { AiProviderKind, AiProviderOptions } from "../config";
10
10
 
11
11
  /** Uso de tokens de uma chamada (ou acumulado de uma review). */
12
12
  export type AiUsage = {
@@ -52,6 +52,8 @@ export type ModelRequest<T> = {
52
52
  maxSteps?: number;
53
53
  maxOutputTokens?: number;
54
54
  temperature?: number;
55
+ /** Opções do AI SDK por provider (repassadas ao SDK sem interpretação). */
56
+ providerOptions?: AiProviderOptions;
55
57
  signal?: AbortSignal;
56
58
  };
57
59
 
package/src/core/brief.ts CHANGED
@@ -27,15 +27,36 @@ export const PROCEDURE: readonly string[] = [
27
27
  '5. **CONCLUDE** — grave o JSON no caminho de output, com `reviewedFiles` preenchido. Sem achados → `"findings": []` (arquivo obrigatório). Não escreva markdown livre.',
28
28
  ];
29
29
 
30
+ /** Limites do contexto injetado em cada brief (o resumo final é maior). */
31
+ const BRIEF_CONTEXT_LIMITS = {
32
+ objective: 200,
33
+ motivation: 240,
34
+ changeSummaryItem: 160,
35
+ changeSummaryItems: 4,
36
+ } as const;
37
+
30
38
  /** Bloco de contexto da PR, injetado no brief de cada watchdog. */
31
39
  export function renderBriefContext(context: WatchdogRenderContext): string[] {
32
40
  const lines = ["## Contexto da PR", ""];
33
- if (context.objective) lines.push(`**Objetivo:** ${context.objective}`);
34
- if (context.motivation) lines.push(`**Motivação:** ${context.motivation}`);
35
- const changeSummary = context.changeSummary ?? [];
41
+ if (context.objective) {
42
+ lines.push(
43
+ `**Objetivo:** ${shorten(context.objective, BRIEF_CONTEXT_LIMITS.objective)}`,
44
+ );
45
+ }
46
+ if (context.motivation) {
47
+ lines.push(
48
+ `**Motivação:** ${shorten(context.motivation, BRIEF_CONTEXT_LIMITS.motivation)}`,
49
+ );
50
+ }
51
+ const changeSummary = (context.changeSummary ?? []).slice(
52
+ 0,
53
+ BRIEF_CONTEXT_LIMITS.changeSummaryItems,
54
+ );
36
55
  if (changeSummary.length > 0) {
37
56
  lines.push("", "**Resumo das alterações:**", "");
38
- for (const item of changeSummary) lines.push(`- ${item}`);
57
+ for (const item of changeSummary) {
58
+ lines.push(`- ${shorten(item, BRIEF_CONTEXT_LIMITS.changeSummaryItem)}`);
59
+ }
39
60
  }
40
61
  lines.push("");
41
62
  return lines;
@@ -219,10 +219,14 @@ export type HoundsmanAst = {
219
219
  /** Providers suportados pela camada de IA. */
220
220
  export type AiProviderKind = "deepseek" | "openai" | "anthropic";
221
221
 
222
+ /** Opções do AI SDK por provider (repassadas sem interpretação). */
223
+ export type AiProviderOptions = Record<string, Record<string, unknown>>;
224
+
222
225
  /** Features de IA do fluxo normal (todas fail-open). */
223
226
  export type HoundsmanAiFeatures = {
224
227
  context?: boolean;
225
228
  brief?: boolean;
229
+ technical?: boolean;
226
230
  classification?: boolean;
227
231
  preAnalysis?: boolean;
228
232
  verify?: boolean;
@@ -245,6 +249,7 @@ export type HoundsmanAi = {
245
249
  maxSteps?: number;
246
250
  maxOutputTokens?: number;
247
251
  temperature?: number;
252
+ providerOptions?: AiProviderOptions;
248
253
  timeoutMs?: number;
249
254
  maxCallsPerReview?: number;
250
255
  maxTokensPerReview?: number;
@@ -57,12 +57,23 @@ export type HoundsmanConcurrency = {
57
57
  /** Providers suportados pela camada de IA. */
58
58
  export type AiProviderKind = "deepseek" | "openai" | "anthropic";
59
59
 
60
+ /**
61
+ * Opções do AI SDK indexadas pelo id do provider
62
+ * (`{ deepseek: { thinking: { type: "disabled" } } }`).
63
+ *
64
+ * O motor não interpreta o conteúdo: só repassa. Use para controlar
65
+ * thinking/reasoning sem acoplar a config a um provider específico.
66
+ */
67
+ export type AiProviderOptions = Record<string, Record<string, unknown>>;
68
+
60
69
  /** Features de IA do fluxo determinístico (todas fail-open). */
61
70
  export type HoundsmanAiFeatures = {
62
71
  /** Resume o contexto da PR (`context-summary`) quando o subagente não gerou. */
63
72
  context?: boolean;
64
73
  /** Enriquece os briefs com um plano de revisão sugerido. */
65
74
  brief?: boolean;
75
+ /** Gera os pontos técnicos estruturais do resumo final. */
76
+ technical?: boolean;
66
77
  /** Classifica arquivos que nenhum watchdog casou. */
67
78
  classification?: boolean;
68
79
  /** Gera candidatos de pré-análise (substitui o Jev quando disponível). */
@@ -94,8 +105,13 @@ export type HoundsmanAi = {
94
105
  maxSteps?: number;
95
106
  /** Teto de tokens de saída por chamada. */
96
107
  maxOutputTokens?: number;
97
- /** Temperatura (default: 0). */
108
+ /**
109
+ * Temperatura (default: 0). Ignorada quando o provider usa thinking
110
+ * (DeepSeek V4 liga thinking por default) — nesse caso o motor não a envia.
111
+ */
98
112
  temperature?: number;
113
+ /** Opções do AI SDK por provider (repassadas sem interpretação). */
114
+ providerOptions?: AiProviderOptions;
99
115
  /** Timeout por chamada em ms (default: 120_000). */
100
116
  timeoutMs?: number;
101
117
  /** Teto de chamadas por review (default: 40). */
@@ -24,8 +24,15 @@ export type GitRunner = (args: string[]) => GitResult;
24
24
  * certo. Sem `cwd`, herda o diretório do processo (default).
25
25
  */
26
26
  export function createGitRunner(cwd?: string): GitRunner {
27
+ // `core.longpaths` evita "Filename too long" no checkout de worktrees
28
+ // dentro de caminhos fundos (MAX_PATH do Windows). O `-c` é propagado aos
29
+ // subprocessos do git via GIT_CONFIG_PARAMETERS.
30
+ const config =
31
+ process.platform === "win32"
32
+ ? ["-c", "core.quotepath=false", "-c", "core.longpaths=true"]
33
+ : ["-c", "core.quotepath=false"];
27
34
  return (args) => {
28
- const result = Bun.spawnSync(["git", "-c", "core.quotepath=false", ...args], {
35
+ const result = Bun.spawnSync(["git", ...config, ...args], {
29
36
  cwd,
30
37
  stdout: "pipe",
31
38
  stderr: "pipe",
@@ -23,6 +23,7 @@ import { AiBriefEnricher } from "./ai/features/brief-enricher";
23
23
  import { AiClassifier } from "./ai/features/classifier";
24
24
  import { AiContextSummarizer } from "./ai/features/context-summarizer";
25
25
  import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
26
+ import { AiTechnicalSummarizer } from "./ai/features/technical-summarizer";
26
27
  import type { BriefPlan } from "./ai/schemas";
27
28
  import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
28
29
  import type { AstService } from "./ast/service";
@@ -56,6 +57,7 @@ import {
56
57
  OUTPUT_FORMAT_PATH,
57
58
  REVIEW_FILE,
58
59
  REVIEW_RESUME_FILE,
60
+ TECHNICAL_SUMMARY_FILE,
59
61
  } from "./paths";
60
62
  import { ProjectConfig } from "./project-config";
61
63
  import { BriefRenderer } from "./renderers/brief-renderer";
@@ -79,6 +81,11 @@ import { systemClock } from "./services/clock";
79
81
  import { GitRepository } from "./services/git-repository";
80
82
  import type { ReviewRecord, StorageProvider } from "./storage/types";
81
83
  import { ReviewWorkArea } from "./storage/work-area";
84
+ import {
85
+ buildTechnicalSummary,
86
+ parseTechnicalSummary,
87
+ type TechnicalSummary,
88
+ } from "./technical-summary";
82
89
  import type { WatchdogMeta } from "./types";
83
90
  import { findWatchdog } from "./watchdog-registry";
84
91
 
@@ -483,6 +490,50 @@ async function resolveContextSummary(
483
490
  return summary;
484
491
  }
485
492
 
493
+ /**
494
+ * Pontos técnicos estruturais: IA quando disponível e, sempre, um fallback
495
+ * determinístico a partir da classificação/AST. Persistido para auditoria —
496
+ * o `finalize` apenas o consome.
497
+ */
498
+ async function resolveTechnicalSummary(
499
+ ctx: ReviewContext,
500
+ ai: AiClient | undefined,
501
+ opts: ReviewOptions,
502
+ input: {
503
+ branch: string;
504
+ classification: RunClassification;
505
+ context: ContextSummary | null;
506
+ ast?: ReadonlyMap<string, AstAnalysis>;
507
+ impact?: AstImpact;
508
+ domains: readonly string[];
509
+ },
510
+ ): Promise<TechnicalSummary> {
511
+ const fallback = buildTechnicalSummary({
512
+ classification: input.classification,
513
+ ast: input.ast,
514
+ impact: input.impact,
515
+ headSha: input.classification.headSha,
516
+ });
517
+ const summary =
518
+ ai && !opts.noAi
519
+ ? ((await new AiTechnicalSummarizer(ai).summarize({
520
+ branch: input.branch,
521
+ context: input.context,
522
+ classification: input.classification,
523
+ ast: input.ast,
524
+ impact: input.impact,
525
+ headSha: input.classification.headSha,
526
+ domains: input.domains,
527
+ })) ?? fallback)
528
+ : fallback;
529
+ ctx.storage.writeArtifact(ctx.review.id, {
530
+ name: TECHNICAL_SUMMARY_FILE,
531
+ kind: "technical",
532
+ content: `${JSON.stringify(summary, null, 2)}\n`,
533
+ });
534
+ return summary;
535
+ }
536
+
486
537
  /**
487
538
  * Planos de revisão por watchdog (uma chamada por brief, concorrência
488
539
  * limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
@@ -574,13 +625,30 @@ export async function prepare(
574
625
  return { messages };
575
626
  }
576
627
 
628
+ const { classification, diff } = scope;
629
+
630
+ // Run recém-criada e sem nenhum arquivo alterado: não persiste uma "run
631
+ // vazia" na lista. Acontece quando o comando roda no clone principal sem
632
+ // `--pr`/`--worktree` (ex.: `houndsman` na `main` logo após revisar uma PR).
633
+ // Run com artefatos anteriores (contexto, revisão) continua existindo.
634
+ if (Object.keys(classification.files).length === 0) {
635
+ const fresh = ctx.storage.listArtifacts(ctx.review.id).length === 0;
636
+ if (fresh) {
637
+ ctx.work.clean();
638
+ ctx.storage.deleteReview(ctx.review.id);
639
+ messages.push(
640
+ `Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
641
+ );
642
+ return { messages };
643
+ }
644
+ }
645
+
577
646
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
578
647
  writeRunStatus(ctx.storage, ctx.review.id, {
579
648
  status: "reviewing",
580
649
  resetStartedAt: true,
581
650
  });
582
651
 
583
- const { classification, diff } = scope;
584
652
  await applyAiClassification(classification, config, ai, opts);
585
653
  ctx.storage.writeArtifact(ctx.review.id, {
586
654
  name: "classification.json",
@@ -645,6 +713,14 @@ export async function prepare(
645
713
 
646
714
  persistContext(ctx);
647
715
  const context = await resolveContextSummary(ctx, ai, opts);
716
+ await resolveTechnicalSummary(ctx, ai, opts, {
717
+ branch,
718
+ classification,
719
+ context,
720
+ ast: astAnalyses,
721
+ impact: astImpact,
722
+ domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
723
+ });
648
724
 
649
725
  const manifestAst: ManifestAst | undefined = astAnalyses
650
726
  ? {
@@ -853,6 +929,40 @@ function readPersistedContextSummary(ctx: ReviewContext): ContextSummary | null
853
929
  }
854
930
  }
855
931
 
932
+ /** JSON persistido, ou `null` quando ausente/inválido. */
933
+ function readArtifactJson<T>(ctx: ReviewContext, name: string): T | null {
934
+ const artifact = ctx.storage.readArtifact(ctx.review.id, name);
935
+ if (!artifact) return null;
936
+ try {
937
+ return JSON.parse(artifact.content) as T;
938
+ } catch {
939
+ return null;
940
+ }
941
+ }
942
+
943
+ /** Pontos técnicos persistidos pelo `prepare`; `null` quando ausentes. */
944
+ function readPersistedTechnicalSummary(ctx: ReviewContext): TechnicalSummary | null {
945
+ const raw = readArtifactJson<unknown>(ctx, TECHNICAL_SUMMARY_FILE);
946
+ return raw ? parseTechnicalSummary(raw) : null;
947
+ }
948
+
949
+ /** Fallback para runs antigas: reconstrói os destaques dos artefatos de AST. */
950
+ function buildTechnicalFallback(
951
+ ctx: ReviewContext,
952
+ classification: RunClassification,
953
+ ): TechnicalSummary {
954
+ const ast = readArtifactJson<{ files?: Record<string, AstAnalysis> }>(
955
+ ctx,
956
+ "ast-summary.json",
957
+ );
958
+ return buildTechnicalSummary({
959
+ classification,
960
+ ast: ast?.files ? new Map(Object.entries(ast.files)) : undefined,
961
+ impact: readArtifactJson<AstImpact>(ctx, "ast-impact.json") ?? undefined,
962
+ headSha: classification.headSha,
963
+ });
964
+ }
965
+
856
966
  /**
857
967
  * Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
858
968
  * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
@@ -1028,6 +1138,8 @@ export function finalize(
1028
1138
 
1029
1139
  persistContext(ctx);
1030
1140
  const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
1141
+ const technical =
1142
+ readPersistedTechnicalSummary(ctx) ?? buildTechnicalFallback(ctx, classification);
1031
1143
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
1032
1144
  const domains = writeDomainReports(
1033
1145
  ctx,
@@ -1061,7 +1173,7 @@ export function finalize(
1061
1173
  context,
1062
1174
  classification,
1063
1175
  findings: merged,
1064
- nameOf,
1176
+ technical,
1065
1177
  }),
1066
1178
  });
1067
1179
 
package/src/core/paths.ts CHANGED
@@ -55,3 +55,6 @@ export const REVIEW_FILE = "review.md";
55
55
 
56
56
  /** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
57
57
  export const REVIEW_RESUME_FILE = "review-resume.md";
58
+
59
+ /** Pontos técnicos estruturais usados no resumo final (`technical-summary.json`). */
60
+ export const TECHNICAL_SUMMARY_FILE = "technical-summary.json";