@payconductor/houndsman 0.11.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.
Files changed (118) hide show
  1. package/README.md +4 -19
  2. package/package.json +1 -1
  3. package/src/cli/help.ts +0 -7
  4. package/src/cli/index.ts +0 -2
  5. package/src/core/ai/ai-client.ts +3 -7
  6. package/src/core/ai/ai-sdk-model-provider.ts +4 -24
  7. package/src/core/ai/features/brief-enricher.ts +0 -1
  8. package/src/core/ai/features/classifier.ts +0 -1
  9. package/src/core/ai/features/context-summarizer.ts +0 -1
  10. package/src/core/ai/features/pre-analyzer.ts +0 -1
  11. package/src/core/ai/features/technical-summarizer.ts +0 -1
  12. package/src/core/ai/features/verifier.ts +0 -1
  13. package/src/core/ai/index.ts +0 -3
  14. package/src/core/ai/prompts/pre-analysis.ts +3 -2
  15. package/src/core/ai/schemas.ts +1 -23
  16. package/src/core/ai/types.ts +2 -15
  17. package/src/core/ai/usage-budget.ts +1 -1
  18. package/src/core/ast/diff.ts +37 -2
  19. package/src/core/ast/service.ts +1 -1
  20. package/src/core/brief.ts +54 -5
  21. package/src/core/changed-scope.ts +207 -0
  22. package/src/core/commands/scaffold.ts +1 -18
  23. package/src/core/config.ts +2 -15
  24. package/src/core/defaults.ts +0 -6
  25. package/src/core/engine/review-scope.ts +55 -8
  26. package/src/core/jev/jev-detector.ts +7 -2
  27. package/src/core/logging/index.ts +1 -1
  28. package/src/core/logging/logger.ts +3 -3
  29. package/src/core/orchestrator.ts +19 -14
  30. package/src/core/project-config.ts +0 -28
  31. package/src/core/registry-check.ts +2 -12
  32. package/src/core/rule-text.ts +3 -55
  33. package/src/core/services/git-repository.ts +8 -0
  34. package/src/core/storage/types.ts +0 -1
  35. package/src/core/types.ts +3 -14
  36. package/src/index.ts +25 -3
  37. package/src/server/client/utils/constants.ts +3 -6
  38. package/src/server/client/utils/types.ts +0 -1
  39. package/src/skill/OUTPUT-FORMAT.md +5 -0
  40. package/src/skill/README.md +2 -1
  41. package/src/skill/SETUP.md +1 -1
  42. package/src/skill/fix.md +1 -1
  43. package/src/skill/hunt.md +2 -6
  44. package/src/tests/__snapshots__/baseline.test.ts.snap +50 -32
  45. package/src/tests/changed-scope.test.ts +197 -0
  46. package/src/tests/fallback.test.ts +1 -96
  47. package/src/tests/fixtures/fake-model-provider.ts +0 -1
  48. package/src/tests/review-scope.test.ts +33 -0
  49. package/src/tests/worktree.test.ts +1 -3
  50. package/src/cli/commands/hunt-command.ts +0 -51
  51. package/src/harness/README.md +0 -15
  52. package/src/harness/agent/agent-context.ts +0 -33
  53. package/src/harness/agent/agent-errors.ts +0 -30
  54. package/src/harness/agent/agent-factory.ts +0 -46
  55. package/src/harness/agent/agent-reporter.ts +0 -34
  56. package/src/harness/agent/agent-result.ts +0 -16
  57. package/src/harness/agent/agent-runner.ts +0 -32
  58. package/src/harness/agent/agent-scheduler.ts +0 -37
  59. package/src/harness/agent/review-agent.ts +0 -401
  60. package/src/harness/config/harness-config.ts +0 -89
  61. package/src/harness/hunt/hunt-harness.ts +0 -83
  62. package/src/harness/hunt/hunt-options.ts +0 -92
  63. package/src/harness/hunt/hunt-result.ts +0 -17
  64. package/src/harness/hunt/hunt-session.ts +0 -359
  65. package/src/harness/hunt/review-planner.ts +0 -98
  66. package/src/harness/index.ts +0 -36
  67. package/src/harness/investigation/agent-state.ts +0 -134
  68. package/src/harness/investigation/evidence-verifier.ts +0 -129
  69. package/src/harness/investigation/evidence.ts +0 -110
  70. package/src/harness/investigation/finding-critic.ts +0 -96
  71. package/src/harness/investigation/investigation-planner.ts +0 -118
  72. package/src/harness/investigation/investigation-task.ts +0 -10
  73. package/src/harness/investigation/review-journal.ts +0 -87
  74. package/src/harness/investigation/rule-analyzer.ts +0 -178
  75. package/src/harness/investigation/rule-signals.ts +0 -110
  76. package/src/harness/prompts/agent-schemas.ts +0 -57
  77. package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
  78. package/src/harness/prompts/states/conclude-prompt.ts +0 -16
  79. package/src/harness/prompts/states/investigate-prompt.ts +0 -37
  80. package/src/harness/prompts/states/orient-prompt.ts +0 -15
  81. package/src/harness/prompts/states/verify-prompt.ts +0 -22
  82. package/src/harness/tools/agent-tool.ts +0 -102
  83. package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
  84. package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
  85. package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
  86. package/src/harness/tools/builtin/find-references-tool.ts +0 -66
  87. package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
  88. package/src/harness/tools/builtin/helpers.ts +0 -115
  89. package/src/harness/tools/builtin/impact-tool.ts +0 -48
  90. package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
  91. package/src/harness/tools/builtin/index.ts +0 -59
  92. package/src/harness/tools/builtin/list-files-tool.ts +0 -85
  93. package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
  94. package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
  95. package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
  96. package/src/harness/tools/builtin/search-code-tool.ts +0 -65
  97. package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
  98. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
  99. package/src/harness/tools/path-guard.ts +0 -90
  100. package/src/harness/tools/result-limiter.ts +0 -49
  101. package/src/harness/tools/secret-redactor.ts +0 -54
  102. package/src/harness/tools/tool-cache.ts +0 -50
  103. package/src/harness/tools/tool-call-budget.ts +0 -60
  104. package/src/harness/tools/tool-context.ts +0 -5
  105. package/src/harness/tools/tool-middleware.ts +0 -105
  106. package/src/harness/tools/tool-policy.ts +0 -51
  107. package/src/harness/tools/tool-registry.ts +0 -112
  108. package/src/harness/tools/tool-result.ts +0 -15
  109. package/src/harness/tools/toolbox.ts +0 -36
  110. package/src/harness/validation/finding-validator.ts +0 -119
  111. package/src/tests/fixtures/fake-agent-tools.ts +0 -69
  112. package/src/tests/fixtures/fake-investigation.ts +0 -64
  113. package/src/tests/harness-agent.test.ts +0 -141
  114. package/src/tests/harness-investigation.test.ts +0 -206
  115. package/src/tests/harness-tools.test.ts +0 -148
  116. package/src/tests/hunt-command.test.ts +0 -212
  117. package/src/tests/rule-analyzer.test.ts +0 -61
  118. 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 e hunt nativo). */
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",
@@ -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 (genérica; o harness nativo também a consome). */
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 o harness nativo). */
170
+ /** Camada de IA (briefs, contexto, classificação, pré-análise e verify). */
184
171
  ai?: HoundsmanAi;
185
172
  };
186
173
 
@@ -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, diff, baseRef)
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
- ...this.classifier
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 ast = this.ast ? await this.ast.analyze(entries, diff, prevSha) : undefined;
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
- ...this.classifier
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
+ }
@@ -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 (imports/declarações) ---", header);
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`, `harness` e `server`. Módulos de
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/harness (infraestrutura).
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/`, `harness/` e
8
- * `server/` escrevem saída.
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";
@@ -16,7 +16,7 @@
16
16
  * review.<watchdog>.md, context e context-summary.
17
17
  */
18
18
 
19
- import { existsSync, readFileSync } from "node:fs";
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 (hunt não usa esta flag). */
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: ClassificationResult,
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
- const snippet = readSnippet(ctx.root, file);
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. */
@@ -282,12 +282,7 @@ function checkAiConfig(
282
282
  if (ai.enabled !== false && ai.model !== undefined && !ai.model.trim()) {
283
283
  warnings.push("ai.model vazio — usando o default.");
284
284
  }
285
- for (const key of [
286
- "concurrency",
287
- "maxSteps",
288
- "maxOutputTokens",
289
- "timeoutMs",
290
- ] as const) {
285
+ for (const key of ["concurrency", "timeoutMs"] as const) {
291
286
  const value = ai[key];
292
287
  if (value !== undefined && value <= 0) {
293
288
  warnings.push(`ai.${key} = ${value} — não-positivo; usando o default.`);
@@ -303,15 +298,10 @@ function checkAiConfig(
303
298
  `ai.maxTokensPerReview = ${ai.maxTokensPerReview} — negativo; sem teto.`,
304
299
  );
305
300
  }
306
- for (const [id, override] of Object.entries(ai.watchdogs ?? {})) {
301
+ for (const id of Object.keys(ai.watchdogs ?? {})) {
307
302
  if (!config.watchdogIds.includes(id)) {
308
303
  warnings.push(`ai.watchdogs['${id}'] não corresponde a nenhum watchdog.`);
309
304
  }
310
- if (override.maxSteps !== undefined && override.maxSteps <= 0) {
311
- warnings.push(
312
- `ai.watchdogs['${id}'].maxSteps não-positivo — usando o default.`,
313
- );
314
- }
315
305
  }
316
306
 
317
307
  const provider = ai.provider ?? "deepseek";