@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.
Files changed (116) 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/golden-command.ts +92 -0
  7. package/src/cli/commands/hunt-command.ts +105 -4
  8. package/src/cli/commands/review-command.ts +38 -0
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +6 -1
  11. package/src/cli/index.ts +5 -1
  12. package/src/cli/progress.ts +7 -2
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/features/brief-enricher.ts +1 -1
  15. package/src/core/ai/features/business-analyzer.ts +61 -0
  16. package/src/core/ai/features/business-summarizer.ts +44 -0
  17. package/src/core/ai/index.ts +8 -0
  18. package/src/core/ai/prompts/brief.ts +33 -7
  19. package/src/core/ai/prompts/business.ts +76 -0
  20. package/src/core/ai/provider-capabilities.ts +12 -0
  21. package/src/core/ai/schemas.ts +70 -2
  22. package/src/core/ai/usage-recorder.ts +53 -1
  23. package/src/core/brief.ts +278 -16
  24. package/src/core/builtins.ts +108 -0
  25. package/src/core/business-summary.ts +583 -0
  26. package/src/core/commands/compare.ts +266 -0
  27. package/src/core/commands/context.ts +6 -2
  28. package/src/core/commands/findings.ts +38 -30
  29. package/src/core/commands/golden.ts +176 -0
  30. package/src/core/commands/review.ts +6 -0
  31. package/src/core/commands/scaffold.ts +29 -0
  32. package/src/core/commands/status.ts +9 -4
  33. package/src/core/compare.ts +310 -0
  34. package/src/core/concurrency.ts +30 -0
  35. package/src/core/config.ts +41 -3
  36. package/src/core/defaults.ts +50 -4
  37. package/src/core/domain/budget.ts +62 -4
  38. package/src/core/domain/classification.ts +12 -2
  39. package/src/core/engine/carryover.ts +4 -0
  40. package/src/core/engine/finalize-pipeline.ts +371 -0
  41. package/src/core/engine/prepare-pipeline.ts +1294 -0
  42. package/src/core/engine/review-context.ts +51 -0
  43. package/src/core/engine/review-options.ts +47 -0
  44. package/src/core/engine/review-scope.ts +14 -1
  45. package/src/core/engine/review-session.ts +11 -13
  46. package/src/core/engine/run-artifacts.ts +196 -0
  47. package/src/core/engine/run-format.ts +4 -0
  48. package/src/core/findings-state.ts +22 -2
  49. package/src/core/github-review.ts +5 -2
  50. package/src/core/golden.ts +346 -0
  51. package/src/core/hunt/hunt-runner.ts +621 -34
  52. package/src/core/hunt/index.ts +40 -1
  53. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  54. package/src/core/hunt/maki-prompt.ts +298 -5
  55. package/src/core/hunt/maki-runner.ts +93 -16
  56. package/src/core/hunt/pool.ts +4 -26
  57. package/src/core/hunt/review-checks.ts +460 -0
  58. package/src/core/logging/index.ts +6 -1
  59. package/src/core/logging/progress.ts +85 -1
  60. package/src/core/orchestrator.ts +33 -1289
  61. package/src/core/paths.ts +6 -3
  62. package/src/core/pr-context.ts +2 -4
  63. package/src/core/prepare-report.ts +61 -0
  64. package/src/core/project-config.ts +18 -1
  65. package/src/core/propagation.ts +127 -0
  66. package/src/core/registry-check.ts +90 -27
  67. package/src/core/renderers/manifest-writer.ts +13 -0
  68. package/src/core/renderers/resume-renderer.ts +151 -11
  69. package/src/core/report-builder.ts +116 -278
  70. package/src/core/severity.ts +245 -0
  71. package/src/core/storage/file/file-storage.ts +7 -3
  72. package/src/core/storage/sqlite/mappers.ts +10 -0
  73. package/src/core/storage/sqlite/schema.ts +104 -23
  74. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  75. package/src/core/storage/types.ts +17 -4
  76. package/src/core/storage/work-area.ts +59 -1
  77. package/src/core/types.ts +31 -0
  78. package/src/core/watchdog-registry.ts +21 -3
  79. package/src/index.ts +51 -4
  80. package/src/server/client/ArtifactsPanel.tsx +4 -2
  81. package/src/server/client/FindingCard.tsx +21 -1
  82. package/src/server/client/RunsSection.tsx +5 -0
  83. package/src/server/client/utils/constants.ts +21 -11
  84. package/src/server/client/utils/markdown.ts +1 -1
  85. package/src/server/client/utils/types.ts +14 -2
  86. package/src/server/run-repository.ts +35 -14
  87. package/src/server/styles.css +2 -0
  88. package/src/skill/OUTPUT-FORMAT.md +55 -10
  89. package/src/skill/README.md +19 -14
  90. package/src/skill/SETUP.md +1 -0
  91. package/src/skill/SKILL.md +13 -4
  92. package/src/skill/example-resume.md +43 -0
  93. package/src/skill/hunt.md +205 -58
  94. package/src/skill/train.md +3 -0
  95. package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
  96. package/src/tests/ai-core.test.ts +46 -0
  97. package/src/tests/ai-features.test.ts +95 -4
  98. package/src/tests/baseline.test.ts +29 -121
  99. package/src/tests/brief-scope.test.ts +250 -0
  100. package/src/tests/business-summary.test.ts +258 -0
  101. package/src/tests/compare.test.ts +256 -0
  102. package/src/tests/findings-state.test.ts +82 -0
  103. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  104. package/src/tests/golden.test.ts +195 -0
  105. package/src/tests/hunt-maki.test.ts +710 -2
  106. package/src/tests/markdown.test.ts +3 -3
  107. package/src/tests/progress.test.ts +29 -1
  108. package/src/tests/propagation.test.ts +119 -0
  109. package/src/tests/review-resume.test.ts +60 -1
  110. package/src/tests/review-scope.test.ts +38 -29
  111. package/src/tests/review-scripts.test.ts +125 -218
  112. package/src/tests/server.test.ts +7 -4
  113. package/src/tests/severity.test.ts +235 -0
  114. package/src/tests/storage.test.ts +194 -9
  115. package/src/core/renderers/report-renderer.ts +0 -66
  116. package/src/skill/example-review.md +0 -66
@@ -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
+ }
@@ -39,7 +39,7 @@ export class FileStorageProvider implements StorageProvider {
39
39
  // -----------------------------------------------------------------------
40
40
 
41
41
  getOrCreateReview(input: CreateReviewInput): ReviewRecord {
42
- const existing = this.findReview(input.repo.key, input.branch);
42
+ const existing = this.findReview(input.repo.key, input.branch, input.variant);
43
43
  if (existing) {
44
44
  if (
45
45
  existing.repoRoot !== input.repo.root ||
@@ -66,6 +66,7 @@ export class FileStorageProvider implements StorageProvider {
66
66
  repoRoot: input.repo.root,
67
67
  repoRemote: input.repo.remote,
68
68
  branch: input.branch,
69
+ variant: input.variant?.trim() || undefined,
69
70
  slug: input.slug,
70
71
  base: input.base,
71
72
  head: input.head,
@@ -78,9 +79,12 @@ export class FileStorageProvider implements StorageProvider {
78
79
  return this.writeReview(record);
79
80
  }
80
81
 
81
- findReview(repoKey: string, branch: string): ReviewRecord | null {
82
+ findReview(repoKey: string, branch: string, variant?: string): ReviewRecord | null {
83
+ const label = variant?.trim() ?? "";
82
84
  return (
83
- this.readReviews(repoKey).find((review) => review.branch === branch) ?? null
85
+ this.readReviews(repoKey).find(
86
+ (review) => review.branch === branch && (review.variant ?? "") === label,
87
+ ) ?? null
84
88
  );
85
89
  }
86
90
 
@@ -22,6 +22,7 @@ export type ReviewRow = {
22
22
  repo_root: string;
23
23
  repo_remote: string | null;
24
24
  branch: string;
25
+ variant: string;
25
26
  slug: string;
26
27
  base: string | null;
27
28
  head: string | null;
@@ -50,6 +51,10 @@ export type FindingRow = {
50
51
  message: string;
51
52
  action: string;
52
53
  evidence: string | null;
54
+ scenario: string | null;
55
+ impact: string | null;
56
+ confidence: number | null;
57
+ basis: string | null;
53
58
  anchor: string | null;
54
59
  found_at_sha: string | null;
55
60
  count: number;
@@ -91,6 +96,7 @@ export function reviewFromRow(row: ReviewRow): ReviewRecord {
91
96
  repoRoot: row.repo_root,
92
97
  repoRemote: row.repo_remote ?? undefined,
93
98
  branch: row.branch,
99
+ variant: row.variant || undefined,
94
100
  slug: row.slug,
95
101
  base: row.base ?? undefined,
96
102
  head: row.head ?? undefined,
@@ -129,6 +135,10 @@ export function findingFromRow(row: FindingRow, history: HistoryRow[]): FindingS
129
135
  message: row.message,
130
136
  action: row.action,
131
137
  evidence: row.evidence ?? undefined,
138
+ scenario: row.scenario ?? undefined,
139
+ impact: row.impact ?? undefined,
140
+ confidence: row.confidence === null ? undefined : Number(row.confidence),
141
+ basis: (row.basis as FindingState["basis"]) ?? undefined,
132
142
  anchor: parseJson<FindingAnchor>(row.anchor) ?? null,
133
143
  foundAtSha: row.found_at_sha ?? undefined,
134
144
  count: Number(row.count),
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { Database } from "bun:sqlite";
9
9
 
10
- export const SCHEMA_VERSION = 3;
10
+ export const SCHEMA_VERSION = 5;
11
11
 
12
12
  const DDL = `
13
13
  CREATE TABLE IF NOT EXISTS reviews (
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS reviews (
16
16
  repo_root TEXT NOT NULL,
17
17
  repo_remote TEXT,
18
18
  branch TEXT NOT NULL,
19
+ variant TEXT NOT NULL DEFAULT '',
19
20
  slug TEXT NOT NULL,
20
21
  base TEXT,
21
22
  head TEXT,
@@ -29,7 +30,7 @@ CREATE TABLE IF NOT EXISTS reviews (
29
30
  pr_number INTEGER,
30
31
  created_at TEXT NOT NULL,
31
32
  updated_at TEXT NOT NULL,
32
- UNIQUE(repo_key, branch)
33
+ UNIQUE(repo_key, branch, variant)
33
34
  );
34
35
 
35
36
  CREATE INDEX IF NOT EXISTS idx_reviews_repo_updated
@@ -48,6 +49,10 @@ CREATE TABLE IF NOT EXISTS findings (
48
49
  message TEXT NOT NULL,
49
50
  action TEXT NOT NULL,
50
51
  evidence TEXT,
52
+ scenario TEXT,
53
+ impact TEXT,
54
+ confidence REAL,
55
+ basis TEXT,
51
56
  anchor TEXT,
52
57
  found_at_sha TEXT,
53
58
  count INTEGER NOT NULL DEFAULT 1,
@@ -93,7 +98,6 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_review ON artifacts(review_id);
93
98
 
94
99
  /** Aplica pragmas e cria o schema quando necessário. */
95
100
  export function migrate(db: Database, path: string): void {
96
- db.exec("PRAGMA foreign_keys = ON;");
97
101
  db.exec("PRAGMA busy_timeout = 5000;");
98
102
  db.exec("PRAGMA synchronous = NORMAL;");
99
103
  if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL;");
@@ -102,25 +106,102 @@ export function migrate(db: Database, path: string): void {
102
106
  user_version?: number;
103
107
  } | null;
104
108
  const version = Number(row?.user_version ?? 0);
105
- if (version >= SCHEMA_VERSION) return;
109
+ if (version >= SCHEMA_VERSION) {
110
+ db.exec("PRAGMA foreign_keys = ON;");
111
+ return;
112
+ }
106
113
 
107
- db.transaction(() => {
108
- db.exec(DDL);
109
- // v2: heartbeat explícito da execução (bancos v1 não tinham a coluna).
110
- // v3: worktree/pr da revisão (revisões paralelas).
111
- const columns = db.query("PRAGMA table_info(reviews)").all() as {
112
- name: string;
113
- }[];
114
- const missing: [string, string][] = [
115
- ["heartbeat_at", "TEXT"],
116
- ["worktree_path", "TEXT"],
117
- ["pr_number", "INTEGER"],
118
- ];
119
- for (const [name, type] of missing) {
120
- if (!columns.some((column) => column.name === name)) {
121
- db.exec(`ALTER TABLE reviews ADD COLUMN ${name} ${type}`);
122
- }
123
- }
124
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
125
- })();
114
+ // O rebuild de `reviews` (v5) troca a chave única e precisa de FKs desligadas:
115
+ // com elas ligadas, `DROP TABLE reviews` cascatearia e apagaria os filhos.
116
+ db.exec("PRAGMA foreign_keys = OFF;");
117
+ try {
118
+ db.transaction(() => {
119
+ db.exec(DDL);
120
+ // v2: heartbeat explícito da execução; v3: worktree/pr da revisão;
121
+ // v4: cenário/impacto/confiança/origem do finding.
122
+ const addMissing = (table: string, columns: [string, string][]): void => {
123
+ const info = db.query(`PRAGMA table_info(${table})`).all() as {
124
+ name: string;
125
+ }[];
126
+ for (const [name, type] of columns) {
127
+ if (!info.some((column) => column.name === name)) {
128
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${type}`);
129
+ }
130
+ }
131
+ };
132
+ addMissing("reviews", [
133
+ ["heartbeat_at", "TEXT"],
134
+ ["worktree_path", "TEXT"],
135
+ ["pr_number", "INTEGER"],
136
+ ]);
137
+ addMissing("findings", [
138
+ ["scenario", "TEXT"],
139
+ ["impact", "TEXT"],
140
+ ["confidence", "REAL"],
141
+ ["basis", "TEXT"],
142
+ ]);
143
+ // v5: variante A/B na identidade da review.
144
+ rebuildReviewsWithVariant(db);
145
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
146
+ })();
147
+ } finally {
148
+ db.exec("PRAGMA foreign_keys = ON;");
149
+ }
150
+ const violations = db.query("PRAGMA foreign_key_check").all();
151
+ if (violations.length > 0) {
152
+ throw new Error(
153
+ `Migração do banco deixou ${violations.length} referência(s) inválida(s).`,
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * v5 — adiciona `variant` e troca `UNIQUE(repo_key, branch)` por
160
+ * `UNIQUE(repo_key, branch, variant)`.
161
+ *
162
+ * SQLite não remove constraint inline: recria a tabela preservando os ids (as
163
+ * FKs dos filhos continuam válidas) e recria o índice.
164
+ */
165
+ function rebuildReviewsWithVariant(db: Database): void {
166
+ const columns = db.query("PRAGMA table_info(reviews)").all() as {
167
+ name: string;
168
+ }[];
169
+ if (columns.some((column) => column.name === "variant")) return;
170
+
171
+ db.exec(`
172
+ CREATE TABLE reviews_v5 (
173
+ id TEXT PRIMARY KEY,
174
+ repo_key TEXT NOT NULL,
175
+ repo_root TEXT NOT NULL,
176
+ repo_remote TEXT,
177
+ branch TEXT NOT NULL,
178
+ variant TEXT NOT NULL DEFAULT '',
179
+ slug TEXT NOT NULL,
180
+ base TEXT,
181
+ head TEXT,
182
+ head_sha TEXT,
183
+ reviewed_sha TEXT,
184
+ reviewed_at TEXT,
185
+ status TEXT,
186
+ started_at TEXT,
187
+ heartbeat_at TEXT,
188
+ worktree_path TEXT,
189
+ pr_number INTEGER,
190
+ created_at TEXT NOT NULL,
191
+ updated_at TEXT NOT NULL,
192
+ UNIQUE(repo_key, branch, variant)
193
+ );
194
+ INSERT INTO reviews_v5
195
+ (id, repo_key, repo_root, repo_remote, branch, variant, slug, base, head,
196
+ head_sha, reviewed_sha, reviewed_at, status, started_at, heartbeat_at,
197
+ worktree_path, pr_number, created_at, updated_at)
198
+ SELECT id, repo_key, repo_root, repo_remote, branch, '', slug, base, head,
199
+ head_sha, reviewed_sha, reviewed_at, status, started_at, heartbeat_at,
200
+ worktree_path, pr_number, created_at, updated_at
201
+ FROM reviews;
202
+ DROP TABLE reviews;
203
+ ALTER TABLE reviews_v5 RENAME TO reviews;
204
+ CREATE INDEX IF NOT EXISTS idx_reviews_repo_updated
205
+ ON reviews(repo_key, updated_at DESC);
206
+ `);
126
207
  }
@@ -69,7 +69,7 @@ export class SQLiteStorageProvider implements StorageProvider {
69
69
  // -----------------------------------------------------------------------
70
70
 
71
71
  getOrCreateReview(input: CreateReviewInput): ReviewRecord {
72
- const existing = this.findReview(input.repo.key, input.branch);
72
+ const existing = this.findReview(input.repo.key, input.branch, input.variant);
73
73
  if (existing) {
74
74
  // O repo pode ter sido movido; a identidade continua a mesma.
75
75
  const moved =
@@ -102,6 +102,7 @@ export class SQLiteStorageProvider implements StorageProvider {
102
102
  repoRoot: input.repo.root,
103
103
  repoRemote: input.repo.remote,
104
104
  branch: input.branch,
105
+ variant: input.variant?.trim() || undefined,
105
106
  slug: input.slug,
106
107
  base: input.base,
107
108
  head: input.head,
@@ -113,15 +114,16 @@ export class SQLiteStorageProvider implements StorageProvider {
113
114
  };
114
115
  this.db.run(
115
116
  `INSERT INTO reviews
116
- (id, repo_key, repo_root, repo_remote, branch, slug, base, head,
117
+ (id, repo_key, repo_root, repo_remote, branch, variant, slug, base, head,
117
118
  worktree_path, pr_number, created_at, updated_at)
118
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
119
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
119
120
  [
120
121
  record.id,
121
122
  record.repoKey,
122
123
  record.repoRoot,
123
124
  record.repoRemote ?? null,
124
125
  record.branch,
126
+ record.variant ?? "",
125
127
  record.slug,
126
128
  record.base ?? null,
127
129
  record.head ?? null,
@@ -134,10 +136,12 @@ export class SQLiteStorageProvider implements StorageProvider {
134
136
  return record;
135
137
  }
136
138
 
137
- findReview(repoKey: string, branch: string): ReviewRecord | null {
139
+ findReview(repoKey: string, branch: string, variant?: string): ReviewRecord | null {
138
140
  const row = this.db
139
- .query("SELECT * FROM reviews WHERE repo_key = ? AND branch = ?")
140
- .get(repoKey, branch) as ReviewRow | null;
141
+ .query(
142
+ "SELECT * FROM reviews WHERE repo_key = ? AND branch = ? AND variant = ?",
143
+ )
144
+ .get(repoKey, branch, variant?.trim() ?? "") as ReviewRow | null;
141
145
  return row ? reviewFromRow(row) : null;
142
146
  }
143
147
 
@@ -253,9 +257,10 @@ export class SQLiteStorageProvider implements StorageProvider {
253
257
  const upsert = this.db.query(
254
258
  `INSERT INTO findings
255
259
  (id, review_id, short_id, fingerprint, watchdog_ids, rule, severity,
256
- file, line, message, action, evidence, anchor, found_at_sha, count,
257
- present, decision, comment, exec_state, github, created_at, updated_at)
258
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
260
+ file, line, message, action, evidence, scenario, impact, confidence, basis,
261
+ anchor, found_at_sha, count, present, decision, comment, exec_state, github,
262
+ created_at, updated_at)
263
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
259
264
  ON CONFLICT(id) DO UPDATE SET
260
265
  short_id = excluded.short_id,
261
266
  fingerprint = excluded.fingerprint,
@@ -267,6 +272,10 @@ export class SQLiteStorageProvider implements StorageProvider {
267
272
  message = excluded.message,
268
273
  action = excluded.action,
269
274
  evidence = excluded.evidence,
275
+ scenario = excluded.scenario,
276
+ impact = excluded.impact,
277
+ confidence = excluded.confidence,
278
+ basis = excluded.basis,
270
279
  anchor = excluded.anchor,
271
280
  found_at_sha = excluded.found_at_sha,
272
281
  count = excluded.count,
@@ -325,6 +334,10 @@ export class SQLiteStorageProvider implements StorageProvider {
325
334
  finding.message,
326
335
  finding.action,
327
336
  finding.evidence ?? null,
337
+ finding.scenario ?? null,
338
+ finding.impact ?? null,
339
+ finding.confidence ?? null,
340
+ finding.basis ?? null,
328
341
  finding.anchor ? JSON.stringify(finding.anchor) : null,
329
342
  finding.foundAtSha ?? null,
330
343
  finding.count,
@@ -28,6 +28,13 @@ export type ReviewRecord = {
28
28
  repoRoot: string;
29
29
  repoRemote?: string;
30
30
  branch: string;
31
+ /**
32
+ * Rótulo da variante A/B (ausente = run principal da branch).
33
+ *
34
+ * Faz parte da identidade junto com `(repoKey, branch)`: permite rodar a
35
+ * mesma PR com dois experimentos sem apagar a revisão anterior.
36
+ */
37
+ variant?: string;
31
38
  slug: string;
32
39
  base?: string;
33
40
  head?: string;
@@ -81,6 +88,8 @@ export type CreateReviewInput = {
81
88
  repo: RepoIdentity;
82
89
  branch: string;
83
90
  slug: string;
91
+ /** Variante A/B (ausente = run principal da branch). */
92
+ variant?: string;
84
93
  base?: string;
85
94
  head?: string;
86
95
  /** Worktree onde a revisão roda (ausente = clone principal). */
@@ -99,11 +108,11 @@ export type ArtifactKind =
99
108
  | "context"
100
109
  | "context-brief"
101
110
  | "context-summary"
111
+ | "business"
112
+ | "business-brief"
102
113
  | "finding-report"
103
- | "review"
104
114
  | "resume"
105
115
  | "technical"
106
- | "domain-review"
107
116
  | "verify"
108
117
  | "ast"
109
118
  | "ast-impact"
@@ -111,6 +120,9 @@ export type ArtifactKind =
111
120
  | "ai-usage"
112
121
  | "ai-brief"
113
122
  | "hunt"
123
+ | "prepare"
124
+ | "compare"
125
+ | "golden"
114
126
  | "other";
115
127
 
116
128
  /** Artefato persistido de uma review. */
@@ -142,9 +154,10 @@ export type WriteArtifactInput = {
142
154
  export interface StorageProvider {
143
155
  readonly kind: "sqlite" | "file";
144
156
 
145
- /** Recupera ou cria a review de uma branch (identidade = repo + branch). */
157
+ /** Busca por identidade `(repo, branch, variante)`; variante ausente = principal. */
158
+ findReview(repoKey: string, branch: string, variant?: string): ReviewRecord | null;
159
+ /** Run da branch + variante (cria quando não existe). */
146
160
  getOrCreateReview(input: CreateReviewInput): ReviewRecord;
147
- findReview(repoKey: string, branch: string): ReviewRecord | null;
148
161
  getReview(id: string): ReviewRecord | null;
149
162
  /** Reviews do repositório (mais recentes primeiro); sem chave = todas. */
150
163
  listReviews(repoKey?: string): ReviewRecord[];