@payconductor/houndsman 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/findings-command.ts +11 -1
  6. package/src/cli/commands/hunt-command.ts +20 -3
  7. package/src/cli/commands/review-command.ts +38 -0
  8. package/src/cli/commands/status-command.ts +3 -2
  9. package/src/cli/help.ts +3 -1
  10. package/src/cli/index.ts +3 -1
  11. package/src/cli/progress.ts +7 -2
  12. package/src/core/ai/ai-client.ts +38 -2
  13. package/src/core/ai/features/brief-enricher.ts +1 -1
  14. package/src/core/ai/features/business-analyzer.ts +61 -0
  15. package/src/core/ai/features/business-summarizer.ts +44 -0
  16. package/src/core/ai/index.ts +8 -0
  17. package/src/core/ai/prompts/brief.ts +33 -7
  18. package/src/core/ai/prompts/business.ts +76 -0
  19. package/src/core/ai/provider-capabilities.ts +12 -0
  20. package/src/core/ai/schemas.ts +70 -2
  21. package/src/core/ai/usage-recorder.ts +53 -1
  22. package/src/core/brief.ts +246 -12
  23. package/src/core/builtins.ts +108 -0
  24. package/src/core/business-summary.ts +583 -0
  25. package/src/core/commands/compare.ts +233 -0
  26. package/src/core/commands/context.ts +6 -2
  27. package/src/core/commands/findings.ts +38 -30
  28. package/src/core/commands/review.ts +6 -0
  29. package/src/core/commands/scaffold.ts +27 -0
  30. package/src/core/commands/status.ts +9 -4
  31. package/src/core/compare.ts +243 -0
  32. package/src/core/concurrency.ts +30 -0
  33. package/src/core/config.ts +31 -3
  34. package/src/core/defaults.ts +50 -4
  35. package/src/core/domain/budget.ts +62 -4
  36. package/src/core/domain/classification.ts +12 -2
  37. package/src/core/engine/carryover.ts +4 -0
  38. package/src/core/engine/finalize-pipeline.ts +347 -0
  39. package/src/core/engine/prepare-pipeline.ts +1294 -0
  40. package/src/core/engine/review-context.ts +51 -0
  41. package/src/core/engine/review-options.ts +47 -0
  42. package/src/core/engine/review-scope.ts +14 -1
  43. package/src/core/engine/review-session.ts +11 -13
  44. package/src/core/engine/run-artifacts.ts +196 -0
  45. package/src/core/engine/run-format.ts +4 -0
  46. package/src/core/findings-state.ts +13 -1
  47. package/src/core/github-review.ts +5 -2
  48. package/src/core/hunt/hunt-runner.ts +90 -0
  49. package/src/core/hunt/index.ts +2 -0
  50. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  51. package/src/core/hunt/maki-prompt.ts +13 -1
  52. package/src/core/hunt/pool.ts +4 -26
  53. package/src/core/logging/index.ts +6 -1
  54. package/src/core/logging/progress.ts +85 -1
  55. package/src/core/orchestrator.ts +33 -1289
  56. package/src/core/paths.ts +6 -3
  57. package/src/core/pr-context.ts +2 -4
  58. package/src/core/prepare-report.ts +61 -0
  59. package/src/core/project-config.ts +8 -1
  60. package/src/core/propagation.ts +127 -0
  61. package/src/core/registry-check.ts +60 -21
  62. package/src/core/renderers/manifest-writer.ts +13 -0
  63. package/src/core/renderers/resume-renderer.ts +151 -11
  64. package/src/core/report-builder.ts +108 -278
  65. package/src/core/storage/file/file-storage.ts +7 -3
  66. package/src/core/storage/sqlite/mappers.ts +10 -0
  67. package/src/core/storage/sqlite/schema.ts +104 -23
  68. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  69. package/src/core/storage/types.ts +16 -4
  70. package/src/core/storage/work-area.ts +59 -1
  71. package/src/core/types.ts +31 -0
  72. package/src/core/watchdog-registry.ts +21 -3
  73. package/src/index.ts +29 -4
  74. package/src/server/client/ArtifactsPanel.tsx +4 -2
  75. package/src/server/client/FindingCard.tsx +21 -1
  76. package/src/server/client/RunsSection.tsx +5 -0
  77. package/src/server/client/utils/constants.ts +21 -11
  78. package/src/server/client/utils/markdown.ts +1 -1
  79. package/src/server/client/utils/types.ts +14 -2
  80. package/src/server/run-repository.ts +35 -14
  81. package/src/server/styles.css +2 -0
  82. package/src/skill/OUTPUT-FORMAT.md +36 -10
  83. package/src/skill/README.md +18 -14
  84. package/src/skill/SETUP.md +1 -0
  85. package/src/skill/SKILL.md +13 -4
  86. package/src/skill/example-resume.md +43 -0
  87. package/src/skill/hunt.md +145 -58
  88. package/src/tests/__snapshots__/baseline.test.ts.snap +187 -178
  89. package/src/tests/ai-core.test.ts +46 -0
  90. package/src/tests/ai-features.test.ts +95 -4
  91. package/src/tests/baseline.test.ts +29 -121
  92. package/src/tests/brief-scope.test.ts +250 -0
  93. package/src/tests/business-summary.test.ts +258 -0
  94. package/src/tests/compare.test.ts +233 -0
  95. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  96. package/src/tests/hunt-maki.test.ts +58 -0
  97. package/src/tests/markdown.test.ts +3 -3
  98. package/src/tests/progress.test.ts +29 -1
  99. package/src/tests/propagation.test.ts +119 -0
  100. package/src/tests/review-resume.test.ts +60 -1
  101. package/src/tests/review-scope.test.ts +38 -29
  102. package/src/tests/review-scripts.test.ts +106 -218
  103. package/src/tests/server.test.ts +7 -4
  104. package/src/tests/storage.test.ts +194 -9
  105. package/src/core/renderers/report-renderer.ts +0 -66
  106. package/src/skill/example-review.md +0 -66
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Comando `compare` (biblioteca) — compara duas runs da mesma branch.
3
+ *
4
+ * Resolve o par por slug (como na URL da UI) ou por `(branch, variante)`, lê os
5
+ * artefatos de cada review e grava `compare-<a>--<b>.md|.json` na run B.
6
+ */
7
+
8
+ import {
9
+ buildComparison,
10
+ type ComparisonFinding,
11
+ comparisonToJson,
12
+ countBySeverity,
13
+ type ReviewSnapshot,
14
+ renderComparison,
15
+ } from "../compare";
16
+ import { currentBranch } from "../pr-context";
17
+ import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
18
+ import type { Severity } from "../types";
19
+
20
+ /** Seleção do par A/B. */
21
+ export type CompareOptions = {
22
+ /** Slugs prontos (como exibidos na UI). */
23
+ slugA?: string;
24
+ slugB?: string;
25
+ /** Identidade alternativa: branch + rótulos de variante. */
26
+ branch?: string;
27
+ variantA?: string;
28
+ variantB?: string;
29
+ };
30
+
31
+ /** Resultado do comando. */
32
+ export type CompareResult = {
33
+ markdown: string;
34
+ json: string;
35
+ a: ReviewRecord;
36
+ b: ReviewRecord;
37
+ artifacts: { markdown: string; json: string };
38
+ };
39
+
40
+ type ManifestShape = {
41
+ watchdogs?: { files?: string[]; overflow?: number }[];
42
+ ai?: { model?: string };
43
+ };
44
+
45
+ type ReportShape = { durationMs?: number };
46
+ type HuntShape = {
47
+ model?: string;
48
+ durationMs?: number;
49
+ totals?: { totalTokens?: number; costUsd?: number };
50
+ };
51
+ type AiUsageShape = { total?: { totalTokens?: number } };
52
+
53
+ /** Resolve a run pelo slug dentro do repositório. */
54
+ function bySlug(
55
+ storage: StorageProvider,
56
+ repo: RepoIdentity,
57
+ slug: string,
58
+ ): ReviewRecord {
59
+ const review = storage
60
+ .listReviews(repo.key)
61
+ .find((candidate) => candidate.slug === slug);
62
+ if (!review) throw new Error(`Nenhuma run com slug '${slug}' neste repositório.`);
63
+ return review;
64
+ }
65
+
66
+ /** Resolve o par A/B (slug ou branch + variantes). */
67
+ export function resolveComparePair(
68
+ storage: StorageProvider,
69
+ repo: RepoIdentity,
70
+ options: CompareOptions,
71
+ ): [ReviewRecord, ReviewRecord] {
72
+ if (options.slugA && options.slugB) {
73
+ return [
74
+ bySlug(storage, repo, options.slugA),
75
+ bySlug(storage, repo, options.slugB),
76
+ ];
77
+ }
78
+ const branch = options.branch ?? currentBranch();
79
+ const variantB = options.variantB?.trim();
80
+ if (!variantB) {
81
+ throw new Error(
82
+ "Informe --b <variante> (ou passe dois slugs: `houndsman compare <a> <b>`).",
83
+ );
84
+ }
85
+ const a = storage.findReview(repo.key, branch, options.variantA);
86
+ const b = storage.findReview(repo.key, branch, variantB);
87
+ if (!a) {
88
+ throw new Error(
89
+ `Nenhuma revisão para '${branch}'${options.variantA ? ` (variante '${options.variantA}')` : ""}.`,
90
+ );
91
+ }
92
+ if (!b) throw new Error(`Nenhuma revisão para '${branch}' (variante '${variantB}').`);
93
+ return [a, b];
94
+ }
95
+
96
+ function readJson<T>(storage: StorageProvider, reviewId: string, name: string): T | null {
97
+ const artifact = storage.readArtifact(reviewId, name);
98
+ if (!artifact) return null;
99
+ try {
100
+ return JSON.parse(artifact.content) as T;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** Extrai o snapshot comparável de uma run. */
107
+ function snapshotOf(
108
+ storage: StorageProvider,
109
+ review: ReviewRecord,
110
+ label: string,
111
+ ): ReviewSnapshot {
112
+ const state = storage.readFindings(review.id);
113
+ const findings: ComparisonFinding[] = Object.values(state?.findings ?? {})
114
+ .filter((finding) => finding.present)
115
+ .map((finding) => ({
116
+ fingerprint: finding.fingerprint,
117
+ rule: finding.rule,
118
+ severity: finding.severity,
119
+ file: finding.file,
120
+ line: finding.line ?? undefined,
121
+ message: finding.message,
122
+ watchdogs: finding.watchdogIds,
123
+ }));
124
+
125
+ const manifest = readJson<ManifestShape>(storage, review.id, "manifest.json");
126
+ const prepare = readJson<ReportShape>(storage, review.id, "prepare-report.json");
127
+ const hunt = readJson<HuntShape>(storage, review.id, "hunt-report.json");
128
+ const aiUsage = readJson<AiUsageShape>(storage, review.id, "ai-usage.json");
129
+
130
+ const started = review.startedAt ? Date.parse(review.startedAt) : Number.NaN;
131
+ const reviewed = review.reviewedAt ? Date.parse(review.reviewedAt) : Number.NaN;
132
+ const durationMs =
133
+ Number.isFinite(started) && Number.isFinite(reviewed) && reviewed >= started
134
+ ? reviewed - started
135
+ : undefined;
136
+
137
+ const watchdogs = manifest?.watchdogs ?? [];
138
+ // Domínios se sobrepõem: conta arquivos únicos, não "slots" por watchdog.
139
+ const files = new Set<string>();
140
+ for (const watchdog of watchdogs) {
141
+ for (const file of watchdog.files ?? []) files.add(file);
142
+ }
143
+ return {
144
+ label,
145
+ slug: review.slug,
146
+ branch: review.branch,
147
+ variant: review.variant,
148
+ headSha: review.headSha ?? review.reviewedSha,
149
+ model: hunt?.model ?? manifest?.ai?.model,
150
+ findings,
151
+ bySeverity: countBySeverity(findings),
152
+ coverage: {
153
+ watchdogs: watchdogs.length,
154
+ files: files.size,
155
+ overflow: watchdogs.reduce(
156
+ (total, watchdog) => total + (watchdog.overflow ?? 0),
157
+ 0,
158
+ ),
159
+ },
160
+ durationMs,
161
+ prepareMs: prepare?.durationMs,
162
+ huntMs: hunt?.durationMs,
163
+ tokens: hunt?.totals?.totalTokens ?? aiUsage?.total?.totalTokens,
164
+ costUsd: hunt?.totals?.costUsd,
165
+ };
166
+ }
167
+
168
+ /** Executa a comparação e persiste o relatório na run B. */
169
+ export function runCompare(
170
+ storage: StorageProvider,
171
+ repo: RepoIdentity,
172
+ options: CompareOptions,
173
+ ): CompareResult {
174
+ const [a, b] = resolveComparePair(storage, repo, options);
175
+ const comparison = buildComparison(
176
+ snapshotOf(storage, a, "A"),
177
+ snapshotOf(storage, b, "B"),
178
+ );
179
+ const markdown = renderComparison(comparison);
180
+ const json = comparisonToJson(comparison);
181
+ const base = `compare-${a.slug}--${b.slug}`;
182
+ storage.writeArtifact(b.id, {
183
+ name: `${base}.md`,
184
+ kind: "compare",
185
+ content: markdown,
186
+ });
187
+ storage.writeArtifact(b.id, {
188
+ name: `${base}.json`,
189
+ kind: "compare",
190
+ content: json,
191
+ });
192
+ return {
193
+ markdown,
194
+ json,
195
+ a,
196
+ b,
197
+ artifacts: { markdown: `${base}.md`, json: `${base}.json` },
198
+ };
199
+ }
200
+
201
+ /** Linha da listagem de variantes da branch. */
202
+ export type CompareListItem = {
203
+ slug: string;
204
+ branch: string;
205
+ variant?: string;
206
+ status: ReviewRecord["status"];
207
+ findings: number;
208
+ bySeverity: Record<Severity, number>;
209
+ updatedAt: string;
210
+ };
211
+
212
+ /** Lista as runs do repositório (filtra por branch quando informado). */
213
+ export function listRunsForCompare(
214
+ storage: StorageProvider,
215
+ repo: RepoIdentity,
216
+ branch?: string,
217
+ ): CompareListItem[] {
218
+ return storage
219
+ .listReviews(repo.key)
220
+ .filter((review) => !branch || review.branch === branch)
221
+ .map((review) => {
222
+ const snapshot = snapshotOf(storage, review, review.variant ?? "principal");
223
+ return {
224
+ slug: review.slug,
225
+ branch: review.branch,
226
+ variant: review.variant,
227
+ status: review.status,
228
+ findings: snapshot.findings.length,
229
+ bySeverity: snapshot.bySeverity,
230
+ updatedAt: review.updatedAt,
231
+ };
232
+ });
233
+ }
@@ -12,7 +12,7 @@ import {
12
12
  type PrContextRaw,
13
13
  renderContextBrief,
14
14
  } from "../pr-context";
15
- import { sanitizeBranch } from "../report-builder";
15
+ import { reviewSlug } from "../report-builder";
16
16
  import type { RepoIdentity, StorageProvider } from "../storage/types";
17
17
  import { type ContextPaths, ReviewWorkArea } from "../storage/work-area";
18
18
 
@@ -28,6 +28,8 @@ export function runContextCommand(
28
28
  repo: RepoIdentity,
29
29
  opts: {
30
30
  branch?: string;
31
+ /** Variante A/B da run (ausente = principal). */
32
+ variant?: string;
31
33
  /** Worktree onde a review roda (persistido na review). */
32
34
  worktreePath?: string;
33
35
  /** PR de origem (persistida na review). */
@@ -35,10 +37,12 @@ export function runContextCommand(
35
37
  } = {},
36
38
  ): ContextRunResult {
37
39
  const branch = opts.branch ?? currentBranch();
40
+ const variant = opts.variant?.trim() || undefined;
38
41
  const review = storage.getOrCreateReview({
39
42
  repo,
40
43
  branch,
41
- slug: sanitizeBranch(branch),
44
+ variant,
45
+ slug: reviewSlug(branch, variant),
42
46
  worktreePath: opts.worktreePath,
43
47
  prNumber: opts.prNumber,
44
48
  });
@@ -14,6 +14,7 @@ import { existsSync } from "node:fs";
14
14
  import { AiClient } from "../ai/ai-client";
15
15
  import { AiVerifier } from "../ai/features/verifier";
16
16
  import { type HoundsmanConfig, resolveJevConcurrency } from "../config";
17
+ import { type ReviewContext, reviewContext } from "../engine/review-context";
17
18
  import { ENV_NAMES } from "../env";
18
19
  import {
19
20
  applyFindingUpdate,
@@ -25,7 +26,6 @@ import {
25
26
  type FindingsFilter,
26
27
  type FindingsStateFile,
27
28
  type FindingUpdate,
28
- fileReader,
29
29
  filterFindings,
30
30
  reorder,
31
31
  resolveFindingId,
@@ -51,9 +51,9 @@ import { VerifyPresenter } from "../renderers/verify-presenter";
51
51
  import { pingFindingsStatus } from "../run-status";
52
52
  import { systemClock } from "../services/clock";
53
53
  import { FixBatchBuilder } from "../services/fix-batch-builder";
54
+ import type { GitRepository } from "../services/git-repository";
54
55
  import { Verifier } from "../services/verifier";
55
- import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
56
- import { ReviewWorkArea } from "../storage/work-area";
56
+ import type { RepoIdentity, StorageProvider } from "../storage/types";
57
57
  import { SEVERITY_ORDER, type Severity } from "../types";
58
58
 
59
59
  export type FindingsCommandResult = {
@@ -68,6 +68,7 @@ const VALUE_FLAGS = new Set([
68
68
  "--severity",
69
69
  "--file",
70
70
  "--branch",
71
+ "--variant",
71
72
  "--limit",
72
73
  "--comment",
73
74
  "--by",
@@ -108,13 +109,11 @@ function parseArgs(args: string[]): ParsedArgs {
108
109
  return parsed;
109
110
  }
110
111
 
111
- /** Contexto de uma review (storage + registro + staging + raiz). */
112
- type CommandContext = {
113
- storage: StorageProvider;
114
- review: ReviewRecord;
115
- work: ReviewWorkArea;
116
- /** Worktree da review quando existir; senão, a raiz do repositório. */
117
- root: string;
112
+ /** Opções de resolução do contexto (o CLI passa root/git do engine). */
113
+ export type FindingsContextOptions = {
114
+ /** Raiz operacional (default: cwd de quem chamou). */
115
+ root?: string;
116
+ git?: GitRepository;
118
117
  };
119
118
 
120
119
  /** Resolve a review da branch atual (explícita ou via git). */
@@ -122,22 +121,25 @@ function openContext(
122
121
  storage: StorageProvider,
123
122
  repo: RepoIdentity,
124
123
  parsed: ParsedArgs,
125
- ): CommandContext {
124
+ options: FindingsContextOptions = {},
125
+ ): ReviewContext {
126
126
  const branch = parsed.values["--branch"] ?? currentBranch();
127
- const review = storage.findReview(repo.key, branch);
127
+ const variant = parsed.values["--variant"]?.trim() || undefined;
128
+ const review = storage.findReview(repo.key, branch, variant);
128
129
  if (!review) {
129
130
  throw new Error(
130
- `Nenhuma revisão para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
131
+ `Nenhuma revisão para '${branch}'${variant ? ` (variante '${variant}')` : ""}. Rode \`houndsman --finalize\` primeiro.`,
131
132
  );
132
133
  }
133
134
  const worktree = review.worktreePath;
134
135
  // Sem worktree, mantém a raiz onde o comando foi invocado (comportamento histórico).
135
- const root = worktree && existsSync(worktree) ? worktree : process.cwd();
136
- return { storage, review, work: new ReviewWorkArea(review), root };
136
+ const root =
137
+ worktree && existsSync(worktree) ? worktree : (options.root ?? process.cwd());
138
+ return reviewContext(storage, review, { root, git: options.git });
137
139
  }
138
140
 
139
141
  /** Lê o estado de triagem; lança se a review ainda não tem findings. */
140
- function loadState(ctx: CommandContext): FindingsStateFile {
142
+ function loadState(ctx: ReviewContext): FindingsStateFile {
141
143
  const state = ctx.storage.readFindings(ctx.review.id);
142
144
  if (!state) {
143
145
  throw new Error(
@@ -149,7 +151,7 @@ function loadState(ctx: CommandContext): FindingsStateFile {
149
151
 
150
152
  /** Lê-modifica-grava o estado, atualizando `updatedAt` e a ordem. */
151
153
  function mutateState(
152
- ctx: CommandContext,
154
+ ctx: ReviewContext,
153
155
  mutator: (state: FindingsStateFile) => void,
154
156
  ): FindingsStateFile {
155
157
  const state = loadState(ctx);
@@ -221,7 +223,7 @@ function filterFrom(parsed: ParsedArgs): FindingsFilter {
221
223
  }
222
224
 
223
225
  /** Faz ping no status da review (derivado do estado atual) após uma mutação. */
224
- function pingStatus(ctx: CommandContext, state: FindingsStateFile): void {
226
+ function pingStatus(ctx: ReviewContext, state: FindingsStateFile): void {
225
227
  pingFindingsStatus(ctx.storage, ctx.review.id, state);
226
228
  }
227
229
 
@@ -230,7 +232,7 @@ function pingStatus(ctx: CommandContext, state: FindingsStateFile): void {
230
232
  * Fail-open (`gh` ausente/sem PR ⇒ só a mensagem). Devolve a linha para o
231
233
  * output ou `null` quando não há vínculo remoto pendente.
232
234
  */
233
- function syncThreadsOutput(ctx: CommandContext, ids: readonly string[]): string | null {
235
+ function syncThreadsOutput(ctx: ReviewContext, ids: readonly string[]): string | null {
234
236
  if (ids.length === 0) return null;
235
237
  const state = loadState(ctx);
236
238
  const outcome = syncResolvedThreads({ state, ids });
@@ -246,7 +248,7 @@ function syncThreadsOutput(ctx: CommandContext, ids: readonly string[]): string
246
248
 
247
249
  /** Aplica uma atualização a um finding e persiste. */
248
250
  function updateOne(
249
- ctx: CommandContext,
251
+ ctx: ReviewContext,
250
252
  input: string,
251
253
  update: FindingUpdate,
252
254
  ): FindingState {
@@ -261,7 +263,7 @@ function updateOne(
261
263
 
262
264
  /** Aplica a mesma atualização a vários ids (done/reset). */
263
265
  function updateMany(
264
- ctx: CommandContext,
266
+ ctx: ReviewContext,
265
267
  inputs: string[],
266
268
  update: FindingUpdate,
267
269
  ): FindingState[] {
@@ -281,10 +283,11 @@ export function runFindingsCommand(
281
283
  storage: StorageProvider,
282
284
  repo: RepoIdentity,
283
285
  args: string[],
286
+ options: FindingsContextOptions = {},
284
287
  ): FindingsCommandResult {
285
288
  try {
286
289
  const parsed = parseArgs(args);
287
- return execute(config, storage, repo, parsed);
290
+ return execute(config, storage, repo, parsed, options);
288
291
  } catch (error) {
289
292
  return {
290
293
  output: error instanceof Error ? error.message : String(error),
@@ -298,8 +301,9 @@ function execute(
298
301
  storage: StorageProvider,
299
302
  repo: RepoIdentity,
300
303
  parsed: ParsedArgs,
304
+ options: FindingsContextOptions,
301
305
  ): FindingsCommandResult {
302
- const ctx = openContext(storage, repo, parsed);
306
+ const ctx = openContext(storage, repo, parsed, options);
303
307
  switch (parsed.sub ?? "list") {
304
308
  case "list": {
305
309
  const state = loadState(ctx);
@@ -386,7 +390,7 @@ function execute(
386
390
  }
387
391
  case "sync": {
388
392
  const state = loadState(ctx);
389
- const result = syncState(state, fileReader(ctx.root));
393
+ const result = syncState(state, ctx.files.reader());
390
394
  ctx.storage.writeFindings(ctx.review.id, state);
391
395
  pingStatus(ctx, state);
392
396
  const output = parsed.bools.has("--json")
@@ -483,10 +487,11 @@ export async function runVerifyCommand(
483
487
  storage: StorageProvider,
484
488
  repo: RepoIdentity,
485
489
  args: string[],
490
+ options: FindingsContextOptions = {},
486
491
  ): Promise<FindingsCommandResult> {
487
492
  try {
488
493
  const parsed = parseArgs(args);
489
- return await executeVerify(config, storage, repo, parsed);
494
+ return await executeVerify(config, storage, repo, parsed, options);
490
495
  } catch (error) {
491
496
  return {
492
497
  output: error instanceof Error ? error.message : String(error),
@@ -514,8 +519,9 @@ async function executeVerify(
514
519
  storage: StorageProvider,
515
520
  repo: RepoIdentity,
516
521
  parsed: ParsedArgs,
522
+ options: FindingsContextOptions,
517
523
  ): Promise<FindingsCommandResult> {
518
- const ctx = openContext(storage, repo, parsed);
524
+ const ctx = openContext(storage, repo, parsed, options);
519
525
  const state = loadState(ctx);
520
526
 
521
527
  // Lazy: `../jev` (axios) só entra no caminho do verify.
@@ -523,7 +529,7 @@ async function executeVerify(
523
529
  const { JevCandidatesStore, JevClient, JevVerifier } = await import("../jev");
524
530
 
525
531
  const findings = verifier.select(state, filterFrom(parsed));
526
- const entries = verifier.verifyLocal(findings, fileReader(ctx.root));
532
+ const entries = verifier.verifyLocal(findings, ctx.files.reader());
527
533
 
528
534
  // Tier 1 — Jev só nos ambíguos (fail-open: sem chave/erro mantém o provisório).
529
535
  let jevChecked = 0;
@@ -571,7 +577,7 @@ async function executeVerify(
571
577
  } else if (!aiVerifier.enabled) {
572
578
  aiReason = "feature ai.features.verify desligada";
573
579
  } else {
574
- const readFile = fileReader(ctx.root);
580
+ const readFile = ctx.files.reader();
575
581
  const byId = new Map(findings.map((finding) => [finding.id, finding]));
576
582
  const confidence = await aiVerifier.verify({
577
583
  findings: stillAmbiguous.map((entry) => ({
@@ -748,10 +754,11 @@ export async function runSubmitCommand(
748
754
  storage: StorageProvider,
749
755
  repo: RepoIdentity,
750
756
  args: string[],
757
+ options: FindingsContextOptions = {},
751
758
  ): Promise<FindingsCommandResult> {
752
759
  try {
753
760
  const parsed = parseArgs(args);
754
- return await executeSubmit(config, storage, repo, parsed);
761
+ return await executeSubmit(config, storage, repo, parsed, options);
755
762
  } catch (error) {
756
763
  return {
757
764
  output: error instanceof Error ? error.message : String(error),
@@ -765,8 +772,9 @@ async function executeSubmit(
765
772
  storage: StorageProvider,
766
773
  repo: RepoIdentity,
767
774
  parsed: ParsedArgs,
775
+ options: FindingsContextOptions,
768
776
  ): Promise<FindingsCommandResult> {
769
- const ctx = openContext(storage, repo, parsed);
777
+ const ctx = openContext(storage, repo, parsed, options);
770
778
  const state = loadState(ctx);
771
779
  const selection = submitSelection(parsed);
772
780
  const target = resolvePrTarget(defaultGhRunner, state.branch);
@@ -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,
@@ -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(`Nenhuma revisão para '${target}'.`);
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
  }