@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
package/src/core/paths.ts CHANGED
@@ -47,12 +47,15 @@ export const CONTEXT_SUMMARY_FILE = "context-summary.json";
47
47
  /** Brief do subagente que resume o contexto da PR. */
48
48
  export const CONTEXT_BRIEF_FILE = "context-brief.md";
49
49
 
50
+ /** Modelo de intenção da PR (negócio) — insumo obrigatório do revisor `general`. */
51
+ export const BUSINESS_SUMMARY_FILE = "business-summary.json";
52
+
53
+ /** Brief do passo de intenção (subagente na skill / pré-passe no hunt). */
54
+ export const BUSINESS_BRIEF_FILE = "business-brief.md";
55
+
50
56
  /** Relatório da verificação de findings (artefato `verify.md`). */
51
57
  export const VERIFY_FILE = "verify.md";
52
58
 
53
- /** Relatório consolidado da review (artefato `review.md`). */
54
- export const REVIEW_FILE = "review.md";
55
-
56
59
  /** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
57
60
  export const REVIEW_RESUME_FILE = "review-resume.md";
58
61
 
@@ -203,7 +203,7 @@ export function renderContextBrief(
203
203
 
204
204
  lines.push("## Objetivo", "");
205
205
  lines.push(
206
- "Resumir a PR para abrir o relatório de revisão em **Objetivo**,",
206
+ "Resumir a PR para abrir o resumo final em **Objetivo**,",
207
207
  "**Motivação** e **Resumo das alterações** (pt-BR).",
208
208
  "",
209
209
  );
@@ -228,9 +228,7 @@ export function renderContextBrief(
228
228
  lines.push("```", "");
229
229
 
230
230
  lines.push("## Regras", "");
231
- lines.push(
232
- "- O resumo abre o relatório e o resumo final; nos briefs ele é recortado.",
233
- );
231
+ lines.push("- O resumo abre o resumo final; nos briefs ele é recortado.");
234
232
  lines.push("- Baseie-se **somente** no `context.json` — não invente intenção.");
235
233
  lines.push(
236
234
  "- `objective`: até 2 frases (≤ 400 caracteres) sobre o que a PR entrega.",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Relatório de duração do `prepare` (artefato `prepare-report.json`).
3
+ *
4
+ * O CLI já imprime cada etapa; o artefato permite comparar runs (antes/depois
5
+ * de tuning) sem poluir o `manifest.json`, que é determinístico nos snapshots.
6
+ */
7
+
8
+ import type { AiUsage } from "./ai/types";
9
+ import type { PrepareStage } from "./orchestrator";
10
+
11
+ /** Duração de uma etapa do prepare. */
12
+ export type PrepareStageTiming = {
13
+ stage: PrepareStage;
14
+ durationMs: number;
15
+ };
16
+
17
+ /** Chamada de IA que falhou (timeout, truncamento, schema inválido). */
18
+ export type PrepareAiFailure = {
19
+ feature: string;
20
+ model: string;
21
+ durationMs: number;
22
+ error: string;
23
+ finishReason?: string;
24
+ };
25
+
26
+ /** Uso de IA do prepare: tokens das chamadas boas + tentativas/falhas. */
27
+ export type PrepareAiReport = AiUsage & {
28
+ attempts: number;
29
+ failures: readonly PrepareAiFailure[];
30
+ };
31
+
32
+ /** Entrada do relatório. */
33
+ export type PrepareReportInput = {
34
+ /** Início do prepare (ISO). */
35
+ startedAt: string;
36
+ durationMs: number;
37
+ stages: readonly PrepareStageTiming[];
38
+ /** Uso agregado de IA e falhas (quando configurada). */
39
+ ai?: PrepareAiReport;
40
+ };
41
+
42
+ /** Monta o objeto persistido. */
43
+ export function buildPrepareReport(input: PrepareReportInput) {
44
+ return {
45
+ version: 1 as const,
46
+ startedAt: input.startedAt,
47
+ durationMs: input.durationMs,
48
+ stages: input.stages.map((item) => ({ ...item })),
49
+ ai: input.ai
50
+ ? {
51
+ ...input.ai,
52
+ failures: input.ai.failures.map((failure) => ({ ...failure })),
53
+ }
54
+ : null,
55
+ };
56
+ }
57
+
58
+ /** Serializa o relatório (JSON indentado + newline). */
59
+ export function renderPrepareReport(input: PrepareReportInput): string {
60
+ return `${JSON.stringify(buildPrepareReport(input), null, 2)}\n`;
61
+ }
@@ -14,6 +14,7 @@ import type {
14
14
  HoundsmanAst,
15
15
  HoundsmanConfig,
16
16
  HoundsmanHunt,
17
+ HoundsmanPriority,
17
18
  HoundsmanStorage,
18
19
  HoundsmanWorktrees,
19
20
  } from "./config";
@@ -105,6 +106,11 @@ export class ProjectConfig<TId extends string = string> {
105
106
  return this.raw.docMappings;
106
107
  }
107
108
 
109
+ /** Prioridade por caminho (vazio = churn puro, comportamento antigo). */
110
+ get priorities(): readonly HoundsmanPriority[] {
111
+ return this.raw.priorities ?? [];
112
+ }
113
+
108
114
  /** Config de IA (objeto vazio quando ausente). */
109
115
  get ai(): HoundsmanAi {
110
116
  return this.raw.ai ?? {};
@@ -148,11 +154,12 @@ export class ProjectConfig<TId extends string = string> {
148
154
  return value !== undefined && value > 0 ? value : undefined;
149
155
  }
150
156
 
151
- /** Features resolvidas com defaults (`context`/`brief`/`technical` ligadas). */
157
+ /** Features resolvidas com defaults (`context`/`business`/`brief`/`technical` ligadas). */
152
158
  get aiFeatures(): Required<HoundsmanAiFeatures> {
153
159
  const features = this.ai.features ?? {};
154
160
  return {
155
161
  context: features.context !== false,
162
+ business: features.business !== false,
156
163
  brief: features.brief !== false,
157
164
  technical: features.technical !== false,
158
165
  classification: features.classification === true,
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Propagação mecânica — detecção determinística de mudanças em massa.
3
+ *
4
+ * Cenário típico: um contrato ganha um parâmetro obrigatório e N arquivos
5
+ * adicionam a mesma linha. Revisar N diffs de uma linha é desperdício; o risco
6
+ * está no padrão (um representante) e nos outliers (quem fugiu do padrão).
7
+ *
8
+ * Tudo aqui é função pura sobre dados já coletados no prepare (patch `-U0`,
9
+ * churn, delta/impacto do AST) — não há leitura extra de git nem chamada de IA.
10
+ */
11
+
12
+ import type { AstAnalysis, AstImpact } from "./ast/types";
13
+ import { churnOf } from "./domain/budget";
14
+ import type { ChurnMap, PropagationCluster, PropagationGap } from "./types";
15
+
16
+ /** Acima disso o arquivo não é "micro-alteração". */
17
+ export const PROPAGATION_MAX_CHURN = 3;
18
+
19
+ /** Mínimo de arquivos com o mesmo padrão para virar cluster. */
20
+ export const PROPAGATION_MIN_FILES = 5;
21
+
22
+ /** Teto de arquivos examinados (proteção para PRs gigantes). */
23
+ export const PROPAGATION_MAX_FILES = 500;
24
+
25
+ /** Outliers listados no brief (os demais são contados). */
26
+ export const PROPAGATION_MAX_OUTLIERS = 8;
27
+
28
+ /** Importadores listados por contrato no brief. */
29
+ export const PROPAGATION_MAX_IMPORTERS = 10;
30
+
31
+ /** Linhas adicionadas do patch (sem cabeçalho), normalizadas. */
32
+ export function addedLines(patch: string | undefined): string[] {
33
+ if (!patch) return [];
34
+ const lines: string[] = [];
35
+ for (const raw of patch.split(/\r?\n/)) {
36
+ if (!raw.startsWith("+") || raw.startsWith("+++")) continue;
37
+ const normalized = raw.slice(1).trim().replace(/\s+/g, " ");
38
+ if (normalized.length > 0) lines.push(normalized);
39
+ }
40
+ return lines;
41
+ }
42
+
43
+ /**
44
+ * Detecta o padrão dominante entre micro-alterações.
45
+ *
46
+ * Agrupa por linha adicionada **exata** (só normaliza espaços): agrupar por
47
+ * identificador esconderia justamente o outlier que precisa de revisão. Sem
48
+ * grupo dominante, devolve `null` e o brief segue como antes.
49
+ */
50
+ export function detectPropagation(
51
+ files: readonly string[],
52
+ patch: ReadonlyMap<string, string> | undefined,
53
+ churn?: ChurnMap,
54
+ ): PropagationCluster | null {
55
+ if (!patch) return null;
56
+ const micro: { file: string; lines: string[] }[] = [];
57
+ for (const file of files.slice(0, PROPAGATION_MAX_FILES)) {
58
+ if (churnOf(file, churn) > PROPAGATION_MAX_CHURN) continue;
59
+ const lines = addedLines(patch.get(file));
60
+ if (lines.length === 0) continue;
61
+ micro.push({ file, lines });
62
+ }
63
+ if (micro.length < PROPAGATION_MIN_FILES) return null;
64
+
65
+ const groups = new Map<string, { file: string; lines: string[] }[]>();
66
+ for (const entry of micro) {
67
+ const key = entry.lines.join("\n");
68
+ const list = groups.get(key);
69
+ if (list) list.push(entry);
70
+ else groups.set(key, [entry]);
71
+ }
72
+ const [patternKey, members] =
73
+ [...groups.entries()].sort(
74
+ (a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
75
+ )[0] ?? [];
76
+ if (!members || members.length < PROPAGATION_MIN_FILES) return null;
77
+
78
+ const dominant = new Set(members.map((entry) => entry.file));
79
+ const outliers = micro
80
+ .filter((entry) => !dominant.has(entry.file))
81
+ .slice(0, PROPAGATION_MAX_OUTLIERS);
82
+ return {
83
+ lines: (patternKey ?? "").split("\n"),
84
+ files: members.map((entry) => entry.file),
85
+ outliers,
86
+ };
87
+ }
88
+
89
+ /** `true` quando o delta mexeu em um contrato exportado (assinatura/export). */
90
+ function changesExportedContract(analysis: AstAnalysis | undefined): boolean {
91
+ const delta = analysis?.delta;
92
+ if (!delta) return false;
93
+ if (delta.changedExports.length > 0) return true;
94
+ if (delta.changedSignatures.some((change) => change.exported)) return true;
95
+ return delta.modifiedSymbols.some(
96
+ (symbol) =>
97
+ symbol.exported &&
98
+ (symbol.kind === "interface" ||
99
+ symbol.kind === "type" ||
100
+ symbol.kind === "enum" ||
101
+ symbol.kind === "class"),
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Contratos alterados com importadores que **não** aparecem no diff.
107
+ *
108
+ * É o cheque de "atualizaram 100 e esqueceram 3": o impacto reverso do AST já
109
+ * lista quem importa o arquivo; quem não está no diff vira suspeito no brief.
110
+ */
111
+ export function detectPropagationGaps(
112
+ files: readonly string[],
113
+ ast: ReadonlyMap<string, AstAnalysis> | undefined,
114
+ impact: AstImpact | undefined,
115
+ changedFiles: ReadonlySet<string>,
116
+ ): PropagationGap[] {
117
+ if (!ast || !impact) return [];
118
+ const gaps: PropagationGap[] = [];
119
+ for (const file of files) {
120
+ if (!changesExportedContract(ast.get(file))) continue;
121
+ const importers = [...new Set(impact.importers[file] ?? [])]
122
+ .filter((importer) => !changedFiles.has(importer))
123
+ .slice(0, PROPAGATION_MAX_IMPORTERS);
124
+ if (importers.length > 0) gaps.push({ file, importers });
125
+ }
126
+ return gaps;
127
+ }
@@ -5,10 +5,12 @@
5
5
  * `HoundsmanConfig` — não há caminhos de arquivo do projeto na config.
6
6
  */
7
7
 
8
+ import { GENERAL_WATCHDOG_ID } from "./builtins";
8
9
  import type { HoundsmanConfig } from "./config";
9
10
  import { env } from "./env";
10
11
  import { JevQuestionFactory } from "./jev";
11
- import { renderReport, sanitizeBranch } from "./report-builder";
12
+ import { renderReviewResume } from "./renderers/resume-renderer";
13
+ import { sanitizeBranch } from "./report-builder";
12
14
  import { hasExplicitPolarity } from "./rule-text";
13
15
  import type { WatchdogMeta } from "./types";
14
16
 
@@ -63,6 +65,15 @@ function checkFallbacks(config: HoundsmanConfig, warnings: string[]): void {
63
65
  }
64
66
  }
65
67
 
68
+ /** O id `general` é reservado: o motor sempre injeta o revisor de fluxo. */
69
+ function checkReservedIds(config: HoundsmanConfig, warnings: string[]): void {
70
+ if (config.watchdogIds.includes(GENERAL_WATCHDOG_ID)) {
71
+ warnings.push(
72
+ `watchdogs['${GENERAL_WATCHDOG_ID}'] usa um id reservado — os metadados são mesclados ao piso de lógica do motor.`,
73
+ );
74
+ }
75
+ }
76
+
66
77
  /** Findings emergentes exigem ao menos uma regra de lógica/domínio como piso. */
67
78
  function checkEmergent(config: HoundsmanConfig, warnings: string[]): void {
68
79
  for (const id of config.watchdogIds) {
@@ -79,35 +90,61 @@ function checkEmergent(config: HoundsmanConfig, warnings: string[]): void {
79
90
  }
80
91
  }
81
92
 
93
+ /**
94
+ * Prioridades por caminho: RegExp, valores finitos (`bonus` ≥ 0, teto > 0) e ao
95
+ * menos um efeito — uma regra sem `bonus` nem `maxRiskFromChurn` é inócua.
96
+ */
97
+ function checkPriorities(config: HoundsmanConfig, problems: string[]): void {
98
+ for (const [index, rule] of (config.priorities ?? []).entries()) {
99
+ if (!(rule.pattern instanceof RegExp)) {
100
+ problems.push(`priorities[${index}].pattern precisa ser uma RegExp`);
101
+ }
102
+ if (
103
+ rule.bonus !== undefined &&
104
+ (!Number.isFinite(rule.bonus) || rule.bonus < 0)
105
+ ) {
106
+ problems.push(`priorities[${index}].bonus precisa ser número ≥ 0`);
107
+ }
108
+ if (
109
+ rule.maxRiskFromChurn !== undefined &&
110
+ (!Number.isFinite(rule.maxRiskFromChurn) || rule.maxRiskFromChurn <= 0)
111
+ ) {
112
+ problems.push(`priorities[${index}].maxRiskFromChurn precisa ser número > 0`);
113
+ }
114
+ if (rule.bonus === undefined && rule.maxRiskFromChurn === undefined) {
115
+ problems.push(
116
+ `priorities[${index}] não tem bonus nem maxRiskFromChurn (regra inócua)`,
117
+ );
118
+ }
119
+ }
120
+ }
121
+
82
122
  /** Smoke test dos helpers críticos de renderização. */
83
- function checkRendering(config: HoundsmanConfig, problems: string[]): void {
123
+ function checkRendering(problems: string[]): void {
84
124
  if (sanitizeBranch("feature/x") !== "feature-x") {
85
125
  problems.push("sanitizeBranch não está normalizando branches");
86
126
  }
87
- const sampleId = config.watchdogIds[0] ?? "watchdog";
88
- const report = {
89
- watchdogId: sampleId,
90
- reviewedFiles: [],
91
- skipped: [],
127
+ const base = { branch: "main", date: "2026-01-01" } as const;
128
+ const empty = renderReviewResume({
129
+ ...base,
130
+ classification: { files: {}, watchdogs: {} },
92
131
  findings: [],
93
- };
94
- const md = renderReport("main", [report], [], {});
95
- if (!md.includes("✅ Aprovado")) {
96
- problems.push("renderReport não marcou status aprovado sem achados");
132
+ });
133
+ if (!empty.includes("✅ Aprovado")) {
134
+ problems.push("renderReviewResume não marcou status aprovado sem achados");
97
135
  }
98
- const withContext = renderReport(
99
- "main",
100
- [report],
101
- [],
102
- {},
103
- {
136
+ const withContext = renderReviewResume({
137
+ ...base,
138
+ classification: { files: {}, watchdogs: {} },
139
+ findings: [],
140
+ context: {
104
141
  available: true,
105
142
  objective: "Entregar X",
106
143
  changeSummary: ["Faz A"],
107
144
  },
108
- );
109
- if (!withContext.includes("## Contexto da PR")) {
110
- problems.push("renderReport não incluiu a seção de contexto");
145
+ });
146
+ if (!withContext.includes("Entregar X")) {
147
+ problems.push("renderReviewResume não incluiu o contexto da PR");
111
148
  }
112
149
  }
113
150
 
@@ -331,12 +368,14 @@ export function checkRegistry(config: HoundsmanConfig): {
331
368
  checkDefinitions(config, problems);
332
369
  checkClassification(config, problems);
333
370
  checkFallbacks(config, warnings);
371
+ checkReservedIds(config, warnings);
334
372
  checkEmergent(config, warnings);
335
- checkRendering(config, problems);
373
+ checkRendering(problems);
336
374
  checkRulePolarity(config, warnings);
337
375
  checkRuleDuplication(config, warnings);
338
376
  checkMetadataPrecision(config, warnings);
339
377
  checkBudgets(config, warnings);
378
+ checkPriorities(config, problems);
340
379
  checkAstConfig(config, problems, warnings);
341
380
  checkAiConfig(config, problems, warnings);
342
381
  const jevQuestions = checkJevSpecs(config, problems);
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { AiUsage } from "../ai/types";
10
+ import type { BusinessSummary } from "../business-summary";
10
11
  import { Classification } from "../domain/classification";
11
12
  import type { ClassificationResult } from "../file-classifier";
12
13
  import type { JevCandidatesFile } from "../jev";
@@ -44,6 +45,16 @@ export type ManifestAi = {
44
45
  usage: AiUsage;
45
46
  };
46
47
 
48
+ /** Resumo do modelo de intenção no manifesto. */
49
+ export type ManifestBusiness = {
50
+ source: BusinessSummary["source"];
51
+ model?: string;
52
+ flows: number;
53
+ invariants: number;
54
+ contracts: number;
55
+ states: number;
56
+ };
57
+
47
58
  /** Classificação enriquecida gravada pelo prepare (modo + incremental). */
48
59
  export type ManifestClassification = ClassificationResult & {
49
60
  headSha?: string;
@@ -81,6 +92,7 @@ export class ManifestWriter {
81
92
  jev: JevCandidatesFile,
82
93
  ast?: ManifestAst,
83
94
  ai?: ManifestAi,
95
+ business?: ManifestBusiness,
84
96
  ): void {
85
97
  const view = Classification.from(classification);
86
98
  const maxFilesPerBrief = this.project.maxFilesPerBrief;
@@ -125,6 +137,7 @@ export class ManifestWriter {
125
137
  context: summary
126
138
  ? { summarized: true, source: summary.source ?? null }
127
139
  : { summarized: false, source: null },
140
+ business: business ?? null,
128
141
  jev: jev.available
129
142
  ? {
130
143
  available: true,
@@ -7,12 +7,25 @@
7
7
  * `RESUME_MAX_CHARS`.
8
8
  */
9
9
 
10
+ import { type BusinessSummary, renderBusinessLines } from "../business-summary";
11
+ import type { JevCandidate } from "../jev";
10
12
  import type { ContextSummary, MergedFinding } from "../report-builder";
11
13
  import { systemClock } from "../services/clock";
12
14
  import type { TechnicalSummary } from "../technical-summary";
13
15
 
14
- /** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
15
- export const RESUME_MAX_CHARS = 5000;
16
+ /**
17
+ * Teto de caracteres do artefato (inclui cabeçalho e quebras de linha).
18
+ *
19
+ * As tabelas e a conclusão têm orçamento reservado — só o texto narrativo
20
+ * (negócio/técnico) é cortado quando o resumo cresce.
21
+ */
22
+ export const RESUME_MAX_CHARS = 8000;
23
+
24
+ /** Fração do orçamento narrativo destinada ao resumo de negócio. */
25
+ const BUSINESS_SHARE = 0.6;
26
+
27
+ /** Piso do orçamento narrativo mesmo em PRs com muitos domínios. */
28
+ const MIN_NARRATIVE_CHARS = 1200;
16
29
 
17
30
  /** Dados de classificação usados no resumo. */
18
31
  export type ResumeClassification = {
@@ -20,15 +33,34 @@ export type ResumeClassification = {
20
33
  watchdogs: Record<string, string[]>;
21
34
  };
22
35
 
36
+ /** Cobertura de um domínio (watchdog) na revisão. */
37
+ export type ResumeDomain = {
38
+ id: string;
39
+ name: string;
40
+ findings: number;
41
+ reviewedFiles: number;
42
+ expectedFiles: number;
43
+ /** Arquivos do escopo declarados em `skipped` (não revisados por decisão). */
44
+ skippedFiles?: number;
45
+ };
46
+
23
47
  export type ReviewResumeInput = {
24
48
  branch: string;
25
49
  /** `YYYY-MM-DD`; default: data corrente do sistema. */
26
50
  date?: string;
27
51
  context?: ContextSummary | null;
52
+ /** Modelo de intenção da PR (negócio). */
53
+ business?: BusinessSummary | null;
28
54
  classification: ResumeClassification;
29
55
  findings: readonly MergedFinding[];
30
56
  /** Pontos técnicos estruturais (não achados). */
31
57
  technical?: TechnicalSummary | null;
58
+ /** Cobertura por domínio (arquivos revisados × esperados). */
59
+ domains?: readonly ResumeDomain[];
60
+ /** Candidatos da pré-análise que nenhum watchdog confirmou. */
61
+ candidates?: readonly JevCandidate[];
62
+ /** Aviso opcional no topo (ex.: revisão incremental). */
63
+ note?: string;
32
64
  };
33
65
 
34
66
  const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
@@ -47,8 +79,28 @@ function plural(count: number, singular: string, plural: string): string {
47
79
  return `${count} ${count === 1 ? singular : plural}`;
48
80
  }
49
81
 
50
- /** Resumo de negócio: contexto da PR; sem ele, o alcance da branch. */
82
+ /** Resumo de negócio: modelo de intenção; sem ele, contexto da PR/branch. */
51
83
  function businessSection(input: ReviewResumeInput): string[] {
84
+ const lines = ["## Resumo de negócio", ""];
85
+ const business = input.business;
86
+
87
+ if (
88
+ business &&
89
+ (business.intent || business.flows.length > 0 || business.invariants.length > 0)
90
+ ) {
91
+ if (business.intent) lines.push(business.intent, "");
92
+ else if (input.context?.objective) lines.push(input.context.objective, "");
93
+ const rendered = renderBusinessLines(business).filter(
94
+ (line) => !line.startsWith("**Intenção:**"),
95
+ );
96
+ while (rendered[0] === "") rendered.shift();
97
+ if (rendered.length > 0) lines.push(...rendered, "");
98
+ if (input.context?.motivation) {
99
+ lines.push(`**Por quê:** ${truncate(input.context.motivation, 480)}`, "");
100
+ }
101
+ return lines;
102
+ }
103
+
52
104
  const context = input.context;
53
105
  const hasContext =
54
106
  context?.available === true &&
@@ -57,7 +109,6 @@ function businessSection(input: ReviewResumeInput): string[] {
57
109
  context.motivation ||
58
110
  (context.changeSummary && context.changeSummary.length > 0),
59
111
  );
60
- const lines = ["## Resumo de negócio", ""];
61
112
 
62
113
  if (!hasContext) {
63
114
  const domainCount = Object.keys(input.classification.watchdogs).length;
@@ -107,9 +158,78 @@ function clamp(markdown: string, max: number): string {
107
158
  return `${(cut > 0 ? head.slice(0, cut) : head).trimEnd()}\n…`;
108
159
  }
109
160
 
161
+ /** Conclusão com os totais por severidade. */
162
+ function conclusionSection(findings: readonly MergedFinding[]): string[] {
163
+ const counts = {
164
+ blocking: findings.filter((f) => f.severity === "blocking").length,
165
+ attention: findings.filter((f) => f.severity === "attention").length,
166
+ suggestion: findings.filter((f) => f.severity === "suggestion").length,
167
+ nit: findings.filter((f) => f.severity === "nit").length,
168
+ };
169
+ return [
170
+ "## Conclusão",
171
+ "",
172
+ `**Resumo:** ${counts.blocking} bloqueantes · ${counts.attention} atenção · ${counts.suggestion} sugestões · ${counts.nit} nits.`,
173
+ "",
174
+ ];
175
+ }
176
+
177
+ /** Cobertura por domínio: revisados, declarados fora do recorte e lacunas. */
178
+ function coverageSection(domains: readonly ResumeDomain[]): string[] {
179
+ if (domains.length === 0) return [];
180
+ const lines = [
181
+ "## Cobertura por domínio",
182
+ "",
183
+ "| Domínio | Achados | Revisados | Fora do recorte | Faltam |",
184
+ "|---|---|---|---|---|",
185
+ ];
186
+ for (const domain of domains) {
187
+ const skipped = domain.skippedFiles ?? 0;
188
+ const missing = Math.max(
189
+ 0,
190
+ domain.expectedFiles - domain.reviewedFiles - skipped,
191
+ );
192
+ lines.push(
193
+ `| ${domain.name} | ${domain.findings} | ${domain.reviewedFiles}/${domain.expectedFiles} | ${skipped > 0 ? skipped : "—"} | ${missing > 0 ? missing : "—"} |`,
194
+ );
195
+ }
196
+ lines.push(
197
+ "",
198
+ "> `Faltam` = arquivos do escopo sem revisão e sem declaração em `skipped`.",
199
+ "",
200
+ );
201
+ return lines;
202
+ }
203
+
204
+ /** Candidatos da pré-análise que nenhum watchdog confirmou (não bloqueiam). */
205
+ function candidatesSection(
206
+ candidates: readonly JevCandidate[],
207
+ findings: readonly MergedFinding[],
208
+ ): string[] {
209
+ if (candidates.length === 0) return [];
210
+ const confirmed = new Set(findings.map((f) => `${f.file}|${f.rule}`));
211
+ const pending = candidates.filter((c) => !confirmed.has(`${c.file}|${c.rule}`));
212
+ if (pending.length === 0) return [];
213
+ const lines = [
214
+ `## Candidatos não verificados (${pending.length})`,
215
+ "",
216
+ "> Indícios da pré-análise que nenhum watchdog confirmou. Não bloqueiam.",
217
+ "",
218
+ ];
219
+ for (const candidate of pending.slice(0, 8)) {
220
+ lines.push(
221
+ `- \`${candidate.file}\` · ${candidate.severity} · ${truncate(candidate.rule, 160)}`,
222
+ );
223
+ }
224
+ if (pending.length > 8) lines.push(`- … +${pending.length - 8}`);
225
+ lines.push("");
226
+ return lines;
227
+ }
228
+
110
229
  /**
111
230
  * Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
112
- * nasce do contexto já disponível e dos pontos técnicos persistidos.
231
+ * nasce do contexto já disponível, do modelo de intenção, dos pontos técnicos
232
+ * e da cobertura por domínio. `review-resume.md` é o único artefato humano.
113
233
  */
114
234
  export function renderReviewResume(input: ReviewResumeInput): string {
115
235
  const blockers = input.findings.filter((f) => f.severity === "blocking").length;
@@ -121,14 +241,34 @@ export function renderReviewResume(input: ReviewResumeInput): string {
121
241
  ? STATUS_BY_SEVERITY.attention
122
242
  : STATUS_BY_SEVERITY.clean;
123
243
 
124
- const lines = [
244
+ const header = [
125
245
  `# Resumo da revisão — ${input.branch}`,
126
246
  "",
127
247
  `**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
128
- "",
129
- ...businessSection(input),
130
- ...technicalSection(input.technical),
131
- ];
248
+ ...(input.note?.trim() ? ["", input.note.trim()] : []),
249
+ ].join("\n");
250
+
251
+ const coverage = coverageSection(input.domains ?? []).join("\n");
252
+ const candidates = candidatesSection(input.candidates ?? [], input.findings).join(
253
+ "\n",
254
+ );
255
+ const conclusion = conclusionSection(input.findings).join("\n");
256
+
257
+ // Tabelas e conclusão primeiro: o que sobra é o orçamento do texto narrativo.
258
+ const reserved = [header, coverage, candidates, conclusion]
259
+ .filter((part) => part.length > 0)
260
+ .join("\n\n").length;
261
+ const budget = Math.max(MIN_NARRATIVE_CHARS, RESUME_MAX_CHARS - reserved);
262
+ const businessMax = Math.floor(budget * BUSINESS_SHARE);
263
+ const business = clamp(businessSection(input).join("\n"), businessMax);
264
+ const technical = clamp(
265
+ technicalSection(input.technical).join("\n"),
266
+ budget - businessMax,
267
+ );
132
268
 
133
- return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
269
+ const body = [header, business, technical, coverage, candidates, conclusion]
270
+ .map((part) => part.trim())
271
+ .filter((part) => part.length > 0)
272
+ .join("\n\n");
273
+ return `${body}\n`;
134
274
  }