@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
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Watchdogs embutidos do motor.
3
+ *
4
+ * Diferente dos watchdogs do consumidor, estes existem por padrão e não podem
5
+ * ser desligados: são parte do fluxo de revisão. O `general` audita a lógica e o
6
+ * domínio do diff inteiro (visão de fluxo, não de arquivo), usando o modelo de
7
+ * intenção (`business-summary.json`) como contrato esperado.
8
+ */
9
+
10
+ import { defineWatchdog, type WatchdogDefinition } from "./types";
11
+
12
+ /** Id reservado do revisor de lógica/domínio. */
13
+ export const GENERAL_WATCHDOG_ID = "general";
14
+
15
+ /**
16
+ * Revisor de fluxo: recebe todos os arquivos alterados e procura gaps de
17
+ * negócio (fluxo incompleto, capacidade não honrada, credencial divergente,
18
+ * estado sem tratamento, identificador trocado, idempotência ausente).
19
+ *
20
+ * As regras são o **piso**; findings fora delas usam `Lógica:`/`Domínio:`
21
+ * (`allowEmergentFindings`). Severidade `blocking` só é aceita com cenário +
22
+ * evidência (normalizado no `report-builder`).
23
+ */
24
+ export const generalWatchdog: WatchdogDefinition = defineWatchdog({
25
+ id: GENERAL_WATCHDOG_ID,
26
+ name: "Lógica e domínio (fluxo)",
27
+ description:
28
+ "Audita o fluxo alterado ponta a ponta: intenção, invariantes, estados, capacidades e credenciais.",
29
+ scope: "Todos os arquivos alterados (visão de fluxo, independente de domínio)",
30
+ classify: [],
31
+ fallback: false,
32
+ allowEmergentFindings: true,
33
+ // Deletados não têm conteúdo para ler: entram como breaking change no brief.
34
+ breakingChange: true,
35
+ reviewFocus: [
36
+ "Reconstrua o modelo de intenção (business-summary.json) antes de julgar o código.",
37
+ "Rastreie cada símbolo alterado até seus chamadores, adapters e tipos compartilhados.",
38
+ "Verifique paridade de capacidade: feature declarada, método exposto, evento/webhook e lista de métodos.",
39
+ "Verifique propagação de credencial/conta/contexto em toda a cadeia (auth, token, split, subconta).",
40
+ "Percorra o ciclo de vida completo dos fluxos tocados: criação, renovação, cancelamento, retry e erro.",
41
+ "Confirme o identificador persistido contra a entidade que o contrato espera.",
42
+ "Só reporte gap com gatilho concreto, comportamento observado e invariante/regra violada.",
43
+ ],
44
+ rules: [
45
+ {
46
+ severity: "attention",
47
+ text: "Promover: todo fluxo alterado tem caminho completo (criação, renovação, cancelamento) e o caminho de erro chega a um estado tratado.",
48
+ },
49
+ {
50
+ severity: "attention",
51
+ text: "Promover: toda capacidade declarada (feature, método, evento, webhook) existe e é honrada em todos os lados do contrato.",
52
+ },
53
+ {
54
+ severity: "attention",
55
+ text: "Promover: a credencial, conta e contexto de execução são os mesmos em toda a cadeia alterada (autenticação, token, split, subconta).",
56
+ },
57
+ {
58
+ severity: "attention",
59
+ text: "Promover: transições de estado e efeitos colaterais do domínio são explícitos, ordenados e idempotentes.",
60
+ },
61
+ {
62
+ severity: "suggestion",
63
+ text: "Promover: o identificador persistido corresponde à entidade que o contrato espera (cobrança, autorização, pedido, assinatura).",
64
+ },
65
+ ],
66
+ contextFiles: [],
67
+ });
68
+
69
+ /** Watchdogs embutidos, por id. */
70
+ export const BUILTIN_WATCHDOGS: Readonly<Record<string, WatchdogDefinition>> = {
71
+ [GENERAL_WATCHDOG_ID]: generalWatchdog,
72
+ };
73
+
74
+ /** Definição embutida de um id (ou null). */
75
+ export function builtinWatchdog(id: string): WatchdogDefinition | null {
76
+ return BUILTIN_WATCHDOGS[id] ?? null;
77
+ }
78
+
79
+ /**
80
+ * Mescla a definição do consumidor com o piso embutido do `general`.
81
+ *
82
+ * O consumidor pode ajustar nome/escopo/docs/regras, mas não pode remover a
83
+ * auditoria de lógica nem os findings emergentes.
84
+ */
85
+ export function mergeGeneralWatchdog(
86
+ configured: WatchdogDefinition | undefined,
87
+ ): WatchdogDefinition {
88
+ if (!configured || configured.id !== GENERAL_WATCHDOG_ID) return generalWatchdog;
89
+ const rules = [...configured.rules];
90
+ for (const rule of generalWatchdog.rules) {
91
+ if (!rules.some((item) => item.text === rule.text)) rules.push(rule);
92
+ }
93
+ return {
94
+ ...generalWatchdog,
95
+ name: configured.name || generalWatchdog.name,
96
+ description: configured.description || generalWatchdog.description,
97
+ scope: configured.scope || generalWatchdog.scope,
98
+ reviewFocus: [
99
+ ...(configured.reviewFocus ?? []),
100
+ ...(generalWatchdog.reviewFocus ?? []),
101
+ ],
102
+ exclude: configured.exclude,
103
+ contextFiles: configured.contextFiles,
104
+ rules,
105
+ allowEmergentFindings: true,
106
+ fallback: false,
107
+ };
108
+ }
@@ -0,0 +1,583 @@
1
+ /**
2
+ * Modelo de intenção da PR (`business-summary.json`).
3
+ *
4
+ * Descreve o que a mudança **promete** entregar — fluxos, invariantes, contratos
5
+ * e estados — antes de qualquer watchdog julgar o código. É insumo obrigatório
6
+ * do revisor `general`: sem ele a revisão volta a caçar só violações de regra.
7
+ *
8
+ * Produtores, em ordem de precedência:
9
+ * 1. `ai` — `AiBusinessSummarizer` no prepare (quando há chave).
10
+ * 2. `reviewer` — subagente (skill) ou pré-passe maki (`houndsman hunt`).
11
+ * 3. `deterministic` — fallback montado a partir de AST/churn/contexto.
12
+ */
13
+
14
+ import type { AstAnalysis, AstImpact, AstSymbolKind } from "./ast/types";
15
+ import { GENERAL_WATCHDOG_ID } from "./builtins";
16
+ import type { ChangedScope } from "./changed-scope";
17
+ import type { ClassificationResult } from "./file-classifier";
18
+
19
+ /** Fluxo de negócio tocado pela mudança. */
20
+ export type BusinessFlow = {
21
+ name: string;
22
+ /** O que dispara o fluxo (endpoint, evento, job, ação do usuário). */
23
+ trigger?: string;
24
+ /** Comportamento esperado ao final do fluxo. */
25
+ expected?: string;
26
+ /** Arquivos envolvidos (para o revisor rastrear). */
27
+ files?: string[];
28
+ };
29
+
30
+ /** Contrato/paridade entre lados (provider × orquestrador, adapter × tipo). */
31
+ export type BusinessContract = {
32
+ name: string;
33
+ /** Lados que precisam concordar (ex.: `asaas`, `charge-processor`). */
34
+ sides?: string[];
35
+ /** O que precisa ser verdadeiro em todos os lados. */
36
+ expectation?: string;
37
+ };
38
+
39
+ /** Estados e transições relevantes do domínio. */
40
+ export type BusinessState = {
41
+ name: string;
42
+ transitions?: string[];
43
+ };
44
+
45
+ /** Fonte do modelo de intenção. */
46
+ export type BusinessSummarySource = "ai" | "reviewer" | "deterministic";
47
+
48
+ /** Modelo de intenção de uma revisão. */
49
+ export type BusinessSummary = {
50
+ source: BusinessSummarySource;
51
+ /** Commit coberto — permite saber a que revisão o modelo se refere. */
52
+ headSha?: string;
53
+ /** O que a PR promete entregar (1 parágrafo curto). */
54
+ intent?: string;
55
+ flows: BusinessFlow[];
56
+ invariants: string[];
57
+ contracts: BusinessContract[];
58
+ states: BusinessState[];
59
+ /** O que a PR explicitamente não se propõe a fazer. */
60
+ nonGoals?: string[];
61
+ /** Pontos que o revisor deve confirmar com o autor. */
62
+ openQuestions?: string[];
63
+ };
64
+
65
+ /** Entrada do builder determinístico (não lê disco). */
66
+ export type BusinessInput = {
67
+ classification: Pick<ClassificationResult, "files" | "churn" | "watchdogs">;
68
+ /** Domínios ativos (id + nome legível). */
69
+ domains?: readonly { id: string; name: string }[];
70
+ ast?: ReadonlyMap<string, AstAnalysis>;
71
+ impact?: AstImpact;
72
+ changedScope?: ChangedScope;
73
+ headSha?: string;
74
+ context?: {
75
+ objective?: string;
76
+ motivation?: string;
77
+ changeSummary?: string[];
78
+ } | null;
79
+ /** Patch por arquivo (`diff.byFile`) — usado no payload da IA. */
80
+ snippets?: ReadonlyMap<string, string>;
81
+ };
82
+
83
+ const MAX_FLOWS = 12;
84
+ const MAX_INVARIANTS = 12;
85
+ const MAX_CONTRACTS = 12;
86
+ const MAX_STATES = 8;
87
+ const MAX_FILES_PER_FLOW = 20;
88
+ const MAX_SYMBOLS_PER_FLOW = 6;
89
+ const MAX_PROMPT_FILES = 40;
90
+ const MAX_SNIPPET_CHARS = 1_200;
91
+
92
+ /** Corta o texto em `max` caracteres, sinalizando com reticências. */
93
+ function truncate(value: string, max: number): string {
94
+ const text = value.trim().replace(/\s+/g, " ");
95
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
96
+ }
97
+
98
+ /** `true` para símbolos que representam contratos. */
99
+ function isContract(kind: AstSymbolKind): boolean {
100
+ return kind === "interface" || kind === "type" || kind === "enum";
101
+ }
102
+
103
+ /** Símbolos alterados de um arquivo (adicionados/alterados), sem duplicatas. */
104
+ function changedSymbolNames(analysis: AstAnalysis | undefined): string[] {
105
+ const delta = analysis?.delta;
106
+ if (!delta) return [];
107
+ const seen = new Set<string>();
108
+ const names: string[] = [];
109
+ for (const symbol of [...delta.addedSymbols, ...delta.modifiedSymbols]) {
110
+ if (seen.has(symbol.name)) continue;
111
+ seen.add(symbol.name);
112
+ names.push(symbol.name);
113
+ }
114
+ return names;
115
+ }
116
+
117
+ /** Indica se o arquivo está entre os alterados com conteúdo revisável. */
118
+ function orderByChurn(
119
+ files: readonly string[],
120
+ churn: ClassificationResult["churn"],
121
+ ): string[] {
122
+ return [...files].sort((a, b) => {
123
+ const ca = (churn[a]?.additions ?? 0) + (churn[a]?.deletions ?? 0);
124
+ const cb = (churn[b]?.additions ?? 0) + (churn[b]?.deletions ?? 0);
125
+ return cb - ca || a.localeCompare(b);
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Monta o modelo determinístico: agrupa por domínio, lista símbolos alterados e
131
+ * extrai contratos declarados. Nunca inventa invariantes nem fluxos de negócio
132
+ * (isso exige leitura do diff — papel da IA/subagente).
133
+ */
134
+ export function buildBusinessSummary(input: BusinessInput): BusinessSummary {
135
+ const flowDomains = (input.domains ?? []).filter(
136
+ (domain) => domain.id !== GENERAL_WATCHDOG_ID,
137
+ );
138
+
139
+ const flows: BusinessFlow[] = [];
140
+ for (const domain of flowDomains) {
141
+ const files = orderByChurn(
142
+ input.classification.watchdogs[domain.id] ?? [],
143
+ input.classification.churn,
144
+ ).slice(0, MAX_FILES_PER_FLOW);
145
+ if (files.length === 0) continue;
146
+ const symbols = new Set<string>();
147
+ for (const file of files) {
148
+ for (const name of changedSymbolNames(input.ast?.get(file))) {
149
+ if (symbols.size >= MAX_SYMBOLS_PER_FLOW) break;
150
+ symbols.add(name);
151
+ }
152
+ }
153
+ flows.push({
154
+ name: domain.name,
155
+ files,
156
+ expected:
157
+ symbols.size > 0
158
+ ? `Símbolos alterados: ${[...symbols].join(", ")}`
159
+ : undefined,
160
+ });
161
+ if (flows.length >= MAX_FLOWS) break;
162
+ }
163
+
164
+ const contracts: BusinessContract[] = [];
165
+ const seenContracts = new Set<string>();
166
+ if (input.ast) {
167
+ for (const [file, analysis] of input.ast) {
168
+ const added = analysis.delta?.addedSymbols ?? [];
169
+ for (const symbol of added) {
170
+ if (!isContract(symbol.kind)) continue;
171
+ const key = `${symbol.name}|${file}`;
172
+ if (seenContracts.has(key)) continue;
173
+ seenContracts.add(key);
174
+ contracts.push({
175
+ name: symbol.name,
176
+ sides: [file],
177
+ expectation: `Declaração \`${symbol.kind}\` nova — verificar se todos os lados do contrato a honram.`,
178
+ });
179
+ if (contracts.length >= MAX_CONTRACTS) break;
180
+ }
181
+ if (contracts.length >= MAX_CONTRACTS) break;
182
+ }
183
+ }
184
+
185
+ return {
186
+ source: "deterministic",
187
+ headSha: input.headSha,
188
+ intent:
189
+ input.context?.objective?.trim() ||
190
+ input.context?.motivation?.trim() ||
191
+ undefined,
192
+ flows,
193
+ invariants: [],
194
+ contracts,
195
+ states: [],
196
+ };
197
+ }
198
+
199
+ /** Texto curto usado no brief e no manifesto. */
200
+ export function summarizeBusiness(business: BusinessSummary): {
201
+ flows: number;
202
+ invariants: number;
203
+ contracts: number;
204
+ states: number;
205
+ } {
206
+ return {
207
+ flows: business.flows.length,
208
+ invariants: business.invariants.length,
209
+ contracts: business.contracts.length,
210
+ states: business.states.length,
211
+ };
212
+ }
213
+
214
+ /** Payload compacto enviado ao modelo (nunca arquivos inteiros). */
215
+ export function buildBusinessPromptPayload(
216
+ input: BusinessInput,
217
+ ): Record<string, unknown> {
218
+ const files = Object.entries(input.classification.files)
219
+ .map(([path, file]) => {
220
+ const churn = input.classification.churn[path];
221
+ const scope = input.changedScope?.[path];
222
+ return {
223
+ path,
224
+ status: file.status,
225
+ additions: churn?.additions ?? 0,
226
+ deletions: churn?.deletions ?? 0,
227
+ symbols: changedSymbolNames(input.ast?.get(path)).slice(
228
+ 0,
229
+ MAX_SYMBOLS_PER_FLOW,
230
+ ),
231
+ ranges:
232
+ scope?.ranges.map((range) =>
233
+ range.start === range.end
234
+ ? `${range.start}`
235
+ : `${range.start}-${range.end}`,
236
+ ) ?? [],
237
+ importedBy: input.impact?.importers[path]?.slice(0, 8) ?? [],
238
+ snippet: input.snippets
239
+ ? truncate(input.snippets.get(path) ?? "", MAX_SNIPPET_CHARS)
240
+ : undefined,
241
+ };
242
+ })
243
+ .sort(
244
+ (a, b) =>
245
+ b.additions + b.deletions - (a.additions + a.deletions) ||
246
+ a.path.localeCompare(b.path),
247
+ )
248
+ .slice(0, MAX_PROMPT_FILES);
249
+
250
+ return {
251
+ headSha: input.headSha,
252
+ context: input.context
253
+ ? {
254
+ objective: input.context.objective,
255
+ motivation: input.context.motivation,
256
+ changeSummary: input.context.changeSummary,
257
+ }
258
+ : undefined,
259
+ domains: (input.domains ?? []).map((domain) => domain.name),
260
+ files,
261
+ deterministic: {
262
+ intent:
263
+ input.context?.objective?.trim() ||
264
+ input.context?.motivation?.trim() ||
265
+ undefined,
266
+ contracts: buildBusinessSummary(input).contracts,
267
+ },
268
+ };
269
+ }
270
+
271
+ /** Normaliza uma lista de strings com limite de tamanho/quantidade. */
272
+ function normalizeList(
273
+ value: unknown,
274
+ max: number,
275
+ maxLength: number,
276
+ ): string[] | undefined {
277
+ if (!Array.isArray(value)) return undefined;
278
+ const items = value
279
+ .map((item) => (typeof item === "string" ? truncate(item, maxLength) : ""))
280
+ .filter((item) => item.length > 0)
281
+ .slice(0, max);
282
+ return items.length > 0 ? items : undefined;
283
+ }
284
+
285
+ /**
286
+ * Valida/normaliza o `business-summary.json`. `null` quando não há conteúdo
287
+ * útil (nem intenção, nem fluxo, nem contrato).
288
+ */
289
+ export function parseBusinessSummary(
290
+ raw: unknown,
291
+ source: BusinessSummarySource = "deterministic",
292
+ headSha?: string,
293
+ ): BusinessSummary | null {
294
+ if (typeof raw !== "object" || raw === null) return null;
295
+ const obj = raw as Record<string, unknown>;
296
+
297
+ const intent =
298
+ typeof obj.intent === "string" && obj.intent.trim().length > 0
299
+ ? truncate(obj.intent, 800)
300
+ : undefined;
301
+
302
+ const flows: BusinessFlow[] = [];
303
+ if (Array.isArray(obj.flows)) {
304
+ for (const item of obj.flows) {
305
+ if (typeof item !== "object" || item === null) continue;
306
+ const entry = item as Record<string, unknown>;
307
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
308
+ if (!name) continue;
309
+ flows.push({
310
+ name: truncate(name, 120),
311
+ trigger:
312
+ typeof entry.trigger === "string"
313
+ ? truncate(entry.trigger, 240)
314
+ : undefined,
315
+ expected:
316
+ typeof entry.expected === "string"
317
+ ? truncate(entry.expected, 400)
318
+ : undefined,
319
+ files: normalizeList(entry.files, MAX_FILES_PER_FLOW, 200),
320
+ });
321
+ if (flows.length >= MAX_FLOWS) break;
322
+ }
323
+ }
324
+
325
+ const contracts: BusinessContract[] = [];
326
+ if (Array.isArray(obj.contracts)) {
327
+ for (const item of obj.contracts) {
328
+ if (typeof item !== "object" || item === null) continue;
329
+ const entry = item as Record<string, unknown>;
330
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
331
+ if (!name) continue;
332
+ contracts.push({
333
+ name: truncate(name, 120),
334
+ sides: normalizeList(entry.sides, 8, 160),
335
+ expectation:
336
+ typeof entry.expectation === "string"
337
+ ? truncate(entry.expectation, 400)
338
+ : undefined,
339
+ });
340
+ if (contracts.length >= MAX_CONTRACTS) break;
341
+ }
342
+ }
343
+
344
+ const states: BusinessState[] = [];
345
+ if (Array.isArray(obj.states)) {
346
+ for (const item of obj.states) {
347
+ if (typeof item !== "object" || item === null) continue;
348
+ const entry = item as Record<string, unknown>;
349
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
350
+ if (!name) continue;
351
+ states.push({
352
+ name: truncate(name, 120),
353
+ transitions: normalizeList(entry.transitions, 12, 200),
354
+ });
355
+ if (states.length >= MAX_STATES) break;
356
+ }
357
+ }
358
+
359
+ const invariants = normalizeList(obj.invariants, MAX_INVARIANTS, 300) ?? [];
360
+ const nonGoals = normalizeList(obj.nonGoals, 8, 240);
361
+ const openQuestions = normalizeList(obj.openQuestions, 8, 300);
362
+
363
+ if (
364
+ !intent &&
365
+ flows.length === 0 &&
366
+ contracts.length === 0 &&
367
+ invariants.length === 0
368
+ ) {
369
+ return null;
370
+ }
371
+
372
+ return {
373
+ source: obj.source === "ai" || obj.source === "reviewer" ? obj.source : source,
374
+ headSha: typeof obj.headSha === "string" ? obj.headSha : headSha,
375
+ intent,
376
+ flows,
377
+ invariants,
378
+ contracts,
379
+ states,
380
+ nonGoals,
381
+ openQuestions,
382
+ };
383
+ }
384
+
385
+ /** Linhas compactas do modelo de intenção (brief e resumo). */
386
+ export function renderBusinessLines(
387
+ business: BusinessSummary,
388
+ limits = { flows: 6, invariants: 6, contracts: 6, states: 4 },
389
+ ): string[] {
390
+ const lines: string[] = [];
391
+ if (business.intent) lines.push(`**Intenção:** ${truncate(business.intent, 400)}`);
392
+ if (business.invariants.length > 0) {
393
+ lines.push("", "**Invariantes:**");
394
+ for (const item of business.invariants.slice(0, limits.invariants)) {
395
+ lines.push(`- ${truncate(item, 240)}`);
396
+ }
397
+ }
398
+ if (business.flows.length > 0) {
399
+ lines.push("", `**Fluxos (${business.flows.length}):**`);
400
+ for (const flow of business.flows.slice(0, limits.flows)) {
401
+ const parts = [`- **${truncate(flow.name, 100)}**`];
402
+ if (flow.trigger) parts.push(`disparo: ${truncate(flow.trigger, 160)}`);
403
+ if (flow.expected) parts.push(`esperado: ${truncate(flow.expected, 240)}`);
404
+ lines.push(parts.join(" — "));
405
+ if (flow.files && flow.files.length > 0) {
406
+ lines.push(` arquivos: ${flow.files.slice(0, 6).join(", ")}`);
407
+ }
408
+ }
409
+ }
410
+ if (business.contracts.length > 0) {
411
+ lines.push("", `**Contratos (${business.contracts.length}):**`);
412
+ for (const contract of business.contracts.slice(0, limits.contracts)) {
413
+ const sides = contract.sides?.slice(0, 4).join(" × ") ?? "";
414
+ const expectation = contract.expectation
415
+ ? ` — ${truncate(contract.expectation, 240)}`
416
+ : "";
417
+ lines.push(
418
+ `- **${truncate(contract.name, 100)}**${sides ? ` (${sides})` : ""}${expectation}`,
419
+ );
420
+ }
421
+ }
422
+ if (business.states.length > 0) {
423
+ lines.push("", "**Estados:**");
424
+ for (const state of business.states.slice(0, limits.states)) {
425
+ const transitions = state.transitions?.slice(0, 6).join(" → ") ?? "";
426
+ lines.push(
427
+ `- ${truncate(state.name, 100)}${transitions ? `: ${truncate(transitions, 240)}` : ""}`,
428
+ );
429
+ }
430
+ }
431
+ if (business.openQuestions && business.openQuestions.length > 0) {
432
+ lines.push("", "**Pontos a confirmar:**");
433
+ for (const item of business.openQuestions.slice(0, 4)) {
434
+ lines.push(`- ${truncate(item, 240)}`);
435
+ }
436
+ }
437
+ return lines;
438
+ }
439
+
440
+ /** Schema textual do output (repetido no brief e no harness maki). */
441
+ export const BUSINESS_SUMMARY_SCHEMA = `{
442
+ "intent": "o que a PR promete entregar (1 parágrafo)",
443
+ "flows": [
444
+ { "name": "fluxo", "trigger": "o que dispara", "expected": "resultado esperado", "files": ["caminho/relativo.ts"] }
445
+ ],
446
+ "invariants": ["o que precisa ser verdadeiro sempre"],
447
+ "contracts": [
448
+ { "name": "contrato/capacidade", "sides": ["lado A", "lado B"], "expectation": "o que os lados precisam honrar" }
449
+ ],
450
+ "states": [{ "name": "estado", "transitions": ["A → B"] }],
451
+ "nonGoals": ["fora do escopo"],
452
+ "openQuestions": ["o que precisa ser confirmado"]
453
+ }`;
454
+
455
+ export type BusinessBriefInput = {
456
+ projectName: string;
457
+ branch: string;
458
+ headSha?: string;
459
+ outputPath: string;
460
+ /** Modelo determinístico já montado (ponto de partida). */
461
+ business: BusinessSummary;
462
+ /** Arquivos + escopo + churn para orientar a leitura. */
463
+ files: readonly {
464
+ path: string;
465
+ status: string;
466
+ ranges?: readonly { start: number; end: number }[];
467
+ symbols?: readonly string[];
468
+ }[];
469
+ docs?: readonly string[];
470
+ context?: {
471
+ objective?: string;
472
+ motivation?: string;
473
+ changeSummary?: string[];
474
+ } | null;
475
+ };
476
+
477
+ /**
478
+ * Brief do passo de intenção. Executado por subagente (skill) ou por pré-passe
479
+ * maki (`houndsman hunt`) — o output é o mesmo `BusinessSummary`.
480
+ */
481
+ export function buildBusinessBrief(input: BusinessBriefInput): string {
482
+ const lines: string[] = [
483
+ `# Brief — Intenção da PR (\`business\`)`,
484
+ "",
485
+ `**Projeto:** ${input.projectName}`,
486
+ `**Branch:** ${input.branch}`,
487
+ ...(input.headSha ? [`**Commit:** \`${input.headSha.slice(0, 7)}\``] : []),
488
+ `**Output obrigatório:** \`${input.outputPath}\``,
489
+ "",
490
+ "## Papel",
491
+ "",
492
+ "Você reconstrói **o que esta PR promete entregar** antes de qualquer watchdog",
493
+ "julgar o código. Leia o diff real (não só a descrição da PR) e produza um",
494
+ "modelo de intenção verificável. **Não produza achados** — este passo não revisa.",
495
+ "",
496
+ "## Schema do output",
497
+ "",
498
+ "```json",
499
+ BUSINESS_SUMMARY_SCHEMA,
500
+ "```",
501
+ "",
502
+ "## Procedimento",
503
+ "",
504
+ "1. **INTENT** — em 1 parágrafo, o comportamento que a PR deve entregar.",
505
+ "2. **FLOWS** — cada fluxo tocado (criação, renovação, cancelamento, webhook,",
506
+ " retry, transferência…) com disparo, resultado esperado e arquivos.",
507
+ "3. **INVARIANTS** — o que precisa valer sempre (paridade de capacidade,",
508
+ " credencial/conta correta, idempotência, estado consistente).",
509
+ "4. **CONTRACTS** — capacidades/interfaces e os lados que precisam concordar.",
510
+ "5. **STATES** — estados e transições afetados.",
511
+ "",
512
+ "Use apenas o que estiver no diff/arquivos do escopo. Se algo não estiver claro,",
513
+ "registre em `openQuestions` em vez de inventar.",
514
+ "",
515
+ ];
516
+
517
+ if (input.context) {
518
+ lines.push("## Contexto da PR", "");
519
+ if (input.context.objective)
520
+ lines.push(`**Objetivo:** ${input.context.objective}`);
521
+ if (input.context.motivation)
522
+ lines.push(`**Motivação:** ${input.context.motivation}`);
523
+ for (const item of input.context.changeSummary ?? []) lines.push(`- ${item}`);
524
+ lines.push("");
525
+ }
526
+
527
+ const starting = renderBusinessLines(input.business, {
528
+ flows: 12,
529
+ invariants: 12,
530
+ contracts: 12,
531
+ states: 8,
532
+ });
533
+ if (starting.length > 0) {
534
+ lines.push(
535
+ "## Modelo determinístico (ponto de partida)",
536
+ "",
537
+ "> Derivado de AST/churn/contexto. Confirme, corrija e complete com o diff real.",
538
+ "",
539
+ ...starting,
540
+ "",
541
+ );
542
+ }
543
+
544
+ lines.push(`## Arquivos do escopo (${input.files.length})`, "");
545
+ for (const file of input.files.slice(0, 60)) {
546
+ const ranges =
547
+ file.ranges && file.ranges.length > 0
548
+ ? ` — linhas ${file.ranges
549
+ .map((range) =>
550
+ range.start === range.end
551
+ ? `${range.start}`
552
+ : `${range.start}-${range.end}`,
553
+ )
554
+ .join(", ")}`
555
+ : "";
556
+ lines.push(`- \`${file.path}\` (${file.status})${ranges}`);
557
+ if (file.symbols && file.symbols.length > 0) {
558
+ lines.push(` símbolos: ${file.symbols.slice(0, 8).join(", ")}`);
559
+ }
560
+ }
561
+ if (input.files.length > 60) lines.push(`- … +${input.files.length - 60}`);
562
+ lines.push("");
563
+
564
+ if (input.docs && input.docs.length > 0) {
565
+ lines.push(
566
+ "## Docs de contexto",
567
+ "",
568
+ ...input.docs.map((doc) => `- \`${doc}\``),
569
+ "",
570
+ );
571
+ }
572
+
573
+ lines.push(
574
+ "## Regra de escopo",
575
+ "",
576
+ "> Só considere o que o diff alterou. Código fora das faixas é contexto,",
577
+ "> nunca um fluxo novo desta PR.",
578
+ "",
579
+ `Grave o JSON em \`${input.outputPath}\` com \`source: "reviewer"\`.`,
580
+ );
581
+
582
+ return `${lines.join("\n")}\n`;
583
+ }