@payconductor/houndsman 0.10.0 → 0.12.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 +4 -19
- package/package.json +1 -1
- package/src/cli/help.ts +0 -7
- package/src/cli/index.ts +9 -3
- package/src/core/ai/ai-client.ts +3 -7
- package/src/core/ai/ai-sdk-model-provider.ts +4 -24
- package/src/core/ai/features/brief-enricher.ts +0 -1
- package/src/core/ai/features/classifier.ts +0 -1
- package/src/core/ai/features/context-summarizer.ts +0 -1
- package/src/core/ai/features/pre-analyzer.ts +0 -1
- package/src/core/ai/features/technical-summarizer.ts +0 -1
- package/src/core/ai/features/verifier.ts +0 -1
- package/src/core/ai/index.ts +0 -3
- package/src/core/ai/prompts/base.ts +7 -0
- package/src/core/ai/prompts/context.ts +2 -1
- package/src/core/ai/prompts/pre-analysis.ts +3 -2
- package/src/core/ai/prompts/technical.ts +2 -1
- package/src/core/ai/schemas.ts +1 -23
- package/src/core/ai/types.ts +2 -15
- package/src/core/ai/usage-budget.ts +1 -1
- package/src/core/ast/diff.ts +37 -2
- package/src/core/ast/service.ts +1 -1
- package/src/core/brief.ts +54 -5
- package/src/core/changed-scope.ts +207 -0
- package/src/core/commands/scaffold.ts +1 -18
- package/src/core/config-loader.ts +18 -4
- package/src/core/config.ts +2 -15
- package/src/core/defaults.ts +0 -6
- package/src/core/engine/review-scope.ts +55 -8
- package/src/core/engine/review-workspace.ts +6 -2
- package/src/core/jev/jev-detector.ts +7 -2
- package/src/core/logging/index.ts +1 -1
- package/src/core/logging/logger.ts +3 -3
- package/src/core/orchestrator.ts +19 -14
- package/src/core/project-config.ts +0 -28
- package/src/core/registry-check.ts +2 -12
- package/src/core/rule-text.ts +3 -55
- package/src/core/services/git-repository.ts +8 -0
- package/src/core/services/worktree-manager.ts +61 -2
- package/src/core/storage/types.ts +0 -1
- package/src/core/types.ts +3 -14
- package/src/index.ts +25 -3
- package/src/server/client/utils/constants.ts +3 -6
- package/src/server/client/utils/types.ts +0 -1
- package/src/skill/OUTPUT-FORMAT.md +5 -0
- package/src/skill/README.md +2 -1
- package/src/skill/SETUP.md +1 -1
- package/src/skill/fix.md +1 -1
- package/src/skill/hunt.md +2 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +50 -32
- package/src/tests/ai-features.test.ts +11 -0
- package/src/tests/changed-scope.test.ts +197 -0
- package/src/tests/fallback.test.ts +1 -96
- package/src/tests/fixtures/fake-model-provider.ts +0 -1
- package/src/tests/review-scope.test.ts +33 -0
- package/src/tests/worktree.test.ts +72 -4
- package/src/cli/commands/hunt-command.ts +0 -51
- package/src/harness/README.md +0 -15
- package/src/harness/agent/agent-context.ts +0 -33
- package/src/harness/agent/agent-errors.ts +0 -30
- package/src/harness/agent/agent-factory.ts +0 -46
- package/src/harness/agent/agent-reporter.ts +0 -34
- package/src/harness/agent/agent-result.ts +0 -16
- package/src/harness/agent/agent-runner.ts +0 -32
- package/src/harness/agent/agent-scheduler.ts +0 -37
- package/src/harness/agent/review-agent.ts +0 -401
- package/src/harness/config/harness-config.ts +0 -89
- package/src/harness/hunt/hunt-harness.ts +0 -83
- package/src/harness/hunt/hunt-options.ts +0 -92
- package/src/harness/hunt/hunt-result.ts +0 -17
- package/src/harness/hunt/hunt-session.ts +0 -359
- package/src/harness/hunt/review-planner.ts +0 -98
- package/src/harness/index.ts +0 -36
- package/src/harness/investigation/agent-state.ts +0 -134
- package/src/harness/investigation/evidence-verifier.ts +0 -129
- package/src/harness/investigation/evidence.ts +0 -110
- package/src/harness/investigation/finding-critic.ts +0 -96
- package/src/harness/investigation/investigation-planner.ts +0 -118
- package/src/harness/investigation/investigation-task.ts +0 -10
- package/src/harness/investigation/review-journal.ts +0 -87
- package/src/harness/investigation/rule-analyzer.ts +0 -178
- package/src/harness/investigation/rule-signals.ts +0 -110
- package/src/harness/prompts/agent-schemas.ts +0 -57
- package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
- package/src/harness/prompts/states/conclude-prompt.ts +0 -16
- package/src/harness/prompts/states/investigate-prompt.ts +0 -37
- package/src/harness/prompts/states/orient-prompt.ts +0 -15
- package/src/harness/prompts/states/verify-prompt.ts +0 -22
- package/src/harness/tools/agent-tool.ts +0 -102
- package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
- package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
- package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
- package/src/harness/tools/builtin/find-references-tool.ts +0 -66
- package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
- package/src/harness/tools/builtin/helpers.ts +0 -115
- package/src/harness/tools/builtin/impact-tool.ts +0 -48
- package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
- package/src/harness/tools/builtin/index.ts +0 -59
- package/src/harness/tools/builtin/list-files-tool.ts +0 -85
- package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
- package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
- package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
- package/src/harness/tools/builtin/search-code-tool.ts +0 -65
- package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
- package/src/harness/tools/path-guard.ts +0 -90
- package/src/harness/tools/result-limiter.ts +0 -49
- package/src/harness/tools/secret-redactor.ts +0 -54
- package/src/harness/tools/tool-cache.ts +0 -50
- package/src/harness/tools/tool-call-budget.ts +0 -60
- package/src/harness/tools/tool-context.ts +0 -5
- package/src/harness/tools/tool-middleware.ts +0 -105
- package/src/harness/tools/tool-policy.ts +0 -51
- package/src/harness/tools/tool-registry.ts +0 -112
- package/src/harness/tools/tool-result.ts +0 -15
- package/src/harness/tools/toolbox.ts +0 -36
- package/src/harness/validation/finding-validator.ts +0 -119
- package/src/tests/fixtures/fake-agent-tools.ts +0 -69
- package/src/tests/fixtures/fake-investigation.ts +0 -64
- package/src/tests/harness-agent.test.ts +0 -141
- package/src/tests/harness-investigation.test.ts +0 -206
- package/src/tests/harness-tools.test.ts +0 -148
- package/src/tests/hunt-command.test.ts +0 -212
- package/src/tests/rule-analyzer.test.ts +0 -61
- package/src/tests/tool-contracts.test.ts +0 -82
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escopo do diff (engine) — o que esta revisão realmente alterou.
|
|
3
|
+
*
|
|
4
|
+
* Traduz os hunks do diff (`-U0`) em faixas de linhas revisáveis por arquivo e,
|
|
5
|
+
* quando o AST está disponível, no menor símbolo folha que contém cada âncora.
|
|
6
|
+
* É a fonte única usada pelo brief e pela pré-análise para evitar
|
|
7
|
+
* achados em código pré-existente.
|
|
8
|
+
*
|
|
9
|
+
* Fail-open: sem AST (grammar ausente/`--no-ast`/binário) o escopo cai nos
|
|
10
|
+
* hunks; sem patch, o arquivo fica sem faixas.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { changedAnchors } from "./ast/diff";
|
|
14
|
+
import type { AstAnalysis, AstSymbol, AstSymbolKind } from "./ast/types";
|
|
15
|
+
import type { DiffSource } from "./diff-source";
|
|
16
|
+
|
|
17
|
+
/** Intervalo de linhas (1-based, inclusivo). */
|
|
18
|
+
export type LineRange = { start: number; end: number };
|
|
19
|
+
|
|
20
|
+
/** Símbolo alterado (o menor símbolo folha que contém uma âncora). */
|
|
21
|
+
export type ChangedSymbol = {
|
|
22
|
+
name: string;
|
|
23
|
+
kind: AstSymbolKind;
|
|
24
|
+
startLine: number;
|
|
25
|
+
endLine: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Origem do escopo: AST (símbolos) ou hunks (fallback determinístico). */
|
|
29
|
+
export type ChangedScopeSource = "ast" | "hunks";
|
|
30
|
+
|
|
31
|
+
/** Escopo de um arquivo alterado. */
|
|
32
|
+
export type FileChangedScope = {
|
|
33
|
+
/** Faixas revisáveis (símbolos alterados + âncoras soltas), ordenadas. */
|
|
34
|
+
ranges: LineRange[];
|
|
35
|
+
/** Símbolos folha tocados pelas âncoras. */
|
|
36
|
+
symbols: ChangedSymbol[];
|
|
37
|
+
source: ChangedScopeSource;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Escopo do diff: caminho do arquivo → faixas revisáveis. */
|
|
41
|
+
export type ChangedScope = Record<string, FileChangedScope>;
|
|
42
|
+
|
|
43
|
+
/** Entrada mínima do builder (status do `git diff --name-status`). */
|
|
44
|
+
export type ScopeEntry = { path: string; status: string };
|
|
45
|
+
|
|
46
|
+
export type BuildChangedScopeInput = {
|
|
47
|
+
entries: readonly ScopeEntry[];
|
|
48
|
+
/** Patch por arquivo (idealmente `git diff -U0`, sem truncar). */
|
|
49
|
+
diff: DiffSource;
|
|
50
|
+
/** Análises da versão atual (`null`/ausente → fallback nos hunks). */
|
|
51
|
+
ast?: ReadonlyMap<string, AstAnalysis>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Espécies que representam containers: não expandem sozinhas o escopo. */
|
|
55
|
+
const CONTAINER_KINDS: ReadonlySet<AstSymbolKind> = new Set([
|
|
56
|
+
"class",
|
|
57
|
+
"interface",
|
|
58
|
+
"enum",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** Tamanho do intervalo de um símbolo (menor = mais específico). */
|
|
62
|
+
function span(symbol: AstSymbol): number {
|
|
63
|
+
return symbol.endLine - symbol.startLine;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Menor símbolo folha que contém a linha (containers são ignorados). */
|
|
67
|
+
function leafAt(symbols: readonly AstSymbol[], line: number): AstSymbol | undefined {
|
|
68
|
+
let best: AstSymbol | undefined;
|
|
69
|
+
for (const symbol of symbols) {
|
|
70
|
+
if (CONTAINER_KINDS.has(symbol.kind)) continue;
|
|
71
|
+
if (line < symbol.startLine || line > symbol.endLine) continue;
|
|
72
|
+
if (!best || span(symbol) < span(best)) best = symbol;
|
|
73
|
+
}
|
|
74
|
+
return best;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Converte um símbolo AST no shape do escopo. */
|
|
78
|
+
function toChangedSymbol(symbol: AstSymbol): ChangedSymbol {
|
|
79
|
+
return {
|
|
80
|
+
name: symbol.name,
|
|
81
|
+
kind: symbol.kind,
|
|
82
|
+
startLine: symbol.startLine,
|
|
83
|
+
endLine: symbol.endLine,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Deduplica símbolos por identidade e posição, mantendo a ordem de leitura. */
|
|
88
|
+
function uniqueSymbols(symbols: readonly AstSymbol[]): ChangedSymbol[] {
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
const result: ChangedSymbol[] = [];
|
|
91
|
+
for (const symbol of symbols) {
|
|
92
|
+
const key = `${symbol.kind}:${symbol.name}:${symbol.startLine}:${symbol.endLine}`;
|
|
93
|
+
if (seen.has(key)) continue;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
result.push(toChangedSymbol(symbol));
|
|
96
|
+
}
|
|
97
|
+
return result.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `true` se a linha está dentro de alguma faixa. */
|
|
101
|
+
export function isLineInScope(scope: ChangedScope, file: string, line: number): boolean {
|
|
102
|
+
const entry = scope[file];
|
|
103
|
+
if (!entry) return false;
|
|
104
|
+
return entry.ranges.some((range) => line >= range.start && line <= range.end);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Une intervalos sobrepostos/adjacentes (ordem estável por início). */
|
|
108
|
+
export function mergeRanges(ranges: readonly LineRange[]): LineRange[] {
|
|
109
|
+
const sorted = [...ranges]
|
|
110
|
+
.filter((range) => range.end >= range.start)
|
|
111
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
112
|
+
const merged: LineRange[] = [];
|
|
113
|
+
for (const range of sorted) {
|
|
114
|
+
const last = merged.at(-1);
|
|
115
|
+
if (last && range.start <= last.end + 1) {
|
|
116
|
+
last.end = Math.max(last.end, range.end);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
merged.push({ ...range });
|
|
120
|
+
}
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Escopo por arquivo alterado. Arquivos deletados/renomeados sem hunks ficam
|
|
126
|
+
* com faixas vazias (não há código atual para ancorar finding).
|
|
127
|
+
*/
|
|
128
|
+
export function buildChangedScope(input: BuildChangedScopeInput): ChangedScope {
|
|
129
|
+
const scope: ChangedScope = {};
|
|
130
|
+
|
|
131
|
+
for (const entry of input.entries) {
|
|
132
|
+
if (entry.status === "deleted") continue;
|
|
133
|
+
const analysis = input.ast?.get(entry.path);
|
|
134
|
+
if (analysis?.deleted) continue;
|
|
135
|
+
|
|
136
|
+
const patch = input.diff.byFile.get(entry.path) ?? "";
|
|
137
|
+
const { added, deletedAt } = changedAnchors(patch);
|
|
138
|
+
const anchors = new Set<number>([...added, ...deletedAt]);
|
|
139
|
+
const source: ChangedScopeSource = analysis ? "ast" : "hunks";
|
|
140
|
+
|
|
141
|
+
const touched: AstSymbol[] = [];
|
|
142
|
+
if (analysis) {
|
|
143
|
+
for (const line of anchors) {
|
|
144
|
+
const symbol = leafAt(analysis.facts.symbols, line);
|
|
145
|
+
if (symbol) touched.push(symbol);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const symbols = uniqueSymbols(touched);
|
|
149
|
+
|
|
150
|
+
// Âncoras fora de símbolo folha (imports, topo do módulo, cabeçalho de
|
|
151
|
+
// classe) continuam revisáveis como faixas avulsas.
|
|
152
|
+
const covered = (line: number): boolean =>
|
|
153
|
+
symbols.some((symbol) => line >= symbol.startLine && line <= symbol.endLine);
|
|
154
|
+
const loose = [...anchors].filter((line) => !covered(line));
|
|
155
|
+
|
|
156
|
+
scope[entry.path] = {
|
|
157
|
+
ranges: mergeRanges([
|
|
158
|
+
...symbols.map((symbol) => ({
|
|
159
|
+
start: symbol.startLine,
|
|
160
|
+
end: symbol.endLine,
|
|
161
|
+
})),
|
|
162
|
+
...loose.map((line) => ({ start: line, end: line })),
|
|
163
|
+
]),
|
|
164
|
+
symbols,
|
|
165
|
+
source,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return scope;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Totais do escopo (mensagens, tool e testes). */
|
|
173
|
+
export function summarizeScope(scope: ChangedScope): {
|
|
174
|
+
files: number;
|
|
175
|
+
ranges: number;
|
|
176
|
+
symbols: number;
|
|
177
|
+
} {
|
|
178
|
+
let ranges = 0;
|
|
179
|
+
let symbols = 0;
|
|
180
|
+
for (const entry of Object.values(scope)) {
|
|
181
|
+
ranges += entry.ranges.length;
|
|
182
|
+
symbols += entry.symbols.length;
|
|
183
|
+
}
|
|
184
|
+
return { files: Object.keys(scope).length, ranges, symbols };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Faixas em texto compacto (`10-14, 22`) para prompts e briefs. */
|
|
188
|
+
export function formatRanges(ranges: readonly LineRange[], max = 8): string {
|
|
189
|
+
if (ranges.length === 0) return "sem linhas alteradas";
|
|
190
|
+
const shown = ranges
|
|
191
|
+
.slice(0, max)
|
|
192
|
+
.map((range) =>
|
|
193
|
+
range.start === range.end ? `${range.start}` : `${range.start}-${range.end}`,
|
|
194
|
+
);
|
|
195
|
+
const rest = ranges.length > max ? ` (+${ranges.length - max})` : "";
|
|
196
|
+
return `${shown.join(", ")}${rest}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Símbolos em texto compacto (`` `foo` (10-40)``) para prompts e briefs. */
|
|
200
|
+
export function formatSymbols(symbols: readonly ChangedSymbol[], max = 6): string {
|
|
201
|
+
if (symbols.length === 0) return "";
|
|
202
|
+
const shown = symbols
|
|
203
|
+
.slice(0, max)
|
|
204
|
+
.map((symbol) => `\`${symbol.name}\` (${symbol.startLine}-${symbol.endLine})`);
|
|
205
|
+
const rest = symbols.length > max ? ` (+${symbols.length - max})` : "";
|
|
206
|
+
return `${shown.join(", ")}${rest}`;
|
|
207
|
+
}
|
|
@@ -131,16 +131,6 @@ export type Rule = {
|
|
|
131
131
|
severity: Severity;
|
|
132
132
|
text: string;
|
|
133
133
|
jev?: JevRuleSpec;
|
|
134
|
-
hints?: RuleHints;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
/** Sinais opcionais de uma regra (símbolos, imports, decorators, paths, checks). */
|
|
138
|
-
export type RuleHints = {
|
|
139
|
-
symbols?: string[];
|
|
140
|
-
imports?: string[];
|
|
141
|
-
decorators?: string[];
|
|
142
|
-
paths?: string[];
|
|
143
|
-
checks?: string[];
|
|
144
134
|
};
|
|
145
135
|
|
|
146
136
|
/** Como o watchdog casa um arquivo alterado. \`value\` é sempre regex. */
|
|
@@ -235,20 +225,15 @@ export type HoundsmanAiFeatures = {
|
|
|
235
225
|
/** Overrides de IA por watchdog. */
|
|
236
226
|
export type HoundsmanAiWatchdog = {
|
|
237
227
|
model?: string;
|
|
238
|
-
maxSteps?: number;
|
|
239
|
-
maxOutputTokens?: number;
|
|
240
228
|
};
|
|
241
229
|
|
|
242
|
-
/** Configuração da camada de IA (briefs, contexto, classificação
|
|
230
|
+
/** Configuração da camada de IA (briefs, contexto, classificação, pré-análise, verify). */
|
|
243
231
|
export type HoundsmanAi = {
|
|
244
232
|
enabled?: boolean;
|
|
245
233
|
provider?: AiProviderKind;
|
|
246
234
|
model?: string;
|
|
247
235
|
baseURL?: string;
|
|
248
236
|
concurrency?: number;
|
|
249
|
-
maxSteps?: number;
|
|
250
|
-
maxOutputTokens?: number;
|
|
251
|
-
temperature?: number;
|
|
252
237
|
providerOptions?: AiProviderOptions;
|
|
253
238
|
timeoutMs?: number;
|
|
254
239
|
maxCallsPerReview?: number;
|
|
@@ -337,7 +322,6 @@ export const defaultWatchdog = defineWatchdog({
|
|
|
337
322
|
{
|
|
338
323
|
severity: "attention",
|
|
339
324
|
text: "Promover: nome, contrato e documentação do símbolo correspondem ao comportamento real.",
|
|
340
|
-
hints: { checks: ["symbol_context"] },
|
|
341
325
|
},
|
|
342
326
|
{
|
|
343
327
|
severity: "attention",
|
|
@@ -350,7 +334,6 @@ export const defaultWatchdog = defineWatchdog({
|
|
|
350
334
|
{
|
|
351
335
|
severity: "suggestion",
|
|
352
336
|
text: "Evitar: lógica de negócio duplicada ou divergente entre camadas.",
|
|
353
|
-
hints: { checks: ["find_references"] },
|
|
354
337
|
},
|
|
355
338
|
{
|
|
356
339
|
severity: "suggestion",
|
|
@@ -31,6 +31,12 @@ export type LoadedConfig<TId extends string = string> = {
|
|
|
31
31
|
path: string;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/** Forma do módulo de config (default ou export nomeado). */
|
|
35
|
+
type ConfigModule<TId extends string> = {
|
|
36
|
+
default?: HoundsmanConfig<TId>;
|
|
37
|
+
houndsmanConfig?: HoundsmanConfig<TId>;
|
|
38
|
+
};
|
|
39
|
+
|
|
34
40
|
/** Primeiro arquivo de config existente no diretório (ou `null`). */
|
|
35
41
|
export function resolveConfigPath(cwd: string = process.cwd()): string | null {
|
|
36
42
|
for (const name of CONFIG_FILE_CANDIDATES) {
|
|
@@ -61,10 +67,18 @@ export async function loadConfig<TId extends string = string>(
|
|
|
61
67
|
throw new Error(MISSING_CONFIG_HINT);
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
70
|
+
let module: ConfigModule<TId>;
|
|
71
|
+
try {
|
|
72
|
+
module = (await import(pathToFileURL(path).href)) as ConfigModule<TId>;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
75
|
+
// Causa comum: camada gerada ausente (`.houndsman/core`), importada pelos
|
|
76
|
+
// watchdogs como `../core/types` — típico de worktree ainda não sincronizado.
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Falha ao carregar ${path}: ${detail}\n` +
|
|
79
|
+
"A camada gerada (`.houndsman/core`) existe? Rode `houndsman update` na raiz do projeto.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
68
82
|
const config = module.default ?? module.houndsmanConfig;
|
|
69
83
|
|
|
70
84
|
if (!config) {
|
package/src/core/config.ts
CHANGED
|
@@ -13,8 +13,6 @@ import type { WatchdogDefinition } from "./types";
|
|
|
13
13
|
export {
|
|
14
14
|
DEFAULT_AI_CONCURRENCY,
|
|
15
15
|
DEFAULT_AI_MAX_CALLS_PER_REVIEW,
|
|
16
|
-
DEFAULT_AI_MAX_OUTPUT_TOKENS,
|
|
17
|
-
DEFAULT_AI_MAX_STEPS,
|
|
18
16
|
DEFAULT_AI_MODEL,
|
|
19
17
|
DEFAULT_AI_PROVIDER,
|
|
20
18
|
DEFAULT_AI_TIMEOUT_MS,
|
|
@@ -85,11 +83,9 @@ export type HoundsmanAiFeatures = {
|
|
|
85
83
|
/** Overrides de IA por watchdog. */
|
|
86
84
|
export type HoundsmanAiWatchdog = {
|
|
87
85
|
model?: string;
|
|
88
|
-
maxSteps?: number;
|
|
89
|
-
maxOutputTokens?: number;
|
|
90
86
|
};
|
|
91
87
|
|
|
92
|
-
/** Configuração da camada de IA (
|
|
88
|
+
/** Configuração da camada de IA (briefs, contexto, classificação, pré-análise, verify). */
|
|
93
89
|
export type HoundsmanAi = {
|
|
94
90
|
/** Liga a IA do fluxo normal (default: `true`; sem chave é fail-open). */
|
|
95
91
|
enabled?: boolean;
|
|
@@ -101,15 +97,6 @@ export type HoundsmanAi = {
|
|
|
101
97
|
baseURL?: string;
|
|
102
98
|
/** Chamadas simultâneas (default: 2). */
|
|
103
99
|
concurrency?: number;
|
|
104
|
-
/** Passos por agente (default: 12). */
|
|
105
|
-
maxSteps?: number;
|
|
106
|
-
/** Teto de tokens de saída por chamada. */
|
|
107
|
-
maxOutputTokens?: number;
|
|
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
|
-
*/
|
|
112
|
-
temperature?: number;
|
|
113
100
|
/** Opções do AI SDK por provider (repassadas sem interpretação). */
|
|
114
101
|
providerOptions?: AiProviderOptions;
|
|
115
102
|
/** Timeout por chamada em ms (default: 120_000). */
|
|
@@ -180,7 +167,7 @@ export type HoundsmanConfig<TId extends string = string> = {
|
|
|
180
167
|
concurrency?: HoundsmanConcurrency;
|
|
181
168
|
/** AST determinístico (classificação estrutural, brief e impacto). */
|
|
182
169
|
ast?: HoundsmanAst;
|
|
183
|
-
/** Camada de IA (briefs, contexto, classificação e
|
|
170
|
+
/** Camada de IA (briefs, contexto, classificação, pré-análise e verify). */
|
|
184
171
|
ai?: HoundsmanAi;
|
|
185
172
|
};
|
|
186
173
|
|
package/src/core/defaults.ts
CHANGED
|
@@ -41,14 +41,8 @@ export const DEFAULT_AI_MODEL = "deepseek-v4-pro";
|
|
|
41
41
|
/** Concorrência default das chamadas de IA. */
|
|
42
42
|
export const DEFAULT_AI_CONCURRENCY = 2;
|
|
43
43
|
|
|
44
|
-
/** Passos default por agente (loop de tools + saída). */
|
|
45
|
-
export const DEFAULT_AI_MAX_STEPS = 12;
|
|
46
|
-
|
|
47
44
|
/** Timeout default de uma chamada de modelo (ms). */
|
|
48
45
|
export const DEFAULT_AI_TIMEOUT_MS = 120_000;
|
|
49
46
|
|
|
50
47
|
/** Teto default de chamadas de IA por review. */
|
|
51
48
|
export const DEFAULT_AI_MAX_CALLS_PER_REVIEW = 40;
|
|
52
|
-
|
|
53
|
-
/** Teto default de tokens de saída por chamada. */
|
|
54
|
-
export const DEFAULT_AI_MAX_OUTPUT_TOKENS = 4096;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AstService } from "../ast/service";
|
|
9
9
|
import type { AstAnalysis, AstFacts } from "../ast/types";
|
|
10
|
+
import { buildChangedScope, type ChangedScope, type ScopeEntry } from "../changed-scope";
|
|
10
11
|
import type { HoundsmanConfig } from "../config";
|
|
11
12
|
import { churnToRecord, type DiffSource } from "../diff-source";
|
|
12
13
|
import { type ClassificationResult, parseEntries } from "../file-classifier";
|
|
@@ -34,6 +35,8 @@ export type RunClassification = ClassificationResult & {
|
|
|
34
35
|
reviewedFrom?: string;
|
|
35
36
|
/** Findings preservados do review anterior (arquivos inalterados). */
|
|
36
37
|
carryover?: MergedFinding[];
|
|
38
|
+
/** Faixas revisáveis por arquivo (símbolos alterados; hunks como fallback). */
|
|
39
|
+
changedScope?: ChangedScope;
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
/** Resultado da resolução de escopo: revisar ou nada a fazer. */
|
|
@@ -104,19 +107,30 @@ export class ReviewScopeResolver {
|
|
|
104
107
|
const baseRef =
|
|
105
108
|
this.git.mergeBase(this.options.base, this.options.head) ??
|
|
106
109
|
this.options.base;
|
|
110
|
+
// Patch `-U0` sem truncar: mais preciso para delta AST e escopo.
|
|
111
|
+
const scopeDiff = this.zeroContext(
|
|
112
|
+
`${this.options.base}...${this.options.head}`,
|
|
113
|
+
diff,
|
|
114
|
+
);
|
|
107
115
|
const ast = this.ast
|
|
108
|
-
? await this.ast.analyze(entries,
|
|
116
|
+
? await this.ast.analyze(entries, scopeDiff, baseRef)
|
|
109
117
|
: undefined;
|
|
118
|
+
const result = this.classifier
|
|
119
|
+
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
120
|
+
.toJSON();
|
|
110
121
|
return {
|
|
111
122
|
kind: "review",
|
|
112
123
|
diff,
|
|
113
124
|
ast,
|
|
114
125
|
classification: {
|
|
115
|
-
...
|
|
116
|
-
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
117
|
-
.toJSON(),
|
|
126
|
+
...result,
|
|
118
127
|
headSha,
|
|
119
128
|
mode: "full",
|
|
129
|
+
changedScope: buildChangedScope({
|
|
130
|
+
entries: scopeEntries(entries, result),
|
|
131
|
+
diff: scopeDiff,
|
|
132
|
+
ast,
|
|
133
|
+
}),
|
|
120
134
|
},
|
|
121
135
|
};
|
|
122
136
|
}
|
|
@@ -125,20 +139,39 @@ export class ReviewScopeResolver {
|
|
|
125
139
|
const diff = this.git.collectDiffSince(prevSha, headSha);
|
|
126
140
|
const entries = parseEntries(this.git.run(["diff", range, "--name-status"]));
|
|
127
141
|
const changedPaths = new Set(entries.map((entry) => entry.path));
|
|
128
|
-
const
|
|
142
|
+
const scopeDiff = this.zeroContext(range, diff);
|
|
143
|
+
const ast = this.ast
|
|
144
|
+
? await this.ast.analyze(entries, scopeDiff, prevSha)
|
|
145
|
+
: undefined;
|
|
146
|
+
const result = this.classifier
|
|
147
|
+
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
148
|
+
.toJSON();
|
|
129
149
|
const classification: RunClassification = {
|
|
130
|
-
...
|
|
131
|
-
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
132
|
-
.toJSON(),
|
|
150
|
+
...result,
|
|
133
151
|
base: prevSha,
|
|
134
152
|
head: this.options.head,
|
|
135
153
|
headSha,
|
|
136
154
|
mode: "incremental",
|
|
137
155
|
reviewedFrom: prevSha,
|
|
138
156
|
carryover: buildCarryover(previous, changedPaths),
|
|
157
|
+
changedScope: buildChangedScope({
|
|
158
|
+
entries: scopeEntries(entries, result),
|
|
159
|
+
diff: scopeDiff,
|
|
160
|
+
ast,
|
|
161
|
+
}),
|
|
139
162
|
};
|
|
140
163
|
return { kind: "review", classification, diff, reviewedFrom: prevSha, ast };
|
|
141
164
|
}
|
|
165
|
+
|
|
166
|
+
/** Patch `-U0` do intervalo; falha aberta cai no diff já coletado. */
|
|
167
|
+
private zeroContext(range: string, fallback: DiffSource): DiffSource {
|
|
168
|
+
try {
|
|
169
|
+
const byFile = this.git.patchZeroContext(range);
|
|
170
|
+
return byFile.size > 0 ? { byFile, churn: fallback.churn } : fallback;
|
|
171
|
+
} catch {
|
|
172
|
+
return fallback;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
142
175
|
}
|
|
143
176
|
|
|
144
177
|
/** Fatos apenas dos arquivos presentes (deletados não classificam). */
|
|
@@ -152,3 +185,17 @@ function factsOf(
|
|
|
152
185
|
}
|
|
153
186
|
return facts;
|
|
154
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Entradas revisáveis do `--name-status`: exclui arquivos do `exclusions` e
|
|
191
|
+
* deletados (não há código atual para ancorar finding).
|
|
192
|
+
*/
|
|
193
|
+
function scopeEntries(
|
|
194
|
+
entries: ReturnType<typeof parseEntries>,
|
|
195
|
+
result: ClassificationResult,
|
|
196
|
+
): ScopeEntry[] {
|
|
197
|
+
const excluded = new Set(result.excluded);
|
|
198
|
+
return entries
|
|
199
|
+
.filter((entry) => entry.status !== "deleted" && !excluded.has(entry.path))
|
|
200
|
+
.map((entry) => ({ path: entry.path, status: entry.status }));
|
|
201
|
+
}
|
|
@@ -11,7 +11,7 @@ import { createGitRunner } from "../git-runner";
|
|
|
11
11
|
import { createGhRunner, type GhRunner } from "../services/gh-client";
|
|
12
12
|
import { GitRepository } from "../services/git-repository";
|
|
13
13
|
import { PrResolver } from "../services/pr-resolver";
|
|
14
|
-
import { WorktreeManager } from "../services/worktree-manager";
|
|
14
|
+
import { syncProjectLayer, WorktreeManager } from "../services/worktree-manager";
|
|
15
15
|
import { resolveWorktreesRoot } from "../storage/paths";
|
|
16
16
|
import { resolveRepoIdentity } from "../storage/repo-identity";
|
|
17
17
|
import type { RepoIdentity } from "../storage/types";
|
|
@@ -105,9 +105,13 @@ export function resolveReviewWorkspace(
|
|
|
105
105
|
`Worktree '${root}' está em detached HEAD. Informe --branch <nome>.`,
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
|
+
// A camada gerada não vem no checkout; sem ela os comandos no worktree
|
|
109
|
+
// não carregam a config (`../core/types`).
|
|
110
|
+
const explicitMainRoot = mainRootOf(git, repo.root);
|
|
111
|
+
syncProjectLayer(explicitMainRoot, root);
|
|
108
112
|
return {
|
|
109
113
|
root,
|
|
110
|
-
mainRoot:
|
|
114
|
+
mainRoot: explicitMainRoot,
|
|
111
115
|
repo,
|
|
112
116
|
git,
|
|
113
117
|
branch,
|
|
@@ -156,10 +156,15 @@ export class JevDetector {
|
|
|
156
156
|
const hunk =
|
|
157
157
|
diff.byFile.get(filePath)?.slice(0, JevDetector.MAX_DIFF_CHARS) ?? "";
|
|
158
158
|
const parts = [`Arquivo: ${filePath} (${status})`];
|
|
159
|
+
parts.push(
|
|
160
|
+
"Escopo: apenas o diff abaixo refere-se ao que mudou nesta revisão.",
|
|
161
|
+
"O topo do arquivo é contexto para entender imports/declarações — não reporte",
|
|
162
|
+
"problemas apenas nele.",
|
|
163
|
+
);
|
|
159
164
|
if (header) {
|
|
160
|
-
parts.push("--- topo do arquivo (
|
|
165
|
+
parts.push("--- topo do arquivo (contexto) ---", header);
|
|
161
166
|
}
|
|
162
|
-
if (hunk) parts.push("--- diff ---", hunk);
|
|
167
|
+
if (hunk) parts.push("--- diff (escopo alterado) ---", hunk);
|
|
163
168
|
return parts.join("\n").slice(0, JevDetector.MAX_STATE_CHARS);
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Instância pronta do logger (`import { logger } from "../core/logging"`).
|
|
3
3
|
*
|
|
4
|
-
* Infraestrutura compartilhada por `cli
|
|
4
|
+
* Infraestrutura compartilhada por `cli` e `server`. Módulos de
|
|
5
5
|
* negócio do core não escrevem saída.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Logger único do CLI
|
|
2
|
+
* Logger único do CLI (infraestrutura).
|
|
3
3
|
*
|
|
4
4
|
* Usa `chalk` para cor e `ora` para spinner, mas cai para texto puro quando a
|
|
5
5
|
* saída não é TTY, `NO_COLOR` está definido, `TERM=dumb` ou `--quiet`.
|
|
6
6
|
*
|
|
7
|
-
* Não é usado por módulos de negócio do `core/`: só `cli
|
|
8
|
-
*
|
|
7
|
+
* Não é usado por módulos de negócio do `core/`: só `cli/` e `server/`
|
|
8
|
+
* escrevem saída.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { Chalk, type ChalkInstance } from "chalk";
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* review.<watchdog>.md, context e context-summary.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { existsSync
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import type { AiClient } from "./ai/ai-client";
|
|
22
22
|
import { AiBriefEnricher } from "./ai/features/brief-enricher";
|
|
@@ -28,6 +28,7 @@ import type { BriefPlan } from "./ai/schemas";
|
|
|
28
28
|
import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
|
|
29
29
|
import type { AstService } from "./ast/service";
|
|
30
30
|
import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
|
|
31
|
+
import { summarizeScope } from "./changed-scope";
|
|
31
32
|
import {
|
|
32
33
|
type HoundsmanConfig,
|
|
33
34
|
resolveJevConcurrency,
|
|
@@ -101,7 +102,7 @@ export type ReviewOptions = {
|
|
|
101
102
|
noAst: boolean;
|
|
102
103
|
/** Força review completo (ignora o modo incremental). */
|
|
103
104
|
full: boolean;
|
|
104
|
-
/** Desativa as features de IA do fluxo normal
|
|
105
|
+
/** Desativa as features de IA do fluxo normal. */
|
|
105
106
|
noAi: boolean;
|
|
106
107
|
/** PR de origem (resolvida via `--pr`). */
|
|
107
108
|
pr?: number;
|
|
@@ -231,7 +232,7 @@ function writeManifest(
|
|
|
231
232
|
|
|
232
233
|
function writeBriefs(
|
|
233
234
|
activeIds: string[],
|
|
234
|
-
classification:
|
|
235
|
+
classification: RunClassification,
|
|
235
236
|
ctx: ReviewContext,
|
|
236
237
|
branch: string,
|
|
237
238
|
context: ContextSummary | null,
|
|
@@ -276,6 +277,7 @@ function writeBriefs(
|
|
|
276
277
|
candidates: candidates.filter((c) => c.watchdogId === id),
|
|
277
278
|
ast: astFiles,
|
|
278
279
|
aiPlan: aiPlans?.get(id),
|
|
280
|
+
changedScope: classification.changedScope,
|
|
279
281
|
});
|
|
280
282
|
const content = `${brief}\n`;
|
|
281
283
|
work.writeBrief(id, content);
|
|
@@ -322,7 +324,9 @@ async function resolveCandidates(
|
|
|
322
324
|
const def = findWatchdog(config, id);
|
|
323
325
|
if (!def) continue;
|
|
324
326
|
const inputs = files.slice(0, 5).flatMap((file) => {
|
|
325
|
-
|
|
327
|
+
// Só o trecho alterado: candidato fora do diff vira ruído de código
|
|
328
|
+
// pré-existente no brief do watchdog.
|
|
329
|
+
const snippet = diff.byFile.get(file);
|
|
326
330
|
return snippet ? [{ file, snippet }] : [];
|
|
327
331
|
});
|
|
328
332
|
candidates.push(
|
|
@@ -406,6 +410,16 @@ function discoveryMessages(
|
|
|
406
410
|
return messages;
|
|
407
411
|
}
|
|
408
412
|
|
|
413
|
+
/** Resumo do escopo — lembra que só as faixas alteradas são revisáveis. */
|
|
414
|
+
function changedScopeMessage(classification: RunClassification): string[] {
|
|
415
|
+
const scope = classification.changedScope;
|
|
416
|
+
if (!scope) return [];
|
|
417
|
+
const totals = summarizeScope(scope);
|
|
418
|
+
return [
|
|
419
|
+
`Escopo: ${totals.files} arquivo(s), ${totals.symbols} símbolo(s) em ${totals.ranges} faixa(s) alterada(s).`,
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
|
|
409
423
|
/** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
|
|
410
424
|
function budgetMessage(
|
|
411
425
|
classification: ClassificationResult,
|
|
@@ -422,16 +436,6 @@ function budgetMessage(
|
|
|
422
436
|
];
|
|
423
437
|
}
|
|
424
438
|
|
|
425
|
-
/** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
|
|
426
|
-
function readSnippet(root: string, file: string): string | null {
|
|
427
|
-
try {
|
|
428
|
-
const path = join(root, file);
|
|
429
|
-
return readFileSync(path, "utf8").split("\n").slice(0, 80).join("\n");
|
|
430
|
-
} catch {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
439
|
/** Resultado do prepare — mensagens para o CLI imprimir. */
|
|
436
440
|
export type PrepareResult = { messages: string[] };
|
|
437
441
|
|
|
@@ -806,6 +810,7 @@ export async function prepare(
|
|
|
806
810
|
messages.push(
|
|
807
811
|
...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
|
|
808
812
|
...budgetMessage(classification, config),
|
|
813
|
+
...changedScopeMessage(classification),
|
|
809
814
|
...jevSummary(jev),
|
|
810
815
|
);
|
|
811
816
|
if (astAnalyses) {
|
|
@@ -19,8 +19,6 @@ import type {
|
|
|
19
19
|
import {
|
|
20
20
|
DEFAULT_AI_CONCURRENCY,
|
|
21
21
|
DEFAULT_AI_MAX_CALLS_PER_REVIEW,
|
|
22
|
-
DEFAULT_AI_MAX_OUTPUT_TOKENS,
|
|
23
|
-
DEFAULT_AI_MAX_STEPS,
|
|
24
22
|
DEFAULT_AI_MODEL,
|
|
25
23
|
DEFAULT_AI_PROVIDER,
|
|
26
24
|
DEFAULT_AI_TIMEOUT_MS,
|
|
@@ -126,19 +124,6 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
126
124
|
return positive(this.ai.concurrency, DEFAULT_AI_CONCURRENCY);
|
|
127
125
|
}
|
|
128
126
|
|
|
129
|
-
get aiMaxSteps(): number {
|
|
130
|
-
return positive(this.ai.maxSteps, DEFAULT_AI_MAX_STEPS);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
get aiMaxOutputTokens(): number {
|
|
134
|
-
return positive(this.ai.maxOutputTokens, DEFAULT_AI_MAX_OUTPUT_TOKENS);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
get aiTemperature(): number {
|
|
138
|
-
const value = this.ai.temperature;
|
|
139
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
127
|
/** Opções do AI SDK por provider (undefined quando ausentes/vazias). */
|
|
143
128
|
get aiProviderOptions(): AiProviderOptions | undefined {
|
|
144
129
|
const value = this.ai.providerOptions;
|
|
@@ -177,19 +162,6 @@ export class ProjectConfig<TId extends string = string> {
|
|
|
177
162
|
const override = this.ai.watchdogs?.[watchdogId]?.model?.trim();
|
|
178
163
|
return override && override.length > 0 ? override : this.aiModel;
|
|
179
164
|
}
|
|
180
|
-
|
|
181
|
-
/** Passos efetivos de um watchdog (override por watchdog ou global). */
|
|
182
|
-
aiMaxStepsFor(watchdogId: string): number {
|
|
183
|
-
return positive(this.ai.watchdogs?.[watchdogId]?.maxSteps, this.aiMaxSteps);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Tokens de saída efetivos de um watchdog. */
|
|
187
|
-
aiMaxOutputTokensFor(watchdogId: string): number {
|
|
188
|
-
return positive(
|
|
189
|
-
this.ai.watchdogs?.[watchdogId]?.maxOutputTokens,
|
|
190
|
-
this.aiMaxOutputTokens,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
165
|
}
|
|
194
166
|
|
|
195
167
|
/** Inteiro positivo ou o default. */
|