@payconductor/houndsman 0.3.0 → 0.5.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 (91) hide show
  1. package/README.md +25 -39
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/commands/status-command.ts +46 -0
  8. package/src/cli/help.ts +8 -3
  9. package/src/cli/index.ts +5 -2
  10. package/src/core/commands/context.ts +29 -13
  11. package/src/core/commands/findings.ts +216 -125
  12. package/src/core/commands/review.ts +7 -8
  13. package/src/core/commands/scaffold.ts +15 -16
  14. package/src/core/commands/status.ts +63 -0
  15. package/src/core/config.ts +16 -7
  16. package/src/core/engine/houndsman-engine.ts +15 -1
  17. package/src/core/engine/review-scope.ts +26 -8
  18. package/src/core/engine/review-session.ts +37 -9
  19. package/src/core/findings-state.ts +53 -75
  20. package/src/core/github-review.ts +620 -57
  21. package/src/core/jev/jev-candidates.ts +49 -35
  22. package/src/core/orchestrator.ts +271 -134
  23. package/src/core/paths.ts +26 -17
  24. package/src/core/pr-context.ts +19 -5
  25. package/src/core/project-config.ts +4 -5
  26. package/src/core/renderers/manifest-writer.ts +29 -18
  27. package/src/core/renderers/resume-renderer.ts +151 -0
  28. package/src/core/report-builder.ts +7 -4
  29. package/src/core/run-layout.ts +4 -104
  30. package/src/core/run-status.ts +59 -50
  31. package/src/core/services/git-repository.ts +16 -0
  32. package/src/core/services/project-files.ts +13 -1
  33. package/src/core/storage/factory.ts +24 -0
  34. package/src/core/storage/file/file-storage.ts +271 -0
  35. package/src/core/storage/paths.ts +64 -0
  36. package/src/core/storage/repo-identity.ts +70 -0
  37. package/src/core/storage/sqlite/mappers.ts +162 -0
  38. package/src/core/storage/sqlite/schema.ts +116 -0
  39. package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
  40. package/src/core/storage/types.ts +152 -0
  41. package/src/core/storage/work-area.ts +191 -0
  42. package/src/index.ts +27 -6
  43. package/src/server/artifacts.ts +8 -61
  44. package/src/server/client/App.tsx +26 -9
  45. package/src/server/client/ArtifactsPanel.tsx +144 -23
  46. package/src/server/client/AutoRefresh.tsx +1 -1
  47. package/src/server/client/Filters.tsx +7 -2
  48. package/src/server/client/FindingCard.tsx +50 -15
  49. package/src/server/client/FindingsList.tsx +2 -2
  50. package/src/server/client/MarkdownText.tsx +181 -0
  51. package/src/server/client/ResumePanel.tsx +368 -0
  52. package/src/server/client/RunList.tsx +3 -3
  53. package/src/server/client/SubmitModal.tsx +58 -10
  54. package/src/server/client/SummaryBar.tsx +1 -1
  55. package/src/server/client/icons.tsx +17 -0
  56. package/src/server/client/utils/code-highlight.ts +422 -0
  57. package/src/server/client/utils/constants.ts +226 -0
  58. package/src/server/client/utils/json-highlight.ts +55 -0
  59. package/src/server/client/utils/markdown.ts +319 -0
  60. package/src/server/client/{types.ts → utils/types.ts} +81 -0
  61. package/src/server/index.ts +154 -16
  62. package/src/server/run-repository.ts +153 -136
  63. package/src/skill/OUTPUT-FORMAT.md +6 -4
  64. package/src/skill/README.md +22 -14
  65. package/src/skill/SETUP.md +4 -4
  66. package/src/skill/example-review.md +22 -14
  67. package/src/skill/fix.md +5 -5
  68. package/src/skill/hunt.md +55 -32
  69. package/src/skill/train.md +1 -1
  70. package/src/skill/verify.md +7 -6
  71. package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
  72. package/src/tests/baseline.test.ts +79 -34
  73. package/src/tests/cli.test.ts +40 -19
  74. package/src/tests/code-highlight.test.ts +123 -0
  75. package/src/tests/domain.test.ts +1 -1
  76. package/src/tests/findings-commands.test.ts +153 -131
  77. package/src/tests/findings-state.test.ts +56 -5
  78. package/src/tests/fixtures/fake-project.ts +0 -1
  79. package/src/tests/github-review.test.ts +409 -60
  80. package/src/tests/infrastructure.test.ts +33 -38
  81. package/src/tests/markdown.test.ts +149 -0
  82. package/src/tests/review-resume.test.ts +101 -0
  83. package/src/tests/review-scope.test.ts +302 -0
  84. package/src/tests/review-scripts.test.ts +81 -5
  85. package/src/tests/run-status-view.test.ts +94 -0
  86. package/src/tests/run-status.test.ts +86 -23
  87. package/src/tests/server.test.ts +184 -96
  88. package/src/tests/storage.test.ts +449 -0
  89. package/src/server/client/constants.ts +0 -91
  90. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  91. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Markdown mínimo do cliente (campo `action` do finding e artefatos).
3
+ *
4
+ * Suporta o suficiente para instruções curtas e relatórios: parágrafos,
5
+ * quebras de linha, listas, blockquote, títulos, tabelas, blocos de código e
6
+ * formatação inline (`code`, **bold**, *itálico*, links). Sem lib externa e
7
+ * sem HTML cru.
8
+ */
9
+
10
+ /** Alinhamento de coluna em tabela markdown. */
11
+ export type MarkdownAlign = "left" | "center" | "right";
12
+
13
+ export type MarkdownBlock =
14
+ | { type: "paragraph"; text: string }
15
+ | { type: "list"; ordered: boolean; items: string[] }
16
+ | { type: "code"; language: string | null; code: string }
17
+ | { type: "quote"; text: string }
18
+ | { type: "heading"; level: number; text: string }
19
+ | {
20
+ type: "table";
21
+ align: (MarkdownAlign | undefined)[];
22
+ header: string[];
23
+ rows: string[][];
24
+ }
25
+ | { type: "hr" };
26
+
27
+ export type InlineToken =
28
+ | { type: "text"; text: string }
29
+ | { type: "code"; text: string }
30
+ | { type: "strong"; text: string }
31
+ | { type: "em"; text: string }
32
+ | { type: "link"; text: string; url: string }
33
+ | { type: "path"; text: string };
34
+
35
+ const FENCE = /^\s*```(\S*)\s*$/;
36
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
37
+ const ORDERED = /^\s*\d+\.\s+(.*)$/;
38
+ const QUOTE = /^\s*>\s?(.*)$/;
39
+ const HEADING = /^(#{1,6})\s+(.*)$/;
40
+ const HR = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
41
+ const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$/;
42
+
43
+ function isBlockStart(line: string): boolean {
44
+ return (
45
+ FENCE.test(line) ||
46
+ HEADING.test(line) ||
47
+ HR.test(line) ||
48
+ BULLET.test(line) ||
49
+ ORDERED.test(line) ||
50
+ QUOTE.test(line)
51
+ );
52
+ }
53
+
54
+ /** Quebra uma linha de tabela em células (remove os pipes das pontas). */
55
+ function splitTableRow(line: string): string[] {
56
+ return line
57
+ .trim()
58
+ .replace(/^\|/, "")
59
+ .replace(/\|$/, "")
60
+ .split("|")
61
+ .map((cell) => cell.trim());
62
+ }
63
+
64
+ /** `true` quando há um cabeçalho seguido da linha separadora de tabela. */
65
+ function isTableStart(lines: string[], index: number): boolean {
66
+ if (index + 1 >= lines.length) return false;
67
+ if (!lines[index].includes("|")) return false;
68
+ return TABLE_SEP.test(lines[index + 1]);
69
+ }
70
+
71
+ /** Alinhamentos declarados na linha separadora (`:---`, `:---:`, `---:`). */
72
+ function parseAlignments(line: string): (MarkdownAlign | undefined)[] {
73
+ return splitTableRow(line).map((cell) => {
74
+ const left = cell.startsWith(":");
75
+ const right = cell.endsWith(":");
76
+ if (left && right) return "center";
77
+ if (right) return "right";
78
+ if (left) return "left";
79
+ return undefined;
80
+ });
81
+ }
82
+
83
+ /** Célula numérica ou razão (`12`, `3/4`) — colunas assim vão para a direita. */
84
+ const NUMERIC_CELL = /^[\d\s/]+$/;
85
+
86
+ /** Resolve o alinhamento: explícito > coluna só de números/barra > padrão. */
87
+ function resolveAlignments(
88
+ align: (MarkdownAlign | undefined)[],
89
+ rows: string[][],
90
+ ): (MarkdownAlign | undefined)[] {
91
+ return align.map((value, column) => {
92
+ if (value) return value;
93
+ let filled = 0;
94
+ for (const row of rows) {
95
+ const cell = (row[column] ?? "").trim();
96
+ if (cell.length === 0) continue;
97
+ if (!NUMERIC_CELL.test(cell) || !/\d/.test(cell)) return undefined;
98
+ filled++;
99
+ }
100
+ return filled > 0 ? "right" : undefined;
101
+ });
102
+ }
103
+
104
+ /** Divide o texto em blocos simples (sem aninhamento profundo). */
105
+ export function parseMarkdown(text: string): MarkdownBlock[] {
106
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
107
+ const blocks: MarkdownBlock[] = [];
108
+ let index = 0;
109
+
110
+ while (index < lines.length) {
111
+ const line = lines[index];
112
+ if (line.trim().length === 0) {
113
+ index++;
114
+ continue;
115
+ }
116
+
117
+ const fence = FENCE.exec(line);
118
+ if (fence) {
119
+ const language = fence[1] || null;
120
+ const code: string[] = [];
121
+ index++;
122
+ while (index < lines.length && !FENCE.test(lines[index])) {
123
+ code.push(lines[index]);
124
+ index++;
125
+ }
126
+ if (index < lines.length) index++;
127
+ blocks.push({ type: "code", language, code: code.join("\n") });
128
+ continue;
129
+ }
130
+
131
+ const heading = HEADING.exec(line);
132
+ if (heading) {
133
+ blocks.push({
134
+ type: "heading",
135
+ level: heading[1].length,
136
+ text: heading[2].trim(),
137
+ });
138
+ index++;
139
+ continue;
140
+ }
141
+
142
+ if (HR.test(line)) {
143
+ blocks.push({ type: "hr" });
144
+ index++;
145
+ continue;
146
+ }
147
+
148
+ if (isTableStart(lines, index)) {
149
+ const header = splitTableRow(line);
150
+ const align = parseAlignments(lines[index + 1]);
151
+ const rows: string[][] = [];
152
+ index += 2;
153
+ while (
154
+ index < lines.length &&
155
+ lines[index].trim().length > 0 &&
156
+ lines[index].includes("|")
157
+ ) {
158
+ rows.push(splitTableRow(lines[index]));
159
+ index++;
160
+ }
161
+ blocks.push({
162
+ type: "table",
163
+ align: resolveAlignments(align, rows),
164
+ header,
165
+ rows,
166
+ });
167
+ continue;
168
+ }
169
+
170
+ if (BULLET.test(line)) {
171
+ const items: string[] = [];
172
+ while (index < lines.length) {
173
+ const item = BULLET.exec(lines[index]);
174
+ if (!item) break;
175
+ items.push(item[1]);
176
+ index++;
177
+ }
178
+ blocks.push({ type: "list", ordered: false, items });
179
+ continue;
180
+ }
181
+
182
+ if (ORDERED.test(line)) {
183
+ const items: string[] = [];
184
+ while (index < lines.length) {
185
+ const item = ORDERED.exec(lines[index]);
186
+ if (!item) break;
187
+ items.push(item[1]);
188
+ index++;
189
+ }
190
+ blocks.push({ type: "list", ordered: true, items });
191
+ continue;
192
+ }
193
+
194
+ if (QUOTE.test(line)) {
195
+ const quote: string[] = [];
196
+ while (index < lines.length) {
197
+ const item = QUOTE.exec(lines[index]);
198
+ if (!item) break;
199
+ quote.push(item[1]);
200
+ index++;
201
+ }
202
+ blocks.push({ type: "quote", text: quote.join("\n") });
203
+ continue;
204
+ }
205
+
206
+ const paragraph: string[] = [line];
207
+ index++;
208
+ while (
209
+ index < lines.length &&
210
+ lines[index].trim().length > 0 &&
211
+ !isBlockStart(lines[index]) &&
212
+ !isTableStart(lines, index)
213
+ ) {
214
+ paragraph.push(lines[index]);
215
+ index++;
216
+ }
217
+ blocks.push({ type: "paragraph", text: paragraph.join("\n") });
218
+ }
219
+
220
+ return blocks;
221
+ }
222
+
223
+ /** Blocos de uma seção `## <título>` (sem o heading); vazio se ausente. */
224
+ export function sectionBlocks(text: string, title: string): MarkdownBlock[] {
225
+ const wanted = title.trim().toLowerCase();
226
+ const blocks: MarkdownBlock[] = [];
227
+ let inside = false;
228
+ for (const block of parseMarkdown(text)) {
229
+ if (block.type === "heading") {
230
+ if (inside) break;
231
+ inside = block.level >= 2 && block.text.trim().toLowerCase() === wanted;
232
+ continue;
233
+ }
234
+ if (inside) blocks.push(block);
235
+ }
236
+ return blocks;
237
+ }
238
+
239
+ /** Extensão reconhecida como arquivo (evita destacar texto comum). */
240
+ const FILE_EXTENSION =
241
+ /\.(?:[cm]?[jt]sx?|json|md|ya?ml|toml|css|scss|html?|py|go|rs|java|kt|cs|php|rb|sh|sql|txt|lock)$/i;
242
+
243
+ /** Caminho/arquivo dentro de um texto corrido (`src/foo.ts`, `review.md`). */
244
+ const PATH_TOKEN =
245
+ /(?:[\w.-]+[\\/])*[\w-]+\.(?:[cm]?[jt]sx?|json|md|ya?ml|toml|css|scss|html?|py|go|rs|java|kt|cs|php|rb|sh|sql|txt|lock)\b/g;
246
+
247
+ /** `true` se o texto parece um caminho/arquivo (sem espaços e com extensão). */
248
+ export function isFilePath(value: string): boolean {
249
+ const text = value.trim();
250
+ if (!text || /\s/.test(text)) return false;
251
+ if (/^(?:https?:|mailto:|#)/i.test(text)) return false;
252
+ const base = text.split(/[\\/]/).pop() ?? text;
253
+ if (FILE_EXTENSION.test(base)) return true;
254
+ return (
255
+ text.startsWith("./") ||
256
+ text.startsWith("../") ||
257
+ text.startsWith("/") ||
258
+ /^[A-Za-z]:[\\/]/.test(text)
259
+ );
260
+ }
261
+
262
+ /** Quebra texto corrido em tokens, destacando caminhos de arquivo. */
263
+ function pushTextTokens(tokens: InlineToken[], text: string): void {
264
+ let last = 0;
265
+ for (const match of text.matchAll(PATH_TOKEN)) {
266
+ const index = match.index ?? 0;
267
+ if (index > last) {
268
+ tokens.push({ type: "text", text: text.slice(last, index) });
269
+ }
270
+ tokens.push({ type: "path", text: match[0] });
271
+ last = index + match[0].length;
272
+ }
273
+ if (last < text.length) {
274
+ tokens.push({ type: "text", text: text.slice(last) });
275
+ }
276
+ }
277
+
278
+ /** Tokeniza formatação inline preservando o texto e a ordem dos marcadores. */
279
+ export function parseInline(text: string): InlineToken[] {
280
+ const tokens: InlineToken[] = [];
281
+ // Ordem importa: code/bold antes de itálico; `_` exige fronteira de palavra.
282
+ const pattern =
283
+ /`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|(?<![\w])_[^_\n]+_(?![\w])|\[[^\]\n]+\]\([^)\s]+\)/g;
284
+ let last = 0;
285
+ for (const match of text.matchAll(pattern)) {
286
+ const start = match.index ?? 0;
287
+ if (start > last) {
288
+ pushTextTokens(tokens, text.slice(last, start));
289
+ }
290
+ const raw = match[0];
291
+ if (raw.startsWith("`")) {
292
+ tokens.push({ type: "code", text: raw.slice(1, -1) });
293
+ } else if (raw.startsWith("**") || raw.startsWith("__")) {
294
+ tokens.push({ type: "strong", text: raw.slice(2, -2) });
295
+ } else if (raw.startsWith("*") || raw.startsWith("_")) {
296
+ tokens.push({ type: "em", text: raw.slice(1, -1) });
297
+ } else {
298
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(raw);
299
+ if (link) {
300
+ tokens.push({ type: "link", text: link[1], url: link[2] });
301
+ } else {
302
+ pushTextTokens(tokens, raw);
303
+ }
304
+ }
305
+ last = start + raw.length;
306
+ }
307
+ if (last < text.length) {
308
+ pushTextTokens(tokens, text.slice(last));
309
+ }
310
+ return tokens;
311
+ }
312
+
313
+ /** URL segura para links de markdown (evita `javascript:` e afins). */
314
+ export function safeUrl(url: string): string | null {
315
+ const value = url.trim();
316
+ if (/^(https?:|mailto:)/i.test(value)) return value;
317
+ if (value.startsWith("/") && !value.startsWith("//")) return value;
318
+ return null;
319
+ }
@@ -50,10 +50,37 @@ export type Summary = {
50
50
  bySeverity: Record<Severity, number>;
51
51
  };
52
52
 
53
+ /** Espécie de artefato persistido (espelha `core/storage/types.ts`). */
54
+ export type ArtifactKind =
55
+ | "brief"
56
+ | "classification"
57
+ | "manifest"
58
+ | "candidates"
59
+ | "context"
60
+ | "context-brief"
61
+ | "context-summary"
62
+ | "finding-report"
63
+ | "review"
64
+ | "resume"
65
+ | "domain-review"
66
+ | "verify"
67
+ | "other";
68
+
69
+ /** Metadados de artefato retornados pela API (sem conteúdo). */
70
+ export type ArtifactMeta = {
71
+ name: string;
72
+ kind: ArtifactKind;
73
+ contentType: string;
74
+ updatedAt: string;
75
+ };
76
+
53
77
  export type RunSummary = {
54
78
  slug: string;
55
79
  branch: string;
56
80
  updatedAt?: string;
81
+ startedAt?: string;
82
+ /** Último sinal de vida de uma execução ativa (revisão/correção). */
83
+ heartbeatAt?: string;
57
84
  status?: RunStatus | null;
58
85
  summary: Summary;
59
86
  };
@@ -64,6 +91,52 @@ export type FindingsResponse = {
64
91
  summary: Summary;
65
92
  };
66
93
 
94
+ /** Manifesto da run (subset consumido pela aba de resumo). */
95
+ export type RunManifest = {
96
+ branch?: string;
97
+ base?: string;
98
+ head?: string;
99
+ generatedAt?: string;
100
+ churn?: { additions: number; deletions: number };
101
+ watchdogs?: {
102
+ id: string;
103
+ name: string;
104
+ files: string[];
105
+ overflow: number;
106
+ status: "pending" | "done";
107
+ }[];
108
+ context?: {
109
+ summarized: boolean;
110
+ source?: {
111
+ number?: number;
112
+ title?: string;
113
+ url?: string;
114
+ base?: string;
115
+ head?: string;
116
+ } | null;
117
+ };
118
+ excludedCount?: number;
119
+ deleted?: string[];
120
+ unclassified?: string[];
121
+ };
122
+
123
+ /** Detalhe da run (`GET /api/runs/:slug`). */
124
+ export type RunDetail = {
125
+ slug: string;
126
+ branch: string;
127
+ manifest: RunManifest | null;
128
+ watchdogs: string[];
129
+ unclassified: string[];
130
+ deleted: string[];
131
+ };
132
+
133
+ /** `classification.json` — só o necessário para os cards do resumo. */
134
+ export type ClassificationArtifact = {
135
+ files?: Record<string, { status: string; oldPath?: string }>;
136
+ churn?: Record<string, { additions: number; deletions: number }>;
137
+ watchdogs?: Record<string, string[]>;
138
+ };
139
+
67
140
  /** Candidato ao envio da revisão (espelha `core/github-review.ts`). */
68
141
  export type SubmitCandidate = {
69
142
  id: string;
@@ -111,6 +184,14 @@ export type SubmitOutcome = {
111
184
  failed: { id: string; reason: string }[];
112
185
  skipped: { id: string; reason: string }[];
113
186
  general?: { commentId: number; url?: string; ids: string[] };
187
+ review?: {
188
+ id?: number;
189
+ url?: string;
190
+ created: boolean;
191
+ finalized: boolean;
192
+ event?: "COMMENT" | "REQUEST_CHANGES";
193
+ error?: string;
194
+ };
114
195
  };
115
196
 
116
197
  export type SubmitResponse = {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Servidor local do houndsman (`Bun.serve`) — UI de triagem + artefatos.
3
3
  *
4
- * Serve um SPA Preact (bundle em memória) + uma API JSON sobre o `state.json`
5
- * das runs em `.houndsman/.runs/`. Sem dependências além do Bun e do Preact.
6
- * Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
4
+ * Serve um SPA Preact (bundle em memória) + uma API JSON sobre o storage global
5
+ * (SQLite por default), escopada ao repositório do cwd. Sem dependências além do
6
+ * Bun e do Preact. Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
7
7
  */
8
8
 
9
9
  import { mediaPath } from "../core/assets";
@@ -14,6 +14,9 @@ import {
14
14
  type GhRunner,
15
15
  type SubmitSelection,
16
16
  } from "../core/github-review";
17
+ import { createStorage } from "../core/storage/factory";
18
+ import { resolveRepoIdentity } from "../core/storage/repo-identity";
19
+ import type { RepoIdentity, StorageProvider } from "../core/storage/types";
17
20
  import { buildClientBundle } from "./client-build";
18
21
  import { RunRepository } from "./run-repository";
19
22
 
@@ -53,7 +56,11 @@ function notFound(detail = "não encontrado"): Response {
53
56
  // ---------------------------------------------------------------------------
54
57
 
55
58
  /** Repositório das runs (dados) — ver `run-repository.ts`. */
56
- type ServerDeps = { ghRunner?: GhRunner };
59
+ type ServerDeps = {
60
+ ghRunner?: GhRunner;
61
+ storage?: StorageProvider;
62
+ repo?: RepoIdentity;
63
+ };
57
64
 
58
65
  // ---------------------------------------------------------------------------
59
66
  // Rotas
@@ -142,6 +149,9 @@ async function route(
142
149
  if (!artifact) return notFound(`artefato '${name}' não encontrado`);
143
150
  return text(artifact.content, artifact.type);
144
151
  }
152
+ if (rest === "/artifacts" && request.method === "GET") {
153
+ return json({ artifacts: runs.artifacts(slug) ?? [] });
154
+ }
145
155
 
146
156
  return notFound();
147
157
  }
@@ -152,7 +162,10 @@ export class HoundsmanServer {
152
162
  private readonly ghRunner: GhRunner;
153
163
 
154
164
  constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
155
- this.runs = RunRepository.fromConfig(config);
165
+ this.runs = new RunRepository(
166
+ deps.storage ?? createStorage(config),
167
+ deps.repo ?? resolveRepoIdentity(),
168
+ );
156
169
  this.ghRunner = deps.ghRunner ?? defaultGhRunner;
157
170
  }
158
171
 
@@ -173,11 +186,26 @@ export class HoundsmanServer {
173
186
  server: BunServer;
174
187
  url: string;
175
188
  } {
189
+ // Manutenção do banco no start: compacta o que sobrou de runs antigas.
190
+ try {
191
+ this.runs.maintain();
192
+ } catch (error) {
193
+ console.warn(
194
+ `[houndsman] VACUUM falhou (seguindo sem compactar): ${
195
+ error instanceof Error ? error.message : String(error)
196
+ }`,
197
+ );
198
+ }
176
199
  const host = options.host ?? "127.0.0.1";
177
200
  const port = options.port ?? 7331;
178
201
  const server = Bun.serve({ hostname: host, port, fetch: this.handler });
179
202
  return { server, url: `http://${host}:${server.port}` };
180
203
  }
204
+
205
+ /** Fecha o provider (após `server.stop()`). */
206
+ dispose(): void {
207
+ this.runs.dispose();
208
+ }
181
209
  }
182
210
 
183
211
  /** Cria o handler de requisições (testável sem abrir porta). */
@@ -194,11 +222,18 @@ export type ServerOptions = {
194
222
  host?: string;
195
223
  /** Runner do `gh` (injetável nos testes). */
196
224
  ghRunner?: GhRunner;
225
+ /** Provider/storage injetável (testes). */
226
+ storage?: StorageProvider;
227
+ repo?: RepoIdentity;
197
228
  };
198
229
 
199
230
  /** Sobe o servidor e devolve a instância + URL. */
200
231
  export function startServer(options: ServerOptions): { server: BunServer; url: string } {
201
- return new HoundsmanServer(options.config, { ghRunner: options.ghRunner }).start({
232
+ return new HoundsmanServer(options.config, {
233
+ ghRunner: options.ghRunner,
234
+ storage: options.storage,
235
+ repo: options.repo,
236
+ }).start({
202
237
  port: options.port,
203
238
  host: options.host,
204
239
  });
@@ -227,7 +262,7 @@ html, body { height:100%; }
227
262
  body { margin:0; font-size:14px; background:var(--bg); color:var(--fg); font-family:var(--font-sans); -webkit-font-smoothing:antialiased; }
228
263
  body::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
229
264
  background:radial-gradient(820px 420px at 8% -12%, rgba(240,165,44,.08), transparent 62%); }
230
- code, pre, .mono { font-family:var(--font-mono); }
265
+ code, pre, .mono { font-family:var(--font-mono); tab-size:4; }
231
266
  ::-webkit-scrollbar { width:10px; height:10px; }
232
267
  ::-webkit-scrollbar-thumb { background:rgba(255,255,255,.1); border:2px solid transparent; border-radius:999px; background-clip:padding-box; }
233
268
  ::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.18); background-clip:padding-box; }
@@ -280,7 +315,7 @@ code, pre, .mono { font-family:var(--font-mono); }
280
315
  .btn:disabled { opacity:.55; cursor:not-allowed; }
281
316
  .icon.spin { animation:spin .8s linear infinite; }
282
317
  .content { width:100%; max-width:1180px; margin:0 auto; padding:22px 24px 64px; }
283
- .page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }
318
+ .page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; margin-bottom:16px; }
284
319
  .page-header-text { min-width:0; }
285
320
  .page-header-actions { display:flex; align-items:center; gap:10px; flex:none; }
286
321
  .btn.submit-review { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); border-color:transparent; box-shadow:0 4px 14px rgba(240,165,44,.22); }
@@ -369,13 +404,48 @@ code, pre, .mono { font-family:var(--font-mono); }
369
404
  .meta { display:inline-flex; align-items:center; gap:7px; }
370
405
  .meta-k { font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
371
406
  .meta code { padding:2px 6px; font-size:11.5px; color:var(--fg); background:rgba(255,255,255,.04); border:1px solid var(--border); border-radius:6px; }
372
- .action-block { display:flex; gap:11px; padding:16px; background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:var(--radius); }
373
- .action-icon { flex:none; display:inline-block; margin-right:5px; margin-bottom:-5px; color:var(--accent); }
374
- .action-label { display:inline-block; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); margin-right:5px; }
375
- .action-block p { margin:0; font-size:13px; color:#f0ece6; }
407
+ .action-block { display:flex; gap:11px; padding:0; background:transparent; border:0; border-radius:0; }
408
+ .action-icon { flex:none; display:inline-block; margin-top:3px; margin-right:2px; color:var(--accent); }
409
+ .action-text { flex:1; min-width:0; }
410
+ .action-label { display:block; margin-bottom:4px; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); }
411
+ .action-text p, .md-p { margin:0 0 6px; font-size:13px; line-height:1.65; color:#f0ece6; white-space:pre-wrap; }
412
+ .action-text p:last-child { margin-bottom:0; }
413
+ .md-list { margin:0 0 6px; padding-left:20px; font-size:13px; line-height:1.65; color:#f0ece6; }
414
+ .md-list li { margin:0 0 2px; }
415
+ .md-quote { margin:0 0 6px; padding:2px 0 2px 10px; border-left:2px solid var(--accent-line); color:var(--muted); font-size:13px; line-height:1.65; white-space:pre-wrap; }
416
+ .action-text code, .md-code { font-family:var(--font-mono); font-size:11.5px; padding:1px 5px; color:#f0ece6; background:rgba(255,255,255,.06); border:1px solid var(--border); border-radius:5px; }
417
+ code.md-path { color:#8fc7ff; background:rgba(90,164,240,.1); border-color:rgba(90,164,240,.24); }
418
+ .action-text a, .md-p a, .md-list a, .md-quote a, .md-table a, .md-h a { color:var(--accent-strong); text-decoration:underline; text-underline-offset:2px; }
419
+ .action-text strong, .md-p strong, .md-list strong, .md-quote strong, .md-table strong, .md-h strong { color:#fff; font-weight:600; }
420
+ .action-text em, .md-p em, .md-list em, .md-quote em, .md-table em, .md-h em { color:#e9e4dc; }
421
+ .md-code-block { margin:6px 0; padding:10px 12px; max-height:320px; overflow:auto; white-space:pre; font-size:11.5px; line-height:1.6; color:#cdc7bf; background:#0b0b0e; border-radius:10px; }
422
+ .md-h { margin:16px 0 8px; font-size:14px; font-weight:700; line-height:1.35; letter-spacing:-.01em; color:#eee9e2; }
423
+ .md-h:first-child { margin-top:0; }
424
+ .md-h1 { font-size:19px; }
425
+ .md-h2 { font-size:16px; }
426
+ .md-h3 { font-size:14px; }
427
+ .md-h4, .md-h5, .md-h6 { font-size:13px; color:#e4dfd7; font-weight:600; }
428
+ .md-hr { margin:14px 0; border:0; border-top:1px solid var(--border); }
429
+ .md-table-wrap { margin:8px 0 12px; padding:4px; overflow-x:auto; border:1px solid rgba(255,255,255,.07); border-radius:10px; background:rgba(255,255,255,.012); }
430
+ .md-table { width:100%; border-collapse:separate; border-spacing:3px; font-size:12px; line-height:1.5; }
431
+ .md-table th, .md-table td { padding:6px 9px; text-align:left; vertical-align:top; border:1px solid rgba(255,255,255,.035); border-radius:6px; }
432
+ .md-table th { color:var(--muted); font-weight:600; background:rgba(255,255,255,.03); }
433
+ .md-table td { color:#d8d3cc; }
376
434
  .decision-tags-block { display:flex; align-items:center; gap:8px; }
377
435
  .evidence { margin:0; padding: 16px; white-space:break-spaces; font-size:12px; line-height:1.6; color:#cdc7bf;
378
436
  background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
437
+ .evidence-block { display:flex; flex-direction:column; gap:6px; }
438
+ .evidence-head { display:flex; justify-content:flex-end; }
439
+ .code-lang { font-family:var(--font-mono); font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
440
+ .code-evidence { white-space:pre; overflow:auto; }
441
+ .tok-keyword { color:#c792ea; }
442
+ .tok-string { color:#9ddc8a; }
443
+ .tok-comment { color:#6d6862; font-style:italic; }
444
+ .tok-number { color:#8fc7ff; }
445
+ .tok-type { color:var(--accent-strong); }
446
+ .tok-function { color:#82aaff; }
447
+ .tok-property { color:#e6c07b; }
448
+ .tok-punct { color:#8d8a85; }
379
449
  .tags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin-left: auto; }
380
450
  .tags-label { display:inline-flex; align-items:center; gap:6px; font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
381
451
  .tag { display:inline-flex; align-items:center; gap:6px; padding:3px 9px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
@@ -398,14 +468,82 @@ code, pre, .mono { font-family:var(--font-mono); }
398
468
  font-family:var(--font-mono); font-size:11px; color:var(--muted); }
399
469
  .comment-status.saved { color:var(--success); }
400
470
  .artifacts { display:flex; flex-direction:column; }
401
- .artifact-tabs { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:12px; }
402
- .artifact-tabs .chip { border:1px solid var(--border); background:var(--surface-2); }
403
- .artifact-desc { margin:0 0 12px; color:var(--muted); font-size:12.5px; line-height:1.55; }
404
- .artifact-view { margin:0; padding:14px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
471
+ .artifact-layout { display:grid; grid-template-columns:minmax(190px,250px) minmax(0,1fr); gap:16px; align-items:start; }
472
+ .artifact-list { display:flex; flex-direction:column; gap:14px; max-height:72vh; overflow:auto; padding:2px 4px 2px 0; }
473
+ .artifact-group { display:flex; flex-direction:column; gap:4px; }
474
+ .artifact-group-label { margin:0 0 2px; font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
475
+ .artifact-item { display:flex; flex-direction:column; gap:2px; text-align:left; cursor:pointer; font:inherit; color:var(--muted);
476
+ padding:8px 10px; background:transparent; border:1px solid transparent; border-radius:10px;
477
+ transition:background .15s ease, border-color .15s ease, color .15s ease; }
478
+ .artifact-item:hover { color:var(--fg); background:var(--surface-2); border-color:var(--border); }
479
+ .artifact-item.active { color:var(--fg); background:var(--accent-soft); border-color:var(--accent-line); }
480
+ .artifact-item-name { font-family:var(--font-mono); font-size:11.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
481
+ .artifact-item-kind { font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
482
+ .artifact-item.active .artifact-item-kind { color:var(--accent); }
483
+ .artifact-pane { min-width:0; }
484
+ .artifact-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:12px; }
485
+ .artifact-title { margin:0 0 5px; font-family:var(--font-mono); font-size:13px; font-weight:600; word-break:break-word; }
486
+ .artifact-desc { margin:0; max-width:78ch; color:var(--muted); font-size:12.5px; line-height:1.55; }
487
+ .artifact-kind-badge { flex:none; display:inline-flex; align-items:center; padding:3px 9px; font-size:10px; font-weight:700;
488
+ letter-spacing:.08em; text-transform:uppercase; color:var(--accent); background:var(--accent-soft);
489
+ border:1px solid var(--accent-line); border-radius:999px; }
490
+ .artifact-view { margin:0; padding:14px 16px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
405
491
  background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
492
+ .artifact-markdown { white-space:normal; }
493
+ .artifact-markdown .md-p, .artifact-markdown .md-list, .artifact-markdown .md-quote { font-size:12.5px; }
494
+ .artifact-markdown .md-p, .artifact-markdown .md-list { color:#d8d3cc; }
495
+ .artifact-markdown .md-code-block { border:1px solid var(--border); }
496
+ .json-view { white-space:pre; }
497
+ .json-key { color:var(--accent-strong); }
498
+ .json-string { color:#9ddc8a; }
499
+ .json-number { color:#8fc7ff; }
500
+ .json-literal { color:#d7a6ff; }
501
+ .json-punct { color:#8d8a85; }
502
+ @media (max-width:900px) { .artifact-layout { grid-template-columns:1fr; } .artifact-list { max-height:240px; } }
406
503
  .sent-tag { display:inline-flex; align-items:center; gap:5px; padding:2px 8px; font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
407
504
  color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); border-radius:999px; text-decoration:none; }
408
505
  .sent-tag:hover { color:#fff; }
506
+ .sent-tag.gh { color:#b9c7d6; background:rgba(139,164,191,.12); border-color:rgba(139,164,191,.35); }
507
+ .sent-tag.gh:hover { color:#fff; background:rgba(139,164,191,.2); }
508
+ .resume { display:flex; flex-direction:column; }
509
+ .resume-hero { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:18px 20px;
510
+ background:linear-gradient(135deg, rgba(240,165,44,.08), rgba(255,255,255,.015) 55%);
511
+ border:1px solid var(--border); border-radius:var(--radius-lg); }
512
+ .resume-kicker { display:block; font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--accent); }
513
+ .resume-title { margin:5px 0 7px; font-size:22px; font-weight:700; letter-spacing:-.02em; word-break:break-word; }
514
+ .resume-meta { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:0; font-size:12px; color:var(--muted); }
515
+ .resume-pr { display:inline-flex; align-items:center; gap:5px; color:var(--accent-strong); text-decoration:none; }
516
+ .resume-pr:hover { text-decoration:underline; }
517
+ .resume-hero-side { display:flex; flex:none; flex-direction:column; align-items:flex-end; gap:10px; }
518
+ .resume-status { display:inline-flex; align-items:center; padding:5px 12px; font-size:11px; font-weight:700; letter-spacing:.04em; border-radius:999px; }
519
+ .resume-status.blocking { color:var(--blocking); background:rgba(242,89,94,.12); border:1px solid rgba(242,89,94,.35); }
520
+ .resume-status.attention { color:var(--attention); background:rgba(237,161,58,.12); border:1px solid rgba(237,161,58,.35); }
521
+ .resume-status.clean { color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.35); }
522
+ .resume-kpis { margin:14px 0 18px; }
523
+ .resume-kpis .kpi-value { font-size:24px; }
524
+ .churn-add { color:var(--success); }
525
+ .churn-del { color:var(--blocking); }
526
+ .churn-sep { margin:0 3px; color:var(--muted-2); font-weight:400; }
527
+ .kpi-blocking .kpi-value { color:var(--blocking); }
528
+ .kpi-attention .kpi-value { color:var(--attention); }
529
+ .kpi-total .kpi-value { color:var(--fg); }
530
+ .resume-grid { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(0,1fr); gap:16px; align-items:start; }
531
+ .resume-card { min-width:0; padding:16px 18px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius-lg); }
532
+ .resume-card-head { display:flex; align-items:center; justify-content:space-between; gap:10px; }
533
+ .resume-card-title { margin:0 0 10px; font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); }
534
+ .resume-card-count { display:inline-flex; align-items:center; justify-content:center; min-width:22px; height:22px; margin-bottom:10px; padding:0 7px;
535
+ font-family:var(--font-mono); font-size:11px; color:var(--accent); background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:999px; }
536
+ .resume-narrative .md-p:last-child { margin-bottom:0; }
537
+ .resume-domains { display:flex; flex-direction:column; gap:8px; margin:0; padding:0; list-style:none; }
538
+ .resume-domain { display:flex; flex-direction:column; gap:7px; padding:10px 12px; background:var(--surface-2);
539
+ border:1px solid var(--border); border-radius:10px; }
540
+ .resume-domain-head { display:flex; align-items:baseline; justify-content:space-between; gap:10px; }
541
+ .resume-domain-name { font-size:12.5px; font-weight:600; color:var(--fg); }
542
+ .resume-domain-files { flex:none; font-family:var(--font-mono); font-size:10.5px; color:var(--muted-2); }
543
+ .resume-domain-sev { display:flex; flex-wrap:wrap; gap:6px; }
544
+ .resume-domain-sev .sev-badge { padding:2px 7px; font-size:9.5px; }
545
+ .resume-domain-ok { font-size:11.5px; color:var(--success); }
546
+ @media (max-width:900px) { .resume-grid { grid-template-columns:1fr; } .resume-hero { flex-direction:column; } .resume-hero-side { flex-direction:row; align-items:flex-start; } }
409
547
  .modal-layer { position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:24px; }
410
548
  .modal-backdrop { position:absolute; inset:0; width:100%; padding:0; background:rgba(5,5,7,.66); border:0; cursor:default; backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); }
411
549
  .modal { position:relative; width:min(640px, 100%); max-height:min(78vh, 780px); display:flex; flex-direction:column; overflow:hidden;