@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
package/src/core/brief.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * específico do domínio vive nas regras do watchdog (`rules`).
6
6
  */
7
7
 
8
+ import type { AstDelta } from "./ast/types";
8
9
  import {
9
10
  type Rule,
10
11
  SEVERITY_EMOJI,
@@ -117,6 +118,141 @@ function deletedSection(args: WatchdogRenderArgs, def: WatchdogDefinition): stri
117
118
  ];
118
119
  }
119
120
 
121
+ /** Escapa um valor para uso em code-span (evita quebrar o markdown). */
122
+ function inlineCode(value: string): string {
123
+ return `\`${value.replace(/`/g, "'")}\``;
124
+ }
125
+
126
+ /** Trunca um texto longo (assinatura) para o brief. */
127
+ function shorten(value: string, max = 120): string {
128
+ const normalized = value.replace(/\s+/g, " ").trim();
129
+ return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
130
+ }
131
+
132
+ /** Linhas de um delta estrutural (compactas e determinísticas). */
133
+ function deltaLines(delta: AstDelta): string[] {
134
+ const lines: string[] = [];
135
+ for (const symbol of delta.addedSymbols.slice(0, 4)) {
136
+ lines.push(`+ ${inlineCode(symbol.name)} (${symbol.kind})`);
137
+ }
138
+ for (const symbol of delta.removedSymbols.slice(0, 4)) {
139
+ lines.push(`− ${inlineCode(symbol.name)} (${symbol.kind})`);
140
+ }
141
+ for (const symbol of delta.modifiedSymbols.slice(0, 4)) {
142
+ lines.push(`~ ${inlineCode(symbol.name)} (${symbol.kind})`);
143
+ }
144
+ for (const change of delta.changedSignatures.slice(0, 3)) {
145
+ lines.push(
146
+ `assinatura ${inlineCode(change.name)}: ${inlineCode(shorten(change.before) || "—")} → ${inlineCode(shorten(change.after) || "—")}`,
147
+ );
148
+ }
149
+ if (delta.addedImports.length > 0) {
150
+ lines.push(
151
+ `imports + ${delta.addedImports.slice(0, 4).map(inlineCode).join(", ")}`,
152
+ );
153
+ }
154
+ if (delta.removedImports.length > 0) {
155
+ lines.push(
156
+ `imports − ${delta.removedImports.slice(0, 4).map(inlineCode).join(", ")}`,
157
+ );
158
+ }
159
+ return lines;
160
+ }
161
+
162
+ /**
163
+ * Plano de revisão sugerido por IA — complementa (nunca substitui) as regras.
164
+ */
165
+ function aiPlanSection(args: WatchdogRenderArgs): string[] {
166
+ const plan = args.aiPlan;
167
+ if (!plan) return [];
168
+ const focus = plan.focus ?? [];
169
+ const questions = plan.questions ?? [];
170
+ const risks = plan.risks ?? [];
171
+ const logic = plan.logic ?? [];
172
+ const domain = plan.domain ?? [];
173
+ if (
174
+ focus.length === 0 &&
175
+ questions.length === 0 &&
176
+ risks.length === 0 &&
177
+ logic.length === 0 &&
178
+ domain.length === 0
179
+ ) {
180
+ return [];
181
+ }
182
+ const lines = [
183
+ "## Plano de revisão (IA) — sugestão",
184
+ "",
185
+ "> Sugestão automática para orientar a leitura. **Não** substitui as regras",
186
+ "> do watchdog nem dispensa confirmar cada violação no código.",
187
+ "",
188
+ ];
189
+ if (focus.length > 0) {
190
+ lines.push("**Foco de leitura:**", "");
191
+ for (const item of focus.slice(0, 5)) lines.push(`- ${item}`);
192
+ lines.push("");
193
+ }
194
+ if (questions.length > 0) {
195
+ lines.push("**Perguntas de verificação:**", "");
196
+ for (const item of questions.slice(0, 3)) lines.push(`- ${item}`);
197
+ lines.push("");
198
+ }
199
+ if (risks.length > 0) {
200
+ lines.push("**Riscos prováveis:**", "");
201
+ for (const item of risks.slice(0, 3)) lines.push(`- ${item}`);
202
+ lines.push("");
203
+ }
204
+ if (logic.length > 0) {
205
+ lines.push("**Consistência lógica:**", "");
206
+ for (const item of logic.slice(0, 3)) lines.push(`- ${item}`);
207
+ lines.push("");
208
+ }
209
+ if (domain.length > 0) {
210
+ lines.push("**Domínio de negócio:**", "");
211
+ for (const item of domain.slice(0, 3)) lines.push(`- ${item}`);
212
+ lines.push("");
213
+ }
214
+ return lines;
215
+ }
216
+
217
+ /**
218
+ * Estrutura/delta dos arquivos do watchdog (AST). É um mapa para orientar a
219
+ * leitura — o watchdog confirma no código e não trata como achado.
220
+ */
221
+ function astSection(args: WatchdogRenderArgs): string[] {
222
+ const entries = args.ast ?? [];
223
+ if (entries.length === 0) return [];
224
+ const lines: string[] = [
225
+ "## Estrutura (AST)",
226
+ "",
227
+ "> Delta estrutural dos arquivos do seu domínio. Use como mapa para",
228
+ "> priorizar a leitura; **não** é achado por si só.",
229
+ "",
230
+ ];
231
+ let rendered = 0;
232
+ for (const entry of entries) {
233
+ const parts = deltaLines(entry.delta);
234
+ if (entry.importers && entry.importers.length > 0) {
235
+ parts.push(
236
+ `impacto: ${entry.importers.slice(0, 5).map(inlineCode).join(", ")}` +
237
+ (entry.importers.length > 5
238
+ ? ` (+${entry.importers.length - 5})`
239
+ : ""),
240
+ );
241
+ }
242
+ if (parts.length === 0) continue;
243
+ lines.push(
244
+ `### ${inlineCode(entry.file)}`,
245
+ "",
246
+ ...parts.map((part) => `- ${part}`),
247
+ "",
248
+ );
249
+ rendered++;
250
+ if (rendered >= 12) break;
251
+ }
252
+ if (rendered === 0) return [];
253
+ return lines;
254
+ }
255
+
120
256
  /**
121
257
  * Render padrão do brief de um watchdog (ordem estável).
122
258
  * `def` é a definição do próprio watchdog (evita ciclo com o registry).
@@ -139,6 +275,8 @@ export function defaultRenderBrief(
139
275
 
140
276
  if (args.context) lines.push(...renderBriefContext(args.context));
141
277
 
278
+ lines.push(...aiPlanSection(args));
279
+ lines.push(...astSection(args));
142
280
  lines.push(...optionalList("Prioridade de leitura", def.reviewFocus));
143
281
  lines.push(
144
282
  "## Regras (por severidade)",
@@ -10,7 +10,10 @@
10
10
  * com o UUID interno no banco.
11
11
  */
12
12
 
13
+ import { AiClient } from "../ai/ai-client";
14
+ import { AiVerifier } from "../ai/features/verifier";
13
15
  import { type HoundsmanConfig, resolveJevConcurrency } from "../config";
16
+ import { ENV_NAMES } from "../env";
14
17
  import {
15
18
  applyFindingUpdate,
16
19
  FINDING_DECISIONS,
@@ -36,9 +39,11 @@ import {
36
39
  type SubmitPreview,
37
40
  type SubmitSelection,
38
41
  submitFindings,
42
+ syncResolvedThreads,
39
43
  } from "../github-review";
40
44
  import { VERIFY_FILE } from "../paths";
41
45
  import { currentBranch } from "../pr-context";
46
+ import { ProjectConfig } from "../project-config";
42
47
  import { BatchPresenter } from "../renderers/batch-presenter";
43
48
  import { FindingsPresenter } from "../renderers/findings-presenter";
44
49
  import { VerifyPresenter } from "../renderers/verify-presenter";
@@ -214,6 +219,25 @@ function pingStatus(ctx: CommandContext, state: FindingsStateFile): void {
214
219
  pingFindingsStatus(ctx.storage, ctx.review.id, state);
215
220
  }
216
221
 
222
+ /**
223
+ * Resolve no GitHub as threads dos findings recém-resolvidos localmente.
224
+ * Fail-open (`gh` ausente/sem PR ⇒ só a mensagem). Devolve a linha para o
225
+ * output ou `null` quando não há vínculo remoto pendente.
226
+ */
227
+ function syncThreadsOutput(ctx: CommandContext, ids: readonly string[]): string | null {
228
+ if (ids.length === 0) return null;
229
+ const state = loadState(ctx);
230
+ const outcome = syncResolvedThreads({ state, ids });
231
+ if (outcome.checked === 0) return null;
232
+ if (outcome.updated.length > 0) ctx.storage.writeFindings(ctx.review.id, state);
233
+ if (!outcome.available) {
234
+ return `GitHub: threads não sincronizadas (${outcome.reason}).`;
235
+ }
236
+ const failures =
237
+ outcome.failed.length > 0 ? `, ${outcome.failed.length} falha(s)` : "";
238
+ return `GitHub: ${outcome.resolved.length} thread(s) resolvida(s), ${outcome.skipped.length} já resolvida(s)/pulada(s)${failures}.`;
239
+ }
240
+
217
241
  /** Aplica uma atualização a um finding e persiste. */
218
242
  function updateOne(
219
243
  ctx: CommandContext,
@@ -309,10 +333,17 @@ function execute(
309
333
  }
310
334
  const finding = updateOne(ctx, input, update);
311
335
  pingStatus(ctx, loadState(ctx));
336
+ const threads =
337
+ update.execState === "done" ? syncThreadsOutput(ctx, [finding.id]) : null;
312
338
  return {
313
339
  output: parsed.bools.has("--json")
314
340
  ? JSON.stringify(finding, null, 2)
315
- : `Atualizado: \`${finding.id}\` → ${finding.decision}/${finding.execState}.`,
341
+ : [
342
+ `Atualizado: \`${finding.id}\` → ${finding.decision}/${finding.execState}.`,
343
+ threads,
344
+ ]
345
+ .filter(Boolean)
346
+ .join("\n"),
316
347
  exitCode: 0,
317
348
  };
318
349
  }
@@ -320,8 +351,17 @@ function execute(
320
351
  const ids = requireIds(parsed, "done");
321
352
  const updated = updateMany(ctx, ids, { execState: "done" });
322
353
  pingStatus(ctx, loadState(ctx));
354
+ const threads = syncThreadsOutput(
355
+ ctx,
356
+ updated.map((finding) => finding.id),
357
+ );
323
358
  return {
324
- output: `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
359
+ output: [
360
+ `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
361
+ threads,
362
+ ]
363
+ .filter(Boolean)
364
+ .join("\n"),
325
365
  exitCode: 0,
326
366
  };
327
367
  }
@@ -449,6 +489,20 @@ export async function runVerifyCommand(
449
489
  }
450
490
  }
451
491
 
492
+ /** Trecho de ~20 linhas ao redor do finding (entrada da verificação por IA). */
493
+ function snippetsAround(content: string | null, line: number | null): string {
494
+ if (!content) return "(arquivo indisponível)";
495
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
496
+ const center = line && line > 0 ? line : 1;
497
+ const start = Math.max(1, center - 10);
498
+ const end = Math.min(lines.length, center + 10);
499
+ return lines
500
+ .slice(start - 1, end)
501
+ .map((text, index) => `${start + index}: ${text}`)
502
+ .join("\n")
503
+ .slice(0, 4_000);
504
+ }
505
+
452
506
  async function executeVerify(
453
507
  config: HoundsmanConfig,
454
508
  storage: StorageProvider,
@@ -474,7 +528,7 @@ async function executeVerify(
474
528
  if (parsed.bools.has("--no-jev")) {
475
529
  jevReason = "desativado (--no-jev)";
476
530
  } else if (!jevVerifier.isConfigured()) {
477
- jevReason = "TYPESAFE_API_KEY não configurada";
531
+ jevReason = `${ENV_NAMES.typeSafeApiKey} não configurada`;
478
532
  } else {
479
533
  const byId = new Map(findings.map((finding) => [finding.id, finding]));
480
534
  const outcome = await jevVerifier.verify(
@@ -495,6 +549,44 @@ async function executeVerify(
495
549
  }
496
550
  }
497
551
 
552
+ // Tier 2 — IA opcional para os ambíguos que o Jev não respondeu.
553
+ let aiChecked = 0;
554
+ let aiReason: string | undefined;
555
+ const stillAmbiguous = entries.filter(
556
+ (entry) => entry.ambiguous && entry.basis === "anchor",
557
+ );
558
+ if (stillAmbiguous.length > 0) {
559
+ const client = new AiClient({ project: new ProjectConfig(config) });
560
+ const aiVerifier = new AiVerifier(client);
561
+ if (parsed.bools.has("--no-ai")) {
562
+ aiReason = "desativado (--no-ai)";
563
+ } else if (!client.available) {
564
+ aiReason = "IA não configurada";
565
+ } else if (!aiVerifier.enabled) {
566
+ aiReason = "feature ai.features.verify desligada";
567
+ } else {
568
+ const readFile = fileReader(process.cwd());
569
+ const byId = new Map(findings.map((finding) => [finding.id, finding]));
570
+ const confidence = await aiVerifier.verify({
571
+ findings: stillAmbiguous.map((entry) => ({
572
+ id: entry.id,
573
+ rule: entry.rule,
574
+ file: entry.file,
575
+ line: entry.line,
576
+ message: byId.get(entry.id)?.message ?? entry.message,
577
+ snippets: snippetsAround(readFile(entry.file), entry.line),
578
+ })),
579
+ });
580
+ aiChecked = verifier.applyConfidence(
581
+ entries,
582
+ confidence,
583
+ JevCandidatesStore.THRESHOLD,
584
+ "ai",
585
+ );
586
+ aiReason = `${aiChecked} caso(s) confirmado(s) pela IA`;
587
+ }
588
+ }
589
+
498
590
  const summary = verifier.summarize(entries);
499
591
  const location = ctx.storage.location(ctx.review.id);
500
592
  ctx.storage.writeArtifact(ctx.review.id, {
@@ -511,6 +603,9 @@ async function executeVerify(
511
603
  pingStatus(ctx, current);
512
604
  }
513
605
 
606
+ // Resolvidos localmente ⇒ resolve as conversas correspondentes no PR.
607
+ const threads = syncThreadsOutput(ctx, applied);
608
+
514
609
  const output = parsed.bools.has("--json")
515
610
  ? JSON.stringify(
516
611
  {
@@ -518,6 +613,8 @@ async function executeVerify(
518
613
  summary,
519
614
  jevChecked,
520
615
  jevReason,
616
+ aiChecked,
617
+ aiReason,
521
618
  applied,
522
619
  entries,
523
620
  },
@@ -534,6 +631,7 @@ async function executeVerify(
534
631
  parsed.bools.has("--apply")
535
632
  ? `Aplicados (done): ${applied.length > 0 ? applied.join(", ") : "—"}.`
536
633
  : "",
634
+ threads,
537
635
  ]
538
636
  .filter(Boolean)
539
637
  .join("\n");
@@ -603,6 +701,21 @@ function renderSubmitOutcome(outcome: SubmitOutcome, json: boolean): string {
603
701
  "",
604
702
  `Enviados: ${outcome.posted.length} · Falhas: ${outcome.failed.length} · Pulados: ${outcome.skipped.length}`,
605
703
  ];
704
+ const review = outcome.review;
705
+ if (review?.id !== undefined) {
706
+ const target = review.url ? ` — ${review.url}` : "";
707
+ if (review.created) {
708
+ const status = review.finalized
709
+ ? `finalizada (${review.event})`
710
+ : "criada, mas não finalizada";
711
+ lines.push(`Revisão #${review.id} ${status}${target}`);
712
+ } else {
713
+ lines.push(
714
+ `Comentários adicionados à revisão pendente #${review.id}${target}`,
715
+ );
716
+ }
717
+ }
718
+ if (review?.error) lines.push(`Revisão: ❌ ${review.error}`);
606
719
  if (outcome.general) {
607
720
  lines.push(
608
721
  `Comentário geral: ${outcome.general.url ?? outcome.general.commentId} (${outcome.general.ids.length})`,
@@ -615,8 +728,9 @@ function renderSubmitOutcome(outcome: SubmitOutcome, json: boolean): string {
615
728
  }
616
729
 
617
730
  /**
618
- * Subcomando `findings submit` — envia os findings selecionados como comentários
619
- * na PR (via `gh`). `--dry-run` mostra a prévia sem postar. Assíncrono (gh).
731
+ * Subcomando `findings submit` — envia os findings selecionados numa revisão da
732
+ * PR (via `gh`): reaproveita a revisão pendente ou cria/finaliza uma nova.
733
+ * `--dry-run` mostra a prévia sem postar. Assíncrono (gh).
620
734
  */
621
735
  export async function runSubmitCommand(
622
736
  config: HoundsmanConfig,
@@ -664,6 +778,6 @@ async function executeSubmit(
664
778
 
665
779
  return {
666
780
  output: renderSubmitOutcome(outcome, json),
667
- exitCode: outcome.failed.length > 0 ? 1 : 0,
781
+ exitCode: outcome.failed.length > 0 || Boolean(outcome.review?.error) ? 1 : 0,
668
782
  };
669
783
  }
@@ -28,7 +28,9 @@ export function parseReviewArgs(args: string[]): ReviewOptions {
28
28
  if (parser.has("--fail-on-blocking")) opts.failOnBlocking = true;
29
29
  if (parser.has("--clean")) opts.clean = true;
30
30
  if (parser.has("--no-jev")) opts.noJev = true;
31
+ if (parser.has("--no-ast")) opts.noAst = true;
31
32
  if (parser.has("--full")) opts.full = true;
33
+ if (parser.has("--no-ai")) opts.noAi = true;
32
34
 
33
35
  return opts;
34
36
  }
@@ -8,14 +8,22 @@
8
8
  * ├── .gitignore AUTO — ignora core/ (gerado)
9
9
  * ├── core/types.ts AUTO — snapshot dos tipos + helpers
10
10
  * ├── core/skill/*.md AUTO — docs do skill (com {{projectName}} resolvido)
11
+ * ├── core/grammars/*.wasm AUTO — runtime + grammars do AST
11
12
  * ├── watchdogs/*.ts USUÁRIO
12
13
  * ├── doc-mappings.ts USUÁRIO
13
14
  * └── exclusions.ts USUÁRIO
14
15
  */
15
16
 
16
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import {
18
+ copyFileSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readdirSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ } from "node:fs";
17
25
  import { basename, join } from "node:path";
18
- import { SKILL_DIR, SKILL_FILE } from "../assets";
26
+ import { GRAMMAR_FILES, grammarPath, SKILL_DIR, SKILL_FILE } from "../assets";
19
27
  import { CORE_DIR, CORE_SKILL_DIR, OUTPUT_FORMAT_PATH, PROJECT_DIR } from "../paths";
20
28
 
21
29
  export type Harness = "agents" | "claude";
@@ -123,11 +131,21 @@ export type Rule = {
123
131
  severity: Severity;
124
132
  text: string;
125
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[];
126
144
  };
127
145
 
128
146
  /** Como o watchdog casa um arquivo alterado. \`value\` é sempre regex. */
129
147
  export type ClassifyRule = {
130
- type?: "filename" | "content";
148
+ type?: "filename" | "content" | "symbol" | "import" | "export" | "decorator";
131
149
  value: string;
132
150
  };
133
151
 
@@ -144,6 +162,10 @@ export type WatchdogDefinition = WatchdogMeta & {
144
162
  id: string;
145
163
  description: string;
146
164
  breakingChange?: boolean;
165
+ /** Só assume arquivos que nenhum outro watchdog casou. */
166
+ fallback?: boolean;
167
+ /** Permite findings emergentes (Lógica:/Domínio:) fora das regras. */
168
+ allowEmergentFindings?: boolean;
147
169
  reviewFocus?: readonly string[];
148
170
  exclude?: readonly string[];
149
171
  };
@@ -182,6 +204,54 @@ export type HoundsmanStorage = {
182
204
  path?: string;
183
205
  };
184
206
 
207
+ /** Linguagens suportadas pelo AST na primeira fase. */
208
+ export type AstLanguage = "typescript" | "tsx" | "javascript";
209
+
210
+ /** Configuração do AST determinístico (classificação estrutural, brief e impacto). */
211
+ export type HoundsmanAst = {
212
+ enabled?: boolean;
213
+ maxFileBytes?: number;
214
+ maxImpactFiles?: number;
215
+ languages?: Record<string, AstLanguage>;
216
+ aliases?: Record<string, string>;
217
+ };
218
+
219
+ /** Providers suportados pela camada de IA. */
220
+ export type AiProviderKind = "deepseek" | "openai" | "anthropic";
221
+
222
+ /** Features de IA do fluxo normal (todas fail-open). */
223
+ export type HoundsmanAiFeatures = {
224
+ context?: boolean;
225
+ brief?: boolean;
226
+ classification?: boolean;
227
+ preAnalysis?: boolean;
228
+ verify?: boolean;
229
+ };
230
+
231
+ /** Overrides de IA por watchdog. */
232
+ export type HoundsmanAiWatchdog = {
233
+ model?: string;
234
+ maxSteps?: number;
235
+ maxOutputTokens?: number;
236
+ };
237
+
238
+ /** Configuração da camada de IA (briefs, contexto, classificação e hunt nativo). */
239
+ export type HoundsmanAi = {
240
+ enabled?: boolean;
241
+ provider?: AiProviderKind;
242
+ model?: string;
243
+ baseURL?: string;
244
+ concurrency?: number;
245
+ maxSteps?: number;
246
+ maxOutputTokens?: number;
247
+ temperature?: number;
248
+ timeoutMs?: number;
249
+ maxCallsPerReview?: number;
250
+ maxTokensPerReview?: number;
251
+ features?: HoundsmanAiFeatures;
252
+ watchdogs?: Record<string, HoundsmanAiWatchdog>;
253
+ };
254
+
185
255
  /** Contrato de configuração do motor. */
186
256
  export type HoundsmanConfig<TId extends string = string> = {
187
257
  projectName: string;
@@ -193,6 +263,8 @@ export type HoundsmanConfig<TId extends string = string> = {
193
263
  alwaysIncludeDocs?: readonly string[];
194
264
  budgets?: HoundsmanBudgets;
195
265
  concurrency?: HoundsmanConcurrency;
266
+ ast?: HoundsmanAst;
267
+ ai?: HoundsmanAi;
196
268
  };
197
269
 
198
270
  /** Helper de autoria (identidade) — apenas tipagem. */
@@ -212,7 +284,8 @@ const WATCHDOG_EXAMPLE = `import { defineWatchdog } from "../core/types";
212
284
 
213
285
  /**
214
286
  * Watchdog de exemplo — substitua pelas regras reais do projeto.
215
- * Um watchdog declara seus arquivos via \`classify\` (regex de path ou conteúdo).
287
+ * Um watchdog declara seus arquivos via \`classify\` (regex de path, conteúdo ou
288
+ * fatos AST: \`symbol\`/\`import\`/\`export\`/\`decorator\`).
216
289
  * O texto de cada regra começa com a polaridade: \`Promover:\` (DEVE acontecer)
217
290
  * ou \`Evitar:\`/\`Não\`/\`Nunca\` (NÃO DEVE acontecer).
218
291
  */
@@ -220,8 +293,8 @@ export const exampleWatchdog = defineWatchdog({
220
293
  id: "example",
221
294
  name: "Exemplo",
222
295
  description: "Watchdog de exemplo — descreva o propósito em uma frase.",
223
- scope: "Todo o repositório (ajuste o escopo)",
224
- classify: [{ value: "^src/" }],
296
+ scope: "src/example/** (ajuste o escopo)",
297
+ classify: [{ value: "^src/example/" }],
225
298
  rules: [
226
299
  {
227
300
  severity: "attention",
@@ -237,13 +310,65 @@ export const exampleWatchdog = defineWatchdog({
237
310
  });
238
311
  `;
239
312
 
240
- const WATCHDOGS_INDEX = `import { exampleWatchdog } from "./example";
313
+ const DEFAULT_WATCHDOG = `import { defineWatchdog } from "../core/types";
314
+
315
+ /**
316
+ * Watchdog de padrões gerais e lógica (fallback).
317
+ *
318
+ * Só assume arquivos que nenhum outro watchdog casou. Cobre consistência
319
+ * lógica, invariantes de domínio e defeitos genéricos que não pertencem a um
320
+ * domínio específico. Findings fora das regras abaixo podem ser reportados com
321
+ * os prefixos reservados "Lógica:" e "Domínio:" (opt-in abaixo).
322
+ */
323
+ export const defaultWatchdog = defineWatchdog({
324
+ id: "default",
325
+ name: "Padrões gerais e lógica",
326
+ description:
327
+ "Cobre arquivos sem domínio específico: consistência lógica, domínio e defeitos genéricos.",
328
+ scope: "Arquivos não classificados por outros watchdogs",
329
+ fallback: true,
330
+ allowEmergentFindings: true,
331
+ rules: [
332
+ {
333
+ severity: "attention",
334
+ text: "Promover: nome, contrato e documentação do símbolo correspondem ao comportamento real.",
335
+ hints: { checks: ["symbol_context"] },
336
+ },
337
+ {
338
+ severity: "attention",
339
+ text: "Evitar: engolir erros/exceções sem propagar, logar ou tratar.",
340
+ },
341
+ {
342
+ severity: "attention",
343
+ text: "Promover: transições de estado e efeitos colaterais do domínio são explícitos, ordenados e idempotentes.",
344
+ },
345
+ {
346
+ severity: "suggestion",
347
+ text: "Evitar: lógica de negócio duplicada ou divergente entre camadas.",
348
+ hints: { checks: ["find_references"] },
349
+ },
350
+ {
351
+ severity: "suggestion",
352
+ text: "Promover: casos de borda do domínio (vazio, zero, negativo, timezone, arredondamento) são tratados.",
353
+ },
354
+ {
355
+ severity: "suggestion",
356
+ text: "Promover: a lógica do domínio respeita as invariantes e contratos documentados.",
357
+ },
358
+ ],
359
+ contextFiles: ["AGENTS.md"],
360
+ });
361
+ `;
362
+
363
+ const WATCHDOGS_INDEX = `import { defaultWatchdog } from "./default";
364
+ import { exampleWatchdog } from "./example";
241
365
 
242
366
  /**
243
367
  * Definições dos watchdogs — as chaves definem o union \`WatchdogId\`.
244
368
  * Adicionar um watchdog = criar \`<id>.ts\` e registrá-lo aqui.
245
369
  */
246
370
  export const WATCHDOG_DEFINITIONS = {
371
+ default: defaultWatchdog,
247
372
  example: exampleWatchdog,
248
373
  };
249
374
 
@@ -267,9 +392,10 @@ export function isExcluded(filePath: string): boolean {
267
392
  const DOC_MAPPINGS_TEMPLATE = `/**
268
393
  * Mapeamento de padrões nos paths → documentação relevante.
269
394
  * Consumido pelo motor via \`HoundsmanConfig.docMappings\`.
270
- */\`core/\` é regenerado por \`houndsman update\`.
271
- # Runs, findings e artefatos vivem no storage global (fora do projeto).
272
- core
395
+ *
396
+ * Exemplo: [/^src\\//, ["README.md"]]
397
+ */
398
+ export const DOC_MAPPINGS: readonly (readonly [RegExp, readonly string[]])[] = [];
273
399
  `;
274
400
 
275
401
  const PROJECT_GITIGNORE = `# Diretórios gerados pelo houndsman — \`core/\` é regenerado por \`houndsman update\`
@@ -307,6 +433,14 @@ class WatchdogExampleTemplate implements ScaffoldTemplate {
307
433
  }
308
434
  }
309
435
 
436
+ class DefaultWatchdogTemplate implements ScaffoldTemplate {
437
+ readonly path = join(PROJECT_DIR, "watchdogs", "default.ts");
438
+
439
+ render(): string {
440
+ return DEFAULT_WATCHDOG;
441
+ }
442
+ }
443
+
310
444
  class WatchdogsIndexTemplate implements ScaffoldTemplate {
311
445
  readonly path = join(PROJECT_DIR, "watchdogs", "index.ts");
312
446
 
@@ -402,6 +536,7 @@ export class ProjectScaffolder {
402
536
  const context = this.context();
403
537
  const templates: ScaffoldTemplate[] = [
404
538
  new ConfigTemplate(),
539
+ new DefaultWatchdogTemplate(),
405
540
  new WatchdogExampleTemplate(),
406
541
  new WatchdogsIndexTemplate(),
407
542
  new ExclusionsTemplate(),
@@ -415,6 +550,7 @@ export class ProjectScaffolder {
415
550
  const context = this.context();
416
551
  this.writeGeneratedFile(new TypesTemplate(), context);
417
552
  this.writeGeneratedFile(new GitignoreTemplate(), context);
553
+ this.syncGrammars();
418
554
 
419
555
  // Docs do skill vão para `.houndsman/core/skill/`. O `SKILL.md` é o entry
420
556
  // point dos harnesses, não um doc da camada.
@@ -428,6 +564,19 @@ export class ProjectScaffolder {
428
564
  }
429
565
  }
430
566
 
567
+ /**
568
+ * Copia as grammars wasm para a camada. O consumidor não precisa rodar nada:
569
+ * `setup` e `update` sincronizam os binários junto dos demais auto-gerados.
570
+ */
571
+ private syncGrammars(): void {
572
+ const directory = join(this.ctx.cwd, CORE_DIR, "grammars");
573
+ mkdirSync(directory, { recursive: true });
574
+ for (const name of GRAMMAR_FILES) {
575
+ copyFileSync(grammarPath(name), join(directory, name));
576
+ }
577
+ this.ctx.generated.push(join(CORE_DIR, "grammars", "*.wasm"));
578
+ }
579
+
431
580
  private context(): TemplateContext {
432
581
  return { projectName: this.ctx.projectName };
433
582
  }