@payconductor/houndsman 0.4.0 → 0.6.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 (196) hide show
  1. package/README.md +35 -41
  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/status-command.ts +46 -0
  11. package/src/cli/help.ts +18 -1
  12. package/src/cli/index.ts +13 -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 +120 -6
  42. package/src/core/commands/review.ts +2 -0
  43. package/src/core/commands/scaffold.ts +159 -10
  44. package/src/core/commands/status.ts +63 -0
  45. package/src/core/commands/update.ts +5 -3
  46. package/src/core/config.ts +85 -0
  47. package/src/core/defaults.ts +27 -0
  48. package/src/core/domain/watchdog.ts +43 -5
  49. package/src/core/engine/houndsman-engine.ts +16 -1
  50. package/src/core/engine/review-scope.ts +77 -13
  51. package/src/core/engine/review-session.ts +27 -2
  52. package/src/core/env.ts +101 -0
  53. package/src/core/file-classifier.ts +25 -4
  54. package/src/core/findings-state.ts +21 -4
  55. package/src/core/github-review.ts +620 -57
  56. package/src/core/jev/jev-client.ts +8 -7
  57. package/src/core/jev/jev-verify.ts +2 -1
  58. package/src/core/logging/index.ts +17 -0
  59. package/src/core/logging/logger.ts +187 -0
  60. package/src/core/orchestrator.ts +454 -16
  61. package/src/core/paths.ts +3 -0
  62. package/src/core/project-config.ts +123 -1
  63. package/src/core/registry-check.ts +148 -6
  64. package/src/core/renderers/manifest-writer.ts +33 -0
  65. package/src/core/renderers/resume-renderer.ts +151 -0
  66. package/src/core/report-builder.ts +7 -4
  67. package/src/core/rule-text.ts +97 -0
  68. package/src/core/run-status.ts +39 -5
  69. package/src/core/services/file-classifier.ts +10 -3
  70. package/src/core/services/verifier.ts +14 -3
  71. package/src/core/storage/file/file-storage.ts +2 -0
  72. package/src/core/storage/paths.ts +6 -5
  73. package/src/core/storage/sqlite/mappers.ts +2 -0
  74. package/src/core/storage/sqlite/schema.ts +9 -1
  75. package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
  76. package/src/core/storage/types.ts +15 -0
  77. package/src/core/storage/work-area.ts +12 -0
  78. package/src/core/types.ts +37 -1
  79. package/src/core/verify.ts +17 -5
  80. package/src/harness/README.md +15 -0
  81. package/src/harness/agent/agent-context.ts +33 -0
  82. package/src/harness/agent/agent-errors.ts +30 -0
  83. package/src/harness/agent/agent-factory.ts +45 -0
  84. package/src/harness/agent/agent-reporter.ts +34 -0
  85. package/src/harness/agent/agent-result.ts +16 -0
  86. package/src/harness/agent/agent-runner.ts +32 -0
  87. package/src/harness/agent/agent-scheduler.ts +37 -0
  88. package/src/harness/agent/review-agent.ts +391 -0
  89. package/src/harness/config/harness-config.ts +84 -0
  90. package/src/harness/hunt/hunt-harness.ts +83 -0
  91. package/src/harness/hunt/hunt-options.ts +75 -0
  92. package/src/harness/hunt/hunt-result.ts +17 -0
  93. package/src/harness/hunt/hunt-session.ts +372 -0
  94. package/src/harness/hunt/review-planner.ts +98 -0
  95. package/src/harness/index.ts +36 -0
  96. package/src/harness/investigation/agent-state.ts +134 -0
  97. package/src/harness/investigation/evidence-verifier.ts +129 -0
  98. package/src/harness/investigation/evidence.ts +110 -0
  99. package/src/harness/investigation/finding-critic.ts +96 -0
  100. package/src/harness/investigation/investigation-planner.ts +118 -0
  101. package/src/harness/investigation/investigation-task.ts +10 -0
  102. package/src/harness/investigation/review-journal.ts +87 -0
  103. package/src/harness/investigation/rule-analyzer.ts +178 -0
  104. package/src/harness/investigation/rule-signals.ts +110 -0
  105. package/src/harness/prompts/agent-schemas.ts +57 -0
  106. package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
  107. package/src/harness/prompts/states/conclude-prompt.ts +16 -0
  108. package/src/harness/prompts/states/investigate-prompt.ts +37 -0
  109. package/src/harness/prompts/states/orient-prompt.ts +15 -0
  110. package/src/harness/prompts/states/verify-prompt.ts +22 -0
  111. package/src/harness/tools/agent-tool.ts +102 -0
  112. package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
  113. package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
  114. package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
  115. package/src/harness/tools/builtin/find-references-tool.ts +66 -0
  116. package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
  117. package/src/harness/tools/builtin/helpers.ts +115 -0
  118. package/src/harness/tools/builtin/impact-tool.ts +48 -0
  119. package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
  120. package/src/harness/tools/builtin/index.ts +59 -0
  121. package/src/harness/tools/builtin/list-files-tool.ts +85 -0
  122. package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
  123. package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
  124. package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
  125. package/src/harness/tools/builtin/search-code-tool.ts +65 -0
  126. package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
  127. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
  128. package/src/harness/tools/path-guard.ts +90 -0
  129. package/src/harness/tools/result-limiter.ts +49 -0
  130. package/src/harness/tools/secret-redactor.ts +54 -0
  131. package/src/harness/tools/tool-cache.ts +50 -0
  132. package/src/harness/tools/tool-call-budget.ts +60 -0
  133. package/src/harness/tools/tool-context.ts +5 -0
  134. package/src/harness/tools/tool-middleware.ts +105 -0
  135. package/src/harness/tools/tool-policy.ts +51 -0
  136. package/src/harness/tools/tool-registry.ts +112 -0
  137. package/src/harness/tools/tool-result.ts +15 -0
  138. package/src/harness/tools/toolbox.ts +36 -0
  139. package/src/harness/validation/finding-validator.ts +119 -0
  140. package/src/index.ts +46 -0
  141. package/src/server/client/App.tsx +26 -9
  142. package/src/server/client/ArtifactsPanel.tsx +12 -5
  143. package/src/server/client/AutoRefresh.tsx +1 -1
  144. package/src/server/client/Filters.tsx +7 -2
  145. package/src/server/client/FindingCard.tsx +14 -10
  146. package/src/server/client/FindingsList.tsx +2 -2
  147. package/src/server/client/MarkdownText.tsx +92 -12
  148. package/src/server/client/ResumePanel.tsx +368 -0
  149. package/src/server/client/RunList.tsx +3 -3
  150. package/src/server/client/SubmitModal.tsx +49 -6
  151. package/src/server/client/SummaryBar.tsx +1 -1
  152. package/src/server/client/icons.tsx +17 -0
  153. package/src/server/client/{constants.ts → utils/constants.ts} +61 -10
  154. package/src/server/client/utils/markdown.ts +319 -0
  155. package/src/server/client/{types.ts → utils/types.ts} +64 -0
  156. package/src/server/index.ts +66 -7
  157. package/src/server/run-repository.ts +9 -0
  158. package/src/skill/OUTPUT-FORMAT.md +12 -0
  159. package/src/skill/SETUP.md +6 -2
  160. package/src/skill/example-review.md +22 -14
  161. package/src/skill/hunt.md +45 -19
  162. package/src/skill/verify.md +3 -2
  163. package/src/tests/__snapshots__/baseline.test.ts.snap +126 -42
  164. package/src/tests/ai-core.test.ts +120 -0
  165. package/src/tests/ai-features.test.ts +183 -0
  166. package/src/tests/ast.test.ts +335 -0
  167. package/src/tests/baseline.test.ts +3 -0
  168. package/src/tests/code-highlight.test.ts +1 -1
  169. package/src/tests/env.test.ts +69 -0
  170. package/src/tests/fallback.test.ts +172 -0
  171. package/src/tests/findings-state.test.ts +50 -2
  172. package/src/tests/fixtures/fake-agent-tools.ts +69 -0
  173. package/src/tests/fixtures/fake-investigation.ts +64 -0
  174. package/src/tests/fixtures/fake-model-provider.ts +48 -0
  175. package/src/tests/github-review.test.ts +409 -60
  176. package/src/tests/harness-agent.test.ts +122 -0
  177. package/src/tests/harness-investigation.test.ts +206 -0
  178. package/src/tests/harness-tools.test.ts +148 -0
  179. package/src/tests/hunt-command.test.ts +212 -0
  180. package/src/tests/logger.test.ts +83 -0
  181. package/src/tests/markdown.test.ts +61 -1
  182. package/src/tests/review-resume.test.ts +101 -0
  183. package/src/tests/review-scope.test.ts +302 -0
  184. package/src/tests/review-scripts.test.ts +73 -0
  185. package/src/tests/rule-analyzer.test.ts +61 -0
  186. package/src/tests/run-status-view.test.ts +94 -0
  187. package/src/tests/run-status.test.ts +53 -0
  188. package/src/tests/server.test.ts +76 -21
  189. package/src/tests/storage.test.ts +63 -0
  190. package/src/tests/tool-contracts.test.ts +82 -0
  191. package/src/tests/verify.test.ts +1 -1
  192. package/src/server/client/markdown.ts +0 -153
  193. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  194. /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
  195. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
  196. /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
@@ -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,9 +36,11 @@ 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";
43
+ import { syncResolvedThreads } from "./github-review";
33
44
  import {
34
45
  type JevCandidate,
35
46
  type JevCandidatesFile,
@@ -39,12 +50,22 @@ import {
39
50
  parseCandidates,
40
51
  serializeCandidates,
41
52
  } from "./jev";
42
- import { displayPath, OUTPUT_FORMAT_PATH, REVIEW_FILE } from "./paths";
53
+ import {
54
+ displayPath,
55
+ OUTPUT_FORMAT_PATH,
56
+ REVIEW_FILE,
57
+ REVIEW_RESUME_FILE,
58
+ } from "./paths";
43
59
  import { currentBranch } from "./pr-context";
44
60
  import { ProjectConfig } from "./project-config";
45
61
  import { BriefRenderer } from "./renderers/brief-renderer";
46
- import { ManifestWriter } from "./renderers/manifest-writer";
62
+ import {
63
+ type ManifestAi,
64
+ type ManifestAst,
65
+ ManifestWriter,
66
+ } from "./renderers/manifest-writer";
47
67
  import { ReportRenderer } from "./renderers/report-renderer";
68
+ import { renderReviewResume } from "./renderers/resume-renderer";
48
69
  import {
49
70
  type ContextSummary,
50
71
  type DomainReport,
@@ -68,8 +89,12 @@ export type ReviewOptions = {
68
89
  failOnBlocking: boolean;
69
90
  clean: boolean;
70
91
  noJev: boolean;
92
+ /** Desativa a análise estrutural (AST) nesta run. */
93
+ noAst: boolean;
71
94
  /** Força review completo (ignora o modo incremental). */
72
95
  full: boolean;
96
+ /** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
97
+ noAi: boolean;
73
98
  };
74
99
 
75
100
  /** Valores default das opções (o CLI sobrepõe com as flags). */
@@ -81,7 +106,9 @@ export function defaultOptions(): ReviewOptions {
81
106
  failOnBlocking: false,
82
107
  clean: false,
83
108
  noJev: false,
109
+ noAst: false,
84
110
  full: false,
111
+ noAi: false,
85
112
  };
86
113
  }
87
114
 
@@ -142,6 +169,8 @@ function writeManifest(
142
169
  activeIds: string[],
143
170
  pending: string[],
144
171
  jev: JevCandidatesFile,
172
+ ast?: ManifestAst,
173
+ ai?: ManifestAi,
145
174
  ): void {
146
175
  new ManifestWriter(
147
176
  ctx.storage,
@@ -149,7 +178,7 @@ function writeManifest(
149
178
  ctx.work,
150
179
  new ProjectConfig(config),
151
180
  opts,
152
- ).write(classification, activeIds, pending, jev);
181
+ ).write(classification, activeIds, pending, jev, ast, ai);
153
182
  }
154
183
 
155
184
  // ---------------------------------------------------------------------------
@@ -164,6 +193,9 @@ function writeBriefs(
164
193
  context: ContextSummary | null,
165
194
  candidates: JevCandidate[],
166
195
  config: HoundsmanConfig,
196
+ astAnalyses?: ReadonlyMap<string, AstAnalysis>,
197
+ astImpact?: AstImpact,
198
+ aiPlans?: ReadonlyMap<string, BriefPlan>,
167
199
  ): void {
168
200
  const work = ctx.work;
169
201
  const docs = discoverDocs(Object.keys(classification.files), config);
@@ -173,6 +205,17 @@ function writeBriefs(
173
205
  const def = findWatchdog(config, id);
174
206
  if (!def) continue;
175
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
+ });
176
219
  const brief = renderer.render(new Watchdog(def), {
177
220
  watchdogId: id,
178
221
  files: selected,
@@ -186,6 +229,8 @@ function writeBriefs(
186
229
  docs,
187
230
  context,
188
231
  candidates: candidates.filter((c) => c.watchdogId === id),
232
+ ast: astFiles,
233
+ aiPlan: aiPlans?.get(id),
189
234
  });
190
235
  const content = `${brief}\n`;
191
236
  work.writeBrief(id, content);
@@ -212,6 +257,7 @@ async function resolveCandidates(
212
257
  ctx: ReviewContext,
213
258
  config: HoundsmanConfig,
214
259
  diff: DiffSource,
260
+ ai?: AiClient,
215
261
  ): Promise<JevCandidatesFile> {
216
262
  const save = (data: JevCandidatesFile): JevCandidatesFile => {
217
263
  ctx.storage.writeArtifact(ctx.review.id, {
@@ -222,13 +268,46 @@ async function resolveCandidates(
222
268
  return data;
223
269
  };
224
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
+
225
302
  if (opts.noJev) {
226
303
  return save(JevCandidatesStore.unavailable("desativado (--no-jev)"));
227
304
  }
228
305
 
229
306
  const client = new JevClient();
230
307
  if (!client.isConfigured()) {
231
- return save(JevCandidatesStore.unavailable("TYPESAFE_API_KEY não configurada"));
308
+ return save(
309
+ JevCandidatesStore.unavailable(`${ENV_NAMES.typeSafeApiKey} não configurada`),
310
+ );
232
311
  }
233
312
 
234
313
  try {
@@ -297,41 +376,208 @@ function budgetMessage(
297
376
  ];
298
377
  }
299
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
+
300
388
  /** Resultado do prepare — mensagens para o CLI imprimir. */
301
389
  export type PrepareResult = { messages: string[] };
302
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
+
303
495
  /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
304
496
  export async function prepare(
305
497
  opts: ReviewOptions,
306
498
  branch: string,
307
499
  config: HoundsmanConfig,
308
500
  ctx: ReviewContext,
501
+ ast?: AstService,
502
+ ai?: AiClient,
309
503
  ): Promise<PrepareResult> {
310
504
  const messages: string[] = [];
311
505
  ctx.work.ensure();
312
506
  if (opts.clean) ctx.work.cleanFindings();
313
507
  ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
314
508
 
509
+ // Marca o commit revisado e decide entre completo, incremental ou no-op.
510
+ const headSha = resolveSha(opts.head);
511
+ const previous = ctx.storage.readFindings(ctx.review.id);
512
+ const scope = await new ReviewScopeResolver(
513
+ opts,
514
+ config,
515
+ opts.noAst ? undefined : ast,
516
+ ).resolve(previous, headSha);
517
+
518
+ // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
519
+ if (scope.kind === "noop") {
520
+ const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
521
+ // Ciclo em andamento continua válido (os findings já despachados devem
522
+ // ser finalizados); fora isso, o relatório anterior é a resposta final.
523
+ const pendingCycle = status === "reviewing";
524
+ if (!pendingCycle) {
525
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
526
+ }
527
+ messages.push(
528
+ `Nenhuma alteração desde a última revisão (${shortSha(scope.reviewedFrom)}).`,
529
+ pendingCycle
530
+ ? "Nada novo a preparar — o ciclo em andamento continua; rode `--finalize` quando os findings estiverem gravados."
531
+ : "Nada a revisar — o relatório anterior foi mantido. Use `--full` para re-revisar.",
532
+ );
533
+ return { messages };
534
+ }
535
+
315
536
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
316
537
  writeRunStatus(ctx.storage, ctx.review.id, {
317
538
  status: "reviewing",
318
539
  resetStartedAt: true,
319
540
  });
320
541
 
321
- // Marca o commit revisado e decide entre review completo e incremental.
322
- const headSha = resolveSha(opts.head);
323
- const previous = ctx.storage.readFindings(ctx.review.id);
324
- const { classification, diff } = new ReviewScopeResolver(opts, config).resolve(
325
- previous,
326
- headSha,
327
- );
542
+ const { classification, diff } = scope;
543
+ await applyAiClassification(classification, config, ai, opts);
328
544
  ctx.storage.writeArtifact(ctx.review.id, {
329
545
  name: "classification.json",
330
546
  kind: "classification",
331
547
  content: `${JSON.stringify(classification, null, 2)}\n`,
332
548
  });
333
549
 
334
- 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);
335
581
 
336
582
  const activeIds = Object.keys(classification.watchdogs);
337
583
  if (Object.keys(classification.files).length === 0) {
@@ -356,17 +602,103 @@ export async function prepare(
356
602
  );
357
603
  }
358
604
 
359
- const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
360
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;
626
+
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;
361
664
 
362
- writeBriefs(activeIds, classification, ctx, branch, context, jev.candidates, config);
363
- writeManifest(ctx, config, opts, classification, activeIds, activeIds, jev);
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
+ );
364
688
 
365
689
  messages.push(
366
690
  ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
367
691
  ...budgetMessage(classification, config),
368
692
  ...jevSummary(jev),
369
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
+ }
370
702
 
371
703
  if (context) {
372
704
  messages.push("Contexto da PR resumido e injetado nos briefs.");
@@ -375,6 +707,17 @@ export async function prepare(
375
707
  "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
376
708
  );
377
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
+ }
378
721
 
379
722
  messages.push(
380
723
  `\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
@@ -415,6 +758,24 @@ function persistContext(ctx: ReviewContext): void {
415
758
  // Passo 2 — finalizar
416
759
  // ---------------------------------------------------------------------------
417
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
+
418
779
  /** Lê o `classification.json` persistido pelo prepare (lança se ausente). */
419
780
  function readClassification(ctx: ReviewContext): RunClassification {
420
781
  const artifact = ctx.storage.readArtifact(ctx.review.id, "classification.json");
@@ -510,6 +871,27 @@ export function finalize(
510
871
  config: HoundsmanConfig,
511
872
  ctx: ReviewContext,
512
873
  ): FinalizeResult {
874
+ // `--finalize` só consolida um ciclo preparado (status `reviewing`). Sem esta
875
+ // guarda, um prepare no-op seguido de `--finalize` reescreveria o relatório
876
+ // anterior com a classificação antiga (perdendo os relatórios por domínio).
877
+ // O `ctx.review` é um snapshot: relê o status atual do storage.
878
+ const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
879
+ if (status !== "reviewing") {
880
+ const state = ctx.storage.readFindings(ctx.review.id);
881
+ const present = Object.values(state?.findings ?? {}).filter(
882
+ (finding) => finding.present,
883
+ );
884
+ return {
885
+ reportPath: ctx.storage.location(ctx.review.id),
886
+ blockers: present.filter((f) => f.severity === "blocking").length,
887
+ attention: present.filter((f) => f.severity === "attention").length,
888
+ messages: [
889
+ `Nada a finalizar: nenhum ciclo de revisão pendente (status: ${status ?? "sem status"}).`,
890
+ "Se nada mudou desde a última revisão, o relatório anterior foi mantido; use `--full` para re-revisar.",
891
+ ],
892
+ };
893
+ }
894
+
513
895
  const classification = readClassification(ctx);
514
896
  const activeIds = Object.keys(classification.watchdogs);
515
897
 
@@ -517,7 +899,18 @@ export function finalize(
517
899
  const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
518
900
  messages.push(...warnings);
519
901
  const jev = readCandidates(ctx, config);
520
- 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
+ );
521
914
 
522
915
  if (pending.length > 0) {
523
916
  throw new Error(
@@ -546,12 +939,44 @@ export function finalize(
546
939
  branch,
547
940
  readFile: fileReader(process.cwd()),
548
941
  previous: ctx.storage.readFindings(ctx.review.id),
942
+ reviewedPaths: new Set([
943
+ ...reports.flatMap((report) => report.reviewedFiles),
944
+ // Arquivo deletado: o achado deixou de existir junto com ele.
945
+ ...classification.deleted,
946
+ ]),
549
947
  now,
550
948
  headSha: classification.headSha,
551
949
  reviewed: classification.headSha
552
950
  ? { headSha: classification.headSha, at: now }
553
951
  : undefined,
554
952
  });
953
+
954
+ // Findings corrigidos entre revisões (ausentes + done) têm a conversa do PR
955
+ // resolvida automaticamente; as já resolvidas no GitHub são puladas. Só
956
+ // toca o `gh` quando há vínculo remoto pendente (fail-open).
957
+ const threadIds = Object.values(state.findings)
958
+ .filter(
959
+ (finding) =>
960
+ !finding.present &&
961
+ finding.execState === "done" &&
962
+ finding.github &&
963
+ finding.github.kind !== "comment" &&
964
+ !finding.github.resolvedAt,
965
+ )
966
+ .map((finding) => finding.id);
967
+ if (threadIds.length > 0) {
968
+ const sync = syncResolvedThreads({ state, ids: threadIds, now });
969
+ if (!sync.available) {
970
+ messages.push(`GitHub: threads não sincronizadas (${sync.reason}).`);
971
+ } else if (sync.checked > 0) {
972
+ const failures =
973
+ sync.failed.length > 0 ? `, ${sync.failed.length} falha(s)` : "";
974
+ messages.push(
975
+ `GitHub: ${sync.resolved.length} thread(s) resolvida(s), ${sync.skipped.length} já resolvida(s)/pulada(s)${failures}.`,
976
+ );
977
+ }
978
+ }
979
+
555
980
  ctx.storage.writeFindings(ctx.review.id, state);
556
981
  ctx.storage.updateReview(ctx.review.id, {
557
982
  base: opts.base,
@@ -585,6 +1010,19 @@ export function finalize(
585
1010
  kind: "review",
586
1011
  content: `${incrementalNote(classification)}${markdown}\n`,
587
1012
  });
1013
+ // Resumo final para leitura humana — não participa de nenhuma etapa seguinte.
1014
+ ctx.storage.writeArtifact(ctx.review.id, {
1015
+ name: REVIEW_RESUME_FILE,
1016
+ kind: "resume",
1017
+ content: renderReviewResume({
1018
+ branch,
1019
+ date: now.slice(0, 10),
1020
+ context,
1021
+ classification,
1022
+ findings: merged,
1023
+ nameOf,
1024
+ }),
1025
+ });
588
1026
 
589
1027
  const location = ctx.storage.location(ctx.review.id);
590
1028
  messages.push(
package/src/core/paths.ts CHANGED
@@ -52,3 +52,6 @@ export const VERIFY_FILE = "verify.md";
52
52
 
53
53
  /** Relatório consolidado da review (artefato `review.md`). */
54
54
  export const REVIEW_FILE = "review.md";
55
+
56
+ /** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
57
+ export const REVIEW_RESUME_FILE = "review-resume.md";