@payconductor/houndsman 0.11.0 → 0.13.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 (140) hide show
  1. package/README.md +14 -13
  2. package/package.json +1 -1
  3. package/src/cli/commands/doctor-command.ts +22 -0
  4. package/src/cli/commands/hunt-command.ts +440 -29
  5. package/src/cli/commands/review-command.ts +20 -2
  6. package/src/cli/help.ts +16 -8
  7. package/src/cli/index.ts +15 -2
  8. package/src/cli/progress.ts +45 -0
  9. package/src/core/ai/ai-client.ts +3 -7
  10. package/src/core/ai/ai-sdk-model-provider.ts +43 -24
  11. package/src/core/ai/features/brief-enricher.ts +0 -1
  12. package/src/core/ai/features/classifier.ts +0 -1
  13. package/src/core/ai/features/context-summarizer.ts +0 -1
  14. package/src/core/ai/features/pre-analyzer.ts +0 -1
  15. package/src/core/ai/features/technical-summarizer.ts +0 -1
  16. package/src/core/ai/features/verifier.ts +0 -1
  17. package/src/core/ai/index.ts +6 -4
  18. package/src/core/ai/prompts/pre-analysis.ts +3 -2
  19. package/src/core/ai/schemas.ts +1 -23
  20. package/src/core/ai/types.ts +4 -15
  21. package/src/core/ai/usage-budget.ts +1 -1
  22. package/src/core/ast/diff.ts +37 -2
  23. package/src/core/ast/service.ts +1 -1
  24. package/src/core/brief.ts +54 -5
  25. package/src/core/changed-scope.ts +207 -0
  26. package/src/core/commands/scaffold.ts +12 -18
  27. package/src/core/config.ts +30 -15
  28. package/src/core/defaults.ts +14 -5
  29. package/src/core/engine/review-scope.ts +131 -17
  30. package/src/core/engine/review-session.ts +7 -3
  31. package/src/core/engine/review-workspace.ts +14 -0
  32. package/src/core/env.ts +14 -0
  33. package/src/core/hunt/hunt-runner.ts +328 -0
  34. package/src/core/hunt/index.ts +59 -0
  35. package/src/core/hunt/maki-install.ts +331 -0
  36. package/src/core/hunt/maki-model.ts +101 -0
  37. package/src/core/hunt/maki-prompt.ts +71 -0
  38. package/src/core/hunt/maki-release.ts +104 -0
  39. package/src/core/hunt/maki-runner.ts +276 -0
  40. package/src/core/hunt/pool.ts +30 -0
  41. package/src/core/jev/jev-detector.ts +7 -2
  42. package/src/core/logging/index.ts +3 -1
  43. package/src/core/logging/logger.ts +22 -3
  44. package/src/core/logging/progress.ts +282 -0
  45. package/src/core/orchestrator.ts +173 -59
  46. package/src/core/project-config.ts +34 -24
  47. package/src/core/registry-check.ts +2 -12
  48. package/src/core/rule-text.ts +3 -55
  49. package/src/core/services/git-repository.ts +8 -0
  50. package/src/core/storage/types.ts +1 -1
  51. package/src/core/types.ts +3 -14
  52. package/src/index.ts +74 -3
  53. package/src/server/client/utils/constants.ts +3 -6
  54. package/src/server/client/utils/types.ts +0 -1
  55. package/src/skill/OUTPUT-FORMAT.md +5 -0
  56. package/src/skill/README.md +14 -1
  57. package/src/skill/SETUP.md +22 -2
  58. package/src/skill/SKILL.md +4 -0
  59. package/src/skill/fix.md +1 -1
  60. package/src/skill/hunt.md +38 -6
  61. package/src/tests/__snapshots__/baseline.test.ts.snap +69 -32
  62. package/src/tests/ai-core.test.ts +38 -1
  63. package/src/tests/changed-scope.test.ts +197 -0
  64. package/src/tests/cli.test.ts +1 -0
  65. package/src/tests/fallback.test.ts +1 -96
  66. package/src/tests/fixtures/fake-model-provider.ts +0 -1
  67. package/src/tests/hunt-cli.test.ts +185 -0
  68. package/src/tests/hunt-install.test.ts +153 -0
  69. package/src/tests/hunt-maki.test.ts +467 -0
  70. package/src/tests/progress.test.ts +117 -0
  71. package/src/tests/review-scope.test.ts +95 -0
  72. package/src/tests/worktree.test.ts +1 -3
  73. package/src/harness/README.md +0 -15
  74. package/src/harness/agent/agent-context.ts +0 -33
  75. package/src/harness/agent/agent-errors.ts +0 -30
  76. package/src/harness/agent/agent-factory.ts +0 -46
  77. package/src/harness/agent/agent-reporter.ts +0 -34
  78. package/src/harness/agent/agent-result.ts +0 -16
  79. package/src/harness/agent/agent-runner.ts +0 -32
  80. package/src/harness/agent/agent-scheduler.ts +0 -37
  81. package/src/harness/agent/review-agent.ts +0 -401
  82. package/src/harness/config/harness-config.ts +0 -89
  83. package/src/harness/hunt/hunt-harness.ts +0 -83
  84. package/src/harness/hunt/hunt-options.ts +0 -92
  85. package/src/harness/hunt/hunt-result.ts +0 -17
  86. package/src/harness/hunt/hunt-session.ts +0 -359
  87. package/src/harness/hunt/review-planner.ts +0 -98
  88. package/src/harness/index.ts +0 -36
  89. package/src/harness/investigation/agent-state.ts +0 -134
  90. package/src/harness/investigation/evidence-verifier.ts +0 -129
  91. package/src/harness/investigation/evidence.ts +0 -110
  92. package/src/harness/investigation/finding-critic.ts +0 -96
  93. package/src/harness/investigation/investigation-planner.ts +0 -118
  94. package/src/harness/investigation/investigation-task.ts +0 -10
  95. package/src/harness/investigation/review-journal.ts +0 -87
  96. package/src/harness/investigation/rule-analyzer.ts +0 -178
  97. package/src/harness/investigation/rule-signals.ts +0 -110
  98. package/src/harness/prompts/agent-schemas.ts +0 -57
  99. package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
  100. package/src/harness/prompts/states/conclude-prompt.ts +0 -16
  101. package/src/harness/prompts/states/investigate-prompt.ts +0 -37
  102. package/src/harness/prompts/states/orient-prompt.ts +0 -15
  103. package/src/harness/prompts/states/verify-prompt.ts +0 -22
  104. package/src/harness/tools/agent-tool.ts +0 -102
  105. package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
  106. package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
  107. package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
  108. package/src/harness/tools/builtin/find-references-tool.ts +0 -66
  109. package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
  110. package/src/harness/tools/builtin/helpers.ts +0 -115
  111. package/src/harness/tools/builtin/impact-tool.ts +0 -48
  112. package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
  113. package/src/harness/tools/builtin/index.ts +0 -59
  114. package/src/harness/tools/builtin/list-files-tool.ts +0 -85
  115. package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
  116. package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
  117. package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
  118. package/src/harness/tools/builtin/search-code-tool.ts +0 -65
  119. package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
  120. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
  121. package/src/harness/tools/path-guard.ts +0 -90
  122. package/src/harness/tools/result-limiter.ts +0 -49
  123. package/src/harness/tools/secret-redactor.ts +0 -54
  124. package/src/harness/tools/tool-cache.ts +0 -50
  125. package/src/harness/tools/tool-call-budget.ts +0 -60
  126. package/src/harness/tools/tool-context.ts +0 -5
  127. package/src/harness/tools/tool-middleware.ts +0 -105
  128. package/src/harness/tools/tool-policy.ts +0 -51
  129. package/src/harness/tools/tool-registry.ts +0 -112
  130. package/src/harness/tools/tool-result.ts +0 -15
  131. package/src/harness/tools/toolbox.ts +0 -36
  132. package/src/harness/validation/finding-validator.ts +0 -119
  133. package/src/tests/fixtures/fake-agent-tools.ts +0 -69
  134. package/src/tests/fixtures/fake-investigation.ts +0 -64
  135. package/src/tests/harness-agent.test.ts +0 -141
  136. package/src/tests/harness-investigation.test.ts +0 -206
  137. package/src/tests/harness-tools.test.ts +0 -148
  138. package/src/tests/hunt-command.test.ts +0 -212
  139. package/src/tests/rule-analyzer.test.ts +0 -61
  140. package/src/tests/tool-contracts.test.ts +0 -82
@@ -16,7 +16,7 @@
16
16
  * review.<watchdog>.md, context e context-summary.
17
17
  */
18
18
 
19
- import { existsSync, readFileSync } from "node:fs";
19
+ import { existsSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import type { AiClient } from "./ai/ai-client";
22
22
  import { AiBriefEnricher } from "./ai/features/brief-enricher";
@@ -28,6 +28,7 @@ import type { BriefPlan } from "./ai/schemas";
28
28
  import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
29
29
  import type { AstService } from "./ast/service";
30
30
  import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
31
+ import { summarizeScope } from "./changed-scope";
31
32
  import {
32
33
  type HoundsmanConfig,
33
34
  resolveJevConcurrency,
@@ -38,7 +39,11 @@ import { discoverDocs } from "./doc-discoverer";
38
39
  import { Classification } from "./domain/classification";
39
40
  import { Watchdog } from "./domain/watchdog";
40
41
  import { mergeCarryover } from "./engine/carryover";
41
- import { ReviewScopeResolver, type RunClassification } from "./engine/review-scope";
42
+ import {
43
+ ReviewScopeResolver,
44
+ type RunClassification,
45
+ type ScopeProgress,
46
+ } from "./engine/review-scope";
42
47
  import { ENV_NAMES } from "./env";
43
48
  import type { BudgetSelection, ClassificationResult } from "./file-classifier";
44
49
  import { buildStateFromFindings, fileReader } from "./findings-state";
@@ -101,7 +106,7 @@ export type ReviewOptions = {
101
106
  noAst: boolean;
102
107
  /** Força review completo (ignora o modo incremental). */
103
108
  full: boolean;
104
- /** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
109
+ /** Desativa as features de IA do fluxo normal. */
105
110
  noAi: boolean;
106
111
  /** PR de origem (resolvida via `--pr`). */
107
112
  pr?: number;
@@ -231,7 +236,7 @@ function writeManifest(
231
236
 
232
237
  function writeBriefs(
233
238
  activeIds: string[],
234
- classification: ClassificationResult,
239
+ classification: RunClassification,
235
240
  ctx: ReviewContext,
236
241
  branch: string,
237
242
  context: ContextSummary | null,
@@ -276,6 +281,7 @@ function writeBriefs(
276
281
  candidates: candidates.filter((c) => c.watchdogId === id),
277
282
  ast: astFiles,
278
283
  aiPlan: aiPlans?.get(id),
284
+ changedScope: classification.changedScope,
279
285
  });
280
286
  const content = `${brief}\n`;
281
287
  work.writeBrief(id, content);
@@ -322,7 +328,9 @@ async function resolveCandidates(
322
328
  const def = findWatchdog(config, id);
323
329
  if (!def) continue;
324
330
  const inputs = files.slice(0, 5).flatMap((file) => {
325
- const snippet = readSnippet(ctx.root, file);
331
+ // Só o trecho alterado: candidato fora do diff vira ruído de código
332
+ // pré-existente no brief do watchdog.
333
+ const snippet = diff.byFile.get(file);
326
334
  return snippet ? [{ file, snippet }] : [];
327
335
  });
328
336
  candidates.push(
@@ -406,6 +414,16 @@ function discoveryMessages(
406
414
  return messages;
407
415
  }
408
416
 
417
+ /** Resumo do escopo — lembra que só as faixas alteradas são revisáveis. */
418
+ function changedScopeMessage(classification: RunClassification): string[] {
419
+ const scope = classification.changedScope;
420
+ if (!scope) return [];
421
+ const totals = summarizeScope(scope);
422
+ return [
423
+ `Escopo: ${totals.files} arquivo(s), ${totals.symbols} símbolo(s) em ${totals.ranges} faixa(s) alterada(s).`,
424
+ ];
425
+ }
426
+
409
427
  /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
410
428
  function budgetMessage(
411
429
  classification: ClassificationResult,
@@ -422,18 +440,85 @@ function budgetMessage(
422
440
  ];
423
441
  }
424
442
 
425
- /** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
426
- function readSnippet(root: string, file: string): string | null {
443
+ /** Resultado do prepare — mensagens para o CLI imprimir. */
444
+ export type PrepareResult = {
445
+ messages: string[];
446
+ /** Watchdogs ativos neste ciclo (vazio em no-op/sem arquivos). */
447
+ activeIds: string[];
448
+ /** Diretório dos briefs do ciclo (ausente quando não há briefs). */
449
+ briefsDir?: string;
450
+ /** Review do ciclo (ausente quando nada foi preparado). */
451
+ reviewId?: string;
452
+ };
453
+
454
+ /** Etapas do prepare reportadas ao CLI (o core não escreve saída). */
455
+ export type PrepareStage =
456
+ | "diff"
457
+ | "ast"
458
+ | "classify"
459
+ | "impact"
460
+ | "jev"
461
+ | "context"
462
+ | "technical"
463
+ | "ai-plans"
464
+ | "briefs"
465
+ | "manifest";
466
+
467
+ /** Evento de progresso de uma etapa do prepare. */
468
+ export type PrepareProgressEvent =
469
+ | { type: "start"; stage: PrepareStage }
470
+ | { type: "done"; stage: PrepareStage; durationMs: number };
471
+
472
+ /** Callback de progresso do prepare (falhas do callback são ignoradas). */
473
+ export type PrepareProgress = (event: PrepareProgressEvent) => void;
474
+
475
+ /** Encaminha o progresso do escopo como etapa do prepare. */
476
+ function scopeProgress(onProgress?: PrepareProgress): ScopeProgress | undefined {
477
+ if (!onProgress) return undefined;
478
+ return (event) => emitProgress(onProgress, event);
479
+ }
480
+
481
+ /** Emite um evento de progresso sem deixar o callback derrubar a revisão. */
482
+ function emitProgress(
483
+ onProgress: PrepareProgress | undefined,
484
+ event: PrepareProgressEvent,
485
+ ): void {
427
486
  try {
428
- const path = join(root, file);
429
- return readFileSync(path, "utf8").split("\n").slice(0, 80).join("\n");
487
+ onProgress?.(event);
430
488
  } catch {
431
- return null;
489
+ // Progresso nunca derruba a revisão.
432
490
  }
433
491
  }
434
492
 
435
- /** Resultado do prepare — mensagens para o CLI imprimir. */
436
- export type PrepareResult = { messages: string[] };
493
+ /** Executa uma etapa reportando início/fim (sync ou async). */
494
+ async function runStage<T>(
495
+ onProgress: PrepareProgress | undefined,
496
+ stage: PrepareStage,
497
+ run: () => Promise<T> | T,
498
+ ): Promise<T> {
499
+ if (!onProgress) return run();
500
+ const started = Date.now();
501
+ emitProgress(onProgress, { type: "start", stage });
502
+ try {
503
+ return await run();
504
+ } finally {
505
+ emitProgress(onProgress, {
506
+ type: "done",
507
+ stage,
508
+ durationMs: Date.now() - started,
509
+ });
510
+ }
511
+ }
512
+
513
+ /** Como `runStage`, mas só reporta quando a etapa realmente vai rodar. */
514
+ async function runOptionalStage<T>(
515
+ onProgress: PrepareProgress | undefined,
516
+ enabled: boolean,
517
+ stage: PrepareStage,
518
+ run: () => Promise<T> | T,
519
+ ): Promise<T> {
520
+ return enabled ? runStage(onProgress, stage, run) : run();
521
+ }
437
522
 
438
523
  /**
439
524
  * Classificação fallback por IA (opcional): atribui arquivos não classificados
@@ -591,6 +676,7 @@ export async function prepare(
591
676
  ctx: ReviewContext,
592
677
  ast?: AstService,
593
678
  ai?: AiClient,
679
+ onProgress?: PrepareProgress,
594
680
  ): Promise<PrepareResult> {
595
681
  const messages: string[] = [];
596
682
  ctx.work.ensure();
@@ -605,6 +691,7 @@ export async function prepare(
605
691
  config,
606
692
  opts.noAst ? undefined : ast,
607
693
  ctx.git,
694
+ scopeProgress(onProgress),
608
695
  ).resolve(previous, headSha);
609
696
 
610
697
  // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
@@ -622,7 +709,7 @@ export async function prepare(
622
709
  ? "Nada novo a preparar — o ciclo em andamento continua; rode `--finalize` quando os findings estiverem gravados."
623
710
  : "Nada a revisar — o relatório anterior foi mantido. Use `--full` para re-revisar.",
624
711
  );
625
- return { messages };
712
+ return { messages, activeIds: [] };
626
713
  }
627
714
 
628
715
  const { classification, diff } = scope;
@@ -639,7 +726,7 @@ export async function prepare(
639
726
  messages.push(
640
727
  `Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
641
728
  );
642
- return { messages };
729
+ return { messages, activeIds: [] };
643
730
  }
644
731
  }
645
732
 
@@ -657,7 +744,10 @@ export async function prepare(
657
744
  });
658
745
 
659
746
  const astAnalyses = scope.ast;
660
- const astImpact = astAnalyses && ast ? await ast.impact(astAnalyses) : undefined;
747
+ const astImpact =
748
+ astAnalyses && ast
749
+ ? await runStage(onProgress, "impact", () => ast.impact(astAnalyses))
750
+ : undefined;
661
751
  if (astAnalyses && ast?.enabled) {
662
752
  const summary = {
663
753
  enabled: true,
@@ -686,7 +776,13 @@ export async function prepare(
686
776
  }
687
777
  }
688
778
 
689
- const jev = await resolveCandidates(opts, classification, ctx, config, diff, ai);
779
+ const jevWillRun = Boolean(
780
+ (ai && !opts.noAi && new AiPreAnalyzer(ai).enabled) ||
781
+ (!opts.noJev && new JevClient().isConfigured()),
782
+ );
783
+ const jev = await runOptionalStage(onProgress, jevWillRun, "jev", () =>
784
+ resolveCandidates(opts, classification, ctx, config, diff, ai),
785
+ );
690
786
 
691
787
  const activeIds = Object.keys(classification.watchdogs);
692
788
  if (Object.keys(classification.files).length === 0) {
@@ -696,14 +792,14 @@ export async function prepare(
696
792
  ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
697
793
  : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
698
794
  );
699
- return { messages };
795
+ return { messages, activeIds: [] };
700
796
  }
701
797
  if (activeIds.length === 0) {
702
798
  writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
703
799
  messages.push(
704
800
  "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
705
801
  );
706
- return { messages };
802
+ return { messages, activeIds: [] };
707
803
  }
708
804
  if (classification.mode === "incremental") {
709
805
  messages.push(
@@ -712,15 +808,21 @@ export async function prepare(
712
808
  }
713
809
 
714
810
  persistContext(ctx);
715
- const context = await resolveContextSummary(ctx, ai, opts);
716
- await resolveTechnicalSummary(ctx, ai, opts, {
717
- branch,
718
- classification,
719
- context,
720
- ast: astAnalyses,
721
- impact: astImpact,
722
- domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
723
- });
811
+ const aiContext = ai && !opts.noAi ? new AiContextSummarizer(ai).enabled : false;
812
+ const context = await runOptionalStage(onProgress, aiContext, "context", () =>
813
+ resolveContextSummary(ctx, ai, opts),
814
+ );
815
+ const aiTechnical = ai && !opts.noAi ? new AiTechnicalSummarizer(ai).enabled : false;
816
+ await runOptionalStage(onProgress, aiTechnical, "technical", () =>
817
+ resolveTechnicalSummary(ctx, ai, opts, {
818
+ branch,
819
+ classification,
820
+ context,
821
+ ast: astAnalyses,
822
+ impact: astImpact,
823
+ domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
824
+ }),
825
+ );
724
826
 
725
827
  const manifestAst: ManifestAst | undefined = astAnalyses
726
828
  ? {
@@ -741,15 +843,18 @@ export async function prepare(
741
843
  }
742
844
  : undefined;
743
845
 
744
- const aiPlans = await buildAiPlans(
745
- activeIds,
746
- classification,
747
- config,
748
- context,
749
- astAnalyses,
750
- astImpact,
751
- ai,
752
- opts,
846
+ const aiBriefs = ai && !opts.noAi ? new AiBriefEnricher(ai).enabled : false;
847
+ const aiPlans = await runOptionalStage(onProgress, aiBriefs, "ai-plans", () =>
848
+ buildAiPlans(
849
+ activeIds,
850
+ classification,
851
+ config,
852
+ context,
853
+ astAnalyses,
854
+ astImpact,
855
+ ai,
856
+ opts,
857
+ ),
753
858
  );
754
859
  if (aiPlans.size > 0) {
755
860
  ctx.storage.writeArtifact(ctx.review.id, {
@@ -779,33 +884,37 @@ export async function prepare(
779
884
  }
780
885
  : undefined;
781
886
 
782
- writeBriefs(
783
- activeIds,
784
- classification,
785
- ctx,
786
- branch,
787
- context,
788
- jev.candidates,
789
- config,
790
- astAnalyses,
791
- astImpact,
792
- aiPlans,
887
+ await runStage(onProgress, "briefs", () =>
888
+ writeBriefs(
889
+ activeIds,
890
+ classification,
891
+ ctx,
892
+ branch,
893
+ context,
894
+ jev.candidates,
895
+ config,
896
+ astAnalyses,
897
+ astImpact,
898
+ aiPlans,
899
+ ),
793
900
  );
794
- writeManifest(
795
- ctx,
796
- config,
797
- opts,
798
- classification,
799
- activeIds,
800
- activeIds,
801
- jev,
802
- manifestAst,
803
- manifestAi,
901
+ await runStage(onProgress, "manifest", () =>
902
+ writeManifest(
903
+ ctx,
904
+ config,
905
+ opts,
906
+ classification,
907
+ activeIds,
908
+ activeIds,
909
+ jev,
910
+ manifestAst,
911
+ manifestAi,
912
+ ),
804
913
  );
805
-
806
914
  messages.push(
807
915
  ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
808
916
  ...budgetMessage(classification, config),
917
+ ...changedScopeMessage(classification),
809
918
  ...jevSummary(jev),
810
919
  );
811
920
  if (astAnalyses) {
@@ -841,7 +950,12 @@ export async function prepare(
841
950
  "Quando os findings estiverem gravados: bun run houndsman --finalize",
842
951
  );
843
952
 
844
- return { messages };
953
+ return {
954
+ messages,
955
+ activeIds,
956
+ briefsDir: ctx.work.briefsDir,
957
+ reviewId: ctx.review.id,
958
+ };
845
959
  }
846
960
  /** Persiste o contexto da PR (bruto + resumo) como artefatos, quando existirem. */
847
961
  function persistContext(ctx: ReviewContext): void {
@@ -13,20 +13,22 @@ import type {
13
13
  HoundsmanAiFeatures,
14
14
  HoundsmanAst,
15
15
  HoundsmanConfig,
16
+ HoundsmanHunt,
16
17
  HoundsmanStorage,
17
18
  HoundsmanWorktrees,
18
19
  } from "./config";
19
20
  import {
20
21
  DEFAULT_AI_CONCURRENCY,
21
22
  DEFAULT_AI_MAX_CALLS_PER_REVIEW,
22
- DEFAULT_AI_MAX_OUTPUT_TOKENS,
23
- DEFAULT_AI_MAX_STEPS,
24
23
  DEFAULT_AI_MODEL,
25
24
  DEFAULT_AI_PROVIDER,
26
25
  DEFAULT_AI_TIMEOUT_MS,
27
26
  DEFAULT_ALWAYS_INCLUDE_DOCS,
28
27
  DEFAULT_AST_MAX_FILE_BYTES,
29
28
  DEFAULT_AST_MAX_IMPACT_FILES,
29
+ DEFAULT_HUNT_CONCURRENCY,
30
+ DEFAULT_HUNT_RETRIES,
31
+ DEFAULT_HUNT_TIMEOUT_MS,
30
32
  DEFAULT_JEV_CONCURRENCY,
31
33
  DEFAULT_MAX_FILES_PER_BRIEF,
32
34
  DEFAULT_WORKTREES_LOCATION,
@@ -126,19 +128,6 @@ export class ProjectConfig<TId extends string = string> {
126
128
  return positive(this.ai.concurrency, DEFAULT_AI_CONCURRENCY);
127
129
  }
128
130
 
129
- get aiMaxSteps(): number {
130
- return positive(this.ai.maxSteps, DEFAULT_AI_MAX_STEPS);
131
- }
132
-
133
- get aiMaxOutputTokens(): number {
134
- return positive(this.ai.maxOutputTokens, DEFAULT_AI_MAX_OUTPUT_TOKENS);
135
- }
136
-
137
- get aiTemperature(): number {
138
- const value = this.ai.temperature;
139
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
140
- }
141
-
142
131
  /** Opções do AI SDK por provider (undefined quando ausentes/vazias). */
143
132
  get aiProviderOptions(): AiProviderOptions | undefined {
144
133
  const value = this.ai.providerOptions;
@@ -178,17 +167,38 @@ export class ProjectConfig<TId extends string = string> {
178
167
  return override && override.length > 0 ? override : this.aiModel;
179
168
  }
180
169
 
181
- /** Passos efetivos de um watchdog (override por watchdog ou global). */
182
- aiMaxStepsFor(watchdogId: string): number {
183
- return positive(this.ai.watchdogs?.[watchdogId]?.maxSteps, this.aiMaxSteps);
170
+ /** Config do harness local (objeto vazio quando ausente). */
171
+ get hunt(): HoundsmanHunt {
172
+ return this.raw.hunt ?? {};
173
+ }
174
+
175
+ /** Harness local habilitado (default: ligado; sem binário é fail-closed no comando). */
176
+ get huntEnabled(): boolean {
177
+ return this.hunt.enabled !== false;
178
+ }
179
+
180
+ /** Processos maki simultâneos. */
181
+ get huntConcurrency(): number {
182
+ return positive(this.hunt.concurrency, DEFAULT_HUNT_CONCURRENCY);
183
+ }
184
+
185
+ /** Timeout por watchdog (ms). */
186
+ get huntTimeoutMs(): number {
187
+ return positive(this.hunt.timeoutMs, DEFAULT_HUNT_TIMEOUT_MS);
188
+ }
189
+
190
+ /** Retentativas extras (0 desliga; valores negativos caem no default). */
191
+ get huntRetries(): number {
192
+ const value = this.hunt.retries;
193
+ return value !== undefined && Number.isInteger(value) && value >= 0
194
+ ? value
195
+ : DEFAULT_HUNT_RETRIES;
184
196
  }
185
197
 
186
- /** Tokens de saída efetivos de um watchdog. */
187
- aiMaxOutputTokensFor(watchdogId: string): number {
188
- return positive(
189
- this.ai.watchdogs?.[watchdogId]?.maxOutputTokens,
190
- this.aiMaxOutputTokens,
191
- );
198
+ /** Modelo base do harness local (sem prefixo de provider). */
199
+ get huntModel(): string {
200
+ const value = this.hunt.model?.trim();
201
+ return value && value.length > 0 ? value : this.aiModel;
192
202
  }
193
203
  }
194
204
 
@@ -282,12 +282,7 @@ function checkAiConfig(
282
282
  if (ai.enabled !== false && ai.model !== undefined && !ai.model.trim()) {
283
283
  warnings.push("ai.model vazio — usando o default.");
284
284
  }
285
- for (const key of [
286
- "concurrency",
287
- "maxSteps",
288
- "maxOutputTokens",
289
- "timeoutMs",
290
- ] as const) {
285
+ for (const key of ["concurrency", "timeoutMs"] as const) {
291
286
  const value = ai[key];
292
287
  if (value !== undefined && value <= 0) {
293
288
  warnings.push(`ai.${key} = ${value} — não-positivo; usando o default.`);
@@ -303,15 +298,10 @@ function checkAiConfig(
303
298
  `ai.maxTokensPerReview = ${ai.maxTokensPerReview} — negativo; sem teto.`,
304
299
  );
305
300
  }
306
- for (const [id, override] of Object.entries(ai.watchdogs ?? {})) {
301
+ for (const id of Object.keys(ai.watchdogs ?? {})) {
307
302
  if (!config.watchdogIds.includes(id)) {
308
303
  warnings.push(`ai.watchdogs['${id}'] não corresponde a nenhum watchdog.`);
309
304
  }
310
- if (override.maxSteps !== undefined && override.maxSteps <= 0) {
311
- warnings.push(
312
- `ai.watchdogs['${id}'].maxSteps não-positivo — usando o default.`,
313
- );
314
- }
315
305
  }
316
306
 
317
307
  const provider = ai.provider ?? "deepseek";
@@ -1,14 +1,10 @@
1
1
  /**
2
- * Texto de regra (biblioteca) — fonte única de polaridade e prefixos reservados.
2
+ * Texto de regra (biblioteca) — fonte única dos prefixos de polaridade.
3
3
  *
4
- * `RuleAnalyzer`, `registry-check` e o harness usam as mesmas listas: evita
5
- * divergência entre “Promover/Evitar” no analyzer e as validações anti-drift.
6
- * Também define o vocabulário de findings emergentes (lógica/domínio).
4
+ * `registry-check` usa a mesma lista para validar que a regra declara
5
+ * “Promover/Evitar” explicitamente.
7
6
  */
8
7
 
9
- /** Intenção de uma regra. */
10
- export type RuleIntent = "promote" | "avoid";
11
-
12
8
  /** Palavras que abrem uma regra de “deve acontecer”. */
13
9
  export const PROMOTE_PREFIXES = [
14
10
  "promover",
@@ -29,12 +25,6 @@ export const AVOID_PREFIXES = [
29
25
  "jamais",
30
26
  ] as const;
31
27
 
32
- /** Conjunto normalizado (usado para remover polaridade das keywords). */
33
- export const POLARITY_WORDS: ReadonlySet<string> = new Set([
34
- ...PROMOTE_PREFIXES,
35
- ...AVOID_PREFIXES,
36
- ]);
37
-
38
28
  /** Escapa um literal para uso em regex. */
39
29
  function escapeWord(word: string): string {
40
30
  return word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -48,50 +38,8 @@ function patternFor(words: readonly string[]): RegExp {
48
38
  const PROMOTE_PATTERN = patternFor(PROMOTE_PREFIXES);
49
39
  const AVOID_PATTERN = patternFor(AVOID_PREFIXES);
50
40
 
51
- /** Polaridade explícita; sem prefixo conhecido, assume `avoid`. */
52
- export function polarityOf(text: string): RuleIntent {
53
- const normalized = text.trim().toLowerCase();
54
- if (PROMOTE_PATTERN.test(normalized)) return "promote";
55
- if (AVOID_PATTERN.test(normalized)) return "avoid";
56
- return "avoid";
57
- }
58
-
59
41
  /** `true` quando a regra declara polaridade explícita. */
60
42
  export function hasExplicitPolarity(text: string): boolean {
61
43
  const normalized = text.trim().toLowerCase();
62
44
  return PROMOTE_PATTERN.test(normalized) || AVOID_PATTERN.test(normalized);
63
45
  }
64
-
65
- /** Prefixos reservados para findings emergentes (fora das regras explícitas). */
66
- export const EMERGENT_PREFIXES = {
67
- logic: "Lógica:",
68
- domain: "Domínio:",
69
- } as const;
70
-
71
- /** Espécie de finding emergente. */
72
- export type EmergentKind = keyof typeof EMERGENT_PREFIXES;
73
-
74
- /** Espécie de um finding emergente (`null` quando não é emergente). */
75
- export function emergentKind(rule: string): EmergentKind | null {
76
- const normalized = rule.trim();
77
- if (normalized.startsWith(EMERGENT_PREFIXES.logic)) return "logic";
78
- if (normalized.startsWith(EMERGENT_PREFIXES.domain)) return "domain";
79
- return null;
80
- }
81
-
82
- /** `true` quando a regra usa um prefixo emergente. */
83
- export function isEmergentRule(rule: string): boolean {
84
- return emergentKind(rule) !== null;
85
- }
86
-
87
- /** Texto que sobra após o prefixo (vazio é inválido). */
88
- export function emergentText(rule: string): string {
89
- const kind = emergentKind(rule);
90
- if (!kind) return "";
91
- return rule.trim().slice(EMERGENT_PREFIXES[kind].length).trim();
92
- }
93
-
94
- /** Monta o texto de um finding emergente. */
95
- export function emergentRule(kind: EmergentKind, text: string): string {
96
- return `${EMERGENT_PREFIXES[kind]} ${text.trim()}`;
97
- }
@@ -138,6 +138,14 @@ export class GitRepository {
138
138
  return parseNumstat(this.run(["diff", "--numstat", "-z", range]));
139
139
  }
140
140
 
141
+ /**
142
+ * Patch com contexto zero (`git diff -U0 range`), sem truncar por arquivo.
143
+ * É compacto e exato nas linhas — base do escopo do diff e do delta AST.
144
+ */
145
+ patchZeroContext(range: string): Map<string, string> {
146
+ return parseDiffIndex(this.run(["diff", "-U0", range]));
147
+ }
148
+
141
149
  /** Patch por arquivo de um intervalo, opcionalmente restrito a `paths`. */
142
150
  diffPatch(
143
151
  range: string,
@@ -110,7 +110,7 @@ export type ArtifactKind =
110
110
  | "ai"
111
111
  | "ai-usage"
112
112
  | "ai-brief"
113
- | "harness-transcript"
113
+ | "hunt"
114
114
  | "other";
115
115
 
116
116
  /** Artefato persistido de uma review. */
package/src/core/types.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { BriefPlan } from "./ai/schemas";
10
10
  import type { AstFileBrief } from "./ast/types";
11
+ import type { ChangedScope } from "./changed-scope";
11
12
  import type { JevRuleSpec } from "./jev/jev-types";
12
13
 
13
14
  /** Severidade de um achado. `blocking` bloqueia o merge. */
@@ -29,19 +30,6 @@ export const SEVERITY_EMOJI: Record<Severity, string> = {
29
30
  nit: "🔎",
30
31
  };
31
32
 
32
- /**
33
- * Sinais opcionais de uma regra: ajudam o `RuleAnalyzer` a transformar texto
34
- * em checagens concretas (símbolos, imports, decorators, paths e tools).
35
- */
36
- export type RuleHints = {
37
- symbols?: string[];
38
- imports?: string[];
39
- decorators?: string[];
40
- paths?: string[];
41
- /** Nomes de tools sugeridos (ex.: `symbol_context`). */
42
- checks?: string[];
43
- };
44
-
45
33
  /**
46
34
  * Regra do watchdog: severidade + texto. O texto declara a polaridade de forma
47
35
  * explícita — `Promover:` (algo que DEVE acontecer) ou `Evitar:`/`Não`/`Nunca`
@@ -54,7 +42,6 @@ export type Rule = {
54
42
  severity: Severity;
55
43
  text: string;
56
44
  jev?: JevRuleSpec;
57
- hints?: RuleHints;
58
45
  };
59
46
 
60
47
  /**
@@ -144,6 +131,8 @@ export type WatchdogRenderArgs = {
144
131
  ast?: readonly AstFileBrief[];
145
132
  /** Plano de revisão sugerido por IA (nunca substitui as regras). */
146
133
  aiPlan?: BriefPlan;
134
+ /** Escopo do diff por arquivo (símbolos alterados; hunks como fallback). */
135
+ changedScope?: ChangedScope;
147
136
  };
148
137
 
149
138
  /** Definição completa de um watchdog — 1 por arquivo em `project/watchdogs/`. */