@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.
- package/package.json +1 -1
- package/src/core/ai/ai-client.ts +21 -0
- package/src/core/ai/ai-sdk-model-provider.ts +43 -18
- package/src/core/ai/features/technical-summarizer.ts +43 -0
- package/src/core/ai/index.ts +4 -1
- package/src/core/ai/prompts/context.ts +3 -3
- package/src/core/ai/prompts/technical.ts +30 -0
- package/src/core/ai/provider-capabilities.ts +45 -0
- package/src/core/ai/schemas.ts +19 -3
- package/src/core/ai/types.ts +3 -1
- package/src/core/brief.ts +25 -4
- package/src/core/commands/scaffold.ts +5 -0
- package/src/core/config.ts +17 -1
- package/src/core/git-runner.ts +8 -1
- package/src/core/orchestrator.ts +114 -2
- package/src/core/paths.ts +3 -0
- package/src/core/pr-context.ts +11 -6
- package/src/core/project-config.ts +9 -1
- package/src/core/renderers/resume-renderer.ts +17 -34
- package/src/core/report-builder.ts +6 -6
- package/src/core/services/worktree-manager.ts +29 -5
- package/src/core/storage/types.ts +1 -0
- package/src/core/technical-summary.ts +398 -0
- package/src/harness/agent/agent-factory.ts +1 -0
- package/src/harness/agent/review-agent.ts +11 -1
- package/src/harness/config/harness-config.ts +6 -1
- package/src/index.ts +2 -0
- package/src/server/client/App.tsx +2 -1
- package/src/server/client/PrsSection.tsx +296 -0
- package/src/server/client/ResumePanel.tsx +10 -13
- package/src/server/client/RunsSection.tsx +73 -0
- package/src/server/client/Sidebar.tsx +171 -413
- package/src/server/client/sidebar-sections.tsx +73 -0
- package/src/server/client/utils/constants.ts +7 -2
- package/src/server/client/utils/sidebar-prefs.ts +64 -0
- package/src/server/client/utils/types.ts +1 -0
- package/src/server/index.ts +23 -409
- package/src/server/styles.css +454 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +5 -0
- package/src/tests/ai-core.test.ts +71 -0
- package/src/tests/ai-features.test.ts +36 -0
- package/src/tests/baseline.test.ts +4 -1
- package/src/tests/harness-agent.test.ts +19 -0
- package/src/tests/review-resume.test.ts +36 -8
- package/src/tests/review-scope.test.ts +67 -0
- package/src/tests/review-scripts.test.ts +2 -2
- package/src/tests/server.test.ts +16 -1
- package/src/tests/sidebar-sections.test.ts +94 -0
- package/src/tests/technical-summary.test.ts +176 -0
- package/src/tests/worktree.test.ts +39 -4
- 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.
|
|
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",
|
package/src/core/ai/ai-client.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/core/ai/index.ts
CHANGED
|
@@ -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
|
|
13
|
-
"Motivação em
|
|
14
|
-
"Resumo das alterações em até
|
|
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
|
+
}
|
package/src/core/ai/schemas.ts
CHANGED
|
@@ -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(
|
|
26
|
-
motivation: z.string().max(
|
|
27
|
-
changeSummary: z.array(z.string().max(
|
|
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([]),
|
package/src/core/ai/types.ts
CHANGED
|
@@ -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)
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
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;
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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). */
|
package/src/core/git-runner.ts
CHANGED
|
@@ -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",
|
|
35
|
+
const result = Bun.spawnSync(["git", ...config, ...args], {
|
|
29
36
|
cwd,
|
|
30
37
|
stdout: "pipe",
|
|
31
38
|
stderr: "pipe",
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|