@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.
- package/package.json +1 -1
- package/src/cli/commands/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +85 -1
- package/src/cli/help.ts +3 -0
- package/src/cli/index.ts +2 -0
- package/src/core/brief.ts +35 -7
- package/src/core/commands/compare.ts +33 -0
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/scaffold.ts +2 -0
- package/src/core/compare.ts +67 -0
- package/src/core/config.ts +10 -0
- package/src/core/engine/finalize-pipeline.ts +25 -1
- package/src/core/findings-state.ts +9 -1
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +546 -49
- package/src/core/hunt/index.ts +38 -1
- package/src/core/hunt/maki-prompt.ts +289 -8
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/review-checks.ts +460 -0
- package/src/core/project-config.ts +10 -0
- package/src/core/registry-check.ts +30 -6
- package/src/core/report-builder.ts +9 -1
- package/src/core/severity.ts +245 -0
- package/src/core/storage/types.ts +1 -0
- package/src/index.ts +22 -0
- package/src/skill/OUTPUT-FORMAT.md +20 -1
- package/src/skill/README.md +1 -0
- package/src/skill/hunt.md +60 -0
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +24 -9
- package/src/tests/compare.test.ts +23 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +652 -2
- package/src/tests/review-scripts.test.ts +19 -0
- package/src/tests/severity.test.ts +235 -0
|
@@ -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
|
+
}
|
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
|
|
131
|
+
severidade declarada **na regra**.
|
|
113
132
|
|
|
114
133
|
Os demais watchdogs podem usar os mesmos prefixos apenas quando declararem
|
|
115
134
|
`allowEmergentFindings: true`.
|
package/src/skill/README.md
CHANGED
|
@@ -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:
|
package/src/skill/train.md
CHANGED
|
@@ -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": "
|
|
636
|
+
"sha256": "8754e8faaf66c4f9",
|
|
629
637
|
},
|
|
630
638
|
{
|
|
631
639
|
"file": ".houndsman/core/skill/README.md",
|
|
632
|
-
"sha256": "
|
|
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": "
|
|
656
|
+
"sha256": "10ecf846c3b6880e",
|
|
649
657
|
},
|
|
650
658
|
{
|
|
651
659
|
"file": ".houndsman/core/skill/train.md",
|
|
652
|
-
"sha256": "
|
|
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
|
-
"
|
|
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", () => {
|