@payconductor/houndsman 0.13.0 → 0.14.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 (106) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/hunt-command.ts +20 -3
  7. package/src/cli/commands/review-command.ts +38 -0
  8. package/src/cli/commands/status-command.ts +3 -2
  9. package/src/cli/help.ts +3 -1
  10. package/src/cli/index.ts +3 -1
  11. package/src/cli/progress.ts +7 -2
  12. package/src/core/ai/ai-client.ts +38 -2
  13. package/src/core/ai/features/brief-enricher.ts +1 -1
  14. package/src/core/ai/features/business-analyzer.ts +61 -0
  15. package/src/core/ai/features/business-summarizer.ts +44 -0
  16. package/src/core/ai/index.ts +8 -0
  17. package/src/core/ai/prompts/brief.ts +33 -7
  18. package/src/core/ai/prompts/business.ts +76 -0
  19. package/src/core/ai/provider-capabilities.ts +12 -0
  20. package/src/core/ai/schemas.ts +70 -2
  21. package/src/core/ai/usage-recorder.ts +53 -1
  22. package/src/core/brief.ts +246 -12
  23. package/src/core/builtins.ts +108 -0
  24. package/src/core/business-summary.ts +583 -0
  25. package/src/core/commands/compare.ts +233 -0
  26. package/src/core/commands/context.ts +6 -2
  27. package/src/core/commands/findings.ts +38 -30
  28. package/src/core/commands/review.ts +6 -0
  29. package/src/core/commands/scaffold.ts +27 -0
  30. package/src/core/commands/status.ts +9 -4
  31. package/src/core/compare.ts +243 -0
  32. package/src/core/concurrency.ts +30 -0
  33. package/src/core/config.ts +31 -3
  34. package/src/core/defaults.ts +50 -4
  35. package/src/core/domain/budget.ts +62 -4
  36. package/src/core/domain/classification.ts +12 -2
  37. package/src/core/engine/carryover.ts +4 -0
  38. package/src/core/engine/finalize-pipeline.ts +347 -0
  39. package/src/core/engine/prepare-pipeline.ts +1294 -0
  40. package/src/core/engine/review-context.ts +51 -0
  41. package/src/core/engine/review-options.ts +47 -0
  42. package/src/core/engine/review-scope.ts +14 -1
  43. package/src/core/engine/review-session.ts +11 -13
  44. package/src/core/engine/run-artifacts.ts +196 -0
  45. package/src/core/engine/run-format.ts +4 -0
  46. package/src/core/findings-state.ts +13 -1
  47. package/src/core/github-review.ts +5 -2
  48. package/src/core/hunt/hunt-runner.ts +90 -0
  49. package/src/core/hunt/index.ts +2 -0
  50. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  51. package/src/core/hunt/maki-prompt.ts +13 -1
  52. package/src/core/hunt/pool.ts +4 -26
  53. package/src/core/logging/index.ts +6 -1
  54. package/src/core/logging/progress.ts +85 -1
  55. package/src/core/orchestrator.ts +33 -1289
  56. package/src/core/paths.ts +6 -3
  57. package/src/core/pr-context.ts +2 -4
  58. package/src/core/prepare-report.ts +61 -0
  59. package/src/core/project-config.ts +8 -1
  60. package/src/core/propagation.ts +127 -0
  61. package/src/core/registry-check.ts +60 -21
  62. package/src/core/renderers/manifest-writer.ts +13 -0
  63. package/src/core/renderers/resume-renderer.ts +151 -11
  64. package/src/core/report-builder.ts +108 -278
  65. package/src/core/storage/file/file-storage.ts +7 -3
  66. package/src/core/storage/sqlite/mappers.ts +10 -0
  67. package/src/core/storage/sqlite/schema.ts +104 -23
  68. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  69. package/src/core/storage/types.ts +16 -4
  70. package/src/core/storage/work-area.ts +59 -1
  71. package/src/core/types.ts +31 -0
  72. package/src/core/watchdog-registry.ts +21 -3
  73. package/src/index.ts +29 -4
  74. package/src/server/client/ArtifactsPanel.tsx +4 -2
  75. package/src/server/client/FindingCard.tsx +21 -1
  76. package/src/server/client/RunsSection.tsx +5 -0
  77. package/src/server/client/utils/constants.ts +21 -11
  78. package/src/server/client/utils/markdown.ts +1 -1
  79. package/src/server/client/utils/types.ts +14 -2
  80. package/src/server/run-repository.ts +35 -14
  81. package/src/server/styles.css +2 -0
  82. package/src/skill/OUTPUT-FORMAT.md +36 -10
  83. package/src/skill/README.md +18 -14
  84. package/src/skill/SETUP.md +1 -0
  85. package/src/skill/SKILL.md +13 -4
  86. package/src/skill/example-resume.md +43 -0
  87. package/src/skill/hunt.md +145 -58
  88. package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
  89. package/src/tests/ai-core.test.ts +46 -0
  90. package/src/tests/ai-features.test.ts +95 -4
  91. package/src/tests/baseline.test.ts +29 -121
  92. package/src/tests/brief-scope.test.ts +250 -0
  93. package/src/tests/business-summary.test.ts +258 -0
  94. package/src/tests/compare.test.ts +233 -0
  95. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  96. package/src/tests/hunt-maki.test.ts +58 -0
  97. package/src/tests/markdown.test.ts +3 -3
  98. package/src/tests/progress.test.ts +29 -1
  99. package/src/tests/propagation.test.ts +119 -0
  100. package/src/tests/review-resume.test.ts +60 -1
  101. package/src/tests/review-scope.test.ts +38 -29
  102. package/src/tests/review-scripts.test.ts +106 -218
  103. package/src/tests/server.test.ts +7 -4
  104. package/src/tests/storage.test.ts +194 -9
  105. package/src/core/renderers/report-renderer.ts +0 -66
  106. package/src/skill/example-review.md +0 -66
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { AstFileBrief } from "../../ast/types";
9
+ import { type BusinessSummary, renderBusinessLines } from "../../business-summary";
10
+ import type { ChangedScope } from "../../changed-scope";
9
11
  import type { ChurnMap, Rule, WatchdogRenderContext } from "../../types";
10
12
  import { buildSystem } from "./base";
11
13
 
@@ -21,6 +23,12 @@ export type BriefPromptInput = {
21
23
  context?: WatchdogRenderContext | null;
22
24
  ast?: readonly AstFileBrief[];
23
25
  docs?: readonly string[];
26
+ /** Modelo de intenção da PR (contrato esperado). */
27
+ business?: BusinessSummary | null;
28
+ /** Trechos alterados — a única fonte real para verificar lógica. */
29
+ snippets?: readonly { file: string; snippet: string }[];
30
+ /** Faixas revisáveis por arquivo. */
31
+ changedScope?: ChangedScope;
24
32
  };
25
33
 
26
34
  /** Instruções de sistema do enriquecimento. */
@@ -29,11 +37,13 @@ export function briefInstructions(): string {
29
37
  "Você é um revisor sênior que prepara um plano de leitura para um watchdog (revisor) especializado.",
30
38
  [
31
39
  "As regras fornecidas são o PISO da revisão, não o teto.",
32
- "Além delas, procure riscos de consistência lógica: nome/contrato vs comportamento (ex.: `fazA` que faz `fazB`), fluxos incompletos, estados inválidos, off-by-one e bordas.",
33
- "Procure também riscos do domínio de negócio: transições de estado, idempotência, arredondamento, timezone, autorização e invariantes implícitas.",
34
- "Sugira foco de leitura (até 5), perguntas de verificação (até 3), riscos (até 3), verificações de lógica (até 3) e de domínio (até 3).",
40
+ "Derive cada verificação dos trechos alterados fornecidos — não apenas do nome do arquivo.",
41
+ "Toda verificação cita arquivo e, quando possível, símbolo; sem âncora, não sugira.",
42
+ "Além das regras, procure riscos de consistência lógica: nome/contrato vs comportamento (ex.: `fazA` que faz `fazB`), fluxos incompletos, estados inválidos, off-by-one e bordas.",
43
+ "Procure também riscos do domínio de negócio: transições de estado, idempotência, arredondamento, timezone, autorização, paridade de capacidade e invariantes implícitas.",
44
+ "Sugira foco de leitura (até 5), perguntas (até 3), riscos (até 3), verificações de lógica (até 4) e de domínio (até 4).",
35
45
  "Sugira verificações, nunca afirme violação; não crie regra nova nem invente arquivo, linha ou achado.",
36
- "Se uma suspeita não couber em nenhuma regra, coloque-a em `logic`/`domain` como pergunta de verificação.",
46
+ "Se uma suspeita não couber em nenhuma regra, coloque-a em `logic`/`domain` como verificação ancorada.",
37
47
  "Não repita o texto literal das regras; o brief já as contém.",
38
48
  ],
39
49
  );
@@ -62,6 +72,15 @@ export function briefPrompt(input: BriefPromptInput): string {
62
72
  lines.push(`Motivação: ${input.context.motivation}`);
63
73
  for (const item of input.context.changeSummary ?? []) lines.push(`- ${item}`);
64
74
  }
75
+ if (input.business) {
76
+ const business = renderBusinessLines(input.business, {
77
+ flows: 8,
78
+ invariants: 8,
79
+ contracts: 8,
80
+ states: 4,
81
+ });
82
+ if (business.length > 0) lines.push("", "## Modelo de intenção", ...business);
83
+ }
65
84
  if (input.ast && input.ast.length > 0) {
66
85
  lines.push("", "## Estrutura (AST)");
67
86
  for (const entry of input.ast) {
@@ -71,15 +90,22 @@ export function briefPrompt(input: BriefPromptInput): string {
71
90
  );
72
91
  }
73
92
  }
93
+ if (input.snippets && input.snippets.length > 0) {
94
+ lines.push("", "## Trechos alterados (diff)");
95
+ for (const item of input.snippets.slice(0, 12)) {
96
+ lines.push(`### ${item.file}`, "```diff", item.snippet.trimEnd(), "```");
97
+ }
98
+ }
74
99
  if (input.docs && input.docs.length > 0) {
75
100
  lines.push("", "## Docs disponíveis", ...input.docs.map((doc) => `- ${doc}`));
76
101
  }
77
102
  lines.push(
78
103
  "",
79
104
  "## Revisão além das regras",
80
- "As regras são o piso. Aponte verificações de consistência lógica e de domínio",
81
- "que mereçam inspeção mesmo sem regra explícita (nome vs comportamento, estados,",
82
- "transições, idempotência, bordas). Sugira verificações; não afirme violações.",
105
+ "As regras são o piso. Aponte verificações de lógica e domínio ancoradas nos",
106
+ "trechos alterados (nome vs comportamento, estados, transições, idempotência,",
107
+ "paridade de capacidade, credencial, bordas). Sugira verificações; não afirme",
108
+ "violações.",
83
109
  "",
84
110
  "Produza o plano de revisão em JSON (focus, questions, risks, logic, domain).",
85
111
  );
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Prompt do modelo de intenção (`AiBusinessSummarizer`) e das hipóteses
3
+ * (`AiBusinessAnalyzer`).
4
+ *
5
+ * Os dois derivam **do diff**, nunca da prosa da PR. O primeiro descreve o
6
+ * contrato esperado; o segundo aponta suspeitas para o revisor `general`
7
+ * confirmar ou refutar (nunca viram achado sozinhas).
8
+ */
9
+
10
+ import { buildSystem, clip, OUTPUT_LANGUAGE_RULE } from "./base";
11
+
12
+ /** Instruções do resumo de negócio. */
13
+ export function businessInstructions(): string {
14
+ return buildSystem(
15
+ "Você reconstrói a intenção de uma Pull Request a partir do diff real.",
16
+ [
17
+ OUTPUT_LANGUAGE_RULE,
18
+ "Descreva o comportamento que a PR promete entregar, não a estrutura do código.",
19
+ "Derive do diff fornecido: fluxos, invariantes, contratos e estados.",
20
+ "Paridade de capacidade é prioridade: se um lado declara feature/método/evento, verifique se o outro lado o honra.",
21
+ "Fluxos de ciclo de vida contam: criação, renovação, cancelamento, retry, webhook e erro.",
22
+ "Registre o que não estiver claro em `openQuestions`; não invente.",
23
+ "NÃO produza achados, severidade, arquivo:linha ou recomendação de correção.",
24
+ "Seja específico e curto: nomes de fluxo/contrato citam símbolos e arquivos reais.",
25
+ ],
26
+ );
27
+ }
28
+
29
+ /** Prompt do usuário com o payload compactado. */
30
+ export function businessPrompt(payload: unknown): string {
31
+ return [
32
+ "Reconstrua a intenção da PR abaixo.",
33
+ "",
34
+ "```json",
35
+ clip(JSON.stringify(payload), 24_000),
36
+ "```",
37
+ "",
38
+ "Produza o modelo de intenção em JSON.",
39
+ ].join("\n");
40
+ }
41
+
42
+ /** Instruções das hipóteses de lógica/domínio. */
43
+ export function businessAnalysisInstructions(): string {
44
+ return buildSystem(
45
+ "Você aponta suspeitas de gap de lógica/domínio para um revisor confirmar.",
46
+ [
47
+ OUTPUT_LANGUAGE_RULE,
48
+ "Use o modelo de intenção e os trechos alterados fornecidos.",
49
+ "Cada hipótese precisa citar um arquivo do escopo e uma afirmação concreta.",
50
+ "Prefira gaps de fluxo: caminho incompleto, capacidade declarada e não honrada, credencial/contexto divergente, estado sem tratamento, identificador trocado.",
51
+ "Confiança calibrada (0–1); abaixo de 0.5 não vale reportar.",
52
+ '`kind: "domain"` para invariantes/fluxo de negócio; `kind: "logic"` para nome/contrato vs comportamento.',
53
+ "NÃO invente arquivos e NÃO produza severidade — isto não é um achado.",
54
+ "No máximo 12 hipóteses.",
55
+ ],
56
+ );
57
+ }
58
+
59
+ /** Prompt do usuário com intenção + trechos alterados. */
60
+ export function businessAnalysisPrompt(business: unknown, payload: unknown): string {
61
+ return [
62
+ "## Modelo de intenção",
63
+ "",
64
+ "```json",
65
+ clip(JSON.stringify(business), 8_000),
66
+ "```",
67
+ "",
68
+ "## Diff compactado",
69
+ "",
70
+ "```json",
71
+ clip(JSON.stringify(payload), 16_000),
72
+ "```",
73
+ "",
74
+ "Produza as hipóteses em JSON.",
75
+ ].join("\n");
76
+ }
@@ -43,3 +43,15 @@ export function temperatureApplies(
43
43
  if (provider !== "deepseek") return true;
44
44
  return !deepSeekThinkingEnabled(modelId, providerOptions);
45
45
  }
46
+
47
+ /**
48
+ * Opções para tarefas estruturadas curtas (resumo, plano, classificação).
49
+ *
50
+ * O thinking do DeepSeek V4 consome o teto de saída e dominava a latência: a
51
+ * resposta JSON truncava (`finishReason: "length"`) e a feature caía no
52
+ * fallback sem produzir nada. Desligar o raciocínio nessas chamadas mantém o
53
+ * schema e corta a latência. Providers não-DeepSeek ignoram a chave.
54
+ */
55
+ export const LEAN_PROVIDER_OPTIONS: AiProviderOptions = {
56
+ deepseek: { thinking: { type: "disabled" } },
57
+ };
@@ -43,18 +43,86 @@ export const TechnicalSummarySchema = z.object({
43
43
  });
44
44
  export type TechnicalSummaryPlan = z.infer<typeof TechnicalSummarySchema>;
45
45
 
46
+ /** Verificação sugerida — sempre ancorada em arquivo/símbolo quando possível. */
47
+ export const BriefCheckSchema = z.object({
48
+ statement: z.string().min(1).max(240),
49
+ file: z.string().max(200).optional(),
50
+ symbol: z.string().max(120).optional(),
51
+ /** Invariante que a verificação protege (`domain`). */
52
+ invariant: z.string().max(200).optional(),
53
+ });
54
+ export type BriefCheck = z.infer<typeof BriefCheckSchema>;
55
+
46
56
  /** Plano de revisão sugerido por IA para um brief. */
47
57
  export const BriefPlanSchema = z.object({
48
58
  focus: z.array(z.string()).max(5).default([]),
49
59
  questions: z.array(z.string()).max(3).default([]),
50
60
  risks: z.array(z.string()).max(3).default([]),
51
61
  /** Verificações de consistência lógica (fora das regras explícitas). */
52
- logic: z.array(z.string()).max(3).default([]),
62
+ logic: z.array(BriefCheckSchema).max(4).default([]),
53
63
  /** Riscos do domínio de negócio (fora das regras explícitas). */
54
- domain: z.array(z.string()).max(3).default([]),
64
+ domain: z.array(BriefCheckSchema).max(4).default([]),
55
65
  });
56
66
  export type BriefPlan = z.infer<typeof BriefPlanSchema>;
57
67
 
68
+ /** Modelo de intenção da PR (`business-summary.json`). */
69
+ export const BusinessSummarySchema = z.object({
70
+ intent: z.string().max(800).optional(),
71
+ flows: z
72
+ .array(
73
+ z.object({
74
+ name: z.string().min(1).max(120),
75
+ trigger: z.string().max(240).optional(),
76
+ expected: z.string().max(400).optional(),
77
+ files: z.array(z.string().max(200)).max(20).optional(),
78
+ }),
79
+ )
80
+ .max(12)
81
+ .default([]),
82
+ invariants: z.array(z.string().max(300)).max(12).default([]),
83
+ contracts: z
84
+ .array(
85
+ z.object({
86
+ name: z.string().min(1).max(120),
87
+ sides: z.array(z.string().max(160)).max(8).optional(),
88
+ expectation: z.string().max(400).optional(),
89
+ }),
90
+ )
91
+ .max(12)
92
+ .default([]),
93
+ states: z
94
+ .array(
95
+ z.object({
96
+ name: z.string().min(1).max(120),
97
+ transitions: z.array(z.string().max(200)).max(12).optional(),
98
+ }),
99
+ )
100
+ .max(8)
101
+ .default([]),
102
+ nonGoals: z.array(z.string().max(240)).max(8).optional(),
103
+ openQuestions: z.array(z.string().max(300)).max(8).optional(),
104
+ });
105
+ export type BusinessSummaryPlan = z.infer<typeof BusinessSummarySchema>;
106
+
107
+ /** Hipóteses de lógica/domínio (candidatos, nunca achados). */
108
+ export const BusinessHypothesesSchema = z.object({
109
+ hypotheses: z
110
+ .array(
111
+ z.object({
112
+ file: z.string().min(1),
113
+ kind: z.enum(["logic", "domain"]),
114
+ statement: z.string().min(1).max(300),
115
+ invariant: z.string().max(200).optional(),
116
+ confidence: z.number().min(0).max(1),
117
+ }),
118
+ )
119
+ .max(12)
120
+ .default([]),
121
+ });
122
+ export type BusinessHypothesis = z.infer<
123
+ typeof BusinessHypothesesSchema
124
+ >["hypotheses"][number];
125
+
58
126
  /** Classificação de arquivos não classificados. */
59
127
  export const ClassificationSchema = z.object({
60
128
  assignments: z
@@ -17,17 +17,36 @@ export type AiUsageEntry = {
17
17
  usage: AiUsage;
18
18
  };
19
19
 
20
+ /** Chamada que falhou (timeout, truncamento, schema) — não consome tokens. */
21
+ export type AiFailureEntry = {
22
+ feature: string;
23
+ model: string;
24
+ at: string;
25
+ durationMs: number;
26
+ finishReason?: string;
27
+ error: string;
28
+ };
29
+
30
+ /** Uso agregado + tentativas/falhas (vai para `prepare-report.json`). */
31
+ export type AiUsageSummary = AiUsage & {
32
+ attempts: number;
33
+ failures: AiFailureEntry[];
34
+ };
35
+
20
36
  /** Formato persistido em `ai-usage.json`. */
21
37
  export type AiUsageFile = {
22
38
  provider: string;
23
39
  model: string;
24
40
  total: AiUsage;
41
+ attempts: number;
25
42
  entries: AiUsageEntry[];
43
+ failures: AiFailureEntry[];
26
44
  };
27
45
 
28
46
  /** Acumulador de uso (em memória) com persistência opcional. */
29
47
  export class AiUsageRecorder {
30
48
  private readonly entries: AiUsageEntry[] = [];
49
+ private readonly failed: AiFailureEntry[] = [];
31
50
  private readonly provider: string;
32
51
  private readonly model: string;
33
52
 
@@ -41,7 +60,23 @@ export class AiUsageRecorder {
41
60
  this.entries.push({ feature, model, at: systemClock(), usage: { ...usage } });
42
61
  }
43
62
 
44
- /** Total agregado de todas as chamadas. */
63
+ /** Registra uma tentativa que falhou (fail-open: nunca derruba a revisão). */
64
+ recordFailure(
65
+ feature: string,
66
+ model: string,
67
+ detail: { durationMs: number; error: string; finishReason?: string },
68
+ ): void {
69
+ this.failed.push({
70
+ feature,
71
+ model,
72
+ at: systemClock(),
73
+ durationMs: detail.durationMs,
74
+ error: detail.error,
75
+ ...(detail.finishReason ? { finishReason: detail.finishReason } : {}),
76
+ });
77
+ }
78
+
79
+ /** Total agregado de todas as chamadas concluídas. */
45
80
  get total(): AiUsage {
46
81
  return this.entries.reduce(
47
82
  (acc, entry) => addUsage(acc, entry.usage),
@@ -49,13 +84,30 @@ export class AiUsageRecorder {
49
84
  );
50
85
  }
51
86
 
87
+ /** Tentativas (sucesso + falha) — a métrica honesta de custo/latência. */
88
+ get attempts(): number {
89
+ return this.entries.length + this.failed.length;
90
+ }
91
+
92
+ /** Falhas registradas, na ordem em que ocorreram. */
93
+ get failures(): readonly AiFailureEntry[] {
94
+ return this.failed;
95
+ }
96
+
97
+ /** Resumo para o `prepare-report.json`. */
98
+ get summary(): AiUsageSummary {
99
+ return { ...this.total, attempts: this.attempts, failures: [...this.failed] };
100
+ }
101
+
52
102
  /** Snapshot serializável. */
53
103
  toJSON(): AiUsageFile {
54
104
  return {
55
105
  provider: this.provider,
56
106
  model: this.model,
57
107
  total: this.total,
108
+ attempts: this.attempts,
58
109
  entries: [...this.entries],
110
+ failures: [...this.failed],
59
111
  };
60
112
  }
61
113
 
package/src/core/brief.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { AstDelta } from "./ast/types";
9
+ import { GENERAL_WATCHDOG_ID } from "./builtins";
10
+ import { type BusinessSummary, renderBusinessLines } from "./business-summary";
9
11
  import { formatRanges, formatSymbols } from "./changed-scope";
10
12
  import {
11
13
  type Rule,
@@ -19,15 +21,49 @@ import {
19
21
 
20
22
  /**
21
23
  * Procedimento de revisão — compartilhado por todos os watchdogs.
24
+ *
25
+ * O eixo é intenção → escopo → rastreio → prova: as regras continuam sendo o
26
+ * piso, mas a revisão começa entendendo o que a mudança deve entregar.
22
27
  */
23
28
  export const PROCEDURE: readonly string[] = [
24
- "1. **FILTER** — descarte o que não for do seu domínio (ver *Prioridade de leitura* e *Não carregar*).",
25
- "2. **READ SCOPE** — carregue apenas as faixas alteradas em *Escopo do diff*; o restante do arquivo é só contexto.",
26
- "3. **GREP** — use as regras críticas/anti-padrões como heurística para localizar violações nas faixas alteradas.",
27
- "4. **DEEP-READ** — aprofunde apenas as faixas com suspeita de violação dentro do escopo.",
28
- '5. **CONCLUDE** — grave o JSON no caminho de output, com `reviewedFiles` preenchido e `line` dentro das faixas alteradas. Sem achados → `"findings": []` (arquivo obrigatório). Não escreva markdown livre.',
29
+ "1. **RECONSTRUCT** — leia o recorte do *Modelo de intenção (negócio)* e diga para si mesmo o que a mudança DEVE entregar (fluxos, invariantes, contratos). Sem modelo, derive do contexto da PR.",
30
+ "2. **SCOPE** — os *Trechos alterados (diff)* já estão no brief: **não releia o arquivo inteiro** para vê-los. Trecho com o marcador `… [trecho truncado]` pode ser aberto, uma vez, para o restante.",
31
+ "3. **TRACE** — siga cada símbolo alterado até seus chamadores, adapters e tipos compartilhados **dentro do seu domínio** (use os importadores do AST), **dentro do orçamento de exploração** (seção acima). Priorize os arquivos de maior churn.",
32
+ "4. **GREP** — use as regras críticas/anti-padrões como heurística para localizar violações nas faixas alteradas, **dentro do orçamento**. Pare quando tiver a prova: repetir busca não aumenta certeza.",
33
+ "5. **PROVE** — para cada suspeita, descreva gatilho → comportamento observado → invariante/regra violada. Sem prova, não é achado. Trecho com o marcador de truncamento só sustenta achado depois de abrir o arquivo para ver o restante.",
34
+ '6. **CONCLUDE** — grave o JSON no caminho de output. `reviewedFiles` é um subconjunto de `## Arquivos do seu domínio` (só o que você revisou; **não** liste chamadores/importadores nem arquivos de outros domínios) e o que ficou de fora entra em `skipped` ("budget", "truncated" ou "fora do fluxo"). Máximo de 20 achados: priorize severidade e agrupe ocorrências idênticas. `line` dentro das faixas alteradas. Sem achados → `"findings": []` (arquivo obrigatório). Não escreva markdown livre.',
29
35
  ];
30
36
 
37
+ /**
38
+ * Orçamento de exploração por brief.
39
+ *
40
+ * O diff já cobre o essencial; o resto é para chamador/contexto de um símbolo
41
+ * citado. Sem teto, o subagente entrava em loop de leitura e cada brief levava
42
+ * minutos — o `general` era o pior caso (PR inteira).
43
+ */
44
+ const EXPLORATION_BUDGET = {
45
+ general: { reads: 10, searches: 10 },
46
+ domain: { reads: 6, searches: 6 },
47
+ } as const;
48
+
49
+ /** Seção com o teto de leituras/buscas do brief (visível para o modelo). */
50
+ function explorationSection(args: WatchdogRenderArgs): string[] {
51
+ const budget =
52
+ args.watchdogId === GENERAL_WATCHDOG_ID
53
+ ? EXPLORATION_BUDGET.general
54
+ : EXPLORATION_BUDGET.domain;
55
+ return [
56
+ "## Orçamento de exploração",
57
+ "",
58
+ `> O diff já está no brief. Limite **neste brief**: ${budget.reads} leitura(s) de arquivo`,
59
+ `> e ${budget.searches} busca(s) (grep).`,
60
+ "> Abra arquivo só para chamador/contexto de um símbolo citado nos trechos.",
61
+ "> Se a prova não estiver no brief nem nesse orçamento, **não é achado** —",
62
+ "> registre a dúvida no campo `action` do finding mais próximo ou descarte.",
63
+ "",
64
+ ];
65
+ }
66
+
31
67
  /** Limites do contexto injetado em cada brief (o resumo final é maior). */
32
68
  const BRIEF_CONTEXT_LIMITS = {
33
69
  objective: 200,
@@ -157,18 +193,18 @@ function fileItems(args: WatchdogRenderArgs): string[] {
157
193
  }
158
194
 
159
195
  /**
160
- * Arquivos do domínio fora do orçamento (triage de PRs grandes): o watchdog NÃO
196
+ * Arquivos do domínio fora do recorte (orçamento ou fluxo): o watchdog NÃO
161
197
  * deve lê-los, mas precisa registrá-los em `skipped` — cobertura honesta.
162
198
  */
163
199
  function overflowSection(args: WatchdogRenderArgs): string[] {
164
200
  const overflow = args.overflow ?? [];
165
201
  if (overflow.length === 0) return [];
166
202
  return [
167
- "## Fora do orçamento (NÃO revisar)",
203
+ "## Fora do recorte (NÃO revisar)",
168
204
  "",
169
- `> ${overflow.length} arquivo(s) do seu domínio ficaram fora do orçamento`,
170
- "> (menor churn). Não os leia: registre-os em `skipped` com o motivo",
171
- '> "budget" para que a cobertura do relatório fique honesta.',
205
+ `> ${overflow.length} arquivo(s) do escopo ficaram fora do recorte`,
206
+ "> (orçamento ou prioridade de fluxo). Não os leia: registre-os em `skipped`",
207
+ "> com o motivo (`budget` ou `fora do fluxo`) para que a cobertura fique honesta.",
172
208
  "",
173
209
  ...overflow.map(codeItem),
174
210
  "",
@@ -227,6 +263,143 @@ function deltaLines(delta: AstDelta): string[] {
227
263
  return lines;
228
264
  }
229
265
 
266
+ /** Rótulo curto da fonte do modelo de intenção. */
267
+ const BUSINESS_SOURCE_LABEL: Record<BusinessSummary["source"], string> = {
268
+ ai: "IA",
269
+ reviewer: "revisor de fluxo",
270
+ deterministic: "determinístico",
271
+ };
272
+
273
+ /**
274
+ * Recorte do modelo de intenção: só fluxos que tocam os arquivos do watchdog.
275
+ *
276
+ * O modelo é global; injetá-lo inteiro em cada brief fazia o subagente
277
+ * perseguir fluxos de outros domínios (mais leituras e buscas sem achado).
278
+ */
279
+ function scopeBusinessToFiles(
280
+ business: BusinessSummary,
281
+ files: readonly string[],
282
+ ): BusinessSummary {
283
+ if (files.length === 0) return business;
284
+ // O modelo vem da IA e pode usar `./`, `\` ou caminho absoluto: normaliza
285
+ // antes de comparar para não perder um fluxo do próprio domínio.
286
+ const normalize = (value: string): string =>
287
+ value.replace(/\\/g, "/").replace(/^\.\//, "");
288
+ const own = new Set(files.map(normalize));
289
+ const flows = business.flows.filter(
290
+ (flow) =>
291
+ !flow.files ||
292
+ flow.files.length === 0 ||
293
+ flow.files.some((file) => own.has(normalize(file))),
294
+ );
295
+ if (flows.length === business.flows.length) return business;
296
+ return { ...business, flows };
297
+ }
298
+
299
+ /**
300
+ * Modelo de intenção da PR — o contrato esperado antes de julgar o código.
301
+ *
302
+ * Injetado em todos os briefs; o revisor `general` recebe o modelo completo, os
303
+ * watchdogs de domínio recebem só o recorte dos seus arquivos.
304
+ */
305
+ function businessSection(args: WatchdogRenderArgs): string[] {
306
+ const business = args.business;
307
+ if (!business) return [];
308
+ const isGeneral = args.watchdogId === GENERAL_WATCHDOG_ID;
309
+ const scoped = isGeneral
310
+ ? business
311
+ : { ...scopeBusinessToFiles(business, args.files), openQuestions: [] };
312
+ const omitted = business.flows.length - scoped.flows.length;
313
+ // O caminho canônico só interessa a quem reconstrói o modelo (`general`):
314
+ // nos demais, citá-lo induzia uma leitura extra do JSON completo.
315
+ const canonical =
316
+ args.businessSummaryPath && (isGeneral || args.flowReview)
317
+ ? ` · \`${args.businessSummaryPath}\` (versão canônica no staging)`
318
+ : "";
319
+ const lines = [
320
+ "## Modelo de intenção (negócio)",
321
+ "",
322
+ `> Fonte: ${BUSINESS_SOURCE_LABEL[business.source]}${canonical}. Use para saber o que a mudança **deve** entregar; não é achado.`,
323
+ "",
324
+ ];
325
+ if (!isGeneral) {
326
+ lines.push(
327
+ "> Recorte do seu domínio: fluxos/arquivos de outros domínios pertencem a outros watchdogs — não os investigue.",
328
+ ...(omitted > 0
329
+ ? [`> ${omitted} fluxo(s) fora do seu domínio foram omitidos.`]
330
+ : []),
331
+ "",
332
+ );
333
+ }
334
+ const rendered = renderBusinessLines(
335
+ scoped,
336
+ isGeneral
337
+ ? { flows: 12, invariants: 12, contracts: 12, states: 8 }
338
+ : { flows: 3, invariants: 6, contracts: 2, states: 2 },
339
+ );
340
+ while (rendered[0] === "") rendered.shift();
341
+ lines.push(...rendered, "");
342
+ return lines;
343
+ }
344
+
345
+ /** Trechos alterados (diff) — todos os watchdogs recebem o patch do seu domínio. */
346
+ function snippetsSection(args: WatchdogRenderArgs): string[] {
347
+ const snippets = args.snippets ?? [];
348
+ if (snippets.length === 0) return [];
349
+ const lines = [
350
+ "## Trechos alterados (diff)",
351
+ "",
352
+ "> Trechos já extraídos do diff (`-U0`, só linhas alteradas). Eles bastam",
353
+ "> para achar violações: **não abra os arquivos apenas para reler o que já",
354
+ "> está aqui**. Use o arquivo só para contexto adjacente ou chamadores.",
355
+ "",
356
+ ];
357
+ for (const item of snippets) {
358
+ lines.push(
359
+ `### \`${item.file}\``,
360
+ "",
361
+ "```diff",
362
+ item.snippet.trimEnd(),
363
+ "```",
364
+ "",
365
+ );
366
+ }
367
+ return lines;
368
+ }
369
+
370
+ /**
371
+ * Obrigação de output do revisor de fluxo: ele reconstrói e persiste o modelo
372
+ * de intenção (ou devolve `businessSummary` no harness local).
373
+ */
374
+ function flowReviewSection(args: WatchdogRenderArgs): string[] {
375
+ if (!args.flowReview) return [];
376
+ const output = args.businessSummaryPath ?? "business-summary.json";
377
+ return [
378
+ "## Obrigação de output (fluxo)",
379
+ "",
380
+ "1. **Recomece pelo modelo**: leia o diff e reconstrua intenção, fluxos, invariantes,",
381
+ " contratos e estados. Se o modelo existente estiver incompleto, corrija-o.",
382
+ `2. **Persista** o modelo em \`${output}\` com \`source: "reviewer"\`.`,
383
+ " No harness local (maki), devolva o mesmo objeto em `businessSummary` no JSON.",
384
+ "3. **Prove cada gap**: fora de regra, preencha `scenario` (gatilho concreto),",
385
+ " `impact` (consequência observável) e `evidence` (trecho). Sem `scenario` + `evidence`,",
386
+ " a severidade máxima é `attention`.",
387
+ "",
388
+ ];
389
+ }
390
+
391
+ /** Linha de verificação (string legada ou objeto ancorado). */
392
+ function checkItem(
393
+ item:
394
+ | string
395
+ | { statement: string; file?: string; symbol?: string; invariant?: string },
396
+ ): string {
397
+ if (typeof item === "string") return `- ${item}`;
398
+ const anchor = [item.file, item.symbol].filter(Boolean).join(" · ");
399
+ const invariant = item.invariant ? ` (invariante: ${item.invariant})` : "";
400
+ return `- ${item.statement}${anchor ? ` — \`${anchor}\`` : ""}${invariant}`;
401
+ }
402
+
230
403
  /**
231
404
  * Plano de revisão sugerido por IA — complementa (nunca substitui) as regras.
232
405
  */
@@ -271,17 +444,72 @@ function aiPlanSection(args: WatchdogRenderArgs): string[] {
271
444
  }
272
445
  if (logic.length > 0) {
273
446
  lines.push("**Consistência lógica:**", "");
274
- for (const item of logic.slice(0, 3)) lines.push(`- ${item}`);
447
+ for (const item of logic.slice(0, 4)) lines.push(checkItem(item));
275
448
  lines.push("");
276
449
  }
277
450
  if (domain.length > 0) {
278
451
  lines.push("**Domínio de negócio:**", "");
279
- for (const item of domain.slice(0, 3)) lines.push(`- ${item}`);
452
+ for (const item of domain.slice(0, 4)) lines.push(checkItem(item));
280
453
  lines.push("");
281
454
  }
282
455
  return lines;
283
456
  }
284
457
 
458
+ /**
459
+ * Micro-alterações iguais em muitos arquivos: mostra o padrão dominante e os
460
+ * outliers em vez de N diffs de uma linha.
461
+ */
462
+ function propagationSection(args: WatchdogRenderArgs): string[] {
463
+ const cluster = args.propagation;
464
+ if (!cluster || cluster.files.length === 0) return [];
465
+ const sample = cluster.files.slice(0, 5).map(inlineCode).join(", ");
466
+ const rest = cluster.files.length > 5 ? ` (+${cluster.files.length - 5})` : "";
467
+ const lines = [
468
+ "## Propagação mecânica",
469
+ "",
470
+ `> ${cluster.files.length} arquivo(s) com a mesma micro-alteração (≤3 linhas).`,
471
+ "> Revise o padrão e os outliers; **não abra** os demais — registre-os em",
472
+ "> `skipped` com o motivo `propagação mecânica`.",
473
+ "",
474
+ "```diff",
475
+ ...cluster.lines.map((line) => `+${line}`),
476
+ "```",
477
+ "",
478
+ `Arquivos: ${sample}${rest}`,
479
+ "",
480
+ ];
481
+ if (cluster.outliers.length > 0) {
482
+ lines.push("**Outliers (revisar):**", "");
483
+ for (const outlier of cluster.outliers) {
484
+ lines.push(
485
+ `- ${inlineCode(outlier.file)}: \`${shorten(outlier.lines.join(" ⏎ "), 160)}\``,
486
+ );
487
+ }
488
+ lines.push("");
489
+ }
490
+ return lines;
491
+ }
492
+
493
+ /** Contratos alterados cujos importadores não aparecem no diff. */
494
+ function propagationGapSection(args: WatchdogRenderArgs): string[] {
495
+ const gaps = args.propagationGaps ?? [];
496
+ if (gaps.length === 0) return [];
497
+ const lines = [
498
+ "## Possível propagação incompleta",
499
+ "",
500
+ "> Contratos alterados cujos importadores **não** aparecem no diff. Confirme",
501
+ "> se cada um precisa acompanhar a mudança antes de concluir.",
502
+ "",
503
+ ];
504
+ for (const gap of gaps) {
505
+ lines.push(
506
+ `- ${inlineCode(gap.file)} → ${gap.importers.map(inlineCode).join(", ")}`,
507
+ );
508
+ }
509
+ lines.push("");
510
+ return lines;
511
+ }
512
+
285
513
  /**
286
514
  * Estrutura/delta dos arquivos do watchdog (AST). É um mapa para orientar a
287
515
  * leitura — o watchdog confirma no código e não trata como achado.
@@ -346,6 +574,11 @@ export function defaultRenderBrief(
346
574
 
347
575
  if (args.context) lines.push(...renderBriefContext(args.context));
348
576
 
577
+ lines.push(...businessSection(args));
578
+ lines.push(...snippetsSection(args));
579
+ lines.push(...propagationSection(args));
580
+ lines.push(...propagationGapSection(args));
581
+ lines.push(...flowReviewSection(args));
349
582
  lines.push(...aiPlanSection(args));
350
583
  lines.push(...astSection(args));
351
584
  lines.push(...optionalList("Prioridade de leitura", def.reviewFocus));
@@ -378,6 +611,7 @@ export function defaultRenderBrief(
378
611
 
379
612
  lines.push(...deletedSection(args, def));
380
613
  lines.push(...optionalList("Não carregar", def.exclude));
614
+ lines.push(...explorationSection(args));
381
615
  lines.push("## Procedimento", "", ...PROCEDURE);
382
616
 
383
617
  return lines;