@payconductor/houndsman 0.5.0 → 0.7.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 (175) hide show
  1. package/README.md +24 -4
  2. package/assets/grammars/tree-sitter-javascript.wasm +0 -0
  3. package/assets/grammars/tree-sitter-tsx.wasm +0 -0
  4. package/assets/grammars/tree-sitter-typescript.wasm +0 -0
  5. package/assets/grammars/web-tree-sitter.wasm +0 -0
  6. package/package.json +11 -3
  7. package/src/cli/commands/command.ts +5 -0
  8. package/src/cli/commands/doctor-command.ts +116 -24
  9. package/src/cli/commands/hunt-command.ts +25 -0
  10. package/src/cli/commands/server-command.ts +7 -1
  11. package/src/cli/help.ts +18 -2
  12. package/src/cli/index.ts +10 -6
  13. package/src/core/ai/ai-client.ts +221 -0
  14. package/src/core/ai/ai-sdk-model-provider.ts +101 -0
  15. package/src/core/ai/errors.ts +36 -0
  16. package/src/core/ai/features/brief-enricher.ts +62 -0
  17. package/src/core/ai/features/classifier.ts +50 -0
  18. package/src/core/ai/features/context-summarizer.ts +31 -0
  19. package/src/core/ai/features/pre-analyzer.ts +51 -0
  20. package/src/core/ai/features/verifier.ts +45 -0
  21. package/src/core/ai/index.ts +56 -0
  22. package/src/core/ai/model-factory.ts +137 -0
  23. package/src/core/ai/prompts/base.ts +36 -0
  24. package/src/core/ai/prompts/brief.ts +87 -0
  25. package/src/core/ai/prompts/classification.ts +36 -0
  26. package/src/core/ai/prompts/context.ts +29 -0
  27. package/src/core/ai/prompts/pre-analysis.ts +49 -0
  28. package/src/core/ai/prompts/verify.ts +40 -0
  29. package/src/core/ai/schemas.ts +110 -0
  30. package/src/core/ai/types.ts +76 -0
  31. package/src/core/ai/usage-budget.ts +75 -0
  32. package/src/core/ai/usage-recorder.ts +79 -0
  33. package/src/core/assets.ts +16 -0
  34. package/src/core/ast/diff.ts +220 -0
  35. package/src/core/ast/impact.ts +127 -0
  36. package/src/core/ast/parser.ts +167 -0
  37. package/src/core/ast/service.ts +233 -0
  38. package/src/core/ast/symbols.ts +294 -0
  39. package/src/core/ast/types.ts +125 -0
  40. package/src/core/brief.ts +138 -0
  41. package/src/core/commands/findings.ts +65 -2
  42. package/src/core/commands/review.ts +2 -0
  43. package/src/core/commands/scaffold.ts +159 -10
  44. package/src/core/commands/update.ts +5 -3
  45. package/src/core/config.ts +85 -0
  46. package/src/core/defaults.ts +27 -0
  47. package/src/core/domain/watchdog.ts +43 -5
  48. package/src/core/engine/houndsman-engine.ts +16 -1
  49. package/src/core/engine/review-scope.ts +52 -6
  50. package/src/core/engine/review-session.ts +27 -2
  51. package/src/core/env.ts +101 -0
  52. package/src/core/file-classifier.ts +25 -4
  53. package/src/core/git-runner.ts +25 -13
  54. package/src/core/github-review.ts +10 -7
  55. package/src/core/jev/jev-client.ts +8 -7
  56. package/src/core/jev/jev-verify.ts +2 -1
  57. package/src/core/logging/index.ts +17 -0
  58. package/src/core/logging/logger.ts +187 -0
  59. package/src/core/orchestrator.ts +357 -9
  60. package/src/core/project-config.ts +123 -1
  61. package/src/core/registry-check.ts +148 -6
  62. package/src/core/renderers/manifest-writer.ts +33 -0
  63. package/src/core/rule-text.ts +97 -0
  64. package/src/core/services/file-classifier.ts +10 -3
  65. package/src/core/services/gh-client.ts +38 -22
  66. package/src/core/services/verifier.ts +14 -3
  67. package/src/core/storage/paths.ts +6 -5
  68. package/src/core/storage/types.ts +6 -0
  69. package/src/core/storage/work-area.ts +12 -0
  70. package/src/core/types.ts +37 -1
  71. package/src/core/verify.ts +17 -5
  72. package/src/harness/README.md +15 -0
  73. package/src/harness/agent/agent-context.ts +33 -0
  74. package/src/harness/agent/agent-errors.ts +30 -0
  75. package/src/harness/agent/agent-factory.ts +45 -0
  76. package/src/harness/agent/agent-reporter.ts +34 -0
  77. package/src/harness/agent/agent-result.ts +16 -0
  78. package/src/harness/agent/agent-runner.ts +32 -0
  79. package/src/harness/agent/agent-scheduler.ts +37 -0
  80. package/src/harness/agent/review-agent.ts +391 -0
  81. package/src/harness/config/harness-config.ts +84 -0
  82. package/src/harness/hunt/hunt-harness.ts +83 -0
  83. package/src/harness/hunt/hunt-options.ts +75 -0
  84. package/src/harness/hunt/hunt-result.ts +17 -0
  85. package/src/harness/hunt/hunt-session.ts +372 -0
  86. package/src/harness/hunt/review-planner.ts +98 -0
  87. package/src/harness/index.ts +36 -0
  88. package/src/harness/investigation/agent-state.ts +134 -0
  89. package/src/harness/investigation/evidence-verifier.ts +129 -0
  90. package/src/harness/investigation/evidence.ts +110 -0
  91. package/src/harness/investigation/finding-critic.ts +96 -0
  92. package/src/harness/investigation/investigation-planner.ts +118 -0
  93. package/src/harness/investigation/investigation-task.ts +10 -0
  94. package/src/harness/investigation/review-journal.ts +87 -0
  95. package/src/harness/investigation/rule-analyzer.ts +178 -0
  96. package/src/harness/investigation/rule-signals.ts +110 -0
  97. package/src/harness/prompts/agent-schemas.ts +57 -0
  98. package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
  99. package/src/harness/prompts/states/conclude-prompt.ts +16 -0
  100. package/src/harness/prompts/states/investigate-prompt.ts +37 -0
  101. package/src/harness/prompts/states/orient-prompt.ts +15 -0
  102. package/src/harness/prompts/states/verify-prompt.ts +22 -0
  103. package/src/harness/tools/agent-tool.ts +102 -0
  104. package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
  105. package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
  106. package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
  107. package/src/harness/tools/builtin/find-references-tool.ts +66 -0
  108. package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
  109. package/src/harness/tools/builtin/helpers.ts +115 -0
  110. package/src/harness/tools/builtin/impact-tool.ts +48 -0
  111. package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
  112. package/src/harness/tools/builtin/index.ts +59 -0
  113. package/src/harness/tools/builtin/list-files-tool.ts +85 -0
  114. package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
  115. package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
  116. package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
  117. package/src/harness/tools/builtin/search-code-tool.ts +65 -0
  118. package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
  119. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
  120. package/src/harness/tools/path-guard.ts +90 -0
  121. package/src/harness/tools/result-limiter.ts +49 -0
  122. package/src/harness/tools/secret-redactor.ts +54 -0
  123. package/src/harness/tools/tool-cache.ts +50 -0
  124. package/src/harness/tools/tool-call-budget.ts +60 -0
  125. package/src/harness/tools/tool-context.ts +5 -0
  126. package/src/harness/tools/tool-middleware.ts +105 -0
  127. package/src/harness/tools/tool-policy.ts +51 -0
  128. package/src/harness/tools/tool-registry.ts +112 -0
  129. package/src/harness/tools/tool-result.ts +15 -0
  130. package/src/harness/tools/toolbox.ts +36 -0
  131. package/src/harness/validation/finding-validator.ts +119 -0
  132. package/src/index.ts +46 -0
  133. package/src/server/client/App.tsx +38 -12
  134. package/src/server/client/CommentsPanel.tsx +255 -0
  135. package/src/server/client/FindingCard.tsx +12 -4
  136. package/src/server/client/FindingsList.tsx +4 -1
  137. package/src/server/client/Sidebar.tsx +397 -0
  138. package/src/server/client/SubmitModal.tsx +61 -5
  139. package/src/server/client/icons.tsx +19 -0
  140. package/src/server/client/index.tsx +1 -1
  141. package/src/server/client/utils/constants.ts +29 -5
  142. package/src/server/client/utils/format.ts +47 -2
  143. package/src/server/client/utils/types.ts +88 -0
  144. package/src/server/editor.ts +129 -0
  145. package/src/server/index.ts +115 -5
  146. package/src/server/pr-comments.ts +256 -0
  147. package/src/server/pr-inbox.ts +202 -0
  148. package/src/server/run-repository.ts +33 -1
  149. package/src/skill/OUTPUT-FORMAT.md +12 -0
  150. package/src/skill/SETUP.md +6 -2
  151. package/src/skill/hunt.md +14 -2
  152. package/src/skill/verify.md +1 -1
  153. package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
  154. package/src/tests/ai-core.test.ts +120 -0
  155. package/src/tests/ai-features.test.ts +183 -0
  156. package/src/tests/ast.test.ts +335 -0
  157. package/src/tests/cli.test.ts +33 -0
  158. package/src/tests/env.test.ts +69 -0
  159. package/src/tests/fallback.test.ts +172 -0
  160. package/src/tests/fixtures/fake-agent-tools.ts +69 -0
  161. package/src/tests/fixtures/fake-investigation.ts +64 -0
  162. package/src/tests/fixtures/fake-model-provider.ts +48 -0
  163. package/src/tests/github-review.test.ts +9 -0
  164. package/src/tests/harness-agent.test.ts +122 -0
  165. package/src/tests/harness-investigation.test.ts +206 -0
  166. package/src/tests/harness-tools.test.ts +148 -0
  167. package/src/tests/hunt-command.test.ts +212 -0
  168. package/src/tests/logger.test.ts +83 -0
  169. package/src/tests/review-scope.test.ts +11 -11
  170. package/src/tests/rule-analyzer.test.ts +61 -0
  171. package/src/tests/server-editor.test.ts +100 -0
  172. package/src/tests/server.test.ts +295 -2
  173. package/src/tests/tool-contracts.test.ts +82 -0
  174. package/src/tests/verify.test.ts +1 -1
  175. package/src/server/client/RunList.tsx +0 -75
@@ -16,6 +16,15 @@
16
16
  * review.<watchdog>.md, context e context-summary.
17
17
  */
18
18
 
19
+ import { readFileSync } from "node:fs";
20
+ import type { AiClient } from "./ai/ai-client";
21
+ import { AiBriefEnricher } from "./ai/features/brief-enricher";
22
+ import { AiClassifier } from "./ai/features/classifier";
23
+ import { AiContextSummarizer } from "./ai/features/context-summarizer";
24
+ import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
25
+ import type { BriefPlan } from "./ai/schemas";
26
+ import type { AstService } from "./ast/service";
27
+ import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
19
28
  import {
20
29
  type HoundsmanConfig,
21
30
  resolveJevConcurrency,
@@ -27,6 +36,7 @@ import { Classification } from "./domain/classification";
27
36
  import { Watchdog } from "./domain/watchdog";
28
37
  import { mergeCarryover } from "./engine/carryover";
29
38
  import { ReviewScopeResolver, type RunClassification } from "./engine/review-scope";
39
+ import { ENV_NAMES } from "./env";
30
40
  import type { BudgetSelection, ClassificationResult } from "./file-classifier";
31
41
  import { buildStateFromFindings, fileReader } from "./findings-state";
32
42
  import { resolveSha } from "./git-revision";
@@ -49,7 +59,11 @@ import {
49
59
  import { currentBranch } from "./pr-context";
50
60
  import { ProjectConfig } from "./project-config";
51
61
  import { BriefRenderer } from "./renderers/brief-renderer";
52
- import { ManifestWriter } from "./renderers/manifest-writer";
62
+ import {
63
+ type ManifestAi,
64
+ type ManifestAst,
65
+ ManifestWriter,
66
+ } from "./renderers/manifest-writer";
53
67
  import { ReportRenderer } from "./renderers/report-renderer";
54
68
  import { renderReviewResume } from "./renderers/resume-renderer";
55
69
  import {
@@ -75,8 +89,12 @@ export type ReviewOptions = {
75
89
  failOnBlocking: boolean;
76
90
  clean: boolean;
77
91
  noJev: boolean;
92
+ /** Desativa a análise estrutural (AST) nesta run. */
93
+ noAst: boolean;
78
94
  /** Força review completo (ignora o modo incremental). */
79
95
  full: boolean;
96
+ /** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
97
+ noAi: boolean;
80
98
  };
81
99
 
82
100
  /** Valores default das opções (o CLI sobrepõe com as flags). */
@@ -88,7 +106,9 @@ export function defaultOptions(): ReviewOptions {
88
106
  failOnBlocking: false,
89
107
  clean: false,
90
108
  noJev: false,
109
+ noAst: false,
91
110
  full: false,
111
+ noAi: false,
92
112
  };
93
113
  }
94
114
 
@@ -149,6 +169,8 @@ function writeManifest(
149
169
  activeIds: string[],
150
170
  pending: string[],
151
171
  jev: JevCandidatesFile,
172
+ ast?: ManifestAst,
173
+ ai?: ManifestAi,
152
174
  ): void {
153
175
  new ManifestWriter(
154
176
  ctx.storage,
@@ -156,7 +178,7 @@ function writeManifest(
156
178
  ctx.work,
157
179
  new ProjectConfig(config),
158
180
  opts,
159
- ).write(classification, activeIds, pending, jev);
181
+ ).write(classification, activeIds, pending, jev, ast, ai);
160
182
  }
161
183
 
162
184
  // ---------------------------------------------------------------------------
@@ -171,6 +193,9 @@ function writeBriefs(
171
193
  context: ContextSummary | null,
172
194
  candidates: JevCandidate[],
173
195
  config: HoundsmanConfig,
196
+ astAnalyses?: ReadonlyMap<string, AstAnalysis>,
197
+ astImpact?: AstImpact,
198
+ aiPlans?: ReadonlyMap<string, BriefPlan>,
174
199
  ): void {
175
200
  const work = ctx.work;
176
201
  const docs = discoverDocs(Object.keys(classification.files), config);
@@ -180,6 +205,17 @@ function writeBriefs(
180
205
  const def = findWatchdog(config, id);
181
206
  if (!def) continue;
182
207
  const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
208
+ const astFiles: AstFileBrief[] = selected.flatMap((file) => {
209
+ const analysis = astAnalyses?.get(file);
210
+ if (!analysis?.delta) return [];
211
+ return [
212
+ {
213
+ file,
214
+ delta: analysis.delta,
215
+ importers: astImpact?.importers[file],
216
+ },
217
+ ];
218
+ });
183
219
  const brief = renderer.render(new Watchdog(def), {
184
220
  watchdogId: id,
185
221
  files: selected,
@@ -193,6 +229,8 @@ function writeBriefs(
193
229
  docs,
194
230
  context,
195
231
  candidates: candidates.filter((c) => c.watchdogId === id),
232
+ ast: astFiles,
233
+ aiPlan: aiPlans?.get(id),
196
234
  });
197
235
  const content = `${brief}\n`;
198
236
  work.writeBrief(id, content);
@@ -219,6 +257,7 @@ async function resolveCandidates(
219
257
  ctx: ReviewContext,
220
258
  config: HoundsmanConfig,
221
259
  diff: DiffSource,
260
+ ai?: AiClient,
222
261
  ): Promise<JevCandidatesFile> {
223
262
  const save = (data: JevCandidatesFile): JevCandidatesFile => {
224
263
  ctx.storage.writeArtifact(ctx.review.id, {
@@ -229,13 +268,46 @@ async function resolveCandidates(
229
268
  return data;
230
269
  };
231
270
 
271
+ // Pré-análise por IA (opcional) tem precedência sobre o Jev.
272
+ if (ai && !opts.noAi) {
273
+ const analyzer = new AiPreAnalyzer(ai);
274
+ if (analyzer.enabled) {
275
+ const candidates: JevCandidate[] = [];
276
+ for (const [id, files] of Object.entries(classification.watchdogs)) {
277
+ const def = findWatchdog(config, id);
278
+ if (!def) continue;
279
+ const inputs = files.slice(0, 5).flatMap((file) => {
280
+ const snippet = readSnippet(file);
281
+ return snippet ? [{ file, snippet }] : [];
282
+ });
283
+ candidates.push(
284
+ ...(await analyzer.analyze({
285
+ watchdogId: id,
286
+ name: def.name,
287
+ rules: def.rules,
288
+ files: inputs,
289
+ })),
290
+ );
291
+ }
292
+ return save({
293
+ available: true,
294
+ model: ai.modelId,
295
+ threshold: JevCandidatesStore.THRESHOLD,
296
+ analyzedFiles: Object.values(classification.watchdogs).flat().length,
297
+ candidates,
298
+ });
299
+ }
300
+ }
301
+
232
302
  if (opts.noJev) {
233
303
  return save(JevCandidatesStore.unavailable("desativado (--no-jev)"));
234
304
  }
235
305
 
236
306
  const client = new JevClient();
237
307
  if (!client.isConfigured()) {
238
- return save(JevCandidatesStore.unavailable("TYPESAFE_API_KEY não configurada"));
308
+ return save(
309
+ JevCandidatesStore.unavailable(`${ENV_NAMES.typeSafeApiKey} não configurada`),
310
+ );
239
311
  }
240
312
 
241
313
  try {
@@ -304,15 +376,130 @@ function budgetMessage(
304
376
  ];
305
377
  }
306
378
 
379
+ /** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
380
+ function readSnippet(file: string): string | null {
381
+ try {
382
+ return readFileSync(file, "utf8").split("\n").slice(0, 80).join("\n");
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+
307
388
  /** Resultado do prepare — mensagens para o CLI imprimir. */
308
389
  export type PrepareResult = { messages: string[] };
309
390
 
391
+ /**
392
+ * Classificação fallback por IA (opcional): atribui arquivos não classificados
393
+ * a watchdogs conhecidos. Fail-open — falha mantém `unclassified`.
394
+ */
395
+ async function applyAiClassification(
396
+ classification: RunClassification,
397
+ config: HoundsmanConfig,
398
+ ai: AiClient | undefined,
399
+ opts: ReviewOptions,
400
+ ): Promise<void> {
401
+ if (!ai || opts.noAi || classification.unclassified.length === 0) return;
402
+ const classifier = new AiClassifier(ai);
403
+ if (!classifier.enabled) return;
404
+ const watchdogs = config.watchdogIds.flatMap((id) => {
405
+ const def = config.watchdogs[id];
406
+ return def ? [{ id, name: def.name, scope: def.scope }] : [];
407
+ });
408
+ const assignments = await classifier.classify(classification.unclassified, watchdogs);
409
+ if (assignments.size === 0) return;
410
+ for (const [file, id] of assignments) {
411
+ const list = classification.watchdogs[id] ?? [];
412
+ if (!list.includes(file)) list.push(file);
413
+ classification.watchdogs[id] = list;
414
+ classification.stats[id] = list.length;
415
+ }
416
+ classification.unclassified = classification.unclassified.filter(
417
+ (file) => !assignments.has(file),
418
+ );
419
+ }
420
+
421
+ /**
422
+ * Resumo do contexto da PR: usa o do subagente ou, na ausência, a IA
423
+ * (`AiContextSummarizer`). Grava o artefato e o staging.
424
+ */
425
+ async function resolveContextSummary(
426
+ ctx: ReviewContext,
427
+ ai: AiClient | undefined,
428
+ opts: ReviewOptions,
429
+ ): Promise<ContextSummary | null> {
430
+ const existing = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
431
+ if (existing) return existing;
432
+ if (!ai || opts.noAi) return null;
433
+ const raw = ctx.work.readContextRaw();
434
+ if (!raw) return null;
435
+ const summary = await new AiContextSummarizer(ai).summarize(raw);
436
+ if (!summary) return null;
437
+ ctx.work.writeContextSummary(summary);
438
+ ctx.storage.writeArtifact(ctx.review.id, {
439
+ name: "context-summary.json",
440
+ kind: "context-summary",
441
+ content: `${JSON.stringify(summary, null, 2)}\n`,
442
+ });
443
+ return summary;
444
+ }
445
+
446
+ /**
447
+ * Planos de revisão por watchdog (uma chamada por brief, concorrência
448
+ * limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
449
+ */
450
+ async function buildAiPlans(
451
+ activeIds: string[],
452
+ classification: ClassificationResult,
453
+ config: HoundsmanConfig,
454
+ context: ContextSummary | null,
455
+ astAnalyses: ReadonlyMap<string, AstAnalysis> | undefined,
456
+ astImpact: AstImpact | undefined,
457
+ ai: AiClient | undefined,
458
+ opts: ReviewOptions,
459
+ ): Promise<Map<string, BriefPlan>> {
460
+ const plans = new Map<string, BriefPlan>();
461
+ if (!ai || opts.noAi) return plans;
462
+ const enricher = new AiBriefEnricher(ai);
463
+ if (!enricher.enabled) return plans;
464
+
465
+ const docs = discoverDocs(Object.keys(classification.files), config);
466
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
467
+ const inputs = [];
468
+ for (const id of activeIds) {
469
+ const def = findWatchdog(config, id);
470
+ if (!def) continue;
471
+ const { selected } = budgetFor(classification, id, maxFilesPerBrief);
472
+ const astFiles: AstFileBrief[] = selected.flatMap((file) => {
473
+ const analysis = astAnalyses?.get(file);
474
+ if (!analysis?.delta) return [];
475
+ return [
476
+ { file, delta: analysis.delta, importers: astImpact?.importers[file] },
477
+ ];
478
+ });
479
+ inputs.push({
480
+ watchdogId: id,
481
+ name: def.name,
482
+ description: def.description,
483
+ scope: def.scope,
484
+ rules: def.rules,
485
+ files: selected,
486
+ churn: classification.churn,
487
+ context,
488
+ ast: astFiles,
489
+ docs,
490
+ });
491
+ }
492
+ return enricher.enrichAll(inputs);
493
+ }
494
+
310
495
  /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
311
496
  export async function prepare(
312
497
  opts: ReviewOptions,
313
498
  branch: string,
314
499
  config: HoundsmanConfig,
315
500
  ctx: ReviewContext,
501
+ ast?: AstService,
502
+ ai?: AiClient,
316
503
  ): Promise<PrepareResult> {
317
504
  const messages: string[] = [];
318
505
  ctx.work.ensure();
@@ -322,7 +509,11 @@ export async function prepare(
322
509
  // Marca o commit revisado e decide entre completo, incremental ou no-op.
323
510
  const headSha = resolveSha(opts.head);
324
511
  const previous = ctx.storage.readFindings(ctx.review.id);
325
- const scope = new ReviewScopeResolver(opts, config).resolve(previous, headSha);
512
+ const scope = await new ReviewScopeResolver(
513
+ opts,
514
+ config,
515
+ opts.noAst ? undefined : ast,
516
+ ).resolve(previous, headSha);
326
517
 
327
518
  // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
328
519
  if (scope.kind === "noop") {
@@ -349,13 +540,44 @@ export async function prepare(
349
540
  });
350
541
 
351
542
  const { classification, diff } = scope;
543
+ await applyAiClassification(classification, config, ai, opts);
352
544
  ctx.storage.writeArtifact(ctx.review.id, {
353
545
  name: "classification.json",
354
546
  kind: "classification",
355
547
  content: `${JSON.stringify(classification, null, 2)}\n`,
356
548
  });
357
549
 
358
- const jev = await resolveCandidates(opts, classification, ctx, config, diff);
550
+ const astAnalyses = scope.ast;
551
+ const astImpact = astAnalyses && ast ? await ast.impact(astAnalyses) : undefined;
552
+ if (astAnalyses && ast?.enabled) {
553
+ const summary = {
554
+ enabled: true,
555
+ files: Object.fromEntries(
556
+ [...astAnalyses].map(([path, analysis]) => [
557
+ path,
558
+ {
559
+ facts: analysis.facts,
560
+ delta: analysis.delta,
561
+ deleted: analysis.deleted,
562
+ },
563
+ ]),
564
+ ),
565
+ };
566
+ ctx.storage.writeArtifact(ctx.review.id, {
567
+ name: "ast-summary.json",
568
+ kind: "ast",
569
+ content: `${JSON.stringify(summary, null, 2)}\n`,
570
+ });
571
+ if (astImpact) {
572
+ ctx.storage.writeArtifact(ctx.review.id, {
573
+ name: "ast-impact.json",
574
+ kind: "ast-impact",
575
+ content: `${JSON.stringify(astImpact, null, 2)}\n`,
576
+ });
577
+ }
578
+ }
579
+
580
+ const jev = await resolveCandidates(opts, classification, ctx, config, diff, ai);
359
581
 
360
582
  const activeIds = Object.keys(classification.watchdogs);
361
583
  if (Object.keys(classification.files).length === 0) {
@@ -380,17 +602,103 @@ export async function prepare(
380
602
  );
381
603
  }
382
604
 
383
- const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
384
605
  persistContext(ctx);
606
+ const context = await resolveContextSummary(ctx, ai, opts);
607
+
608
+ const manifestAst: ManifestAst | undefined = astAnalyses
609
+ ? {
610
+ enabled: ast?.enabled ?? false,
611
+ languages: [
612
+ ...new Set(
613
+ [...astAnalyses.values()].map(
614
+ (analysis) => analysis.facts.language,
615
+ ),
616
+ ),
617
+ ].sort(),
618
+ files: astAnalyses.size,
619
+ impact: {
620
+ affectedFiles: astImpact?.affectedFiles.length ?? 0,
621
+ importers: Object.keys(astImpact?.importers ?? {}).length,
622
+ exportedChanges: astImpact?.exportedChanges.length ?? 0,
623
+ },
624
+ }
625
+ : undefined;
385
626
 
386
- writeBriefs(activeIds, classification, ctx, branch, context, jev.candidates, config);
387
- writeManifest(ctx, config, opts, classification, activeIds, activeIds, jev);
627
+ const aiPlans = await buildAiPlans(
628
+ activeIds,
629
+ classification,
630
+ config,
631
+ context,
632
+ astAnalyses,
633
+ astImpact,
634
+ ai,
635
+ opts,
636
+ );
637
+ if (aiPlans.size > 0) {
638
+ ctx.storage.writeArtifact(ctx.review.id, {
639
+ name: "ai-briefs.json",
640
+ kind: "ai-brief",
641
+ content: `${JSON.stringify(
642
+ { plans: Object.fromEntries(aiPlans) },
643
+ null,
644
+ 2,
645
+ )}\n`,
646
+ });
647
+ }
648
+ const aiUsage = ai?.recorder.total;
649
+ const manifestAi: ManifestAi | undefined =
650
+ ai && !opts.noAi && (aiPlans.size > 0 || (aiUsage?.calls ?? 0) > 0)
651
+ ? {
652
+ enabled: true,
653
+ provider: ai.projectConfig.aiProvider,
654
+ model: ai.modelId,
655
+ briefPlans: aiPlans.size,
656
+ usage: aiUsage ?? {
657
+ inputTokens: 0,
658
+ outputTokens: 0,
659
+ totalTokens: 0,
660
+ calls: 0,
661
+ },
662
+ }
663
+ : undefined;
664
+
665
+ writeBriefs(
666
+ activeIds,
667
+ classification,
668
+ ctx,
669
+ branch,
670
+ context,
671
+ jev.candidates,
672
+ config,
673
+ astAnalyses,
674
+ astImpact,
675
+ aiPlans,
676
+ );
677
+ writeManifest(
678
+ ctx,
679
+ config,
680
+ opts,
681
+ classification,
682
+ activeIds,
683
+ activeIds,
684
+ jev,
685
+ manifestAst,
686
+ manifestAi,
687
+ );
388
688
 
389
689
  messages.push(
390
690
  ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
391
691
  ...budgetMessage(classification, config),
392
692
  ...jevSummary(jev),
393
693
  );
694
+ if (astAnalyses) {
695
+ messages.push(
696
+ `AST: ${astAnalyses.size} arquivo(s) analisado(s), ` +
697
+ `${astImpact?.affectedFiles.length ?? 0} impactado(s).`,
698
+ );
699
+ } else {
700
+ messages.push("AST: desabilitado ou sem grammar compatível.");
701
+ }
394
702
 
395
703
  if (context) {
396
704
  messages.push("Contexto da PR resumido e injetado nos briefs.");
@@ -399,6 +707,17 @@ export async function prepare(
399
707
  "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
400
708
  );
401
709
  }
710
+ if (aiPlans.size > 0) {
711
+ messages.push(
712
+ `IA: plano de revisão em ${aiPlans.size} brief(s) (sugestão, não substitui regras).`,
713
+ );
714
+ }
715
+ if (aiUsage && aiUsage.calls > 0) {
716
+ messages.push(
717
+ `IA: ${aiUsage.calls} chamada(s), ${aiUsage.totalTokens} token(s).`,
718
+ );
719
+ ai?.recorder.write(ctx.storage, ctx.review.id);
720
+ }
402
721
 
403
722
  messages.push(
404
723
  `\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
@@ -439,6 +758,24 @@ function persistContext(ctx: ReviewContext): void {
439
758
  // Passo 2 — finalizar
440
759
  // ---------------------------------------------------------------------------
441
760
 
761
+ /** Reaproveita `ast`/`ai` do manifesto anterior (o finalize não os recalcula). */
762
+ function readManifestExtras(ctx: ReviewContext): {
763
+ ast?: ManifestAst;
764
+ ai?: ManifestAi;
765
+ } {
766
+ const artifact = ctx.storage.readArtifact(ctx.review.id, "manifest.json");
767
+ if (!artifact) return {};
768
+ try {
769
+ const parsed = JSON.parse(artifact.content) as {
770
+ ast?: ManifestAst;
771
+ ai?: ManifestAi;
772
+ };
773
+ return { ast: parsed.ast, ai: parsed.ai };
774
+ } catch {
775
+ return {};
776
+ }
777
+ }
778
+
442
779
  /** Lê o `classification.json` persistido pelo prepare (lança se ausente). */
443
780
  function readClassification(ctx: ReviewContext): RunClassification {
444
781
  const artifact = ctx.storage.readArtifact(ctx.review.id, "classification.json");
@@ -562,7 +899,18 @@ export function finalize(
562
899
  const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
563
900
  messages.push(...warnings);
564
901
  const jev = readCandidates(ctx, config);
565
- writeManifest(ctx, config, opts, classification, activeIds, pending, jev);
902
+ const extras = readManifestExtras(ctx);
903
+ writeManifest(
904
+ ctx,
905
+ config,
906
+ opts,
907
+ classification,
908
+ activeIds,
909
+ pending,
910
+ jev,
911
+ extras.ast,
912
+ extras.ai,
913
+ );
566
914
 
567
915
  if (pending.length > 0) {
568
916
  throw new Error(
@@ -6,9 +6,25 @@
6
6
  * O contrato do consumidor (`houndsman.config.ts`) não muda.
7
7
  */
8
8
 
9
- import type { HoundsmanConfig, HoundsmanStorage } from "./config";
9
+ import type {
10
+ AiProviderKind,
11
+ HoundsmanAi,
12
+ HoundsmanAiFeatures,
13
+ HoundsmanAst,
14
+ HoundsmanConfig,
15
+ HoundsmanStorage,
16
+ } from "./config";
10
17
  import {
18
+ DEFAULT_AI_CONCURRENCY,
19
+ DEFAULT_AI_MAX_CALLS_PER_REVIEW,
20
+ DEFAULT_AI_MAX_OUTPUT_TOKENS,
21
+ DEFAULT_AI_MAX_STEPS,
22
+ DEFAULT_AI_MODEL,
23
+ DEFAULT_AI_PROVIDER,
24
+ DEFAULT_AI_TIMEOUT_MS,
11
25
  DEFAULT_ALWAYS_INCLUDE_DOCS,
26
+ DEFAULT_AST_MAX_FILE_BYTES,
27
+ DEFAULT_AST_MAX_IMPACT_FILES,
12
28
  DEFAULT_JEV_CONCURRENCY,
13
29
  DEFAULT_MAX_FILES_PER_BRIEF,
14
30
  } from "./defaults";
@@ -32,6 +48,26 @@ export class ProjectConfig<TId extends string = string> {
32
48
  return this.raw.storage ?? {};
33
49
  }
34
50
 
51
+ /** Config do AST (objeto vazio quando ausente). */
52
+ get ast(): HoundsmanAst {
53
+ return this.raw.ast ?? {};
54
+ }
55
+
56
+ /** AST habilitado (default: ligado; falhas de grammar são fail-open). */
57
+ get astEnabled(): boolean {
58
+ return this.ast.enabled !== false;
59
+ }
60
+
61
+ get astMaxFileBytes(): number {
62
+ const value = this.ast.maxFileBytes;
63
+ return value !== undefined && value > 0 ? value : DEFAULT_AST_MAX_FILE_BYTES;
64
+ }
65
+
66
+ get astMaxImpactFiles(): number {
67
+ const value = this.ast.maxImpactFiles;
68
+ return value !== undefined && value >= 0 ? value : DEFAULT_AST_MAX_IMPACT_FILES;
69
+ }
70
+
35
71
  get maxFilesPerBrief(): number {
36
72
  const value = this.raw.budgets?.maxFilesPerBrief;
37
73
  return value !== undefined && value > 0 ? value : DEFAULT_MAX_FILES_PER_BRIEF;
@@ -53,4 +89,90 @@ export class ProjectConfig<TId extends string = string> {
53
89
  get docMappings(): readonly (readonly [RegExp, readonly string[]])[] {
54
90
  return this.raw.docMappings;
55
91
  }
92
+
93
+ /** Config de IA (objeto vazio quando ausente). */
94
+ get ai(): HoundsmanAi {
95
+ return this.raw.ai ?? {};
96
+ }
97
+
98
+ /** IA habilitada (default: ligada; sem chave é fail-open). */
99
+ get aiEnabled(): boolean {
100
+ return this.ai.enabled !== false;
101
+ }
102
+
103
+ get aiProvider(): AiProviderKind {
104
+ return this.ai.provider ?? DEFAULT_AI_PROVIDER;
105
+ }
106
+
107
+ get aiModel(): string {
108
+ const value = this.ai.model?.trim();
109
+ return value && value.length > 0 ? value : DEFAULT_AI_MODEL;
110
+ }
111
+
112
+ get aiConcurrency(): number {
113
+ return positive(this.ai.concurrency, DEFAULT_AI_CONCURRENCY);
114
+ }
115
+
116
+ get aiMaxSteps(): number {
117
+ return positive(this.ai.maxSteps, DEFAULT_AI_MAX_STEPS);
118
+ }
119
+
120
+ get aiMaxOutputTokens(): number {
121
+ return positive(this.ai.maxOutputTokens, DEFAULT_AI_MAX_OUTPUT_TOKENS);
122
+ }
123
+
124
+ get aiTemperature(): number {
125
+ const value = this.ai.temperature;
126
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
127
+ }
128
+
129
+ get aiTimeoutMs(): number {
130
+ return positive(this.ai.timeoutMs, DEFAULT_AI_TIMEOUT_MS);
131
+ }
132
+
133
+ get aiMaxCallsPerReview(): number {
134
+ return positive(this.ai.maxCallsPerReview, DEFAULT_AI_MAX_CALLS_PER_REVIEW);
135
+ }
136
+
137
+ /** Teto de tokens por review (0/ausente = sem teto). */
138
+ get aiMaxTokensPerReview(): number | undefined {
139
+ const value = this.ai.maxTokensPerReview;
140
+ return value !== undefined && value > 0 ? value : undefined;
141
+ }
142
+
143
+ /** Features resolvidas com defaults (`context`/`brief` ligadas). */
144
+ get aiFeatures(): Required<HoundsmanAiFeatures> {
145
+ const features = this.ai.features ?? {};
146
+ return {
147
+ context: features.context !== false,
148
+ brief: features.brief !== false,
149
+ classification: features.classification === true,
150
+ preAnalysis: features.preAnalysis === true,
151
+ verify: features.verify === true,
152
+ };
153
+ }
154
+
155
+ /** Modelo efetivo de um watchdog (override por watchdog ou global). */
156
+ aiModelFor(watchdogId: string): string {
157
+ const override = this.ai.watchdogs?.[watchdogId]?.model?.trim();
158
+ return override && override.length > 0 ? override : this.aiModel;
159
+ }
160
+
161
+ /** Passos efetivos de um watchdog (override por watchdog ou global). */
162
+ aiMaxStepsFor(watchdogId: string): number {
163
+ return positive(this.ai.watchdogs?.[watchdogId]?.maxSteps, this.aiMaxSteps);
164
+ }
165
+
166
+ /** Tokens de saída efetivos de um watchdog. */
167
+ aiMaxOutputTokensFor(watchdogId: string): number {
168
+ return positive(
169
+ this.ai.watchdogs?.[watchdogId]?.maxOutputTokens,
170
+ this.aiMaxOutputTokens,
171
+ );
172
+ }
173
+ }
174
+
175
+ /** Inteiro positivo ou o default. */
176
+ function positive(value: number | undefined, fallback: number): number {
177
+ return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback;
56
178
  }