@payconductor/houndsman 0.14.0 → 0.16.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.
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Severidade determinística (biblioteca) — a regra manda, não a opinião do watchdog.
3
+ *
4
+ * Finding de regra (`basis: "rule"`) usa a severidade declarada em `Rule.severity`;
5
+ * finding emergente (`Lógica:`/`Domínio:`) usa a tabela fixa de `impactClass`. O valor
6
+ * emitido só prevalece quando não há como resolver (paráfrase sem casamento, relatório
7
+ * legado sem `impactClass`) — fail-open. Toda divergência é contabilizada para o
8
+ * finalize avisar e para o `compare` provar a estabilidade entre runs.
9
+ */
10
+
11
+ import { type Finding, inferBasis, type WatchdogReport } from "./report-builder";
12
+ import { type Rule, type Severity, severityRank } from "./types";
13
+
14
+ /** Classe de impacto de um finding emergente — define a severidade fora de regra. */
15
+ export type ImpactClass = "flow-break" | "contract-drift" | "degradation" | "cosmetic";
16
+
17
+ /** Classes válidas, da mais para a menos grave. */
18
+ export const IMPACT_CLASSES: readonly ImpactClass[] = [
19
+ "flow-break",
20
+ "contract-drift",
21
+ "degradation",
22
+ "cosmetic",
23
+ ];
24
+
25
+ /** Tabela fixa `impactClass` → severidade. */
26
+ export const SEVERITY_BY_IMPACT: Record<ImpactClass, Severity> = {
27
+ "flow-break": "blocking",
28
+ "contract-drift": "attention",
29
+ degradation: "suggestion",
30
+ cosmetic: "nit",
31
+ };
32
+
33
+ const IMPACT_SET = new Set<string>(IMPACT_CLASSES);
34
+
35
+ /** Tamanho mínimo para casar uma regra com o trecho citado pelo watchdog. */
36
+ const MIN_FRAGMENT_CHARS = 24;
37
+
38
+ /** `true` quando `value` é uma classe de impacto conhecida. */
39
+ export function isImpactClass(value: unknown): value is ImpactClass {
40
+ return typeof value === "string" && IMPACT_SET.has(value);
41
+ }
42
+
43
+ /**
44
+ * Texto canônico da regra para casar `findings[].rule` com `rules[].text`.
45
+ *
46
+ * O brief imprime `- ❌ **blocking** — Promover: …`; o watchdog às vezes copia a
47
+ * linha inteira, às vezes só o trecho. A normalização remove markdown, emoji,
48
+ * bullet e o prefixo de severidade antes de comparar.
49
+ */
50
+ export function normalizeRuleText(text: string): string {
51
+ return text
52
+ .normalize("NFC")
53
+ .replace(/[`*_]/g, "")
54
+ .replace(/\p{Extended_Pictographic}|\uFE0F/gu, "")
55
+ .replace(/\s+/g, " ")
56
+ .trim()
57
+ .toLowerCase()
58
+ .replace(/^[-•]\s*/u, "")
59
+ .replace(/^(blocking|attention|suggestion|nit)\s*[—–-]\s*/u, "")
60
+ .replace(/[.:;]+$/u, "")
61
+ .trim();
62
+ }
63
+
64
+ /** Regra declarada correspondente a um texto de finding. */
65
+ export type DeclaredSeverity = { rule: Rule; severity: Severity };
66
+
67
+ /**
68
+ * Resolve a regra declarada que corresponde ao texto reportado.
69
+ *
70
+ * Exato primeiro; depois contenção (o modelo citou a regra inteira ou um trecho
71
+ * com pelo menos `MIN_FRAGMENT_CHARS`). Empate entre watchdogs fica com a regra
72
+ * mais grave — determinístico e coerente com o dedupe.
73
+ */
74
+ export function resolveDeclaredSeverity(
75
+ ruleText: string,
76
+ rules: readonly Rule[],
77
+ ): DeclaredSeverity | null {
78
+ const needle = normalizeRuleText(ruleText);
79
+ if (!needle) return null;
80
+
81
+ const candidates = rules.map((rule) => ({
82
+ rule,
83
+ text: normalizeRuleText(rule.text),
84
+ }));
85
+ const exact = candidates.filter((item) => item.text === needle);
86
+ const matches =
87
+ exact.length > 0
88
+ ? exact
89
+ : candidates.filter((item) => isFragment(needle, item.text));
90
+ if (matches.length === 0) return null;
91
+
92
+ let best = matches[0].rule;
93
+ for (const item of matches) {
94
+ if (severityRank(item.rule.severity) < severityRank(best.severity)) {
95
+ best = item.rule;
96
+ }
97
+ }
98
+ return { rule: best, severity: best.severity };
99
+ }
100
+
101
+ /** Contenção nos dois sentidos, exigindo fragmento longo o bastante. */
102
+ function isFragment(a: string, b: string): boolean {
103
+ if (a.length < MIN_FRAGMENT_CHARS || b.length < MIN_FRAGMENT_CHARS) return false;
104
+ return a.includes(b) || b.includes(a);
105
+ }
106
+
107
+ /** De onde veio a severidade aplicada. */
108
+ export type SeveritySource = "rule" | "impact";
109
+
110
+ /** Divergência entre o valor emitido e o resolvido (para aviso no finalize). */
111
+ export type SeverityDivergence = {
112
+ watchdogId: string;
113
+ file: string;
114
+ rule: string;
115
+ emitted: Severity;
116
+ resolved: Severity;
117
+ source: SeveritySource;
118
+ };
119
+
120
+ /** Resultado da ancoragem sobre os relatórios de uma run. */
121
+ export type SeverityAnchoring = {
122
+ reports: WatchdogReport[];
123
+ /** Findings de regra cujo texto casou com uma regra declarada. */
124
+ anchored: number;
125
+ /** Findings emergentes com `impactClass` válido. */
126
+ classified: number;
127
+ /** Findings de regra sem casamento (severidade emitida mantida). */
128
+ unmatched: number;
129
+ diverged: SeverityDivergence[];
130
+ };
131
+
132
+ type Resolution = { severity: Severity; source: SeveritySource };
133
+
134
+ /** Severidade declarada pelo motor (null = manter a emitida). */
135
+ function resolveSeverity(finding: Finding, rules: readonly Rule[]): Resolution | null {
136
+ if ((finding.basis ?? inferBasis(finding.rule)) !== "rule") {
137
+ const impact = finding.impactClass;
138
+ if (!isImpactClass(impact)) return null;
139
+ return { severity: SEVERITY_BY_IMPACT[impact], source: "impact" };
140
+ }
141
+ const declared = resolveDeclaredSeverity(finding.rule, rules);
142
+ return declared ? { severity: declared.severity, source: "rule" } : null;
143
+ }
144
+
145
+ /**
146
+ * Gate de prova: fora de regra, `blocking` exige gatilho + evidência.
147
+ *
148
+ * `normalizeFinding` já aplica isso na leitura do relatório; a checagem aqui
149
+ * cobre chamadas com findings construídos em memória (harness/testes).
150
+ */
151
+ function provenSeverity(finding: Finding, resolution: Resolution): Severity {
152
+ if (resolution.source !== "impact" || resolution.severity !== "blocking") {
153
+ return resolution.severity;
154
+ }
155
+ const proven = Boolean(finding.scenario?.trim() && finding.evidence?.trim());
156
+ return proven ? "blocking" : "attention";
157
+ }
158
+
159
+ /**
160
+ * Ancora a severidade de todos os relatórios (função pura — devolve novos objetos).
161
+ * `rules` é a união das regras de todos os watchdogs ativos (inclui o piso do `general`).
162
+ */
163
+ export function anchorReports(
164
+ reports: readonly WatchdogReport[],
165
+ rules: readonly Rule[],
166
+ ): SeverityAnchoring {
167
+ const anchoring: SeverityAnchoring = {
168
+ reports: [],
169
+ anchored: 0,
170
+ classified: 0,
171
+ unmatched: 0,
172
+ diverged: [],
173
+ };
174
+
175
+ for (const report of reports) {
176
+ const findings = report.findings.map((finding) => {
177
+ const resolution = resolveSeverity(finding, rules);
178
+ if (!resolution) {
179
+ if ((finding.basis ?? inferBasis(finding.rule)) === "rule") {
180
+ anchoring.unmatched++;
181
+ }
182
+ return finding;
183
+ }
184
+
185
+ if (resolution.source === "rule") anchoring.anchored++;
186
+ else anchoring.classified++;
187
+
188
+ const severity = provenSeverity(finding, resolution);
189
+ if (severity === finding.severity) return finding;
190
+
191
+ anchoring.diverged.push({
192
+ watchdogId: report.watchdogId,
193
+ file: finding.file,
194
+ rule: finding.rule,
195
+ emitted: finding.severity,
196
+ resolved: severity,
197
+ source: resolution.source,
198
+ });
199
+ return { ...finding, severity };
200
+ });
201
+ anchoring.reports.push({ ...report, findings });
202
+ }
203
+
204
+ return anchoring;
205
+ }
206
+
207
+ /** Texto curto do finding nas mensagens do finalize. */
208
+ function describe(divergence: SeverityDivergence, max = 72): string {
209
+ const text =
210
+ divergence.rule.length <= max
211
+ ? divergence.rule
212
+ : `${divergence.rule.slice(0, max - 1).trimEnd()}…`;
213
+ return `· ${divergence.watchdogId} \`${divergence.file}\` — ${divergence.emitted} → ${divergence.resolved} (${divergence.source === "rule" ? "regra" : "impacto"}): ${text}`;
214
+ }
215
+
216
+ /**
217
+ * Mensagens de resumo da ancoragem (vazias quando nada foi resolvido nem corrigido).
218
+ * `limit` controla quantos exemplos de divergência aparecem.
219
+ */
220
+ export function severityAnchoringMessages(
221
+ anchoring: SeverityAnchoring,
222
+ limit = 3,
223
+ ): string[] {
224
+ const messages: string[] = [];
225
+ if (anchoring.anchored > 0 || anchoring.classified > 0) {
226
+ const parts = [
227
+ anchoring.anchored > 0 ? `${anchoring.anchored} pela regra` : "",
228
+ anchoring.classified > 0 ? `${anchoring.classified} pelo impacto` : "",
229
+ ].filter(Boolean);
230
+ const corrected =
231
+ anchoring.diverged.length > 0
232
+ ? ` — ${anchoring.diverged.length} divergência(s) corrigida(s).`
233
+ : ".";
234
+ messages.push(`Severidade: ${parts.join(", ")}${corrected}`);
235
+ }
236
+ for (const divergence of anchoring.diverged.slice(0, limit)) {
237
+ messages.push(describe(divergence));
238
+ }
239
+ if (anchoring.unmatched > 0) {
240
+ messages.push(
241
+ `Severidade: ${anchoring.unmatched} finding(s) de regra sem casamento com as regras declaradas (valor emitido mantido).`,
242
+ );
243
+ }
244
+ return messages;
245
+ }
@@ -122,6 +122,7 @@ export type ArtifactKind =
122
122
  | "hunt"
123
123
  | "prepare"
124
124
  | "compare"
125
+ | "golden"
125
126
  | "other";
126
127
 
127
128
  /** Artefato persistido de uma review. */
package/src/index.ts CHANGED
@@ -214,6 +214,9 @@ export {
214
214
  } from "./core/findings-state";
215
215
  export type { GitResult, GitRunner } from "./core/git-runner";
216
216
  export type {
217
+ BusinessDecision,
218
+ CheckCoverage,
219
+ CheckVerdict,
217
220
  HuntEvent,
218
221
  HuntReport,
219
222
  HuntRunnerOptions,
@@ -230,32 +233,51 @@ export type {
230
233
  MakiRunRequest,
231
234
  MakiRunResult,
232
235
  MakiUsage,
236
+ ReviewCheck,
237
+ ReviewRule,
238
+ RuleCheck,
233
239
  } from "./core/hunt";
234
240
  export {
241
+ briefRules,
242
+ businessScore,
243
+ chooseBusinessSummary,
244
+ expectedReviewItems,
235
245
  extractJsonObject,
236
246
  HuntRunner,
237
247
  houndsmanKeyEnvFor,
238
248
  installMaki,
239
249
  MAKI_API_KEY_ENV,
240
250
  MAKI_DISALLOWED_TOOLS,
251
+ MAKI_REVIEW_SYSTEM_PROMPT,
241
252
  makiArgs,
242
253
  makiCurrentFile,
243
254
  makiInstallDir,
244
255
  makiSourceLabel,
245
256
  makiToolsRoot,
246
257
  mapPool,
258
+ mergeFindings,
259
+ mergeRuleChecks,
247
260
  metricsFrom,
261
+ missingReviewItems,
262
+ missingRuleChecks,
248
263
  parseMakiOutput,
264
+ parseReviewChecks,
265
+ parseRuleChecks,
249
266
  probeMakiVersion,
250
267
  readMakiInstall,
268
+ renderChecklistSuffix,
251
269
  renderHuntPrompt,
270
+ renderResumeSuffix,
252
271
  renderRetrySuffix,
253
272
  requireMakiBinary,
254
273
  requireMakiModel,
255
274
  resolveMakiBinary,
256
275
  resolveMakiModel,
257
276
  SpawnMakiRunner,
277
+ summarizeChecks,
278
+ summarizeRuleChecks,
258
279
  uninstallMaki,
280
+ withInferredRule,
259
281
  } from "./core/hunt";
260
282
  export {
261
283
  CORE_DIR,
@@ -37,6 +37,7 @@ staging global da review:
37
37
  "evidence": "import { db } from '../db'",
38
38
  "scenario": "request autenticada em /orders executa a query na rota",
39
39
  "impact": "acoplamento e bypass das regras do service",
40
+ "impactClass": "flow-break",
40
41
  "confidence": 0.9,
41
42
  "basis": "logic"
42
43
  }
@@ -61,11 +62,26 @@ staging global da review:
61
62
  | `findings[].evidence` | string | — | Trecho que comprova o achado |
62
63
  | `findings[].scenario` | string | — | Gatilho concreto que expõe o gap (ex.: "renovação de assinatura Pix sem split") |
63
64
  | `findings[].impact` | string | — | Consequência observável (ex.: "cobrança recriada sem o split configurado") |
65
+ | `findings[].impactClass` | `"flow-break" \| "contract-drift" \| "degradation" \| "cosmetic"` | emergente | Classe de impacto que define a severidade fora de regra |
64
66
  | `findings[].confidence` | number | — | Confiança do revisor (0–1) |
65
67
  | `findings[].basis` | `"rule" \| "logic" \| "domain"` | — | Origem do achado; inferida pelo prefixo `Lógica:`/`Domínio:` |
66
68
 
67
69
  ## Severidade
68
70
 
71
+ A severidade é **derivada**, não escolhida pelo watchdog:
72
+
73
+ | Origem do finding | Fonte da severidade |
74
+ |---|---|
75
+ | Regra do brief (`rule` copiada) | `severity` **declarada na regra** — outro valor é ignorado e corrigido pelo motor |
76
+ | Emergente (`Lógica:`/`Domínio:`) | `impactClass`, pela tabela abaixo |
77
+
78
+ | `impactClass` | Quando usar | Severidade |
79
+ |---|---|---|
80
+ | `flow-break` | o caminho não completa (criação/renovação/cancelamento) ou a capacidade declarada não é honrada | `blocking` (exige `scenario` + `evidence`) |
81
+ | `contract-drift` | contrato, identificador ou estado divergente — mas o fluxo completa | `attention` |
82
+ | `degradation` | funciona, mas com borda, erro, retry ou idempotência frágil | `suggestion` |
83
+ | `cosmetic` | clareza, nome, detalhe cosmético | `nit` |
84
+
69
85
  | Severidade | Emoji | Bloqueia merge? |
70
86
  |---|---|---|
71
87
  | `blocking` | ❌ | Sim |
@@ -73,6 +89,9 @@ staging global da review:
73
89
  | `suggestion` | 💡 | Não |
74
90
  | `nit` | 🔎 | Não (detalhe/observação) |
75
91
 
92
+ > Sem `impactClass` (relatório antigo), o motor mantém o valor emitido em `severity`.
93
+ > Conflito de severidade numa mesma regra entre reviews deixa de existir: a regra é a fonte.
94
+
76
95
  ## Regras
77
96
 
78
97
  - Sem achados → `"findings": []` (arquivo **obrigatório**).
@@ -109,7 +128,7 @@ Demonstre o gap em três partes:
109
128
 
110
129
  **Severidade:** fora de regra, `blocking` só é aceito com `scenario` **e** `evidence`. Sem os
111
130
  dois, o motor rebaixa para `attention` (e registra um aviso). Regras explícitas mantêm a
112
- severidade declarada pelo watchdog.
131
+ severidade declarada **na regra**.
113
132
 
114
133
  Os demais watchdogs podem usar os mesmos prefixos apenas quando declararem
115
134
  `allowEmergentFindings: true`.
@@ -67,6 +67,7 @@ O CLI só substitui o despacho manual dos subagentes por processos maki headless
67
67
  | `houndsman --finalize` | Passo 3 — gravar a triagem e o resumo final (`--pr <n>` reusa o worktree) |
68
68
  | `houndsman hunt [--pr <n>]` | Harness local opcional (maki): prepare → watchdogs → finalize |
69
69
  | `houndsman compare` | Compara duas runs/variantes (`--list`, `--a`, `--b` ou dois slugs) |
70
+ | `houndsman golden` | Avalia runs contra um golden set de defeitos (`recall`/`precisão`) |
70
71
  | `houndsman hunt install` | Baixa o maki fixado para `<HOUNDSMAN_HOME>/tools/maki` |
71
72
  | `houndsman worktree <ação>` | `list`/`add`/`remove`/`prune`/`path` dos worktrees |
72
73
  | `houndsman watchdog <id>` | Contexto de um watchdog (`--json`, `--rules`) |
package/src/skill/hunt.md CHANGED
@@ -22,6 +22,66 @@ Os dois modos produzem os mesmos artefatos, usam o mesmo staging e o mesmo ciclo
22
22
  > maki e despacha outro harness. Siga os passos 0–3 normalmente; o comando CLI é para quem
23
23
  > quer rodar tudo no terminal.
24
24
 
25
+ ### Harness local (CLI) — profundidade por checklist
26
+
27
+ `houndsman hunt` usa o mesmo `prepare`/`finalize`, mas cada watchdog roda headless. Para não
28
+ trocar profundidade por velocidade:
29
+
30
+ - o **`general` roda em três papéis separados** (sessões distintas): (1) **fluxo** — caça
31
+ achados sem devolver `checks`/`rules`; (2) **bookkeeping** — tarefa estreita, só vereditos
32
+ com `at` comprovado; (3) **adversarial** — sessão nova, com os achados já cobertos como
33
+ contexto, atacando as cinco classes (credencial/estado, capacidade, identificador,
34
+ idempotência, contrato). Antes, um único JSON de 60 KB misturava tudo e o revisor entregava
35
+ poucos achados com checklist “ok” sem lastro;
36
+ - nos demais watchdogs, o prompt exige **uma verificação por fluxo/invariante** (`checks`:
37
+ `ok`/`gap`/`n/a` + `arquivo:linha`) e **uma por regra numerada** (`rules`);
38
+ - **`n/a` não é cobertura**: só conta como verificado o `ok`/`gap` cujo `at` resolve para um
39
+ arquivo existente e uma linha dentro dele (e, quando o arquivo não está na raiz, não conta);
40
+ - se a primeira resposta não cobrir o checklist, o harness **retoma a sessão** (`--resume`) e
41
+ pede só os vereditos pendentes; os achados das passadas são **unidos** (dedupe por
42
+ arquivo+linha+regra, maior severidade vence);
43
+ - o prompt do `general` só pede o `businessSummary` quando o prepare **não** trouxe um modelo
44
+ da IA (gerar um resumo que seria rejeitado custava atenção do revisor);
45
+ - o orçamento de exploração cresce com o tamanho do domínio (piso 6/10, teto 14/24 leituras);
46
+ - a saída do maki concatena **todas** as mensagens do agente; o parser procura o último JSON
47
+ válido (bloco cercado ou objeto balanceado), não o primeiro `{`;
48
+ - retry de saída inválida **retoma a mesma sessão** enviando só o schema — não refaz a
49
+ exploração inteira; e o system prompt do harness prioriza profundidade sobre economia de
50
+ tool calls;
51
+ - `hunt-report.json` traz `rules`/`checks { expected, verified, missing, notApplicable, gaps }`,
52
+ `coverage` (`reviewed`/`skipped`/`undeclared`), o custo de cada passada extra (`checklist`,
53
+ `secondOpinion`) e a decisão sobre o modelo de intenção por watchdog. Desligue com
54
+ `--no-checklist`/`--no-second-opinion` (ou `hunt.checklist`/`hunt.secondOpinion: false`).
55
+
56
+ ### Golden set — medir em vez de contar
57
+
58
+ Contagem de findings não mede profundidade: variantes diferentes amostram pontos diferentes do
59
+ mesmo diff. O golden set **não gera** achados nem julga intenção — é uma lista de defeitos que
60
+ você já confirmou no código (âncora), usada para dar nota a uma execução:
61
+
62
+ - **recall** = quantos defeitos congelados a run encontrou;
63
+ - **precisão** = quanto do que a run reportou cai num defeito congelado (o resto pode ser achado
64
+ legítimo ainda não triado — o relatório avisa);
65
+ - **faltaram / fora do golden** = onde melhorar o brief/harness e o que ainda falta triar.
66
+
67
+ ```bash
68
+ houndsman golden .houndsman/golden/<pr>.json # todas as variantes da branch
69
+ houndsman golden .houndsman/golden/<pr>.json --variant x # uma variante
70
+ ```
71
+
72
+ - formato: `{ "version": 1, "commit": "abc1234", "expected": [{ "id", "file", "line",
73
+ "tolerance", "match", "severity", "note" }] }`;
74
+ - cada expectativa é uma **âncora** (arquivo + faixa de linha + trecho), nunca um fingerprint;
75
+ um finding comprova no máximo um defeito;
76
+ - o `commit` é a referência do congelamento. Run que revisou **outro commit** é avaliada
77
+ normalmente, mas o relatório abre com **aviso** de commit divergente (não é erro): as linhas
78
+ podem ter se deslocado e a “falta” pode ser drift, não perda de profundidade — revalide o
79
+ golden antes de concluir;
80
+ - `.houndsman/golden/` é massa local de avaliação (fica no `.gitignore` da camada), não
81
+ configuração do projeto;
82
+ - saída gravada como `golden-<slug>.md|json` na run avaliada. Use para comparar
83
+ prompts/harness na **mesma** PR.
84
+
25
85
  ## Alvo da revisão
26
86
 
27
87
  Traduza o pedido do usuário para flags **antes** de começar:
@@ -147,6 +147,9 @@ Cada informação tem o seu campo — não espelhe a regra em vários lugares:
147
147
 
148
148
  > Severidade não é aspiração: se o projeto só aplica o padrão em 15/72 lugares, é `suggestion`.
149
149
 
150
+ > A severidade é **derivada**, não escolhida na revisão: finding de regra usa a severidade
151
+ > declarada aqui; emergente usa `impactClass`. O motor corrige divergências no `--finalize`.
152
+
150
153
  ---
151
154
 
152
155
  ## Pré-análise Jev (opcional)
@@ -27,8 +27,14 @@ src/alpha/**
27
27
 
28
28
  ## Regras (por severidade)
29
29
 
30
- - ❌ **blocking** — Nunca usar \`any\`.
31
- - ⚠️ **attention** — Promover: validar a entrada.
30
+ - 1. ❌ **blocking** — Nunca usar \`any\`.
31
+ - 2. ⚠️ **attention** — Promover: validar a entrada.
32
+
33
+ ## Como declarar a severidade
34
+
35
+ - **Finding de regra:** copie \`rule\` exatamente como no brief. A severidade é a **declarada na regra** — outro valor é ignorado e corrigido pelo motor.
36
+ - **Finding emergente** (\`Lógica:\`/\`Domínio:\`): preencha \`impactClass\` — \`flow-break\` (fluxo/capacidade não honrada), \`contract-drift\` (contrato/estado divergente), \`degradation\` (borda/erro frágil) ou \`cosmetic\`.
37
+ - \`blocking\` fora de regra continua exigindo \`scenario\` + \`evidence\`; sem os dois, o motor rebaixa para \`attention\`.
32
38
 
33
39
  ## Docs de contexto (carregar sob demanda)
34
40
 
@@ -307,10 +313,12 @@ houndsman --pr 456 --finalize
307
313
  ".houndsman/.gitignore":
308
314
  "# Diretórios gerados pelo houndsman — \`core/\` é regenerado por \`houndsman update\`.
309
315
  # \`work/\` e \`worktrees/\` são runtime de revisão (staging e worktrees).
316
+ # \`golden/\` é massa de avaliação local (defeitos congelados por commit).
310
317
  core/
311
318
  .runs/
312
319
  work/
313
320
  worktrees/
321
+ golden/
314
322
  "
315
323
  ,
316
324
  ".houndsman/core/types.ts":
@@ -625,11 +633,11 @@ exports[`baseline — camada gerada setup gera os mesmos arquivos e conteúdos 3
625
633
  },
626
634
  {
627
635
  "file": ".houndsman/core/skill/OUTPUT-FORMAT.md",
628
- "sha256": "49d36854d10f795c",
636
+ "sha256": "8754e8faaf66c4f9",
629
637
  },
630
638
  {
631
639
  "file": ".houndsman/core/skill/README.md",
632
- "sha256": "4d200cf9e5c1d748",
640
+ "sha256": "b9d5725a87365c06",
633
641
  },
634
642
  {
635
643
  "file": ".houndsman/core/skill/SETUP.md",
@@ -645,11 +653,11 @@ exports[`baseline — camada gerada setup gera os mesmos arquivos e conteúdos 3
645
653
  },
646
654
  {
647
655
  "file": ".houndsman/core/skill/hunt.md",
648
- "sha256": "0a81003a5b39b84a",
656
+ "sha256": "10ecf846c3b6880e",
649
657
  },
650
658
  {
651
659
  "file": ".houndsman/core/skill/train.md",
652
- "sha256": "d774215c68d15159",
660
+ "sha256": "e2742937c6f943a5",
653
661
  },
654
662
  {
655
663
  "file": ".houndsman/core/skill/verify.md",
@@ -893,8 +901,14 @@ index 0000000..f4082f0
893
901
 
894
902
  ## Regras (por severidade)
895
903
 
896
- - ❌ **blocking** — Nunca usar \`any\`.
897
- - ⚠️ **attention** — Promover: validar a entrada.
904
+ - 1. ❌ **blocking** — Nunca usar \`any\`.
905
+ - 2. ⚠️ **attention** — Promover: validar a entrada.
906
+
907
+ ## Como declarar a severidade
908
+
909
+ - **Finding de regra:** copie \`rule\` exatamente como no brief. A severidade é a **declarada na regra** — outro valor é ignorado e corrigido pelo motor.
910
+ - **Finding emergente** (\`Lógica:\`/\`Domínio:\`): preencha \`impactClass\` — \`flow-break\` (fluxo/capacidade não honrada), \`contract-drift\` (contrato/estado divergente), \`degradation\` (borda/erro frágil) ou \`cosmetic\`.
911
+ - \`blocking\` fora de regra continua exigindo \`scenario\` + \`evidence\`; sem os dois, o motor rebaixa para \`attention\`.
898
912
 
899
913
  ## Docs de contexto (carregar sob demanda)
900
914
 
@@ -929,7 +943,8 @@ index 0000000..f4082f0
929
943
  `;
930
944
 
931
945
  exports[`baseline — artefatos de prepare/finalize gera classification, brief, manifest, resumo e state 2`] = `
932
- "Triagem: sqlite:<cwd>\\houndsman.db#<uuid> — 1 finding(s).
946
+ "Severidade: 1 pela regra.
947
+ Triagem: sqlite:<cwd>\\houndsman.db#<uuid> — 1 finding(s).
933
948
  Resumo: sqlite:<cwd>\\houndsman.db#<uuid> (review-resume.md) — 1 bloqueante(s), 0 atenção."
934
949
  `;
935
950
 
@@ -121,6 +121,29 @@ describe("buildComparison", () => {
121
121
  expect(markdown).toContain("## Severidade mudou (1)");
122
122
  expect(markdown).toContain("## Só em B");
123
123
  });
124
+
125
+ test("expõe checklist, cobertura e custo das passadas extras do harness", () => {
126
+ const withHarness: ReviewSnapshot = {
127
+ ...snapshot("B", []),
128
+ harness: {
129
+ checksExpected: 8,
130
+ checksVerified: 6,
131
+ checksGaps: 2,
132
+ reviewedFiles: 11,
133
+ skippedFiles: 3,
134
+ undeclaredFiles: 4,
135
+ businessScore: 20,
136
+ extraCostUsd: 0.035,
137
+ secondOpinionFindings: 2,
138
+ },
139
+ };
140
+ const markdown = renderComparison(buildComparison(a, withHarness));
141
+ expect(markdown).toContain("| Checklist verificado | — | 6/8 | — |");
142
+ expect(markdown).toContain("| Arquivos sem declaração | — | 4 | — |");
143
+ expect(markdown).toContain("| Modelo de intenção (score) | — | 20 | — |");
144
+ expect(markdown).toContain("| Passadas extras (custo) | — | $0.0350 | — |");
145
+ expect(markdown).toContain("| Segunda opinião (findings) | — | 2 | — |");
146
+ });
124
147
  });
125
148
 
126
149
  describe("--variant", () => {
@@ -33,6 +33,7 @@ function merged(
33
33
  partial: Partial<MergedFinding> & { file: string; rule: string },
34
34
  ): MergedFinding {
35
35
  return {
36
+ ...partial,
36
37
  rule: partial.rule,
37
38
  severity: (partial.severity ?? "attention") as Severity,
38
39
  file: partial.file,
@@ -209,6 +210,87 @@ describe("buildStateFromFindings", () => {
209
210
  project.cleanup();
210
211
  }
211
212
  });
213
+
214
+ test("emergente sem impactClass mantém a severidade já triada", () => {
215
+ const project = makeProject({ "src/f.ts": DUPLICATED });
216
+ try {
217
+ const reader = fileReader(project.cwd);
218
+ const base = {
219
+ file: "src/f.ts",
220
+ rule: "Lógica: gap",
221
+ line: 2,
222
+ message: "gap",
223
+ basis: "logic" as const,
224
+ };
225
+ const first = buildStateFromFindings(
226
+ [merged({ ...base, severity: "blocking", scenario: "s", evidence: "x" })],
227
+ { branch: "main", readFile: reader },
228
+ );
229
+ const second = buildStateFromFindings(
230
+ [merged({ ...base, severity: "suggestion" })],
231
+ { branch: "main", readFile: reader, previous: first },
232
+ );
233
+ expect(Object.values(second.findings)[0].severity).toBe("blocking");
234
+ } finally {
235
+ project.cleanup();
236
+ }
237
+ });
238
+
239
+ test("emergente com impactClass acompanha o valor derivado", () => {
240
+ const project = makeProject({ "src/f.ts": DUPLICATED });
241
+ try {
242
+ const reader = fileReader(project.cwd);
243
+ const base = {
244
+ file: "src/f.ts",
245
+ rule: "Domínio: gap",
246
+ line: 2,
247
+ message: "gap",
248
+ basis: "domain" as const,
249
+ };
250
+ const first = buildStateFromFindings(
251
+ [merged({ ...base, severity: "suggestion", impactClass: "degradation" })],
252
+ { branch: "main", readFile: reader },
253
+ );
254
+ const second = buildStateFromFindings(
255
+ [
256
+ merged({
257
+ ...base,
258
+ severity: "attention",
259
+ impactClass: "contract-drift",
260
+ }),
261
+ ],
262
+ { branch: "main", readFile: reader, previous: first },
263
+ );
264
+ expect(Object.values(second.findings)[0].severity).toBe("attention");
265
+ } finally {
266
+ project.cleanup();
267
+ }
268
+ });
269
+
270
+ test("finding de regra sempre acompanha a severidade nova (config manda)", () => {
271
+ const project = makeProject({ "src/f.ts": DUPLICATED });
272
+ try {
273
+ const reader = fileReader(project.cwd);
274
+ const base = {
275
+ file: "src/f.ts",
276
+ rule: "Nunca usar any",
277
+ line: 2,
278
+ message: "any",
279
+ basis: "rule" as const,
280
+ };
281
+ const first = buildStateFromFindings(
282
+ [merged({ ...base, severity: "blocking" })],
283
+ { branch: "main", readFile: reader },
284
+ );
285
+ const second = buildStateFromFindings(
286
+ [merged({ ...base, severity: "attention" })],
287
+ { branch: "main", readFile: reader, previous: first },
288
+ );
289
+ expect(Object.values(second.findings)[0].severity).toBe("attention");
290
+ } finally {
291
+ project.cleanup();
292
+ }
293
+ });
212
294
  });
213
295
 
214
296
  describe("syncState", () => {