@payconductor/houndsman 0.13.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/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/commands/compare-command.ts +112 -0
- package/src/cli/commands/context-command.ts +1 -0
- package/src/cli/commands/findings-command.ts +11 -1
- package/src/cli/commands/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +105 -4
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +6 -1
- package/src/cli/index.ts +5 -1
- package/src/cli/progress.ts +7 -2
- package/src/core/ai/ai-client.ts +38 -2
- package/src/core/ai/features/brief-enricher.ts +1 -1
- package/src/core/ai/features/business-analyzer.ts +61 -0
- package/src/core/ai/features/business-summarizer.ts +44 -0
- package/src/core/ai/index.ts +8 -0
- package/src/core/ai/prompts/brief.ts +33 -7
- package/src/core/ai/prompts/business.ts +76 -0
- package/src/core/ai/provider-capabilities.ts +12 -0
- package/src/core/ai/schemas.ts +70 -2
- package/src/core/ai/usage-recorder.ts +53 -1
- package/src/core/brief.ts +278 -16
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +266 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +29 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +310 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +41 -3
- package/src/core/defaults.ts +50 -4
- package/src/core/domain/budget.ts +62 -4
- package/src/core/domain/classification.ts +12 -2
- package/src/core/engine/carryover.ts +4 -0
- package/src/core/engine/finalize-pipeline.ts +371 -0
- package/src/core/engine/prepare-pipeline.ts +1294 -0
- package/src/core/engine/review-context.ts +51 -0
- package/src/core/engine/review-options.ts +47 -0
- package/src/core/engine/review-scope.ts +14 -1
- package/src/core/engine/review-session.ts +11 -13
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/findings-state.ts +22 -2
- package/src/core/github-review.ts +5 -2
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +621 -34
- package/src/core/hunt/index.ts +40 -1
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +298 -5
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/hunt/review-checks.ts +460 -0
- package/src/core/logging/index.ts +6 -1
- package/src/core/logging/progress.ts +85 -1
- package/src/core/orchestrator.ts +33 -1289
- package/src/core/paths.ts +6 -3
- package/src/core/pr-context.ts +2 -4
- package/src/core/prepare-report.ts +61 -0
- package/src/core/project-config.ts +18 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +90 -27
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +116 -278
- package/src/core/severity.ts +245 -0
- package/src/core/storage/file/file-storage.ts +7 -3
- package/src/core/storage/sqlite/mappers.ts +10 -0
- package/src/core/storage/sqlite/schema.ts +104 -23
- package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
- package/src/core/storage/types.ts +17 -4
- package/src/core/storage/work-area.ts +59 -1
- package/src/core/types.ts +31 -0
- package/src/core/watchdog-registry.ts +21 -3
- package/src/index.ts +51 -4
- package/src/server/client/ArtifactsPanel.tsx +4 -2
- package/src/server/client/FindingCard.tsx +21 -1
- package/src/server/client/RunsSection.tsx +5 -0
- package/src/server/client/utils/constants.ts +21 -11
- package/src/server/client/utils/markdown.ts +1 -1
- package/src/server/client/utils/types.ts +14 -2
- package/src/server/run-repository.ts +35 -14
- package/src/server/styles.css +2 -0
- package/src/skill/OUTPUT-FORMAT.md +55 -10
- package/src/skill/README.md +19 -14
- package/src/skill/SETUP.md +1 -0
- package/src/skill/SKILL.md +13 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +205 -58
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
- package/src/tests/ai-core.test.ts +46 -0
- package/src/tests/ai-features.test.ts +95 -4
- package/src/tests/baseline.test.ts +29 -121
- package/src/tests/brief-scope.test.ts +250 -0
- package/src/tests/business-summary.test.ts +258 -0
- package/src/tests/compare.test.ts +256 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +710 -2
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +29 -1
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +38 -29
- package/src/tests/review-scripts.test.ts +125 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/severity.test.ts +235 -0
- package/src/tests/storage.test.ts +194 -9
- package/src/core/renderers/report-renderer.ts +0 -66
- package/src/skill/example-review.md +0 -66
|
@@ -22,10 +22,16 @@ export function parseReviewArgs(args: string[]): ReviewOptions {
|
|
|
22
22
|
const branch = parser.value("--branch");
|
|
23
23
|
const pr = parser.value("--pr");
|
|
24
24
|
const worktree = parser.value("--worktree");
|
|
25
|
+
const variant = parser.value("--variant");
|
|
25
26
|
if (base) opts.base = base;
|
|
26
27
|
if (head) opts.head = head;
|
|
27
28
|
if (branch) opts.branch = branch;
|
|
28
29
|
if (worktree) opts.worktree = worktree;
|
|
30
|
+
if (variant !== undefined) {
|
|
31
|
+
const label = variant.trim();
|
|
32
|
+
if (!label) throw new Error("--variant não pode ser vazio.");
|
|
33
|
+
opts.variant = label;
|
|
34
|
+
}
|
|
29
35
|
if (pr) {
|
|
30
36
|
const number = Number.parseInt(pr, 10);
|
|
31
37
|
if (!Number.isFinite(number) || number <= 0) {
|
|
@@ -98,6 +98,15 @@ export const houndsmanConfig = {
|
|
|
98
98
|
watchdogs: WATCHDOG_DEFINITIONS,
|
|
99
99
|
exclusions: EXCLUSION_PATTERNS,
|
|
100
100
|
docMappings: DOC_MAPPINGS,
|
|
101
|
+
// Prioridade por caminho (opcional): "linhas de churn equivalentes".
|
|
102
|
+
// \`bonus\` promove caminho crítico; \`maxRiskFromChurn\` satura o superchurn de
|
|
103
|
+
// caminho de baixa criticidade (risco = min(churn, teto) + bonus).
|
|
104
|
+
// A primeira regra que casar vence; sem regra, o risco é o churn puro.
|
|
105
|
+
// priorities: [
|
|
106
|
+
// { pattern: /^src\\/(db|repositories)\\//, bonus: 300 },
|
|
107
|
+
// { pattern: /^src\\/(services|domain)\\//, bonus: 200 },
|
|
108
|
+
// { pattern: /^src\\/(components|styles)\\//, maxRiskFromChurn: 120 },
|
|
109
|
+
// ],
|
|
101
110
|
} satisfies HoundsmanConfig;
|
|
102
111
|
|
|
103
112
|
export default houndsmanConfig;
|
|
@@ -215,6 +224,7 @@ export type AiProviderOptions = Record<string, Record<string, unknown>>;
|
|
|
215
224
|
/** Features de IA do fluxo normal (todas fail-open). */
|
|
216
225
|
export type HoundsmanAiFeatures = {
|
|
217
226
|
context?: boolean;
|
|
227
|
+
business?: boolean;
|
|
218
228
|
brief?: boolean;
|
|
219
229
|
technical?: boolean;
|
|
220
230
|
classification?: boolean;
|
|
@@ -252,6 +262,18 @@ export type HoundsmanHunt = {
|
|
|
252
262
|
binaryPath?: string;
|
|
253
263
|
};
|
|
254
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Prioridade por caminho.
|
|
267
|
+
*
|
|
268
|
+
* \`bonus\` promove (linhas de churn equivalentes) e \`maxRiskFromChurn\` satura o
|
|
269
|
+
* superchurn de caminhos de baixa criticidade.
|
|
270
|
+
*/
|
|
271
|
+
export type HoundsmanPriority = {
|
|
272
|
+
pattern: RegExp;
|
|
273
|
+
bonus?: number;
|
|
274
|
+
maxRiskFromChurn?: number;
|
|
275
|
+
};
|
|
276
|
+
|
|
255
277
|
/** Contrato de configuração do motor. */
|
|
256
278
|
export type HoundsmanConfig<TId extends string = string> = {
|
|
257
279
|
projectName: string;
|
|
@@ -260,6 +282,8 @@ export type HoundsmanConfig<TId extends string = string> = {
|
|
|
260
282
|
watchdogs: Record<TId, WatchdogDefinition>;
|
|
261
283
|
exclusions: readonly RegExp[];
|
|
262
284
|
docMappings: readonly (readonly [RegExp, readonly string[]])[];
|
|
285
|
+
/** Prioridade por caminho (opcional; a primeira regra que casar vence). */
|
|
286
|
+
priorities?: readonly HoundsmanPriority[];
|
|
263
287
|
alwaysIncludeDocs?: readonly string[];
|
|
264
288
|
budgets?: HoundsmanBudgets;
|
|
265
289
|
concurrency?: HoundsmanConcurrency;
|
|
@@ -365,6 +389,9 @@ import { exampleWatchdog } from "./example";
|
|
|
365
389
|
/**
|
|
366
390
|
* Definições dos watchdogs — as chaves definem o union \`WatchdogId\`.
|
|
367
391
|
* Adicionar um watchdog = criar \`<id>.ts\` e registrá-lo aqui.
|
|
392
|
+
*
|
|
393
|
+
* NÃO declare um watchdog com id \`general\`: ele é reservado pelo motor
|
|
394
|
+
* (revisor de fluxo obrigatório) e é injetado em todo ciclo.
|
|
368
395
|
*/
|
|
369
396
|
export const WATCHDOG_DEFINITIONS = {
|
|
370
397
|
default: defaultWatchdog,
|
|
@@ -399,10 +426,12 @@ export const DOC_MAPPINGS: readonly (readonly [RegExp, readonly string[]])[] = [
|
|
|
399
426
|
|
|
400
427
|
const PROJECT_GITIGNORE = `# Diretórios gerados pelo houndsman — \`core/\` é regenerado por \`houndsman update\`.
|
|
401
428
|
# \`work/\` e \`worktrees/\` são runtime de revisão (staging e worktrees).
|
|
429
|
+
# \`golden/\` é massa de avaliação local (defeitos congelados por commit).
|
|
402
430
|
core/
|
|
403
431
|
.runs/
|
|
404
432
|
work/
|
|
405
433
|
worktrees/
|
|
434
|
+
golden/
|
|
406
435
|
`;
|
|
407
436
|
|
|
408
437
|
// ---------------------------------------------------------------------------
|
|
@@ -33,11 +33,14 @@ function findReview(
|
|
|
33
33
|
storage: StorageProvider,
|
|
34
34
|
repo: RepoIdentity,
|
|
35
35
|
branch?: string,
|
|
36
|
+
variant?: string,
|
|
36
37
|
): ReviewRecord {
|
|
37
38
|
const target = branch ?? currentBranch();
|
|
38
|
-
const review = storage.findReview(repo.key, target);
|
|
39
|
+
const review = storage.findReview(repo.key, target, variant);
|
|
39
40
|
if (!review) {
|
|
40
|
-
throw new Error(
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Nenhuma revisão para '${target}'${variant ? ` (variante '${variant}')` : ""}.`,
|
|
43
|
+
);
|
|
41
44
|
}
|
|
42
45
|
return review;
|
|
43
46
|
}
|
|
@@ -47,8 +50,9 @@ export function readStatus(
|
|
|
47
50
|
storage: StorageProvider,
|
|
48
51
|
repo: RepoIdentity,
|
|
49
52
|
branch?: string,
|
|
53
|
+
variant?: string,
|
|
50
54
|
): StatusResult {
|
|
51
|
-
return toResult(findReview(storage, repo, branch));
|
|
55
|
+
return toResult(findReview(storage, repo, branch, variant));
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** Renova o heartbeat de uma execução ativa (`reviewing`/`fixing`). */
|
|
@@ -56,8 +60,9 @@ export function pingStatus(
|
|
|
56
60
|
storage: StorageProvider,
|
|
57
61
|
repo: RepoIdentity,
|
|
58
62
|
branch?: string,
|
|
63
|
+
variant?: string,
|
|
59
64
|
now?: string,
|
|
60
65
|
): StatusResult {
|
|
61
|
-
const review = findReview(storage, repo, branch);
|
|
66
|
+
const review = findReview(storage, repo, branch, variant);
|
|
62
67
|
return toResult(touchRunStatus(storage, review.id, now));
|
|
63
68
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparação A/B de duas revisões da mesma branch (funções puras).
|
|
3
|
+
*
|
|
4
|
+
* O objetivo é responder "o que mudou entre as duas execuções?" sem tentar
|
|
5
|
+
* eleger um vencedor: conta achados por severidade, cobertura, tempo e tokens,
|
|
6
|
+
* e lista o que apareceu/sumiu entre A e B.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SEVERITY_ORDER, type Severity } from "./types";
|
|
10
|
+
|
|
11
|
+
/** Finding normalizado para a comparação. */
|
|
12
|
+
export type ComparisonFinding = {
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
rule: string;
|
|
15
|
+
severity: Severity;
|
|
16
|
+
file: string;
|
|
17
|
+
line?: number;
|
|
18
|
+
message: string;
|
|
19
|
+
watchdogs: readonly string[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Snapshot de uma execução (uma das pontas do A/B). */
|
|
23
|
+
export type ReviewSnapshot = {
|
|
24
|
+
/** Rótulo exibido (`A`, `B` ou a variante). */
|
|
25
|
+
label: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
branch: string;
|
|
28
|
+
variant?: string;
|
|
29
|
+
headSha?: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
/** Achados presentes no último relatório. */
|
|
32
|
+
findings: readonly ComparisonFinding[];
|
|
33
|
+
bySeverity: Record<Severity, number>;
|
|
34
|
+
coverage: { watchdogs: number; files: number; overflow: number };
|
|
35
|
+
/** Duração do ciclo (`startedAt` → `reviewedAt`), quando medida. */
|
|
36
|
+
durationMs?: number;
|
|
37
|
+
/** Duração do prepare (`prepare-report.json`). */
|
|
38
|
+
prepareMs?: number;
|
|
39
|
+
/** Duração do harness maki (`hunt-report.json`). */
|
|
40
|
+
huntMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Diagnóstico do harness local: checklist de intenção e cobertura declarada.
|
|
43
|
+
* Ausente no modo skill (não há `hunt-report.json`).
|
|
44
|
+
*/
|
|
45
|
+
harness?: {
|
|
46
|
+
checksExpected?: number;
|
|
47
|
+
checksVerified?: number;
|
|
48
|
+
checksGaps?: number;
|
|
49
|
+
reviewedFiles?: number;
|
|
50
|
+
skippedFiles?: number;
|
|
51
|
+
undeclaredFiles?: number;
|
|
52
|
+
businessScore?: number;
|
|
53
|
+
/** Custo somado das passadas extras (checklist + segunda opinião). */
|
|
54
|
+
extraCostUsd?: number;
|
|
55
|
+
/** Findings adicionados pela segunda opinião. */
|
|
56
|
+
secondOpinionFindings?: number;
|
|
57
|
+
};
|
|
58
|
+
tokens?: number;
|
|
59
|
+
costUsd?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Resultado da comparação. */
|
|
63
|
+
export type ReviewComparison = {
|
|
64
|
+
branch: string;
|
|
65
|
+
headSha?: string;
|
|
66
|
+
a: ReviewSnapshot;
|
|
67
|
+
b: ReviewSnapshot;
|
|
68
|
+
onlyA: readonly ComparisonFinding[];
|
|
69
|
+
onlyB: readonly ComparisonFinding[];
|
|
70
|
+
common: readonly { a: ComparisonFinding; b: ComparisonFinding }[];
|
|
71
|
+
severityChanged: readonly { a: ComparisonFinding; b: ComparisonFinding }[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Conta os achados por severidade. */
|
|
75
|
+
export function countBySeverity(
|
|
76
|
+
findings: readonly ComparisonFinding[],
|
|
77
|
+
): Record<Severity, number> {
|
|
78
|
+
const counts: Record<Severity, number> = {
|
|
79
|
+
blocking: 0,
|
|
80
|
+
attention: 0,
|
|
81
|
+
suggestion: 0,
|
|
82
|
+
nit: 0,
|
|
83
|
+
};
|
|
84
|
+
for (const finding of findings) counts[finding.severity]++;
|
|
85
|
+
return counts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Compara dois snapshots (ordem estável por severidade e caminho). */
|
|
89
|
+
export function buildComparison(a: ReviewSnapshot, b: ReviewSnapshot): ReviewComparison {
|
|
90
|
+
const aByKey = new Map(a.findings.map((finding) => [finding.fingerprint, finding]));
|
|
91
|
+
const bByKey = new Map(b.findings.map((finding) => [finding.fingerprint, finding]));
|
|
92
|
+
const onlyA = a.findings.filter((finding) => !bByKey.has(finding.fingerprint));
|
|
93
|
+
const onlyB = b.findings.filter((finding) => !aByKey.has(finding.fingerprint));
|
|
94
|
+
const common: { a: ComparisonFinding; b: ComparisonFinding }[] = [];
|
|
95
|
+
const severityChanged: { a: ComparisonFinding; b: ComparisonFinding }[] = [];
|
|
96
|
+
|
|
97
|
+
for (const finding of a.findings) {
|
|
98
|
+
const other = bByKey.get(finding.fingerprint);
|
|
99
|
+
if (!other) continue;
|
|
100
|
+
common.push({ a: finding, b: other });
|
|
101
|
+
if (finding.severity !== other.severity) {
|
|
102
|
+
severityChanged.push({ a: finding, b: other });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sortFindings = (items: ComparisonFinding[]): ComparisonFinding[] =>
|
|
107
|
+
[...items].sort(
|
|
108
|
+
(x, y) =>
|
|
109
|
+
SEVERITY_ORDER.indexOf(x.severity) - SEVERITY_ORDER.indexOf(y.severity) ||
|
|
110
|
+
x.file.localeCompare(y.file) ||
|
|
111
|
+
(x.line ?? 0) - (y.line ?? 0) ||
|
|
112
|
+
x.rule.localeCompare(y.rule),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
branch: a.branch,
|
|
117
|
+
headSha: a.headSha ?? b.headSha,
|
|
118
|
+
a,
|
|
119
|
+
b,
|
|
120
|
+
onlyA: sortFindings(onlyA),
|
|
121
|
+
onlyB: sortFindings(onlyB),
|
|
122
|
+
common,
|
|
123
|
+
severityChanged: severityChanged.sort(
|
|
124
|
+
(x, y) =>
|
|
125
|
+
SEVERITY_ORDER.indexOf(x.a.severity) -
|
|
126
|
+
SEVERITY_ORDER.indexOf(y.a.severity) ||
|
|
127
|
+
x.a.file.localeCompare(y.a.file),
|
|
128
|
+
),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Duração legível (`820ms`, `12.3s`, `2m05s`). */
|
|
133
|
+
function formatMs(ms: number | undefined): string {
|
|
134
|
+
if (ms === undefined || !Number.isFinite(ms)) return "—";
|
|
135
|
+
const safe = Math.max(0, Math.round(ms));
|
|
136
|
+
if (safe < 1_000) return `${safe}ms`;
|
|
137
|
+
const seconds = safe / 1_000;
|
|
138
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
139
|
+
const minutes = Math.floor(seconds / 60);
|
|
140
|
+
return `${minutes}m${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Contagem simples (`—` quando não medida). */
|
|
144
|
+
function formatCount(value: number | undefined): string {
|
|
145
|
+
return value === undefined || !Number.isFinite(value) ? "—" : String(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Razão verificada/esperada (`4/6`). */
|
|
149
|
+
function formatRatio(verified: number | undefined, expected: number | undefined): string {
|
|
150
|
+
if (verified === undefined || expected === undefined) return "—";
|
|
151
|
+
return `${verified}/${expected}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Custo em dólar (`$0.1234`). */
|
|
155
|
+
function formatCost(value: number | undefined): string {
|
|
156
|
+
return value === undefined || !Number.isFinite(value) ? "—" : `$${value.toFixed(4)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatDelta(
|
|
160
|
+
a: number | undefined,
|
|
161
|
+
b: number | undefined,
|
|
162
|
+
format: (value: number) => string,
|
|
163
|
+
): string {
|
|
164
|
+
if (a === undefined || b === undefined) return "—";
|
|
165
|
+
const delta = b - a;
|
|
166
|
+
return `${delta > 0 ? "+" : ""}${format(delta)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Linha da tabela de métricas. */
|
|
170
|
+
function metricRow(label: string, a: string, b: string, delta = "—"): string {
|
|
171
|
+
return `| ${label} | ${a} | ${b} | ${delta} |`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Lista compacta de um conjunto de achados. */
|
|
175
|
+
function findingList(title: string, findings: readonly ComparisonFinding[]): string[] {
|
|
176
|
+
if (findings.length === 0) return [`## ${title}`, "", "_Nenhum._", ""];
|
|
177
|
+
const shown = findings.slice(0, 20);
|
|
178
|
+
const lines = [`## ${title} (${findings.length})`, ""];
|
|
179
|
+
for (const finding of shown) {
|
|
180
|
+
const line = finding.line !== undefined ? `:${finding.line}` : "";
|
|
181
|
+
lines.push(
|
|
182
|
+
`- **${finding.severity}** · \`${finding.file}${line}\` — ${finding.message}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (findings.length > shown.length) {
|
|
186
|
+
lines.push(`- … +${findings.length - shown.length} achado(s)`);
|
|
187
|
+
}
|
|
188
|
+
lines.push("");
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Renderiza a comparação em Markdown (artefato e stdout). */
|
|
193
|
+
export function renderComparison(comparison: ReviewComparison): string {
|
|
194
|
+
const { a, b } = comparison;
|
|
195
|
+
const severityDelta = SEVERITY_ORDER.map((severity) =>
|
|
196
|
+
metricRow(
|
|
197
|
+
severity,
|
|
198
|
+
String(a.bySeverity[severity]),
|
|
199
|
+
String(b.bySeverity[severity]),
|
|
200
|
+
formatDelta(a.bySeverity[severity], b.bySeverity[severity], (value) =>
|
|
201
|
+
String(value),
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
const totalA = a.findings.length;
|
|
206
|
+
const totalB = b.findings.length;
|
|
207
|
+
const lines = [
|
|
208
|
+
`# Comparação A/B — \`${comparison.branch}\``,
|
|
209
|
+
"",
|
|
210
|
+
`**A:** ${labelOf(a)} · **B:** ${labelOf(b)}`,
|
|
211
|
+
...(comparison.headSha
|
|
212
|
+
? [`**Commit:** \`${comparison.headSha.slice(0, 7)}\``]
|
|
213
|
+
: []),
|
|
214
|
+
"",
|
|
215
|
+
"> Compara o que apareceu/sumiu entre as execuções. Não é veredito: confirme",
|
|
216
|
+
"> cada achado novo no código antes de considerá-lo ganho ou perda.",
|
|
217
|
+
"",
|
|
218
|
+
"## Métricas",
|
|
219
|
+
"",
|
|
220
|
+
`| Métrica | A | B | Δ |`,
|
|
221
|
+
`| --- | --- | --- | --- |`,
|
|
222
|
+
metricRow(
|
|
223
|
+
"Findings (presentes)",
|
|
224
|
+
String(totalA),
|
|
225
|
+
String(totalB),
|
|
226
|
+
formatDelta(totalA, totalB, (value) => String(value)),
|
|
227
|
+
),
|
|
228
|
+
...severityDelta,
|
|
229
|
+
metricRow(
|
|
230
|
+
"Cobertura (arquivos)",
|
|
231
|
+
String(a.coverage.files),
|
|
232
|
+
String(b.coverage.files),
|
|
233
|
+
),
|
|
234
|
+
metricRow(
|
|
235
|
+
"Watchdogs",
|
|
236
|
+
String(a.coverage.watchdogs),
|
|
237
|
+
String(b.coverage.watchdogs),
|
|
238
|
+
),
|
|
239
|
+
metricRow("Duração total", formatMs(a.durationMs), formatMs(b.durationMs)),
|
|
240
|
+
metricRow("Prepare", formatMs(a.prepareMs), formatMs(b.prepareMs)),
|
|
241
|
+
metricRow("Harness", formatMs(a.huntMs), formatMs(b.huntMs)),
|
|
242
|
+
...(a.harness || b.harness
|
|
243
|
+
? [
|
|
244
|
+
metricRow(
|
|
245
|
+
"Checklist verificado",
|
|
246
|
+
formatRatio(a.harness?.checksVerified, a.harness?.checksExpected),
|
|
247
|
+
formatRatio(b.harness?.checksVerified, b.harness?.checksExpected),
|
|
248
|
+
),
|
|
249
|
+
metricRow(
|
|
250
|
+
"Arquivos revisados (declarado)",
|
|
251
|
+
formatCount(a.harness?.reviewedFiles),
|
|
252
|
+
formatCount(b.harness?.reviewedFiles),
|
|
253
|
+
),
|
|
254
|
+
metricRow(
|
|
255
|
+
"Arquivos sem declaração",
|
|
256
|
+
formatCount(a.harness?.undeclaredFiles),
|
|
257
|
+
formatCount(b.harness?.undeclaredFiles),
|
|
258
|
+
),
|
|
259
|
+
metricRow(
|
|
260
|
+
"Modelo de intenção (score)",
|
|
261
|
+
formatCount(a.harness?.businessScore),
|
|
262
|
+
formatCount(b.harness?.businessScore),
|
|
263
|
+
),
|
|
264
|
+
metricRow(
|
|
265
|
+
"Passadas extras (custo)",
|
|
266
|
+
formatCost(a.harness?.extraCostUsd),
|
|
267
|
+
formatCost(b.harness?.extraCostUsd),
|
|
268
|
+
),
|
|
269
|
+
metricRow(
|
|
270
|
+
"Segunda opinião (findings)",
|
|
271
|
+
formatCount(a.harness?.secondOpinionFindings),
|
|
272
|
+
formatCount(b.harness?.secondOpinionFindings),
|
|
273
|
+
),
|
|
274
|
+
]
|
|
275
|
+
: []),
|
|
276
|
+
metricRow(
|
|
277
|
+
"Tokens",
|
|
278
|
+
a.tokens !== undefined ? String(a.tokens) : "—",
|
|
279
|
+
b.tokens !== undefined ? String(b.tokens) : "—",
|
|
280
|
+
),
|
|
281
|
+
metricRow(
|
|
282
|
+
"Custo",
|
|
283
|
+
a.costUsd !== undefined ? `$${a.costUsd.toFixed(4)}` : "—",
|
|
284
|
+
b.costUsd !== undefined ? `$${b.costUsd.toFixed(4)}` : "—",
|
|
285
|
+
),
|
|
286
|
+
"",
|
|
287
|
+
...findingList("Só em B (novos)", comparison.onlyB),
|
|
288
|
+
...findingList("Só em A (não apareceram em B)", comparison.onlyA),
|
|
289
|
+
];
|
|
290
|
+
if (comparison.severityChanged.length > 0) {
|
|
291
|
+
lines.push(`## Severidade mudou (${comparison.severityChanged.length})`, "");
|
|
292
|
+
for (const item of comparison.severityChanged.slice(0, 20)) {
|
|
293
|
+
lines.push(
|
|
294
|
+
`- \`${item.a.file}\` — ${item.a.rule}: **${item.a.severity} → ${item.b.severity}**`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
lines.push("");
|
|
298
|
+
}
|
|
299
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** JSON estável da comparação (artefato para automação). */
|
|
303
|
+
export function comparisonToJson(comparison: ReviewComparison): string {
|
|
304
|
+
return `${JSON.stringify(comparison, null, 2)}\n`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function labelOf(snapshot: ReviewSnapshot): string {
|
|
308
|
+
const variant = snapshot.variant ? ` (variante \`${snapshot.variant}\`)` : "";
|
|
309
|
+
return `\`${snapshot.slug}\`${variant} · ${snapshot.findings.length} finding(s)`;
|
|
310
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool de concorrência mínimo (biblioteca).
|
|
3
|
+
*
|
|
4
|
+
* Mantém no máximo `limit` promessas ativas e preserva a ordem dos resultados.
|
|
5
|
+
* Não trata erros: o chamador decide se cada item falha isoladamente.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Executa `mapper` para cada item com concorrência limitada. */
|
|
9
|
+
export async function mapPool<T, R>(
|
|
10
|
+
items: readonly T[],
|
|
11
|
+
limit: number,
|
|
12
|
+
mapper: (item: T, index: number) => Promise<R>,
|
|
13
|
+
): Promise<R[]> {
|
|
14
|
+
const results = new Array<R>(items.length);
|
|
15
|
+
if (items.length === 0) return results;
|
|
16
|
+
|
|
17
|
+
let next = 0;
|
|
18
|
+
const workers = Array.from(
|
|
19
|
+
{ length: Math.max(1, Math.min(limit, items.length)) },
|
|
20
|
+
async () => {
|
|
21
|
+
while (true) {
|
|
22
|
+
const index = next++;
|
|
23
|
+
if (index >= items.length) return;
|
|
24
|
+
results[index] = await mapper(items[index], index);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
await Promise.all(workers);
|
|
29
|
+
return results;
|
|
30
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -72,6 +72,8 @@ export type AiProviderOptions = Record<string, Record<string, unknown>>;
|
|
|
72
72
|
export type HoundsmanAiFeatures = {
|
|
73
73
|
/** Resume o contexto da PR (`context-summary`) quando o subagente não gerou. */
|
|
74
74
|
context?: boolean;
|
|
75
|
+
/** Reconstrói a intenção da PR (`business-summary`) antes dos watchdogs. */
|
|
76
|
+
business?: boolean;
|
|
75
77
|
/** Enriquece os briefs com um plano de revisão sugerido. */
|
|
76
78
|
brief?: boolean;
|
|
77
79
|
/** Gera os pontos técnicos estruturais do resumo final. */
|
|
@@ -99,7 +101,7 @@ export type HoundsmanAi = {
|
|
|
99
101
|
model?: string;
|
|
100
102
|
/** Base URL customizada do provider. */
|
|
101
103
|
baseURL?: string;
|
|
102
|
-
/** Chamadas simultâneas (default:
|
|
104
|
+
/** Chamadas simultâneas (default: 4). */
|
|
103
105
|
concurrency?: number;
|
|
104
106
|
/** Opções do AI SDK por provider (repassadas sem interpretação). */
|
|
105
107
|
providerOptions?: AiProviderOptions;
|
|
@@ -130,7 +132,7 @@ export type HoundsmanWorktrees = {
|
|
|
130
132
|
* Harness local da capability `hunt` (`houndsman hunt`) — opcional.
|
|
131
133
|
*
|
|
132
134
|
* Sem esta config, o comando usa defaults seguros: modelo derivado de `ai`,
|
|
133
|
-
*
|
|
135
|
+
* 8 processos simultâneos (uma onda) e timeout de 15 min por watchdog. A skill
|
|
134
136
|
* `/houndsman hunt` (harness externo) não depende deste bloco.
|
|
135
137
|
*/
|
|
136
138
|
export type HoundsmanHunt = {
|
|
@@ -138,12 +140,22 @@ export type HoundsmanHunt = {
|
|
|
138
140
|
enabled?: boolean;
|
|
139
141
|
/** Modelo no formato do maki (`provider/model-id`). Default: derivado de `ai`. */
|
|
140
142
|
model?: string;
|
|
141
|
-
/** Processos maki simultâneos (default:
|
|
143
|
+
/** Processos maki simultâneos (default: 8). */
|
|
142
144
|
concurrency?: number;
|
|
143
145
|
/** Timeout por watchdog em ms (default: 900_000). */
|
|
144
146
|
timeoutMs?: number;
|
|
145
147
|
/** Tentativas extras quando a saída do watchdog é inválida (default: 1). */
|
|
146
148
|
retries?: number;
|
|
149
|
+
/**
|
|
150
|
+
* Passada extra por watchdog para cobrir fluxos/invariantes/regras que a
|
|
151
|
+
* primeira resposta não verificou (default: ligada).
|
|
152
|
+
*/
|
|
153
|
+
checklist?: boolean;
|
|
154
|
+
/**
|
|
155
|
+
* Passada adversarial do `general` nas 5 classes (credencial/estado,
|
|
156
|
+
* capacidade, identificador, idempotência, contrato). Default: ligada.
|
|
157
|
+
*/
|
|
158
|
+
secondOpinion?: boolean;
|
|
147
159
|
/** Caminho explícito do binário (`HOUNDSMAN_MAKI_PATH` tem precedência). */
|
|
148
160
|
binaryPath?: string;
|
|
149
161
|
};
|
|
@@ -162,6 +174,27 @@ export type HoundsmanStorage = {
|
|
|
162
174
|
path?: string;
|
|
163
175
|
};
|
|
164
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Regra de prioridade por caminho (ordenada; a primeira que casar vence).
|
|
179
|
+
*
|
|
180
|
+
* O risco do arquivo é `min(churn, maxRiskFromChurn) + bonus`. O `bonus` promove
|
|
181
|
+
* caminhos críticos (em linhas de churn equivalentes) e o `maxRiskFromChurn`
|
|
182
|
+
* satura o superchurn de caminhos de baixa criticidade: um frontend que
|
|
183
|
+
* reformatou 2.000 linhas não escala além do teto, mas um ajuste pequeno no
|
|
184
|
+
* mesmo caminho mantém o peso natural.
|
|
185
|
+
*/
|
|
186
|
+
export type HoundsmanPriority = {
|
|
187
|
+
/** Padrão sobre o caminho relativo (ex.: `/^server\\/src\\/db\\//`). */
|
|
188
|
+
pattern: RegExp;
|
|
189
|
+
/** Peso em linhas de churn equivalentes (≥ 0; default: 0). */
|
|
190
|
+
bonus?: number;
|
|
191
|
+
/**
|
|
192
|
+
* Teto do churn considerado no risco (em linhas equivalentes). Acima dele os
|
|
193
|
+
* arquivos do caminho empatam e só o `bonus` diferencia. Default: sem teto.
|
|
194
|
+
*/
|
|
195
|
+
maxRiskFromChurn?: number;
|
|
196
|
+
};
|
|
197
|
+
|
|
165
198
|
/**
|
|
166
199
|
* Configuração completa do motor de revisão para um projeto.
|
|
167
200
|
*
|
|
@@ -185,6 +218,11 @@ export type HoundsmanConfig<TId extends string = string> = {
|
|
|
185
218
|
exclusions: readonly RegExp[];
|
|
186
219
|
/** Mapeamentos path → documentação relevante. */
|
|
187
220
|
docMappings: readonly (readonly [RegExp, readonly string[]])[];
|
|
221
|
+
/**
|
|
222
|
+
* Prioridade por caminho (opcional). Ordena arquivos no orçamento, no rateio
|
|
223
|
+
* do diff e no recorte do `general` — a primeira regra que casar vence.
|
|
224
|
+
*/
|
|
225
|
+
priorities?: readonly HoundsmanPriority[];
|
|
188
226
|
/** Docs sempre incluídos nos briefs (default: `DEFAULT_ALWAYS_INCLUDE_DOCS`). */
|
|
189
227
|
alwaysIncludeDocs?: readonly string[];
|
|
190
228
|
/** Limites de triage para PRs grandes (default: `DEFAULT_MAX_FILES_PER_BRIEF`). */
|
package/src/core/defaults.ts
CHANGED
|
@@ -38,8 +38,13 @@ export const DEFAULT_AI_PROVIDER = "deepseek" as const;
|
|
|
38
38
|
/** Modelo default da camada de IA. */
|
|
39
39
|
export const DEFAULT_AI_MODEL = "deepseek-v4-pro";
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Concorrência default das chamadas de IA do prepare.
|
|
43
|
+
*
|
|
44
|
+
* Vale principalmente para os planos de brief (1 chamada por watchdog): com 4,
|
|
45
|
+
* 8 watchdogs fecham em 2 rodadas em vez de 4.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_AI_CONCURRENCY = 4;
|
|
43
48
|
|
|
44
49
|
/** Timeout default de uma chamada de modelo (ms). */
|
|
45
50
|
export const DEFAULT_AI_TIMEOUT_MS = 120_000;
|
|
@@ -47,12 +52,53 @@ export const DEFAULT_AI_TIMEOUT_MS = 120_000;
|
|
|
47
52
|
/** Teto default de chamadas de IA por review. */
|
|
48
53
|
export const DEFAULT_AI_MAX_CALLS_PER_REVIEW = 40;
|
|
49
54
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Processos maki simultâneos no harness local (`houndsman hunt`).
|
|
57
|
+
*
|
|
58
|
+
* 8 = uma onda para os watchdogs usuais (domínios + `general`), em vez de 2.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_HUNT_CONCURRENCY = 8;
|
|
52
61
|
|
|
53
62
|
/** Timeout default de um watchdog no harness local (15 min). */
|
|
54
63
|
export const DEFAULT_HUNT_TIMEOUT_MS = 900_000;
|
|
55
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Trechos alterados injetados no brief (watchdog de domínio).
|
|
67
|
+
*
|
|
68
|
+
* Substituem a leitura do arquivo inteiro: o subagente começa pelo diff e só
|
|
69
|
+
* abre o arquivo para contexto adjacente. O orçamento é distribuído entre os
|
|
70
|
+
* arquivos (metade em partes iguais, o resto por churn) — antes, os primeiros
|
|
71
|
+
* arquivos consumiam o teto e o subagente lia os demais à mão.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_BRIEF_SNIPPET_CHARS = 24_000;
|
|
74
|
+
export const DEFAULT_BRIEF_SNIPPET_CHARS_PER_FILE = 4_000;
|
|
75
|
+
|
|
76
|
+
/** Mesmos limites para o revisor de fluxo (`general`), que vê todos os arquivos. */
|
|
77
|
+
export const GENERAL_BRIEF_SNIPPET_CHARS = 32_000;
|
|
78
|
+
export const GENERAL_BRIEF_SNIPPET_CHARS_PER_FILE = 5_000;
|
|
79
|
+
|
|
80
|
+
/** Fração do teto dividida igualmente entre os arquivos (o resto vai por churn). */
|
|
81
|
+
export const BRIEF_SNIPPET_FLOOR_RATIO = 0.5;
|
|
82
|
+
|
|
83
|
+
/** Piso de chars por arquivo no rateio igual (evita trechos inúteis). */
|
|
84
|
+
export const BRIEF_SNIPPET_MIN_PER_FILE = 120;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A partir daqui o `general` revisa por **fluxo** em vez de amostrar arquivos.
|
|
88
|
+
*
|
|
89
|
+
* Em PRs grandes, 40 arquivos com ~800 chars cada viram amostragem: melhor ver
|
|
90
|
+
* os arquivos que participam dos fluxos do modelo de intenção (com diff de
|
|
91
|
+
* verdade) e declarar o resto em `skipped`.
|
|
92
|
+
*/
|
|
93
|
+
export const GENERAL_FLOW_MIN_FILES = 40;
|
|
94
|
+
export const GENERAL_FLOW_MIN_LINES = 2_000;
|
|
95
|
+
|
|
96
|
+
/** Arquivos do recorte de fluxo do `general`. */
|
|
97
|
+
export const GENERAL_FLOW_MAX_FILES = 15;
|
|
98
|
+
|
|
99
|
+
/** Diff por arquivo no recorte de fluxo — profundidade em vez de cobertura. */
|
|
100
|
+
export const GENERAL_FLOW_SNIPPET_CHARS_PER_FILE = 2_000;
|
|
101
|
+
|
|
56
102
|
/** Retentativas default quando a saída do watchdog é inválida. */
|
|
57
103
|
export const DEFAULT_HUNT_RETRIES = 1;
|
|
58
104
|
|