@payconductor/houndsman 0.3.0 → 0.4.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 (69) hide show
  1. package/README.md +18 -6
  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/help.ts +7 -3
  8. package/src/cli/index.ts +2 -2
  9. package/src/core/commands/context.ts +29 -13
  10. package/src/core/commands/findings.ts +155 -120
  11. package/src/core/commands/review.ts +7 -8
  12. package/src/core/commands/scaffold.ts +15 -16
  13. package/src/core/config.ts +16 -7
  14. package/src/core/engine/houndsman-engine.ts +15 -1
  15. package/src/core/engine/review-session.ts +37 -9
  16. package/src/core/findings-state.ts +32 -71
  17. package/src/core/jev/jev-candidates.ts +49 -35
  18. package/src/core/orchestrator.ts +175 -128
  19. package/src/core/paths.ts +23 -17
  20. package/src/core/pr-context.ts +19 -5
  21. package/src/core/project-config.ts +4 -5
  22. package/src/core/renderers/manifest-writer.ts +29 -18
  23. package/src/core/run-layout.ts +4 -104
  24. package/src/core/run-status.ts +28 -53
  25. package/src/core/services/git-repository.ts +16 -0
  26. package/src/core/services/project-files.ts +13 -1
  27. package/src/core/storage/factory.ts +24 -0
  28. package/src/core/storage/file/file-storage.ts +269 -0
  29. package/src/core/storage/paths.ts +64 -0
  30. package/src/core/storage/repo-identity.ts +70 -0
  31. package/src/core/storage/sqlite/mappers.ts +160 -0
  32. package/src/core/storage/sqlite/schema.ts +108 -0
  33. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  34. package/src/core/storage/types.ts +143 -0
  35. package/src/core/storage/work-area.ts +191 -0
  36. package/src/index.ts +27 -6
  37. package/src/server/artifacts.ts +8 -61
  38. package/src/server/client/ArtifactsPanel.tsx +137 -23
  39. package/src/server/client/FindingCard.tsx +37 -6
  40. package/src/server/client/MarkdownText.tsx +101 -0
  41. package/src/server/client/SubmitModal.tsx +9 -4
  42. package/src/server/client/code-highlight.ts +422 -0
  43. package/src/server/client/constants.ts +130 -24
  44. package/src/server/client/json-highlight.ts +55 -0
  45. package/src/server/client/markdown.ts +153 -0
  46. package/src/server/client/types.ts +23 -0
  47. package/src/server/index.ts +95 -15
  48. package/src/server/run-repository.ts +144 -136
  49. package/src/skill/OUTPUT-FORMAT.md +6 -4
  50. package/src/skill/README.md +22 -14
  51. package/src/skill/SETUP.md +4 -4
  52. package/src/skill/fix.md +5 -5
  53. package/src/skill/hunt.md +27 -18
  54. package/src/skill/train.md +1 -1
  55. package/src/skill/verify.md +5 -5
  56. package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
  57. package/src/tests/baseline.test.ts +76 -34
  58. package/src/tests/cli.test.ts +40 -19
  59. package/src/tests/code-highlight.test.ts +123 -0
  60. package/src/tests/domain.test.ts +1 -1
  61. package/src/tests/findings-commands.test.ts +153 -131
  62. package/src/tests/findings-state.test.ts +6 -3
  63. package/src/tests/fixtures/fake-project.ts +0 -1
  64. package/src/tests/infrastructure.test.ts +33 -38
  65. package/src/tests/markdown.test.ts +89 -0
  66. package/src/tests/review-scripts.test.ts +8 -5
  67. package/src/tests/run-status.test.ts +33 -23
  68. package/src/tests/server.test.ts +108 -75
  69. package/src/tests/storage.test.ts +386 -0
@@ -2,7 +2,15 @@
2
2
  * Constantes de apresentação do cliente (labels, ordem, emojis).
3
3
  */
4
4
 
5
- import type { Decision, ExecState, RunStatus, RunSummary, Severity } from "./types";
5
+ import type {
6
+ ArtifactKind,
7
+ ArtifactMeta,
8
+ Decision,
9
+ ExecState,
10
+ RunStatus,
11
+ RunSummary,
12
+ Severity,
13
+ } from "./types";
6
14
 
7
15
  /** Rótulo de exibição de cada severidade (o CSS dá a cor via `sev-<nivel>`). */
8
16
  export const SEVERITY_LABELS: Record<Severity, string> = {
@@ -58,34 +66,132 @@ export function describeRunStatus(
58
66
  return { kind: status, label: RUN_STATUS_LABELS[status], timeout: false };
59
67
  }
60
68
 
61
- /** Artefatos que podem ser lidos na aba "Artefatos". */
62
- export const ARTIFACTS = [
63
- "manifest.json",
64
- "classification.json",
65
- "candidates.json",
66
- "context-summary.json",
67
- "review.md",
68
- ] as const;
69
-
70
- export type ArtifactName = (typeof ARTIFACTS)[number];
71
-
72
- /** Descrição de cada artefato (mostrada ao selecioná-lo na aba). */
73
- export const ARTIFACT_INFO: Record<ArtifactName, string> = {
74
- "manifest.json":
75
- "Resumo da execução: watchdogs ativos, arquivos, orçamento, churn, Jev e contexto da PR.",
76
- "classification.json":
77
- "Classificação do diff: watchdogs ativados por arquivo, status (A/M/D/R) e churn por arquivo.",
78
- "candidates.json":
79
- "Indícios da pré-análise Jev (regra + arquivo + confiança) — não são achados.",
80
- "context-summary.json":
81
- "Resumo da PR (objetivo, motivação e alterações) gerado no Passo 0.",
82
- "review.md": "Relatório consolidado da revisão — o entregável.",
69
+ /** Ordem canônica dos tipos de artefato na lista da UI. */
70
+ const KIND_ORDER: ArtifactKind[] = [
71
+ "review",
72
+ "domain-review",
73
+ "verify",
74
+ "brief",
75
+ "manifest",
76
+ "classification",
77
+ "candidates",
78
+ "context",
79
+ "context-brief",
80
+ "context-summary",
81
+ "finding-report",
82
+ "other",
83
+ ];
84
+
85
+ /** Grupo visual de cada tipo (relatórios, briefs, núcleo, contexto…). */
86
+ const GROUP_BY_KIND: Record<ArtifactKind, { key: string; label: string; order: number }> =
87
+ {
88
+ review: { key: "report", label: "Relatórios", order: 0 },
89
+ "domain-review": { key: "report", label: "Relatórios", order: 0 },
90
+ verify: { key: "verify", label: "Verificação", order: 1 },
91
+ brief: { key: "brief", label: "Briefs (auditoria)", order: 2 },
92
+ manifest: { key: "core", label: "Núcleo da revisão", order: 3 },
93
+ classification: { key: "core", label: "Núcleo da revisão", order: 3 },
94
+ candidates: { key: "core", label: "Núcleo da revisão", order: 3 },
95
+ context: { key: "context", label: "Contexto da PR", order: 4 },
96
+ "context-brief": { key: "context", label: "Contexto da PR", order: 4 },
97
+ "context-summary": { key: "context", label: "Contexto da PR", order: 4 },
98
+ "finding-report": { key: "other", label: "Outros", order: 5 },
99
+ other: { key: "other", label: "Outros", order: 5 },
100
+ };
101
+
102
+ /** Rótulo curto do tipo (badge do item). */
103
+ export const ARTIFACT_KIND_LABELS: Record<ArtifactKind, string> = {
104
+ review: "relatório",
105
+ "domain-review": "domínio",
106
+ verify: "verify",
107
+ brief: "brief",
108
+ manifest: "manifesto",
109
+ classification: "classificação",
110
+ candidates: "jev",
111
+ context: "contexto",
112
+ "context-brief": "brief ctx",
113
+ "context-summary": "resumo ctx",
114
+ "finding-report": "findings",
115
+ other: "outro",
83
116
  };
84
117
 
118
+ /** Nome exibido (briefs perdem o prefixo `briefs/`). */
119
+ export function displayArtifactName(meta: ArtifactMeta): string {
120
+ return meta.name.startsWith("briefs/")
121
+ ? meta.name.slice("briefs/".length)
122
+ : meta.name;
123
+ }
124
+
125
+ /** Descrição curta do artefato (cabeçalho do painel). */
126
+ export function describeArtifact(meta: ArtifactMeta): string {
127
+ switch (meta.kind) {
128
+ case "review":
129
+ return "Relatório consolidado da revisão — o entregável.";
130
+ case "domain-review":
131
+ return "Relatório isolado do domínio: achados daquele watchdog.";
132
+ case "verify":
133
+ return "Resultado do `findings verify` (resolvidos, persistentes e incertos).";
134
+ case "brief":
135
+ return "Instruções enviadas ao subagente (auditoria do prompt).";
136
+ case "manifest":
137
+ return "Resumo da execução: watchdogs, arquivos, orçamento, churn, Jev e contexto.";
138
+ case "classification":
139
+ return "Classificação do diff: watchdogs ativados por arquivo, status (A/M/D/R) e churn.";
140
+ case "candidates":
141
+ return "Indícios da pré-análise Jev (regra + arquivo + confiança) — não são achados.";
142
+ case "context":
143
+ return "Payload bruto da PR coletado via `gh` (ou marcador de indisponível).";
144
+ case "context-brief":
145
+ return "Brief do subagente que resume o contexto da PR.";
146
+ case "context-summary":
147
+ return "Resumo da PR (objetivo, motivação e alterações) gerado no Passo 0.";
148
+ default:
149
+ return meta.name;
150
+ }
151
+ }
152
+
153
+ /** `true` se o conteúdo deve ser renderizado com destaque de JSON. */
154
+ export function isJsonArtifact(meta: ArtifactMeta | undefined): boolean {
155
+ if (!meta) return false;
156
+ return meta.contentType.includes("json") || meta.name.toLowerCase().endsWith(".json");
157
+ }
158
+
159
+ /** Agrupa os artefatos para a navegação lateral (ordem estável). */
160
+ export function groupArtifacts(
161
+ items: ArtifactMeta[],
162
+ ): { key: string; label: string; items: ArtifactMeta[] }[] {
163
+ const groups = new Map<
164
+ string,
165
+ { key: string; label: string; order: number; items: ArtifactMeta[] }
166
+ >();
167
+ for (const item of items) {
168
+ const meta = GROUP_BY_KIND[item.kind] ?? GROUP_BY_KIND.other;
169
+ const group = groups.get(meta.key) ?? {
170
+ key: meta.key,
171
+ label: meta.label,
172
+ order: meta.order,
173
+ items: [],
174
+ };
175
+ group.items.push(item);
176
+ groups.set(meta.key, group);
177
+ }
178
+ return [...groups.values()]
179
+ .sort((a, b) => a.order - b.order)
180
+ .map((group) => ({
181
+ key: group.key,
182
+ label: group.label,
183
+ items: group.items.sort(
184
+ (a, b) =>
185
+ KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) ||
186
+ a.name.localeCompare(b.name),
187
+ ),
188
+ }));
189
+ }
190
+
85
191
  /** Legenda única de cada aba (explica o que a aba é; mostrada quando selecionada). */
86
192
  export const TAB_CAPTIONS = {
87
193
  findings:
88
194
  "Triagem dos achados da revisão: decida Executar, Ignorar ou deixe Pendente — o comentário orienta o auto-fix. Agente: houndsman findings list --decision pending --json · findings done <id>.",
89
195
  artifacts:
90
- "Artefatos brutos da run: manifesto, classificação, candidatos Jev, contexto da PR e o relatório consolidado (review.md).",
196
+ "Artefatos persistidos da run: relatórios, briefs de auditoria, manifesto, classificação, Jev, contexto da PR e verificação. JSON com destaque de sintaxe.",
91
197
  } as const;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Destaque de sintaxe JSON (cliente) — implementação própria e leve.
3
+ *
4
+ * Não puxa lib de highlight: tokeniza o texto já formatado e devolve spans.
5
+ * Funciona para qualquer JSON gerado pelo houndsman (manifest, classification,
6
+ * context, candidates, …).
7
+ */
8
+
9
+ export type JsonTokenType = "plain" | "key" | "string" | "number" | "literal" | "punct";
10
+
11
+ export type JsonToken = { text: string; type: JsonTokenType };
12
+
13
+ const TOKEN =
14
+ /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b|([{}[\],:])/g;
15
+
16
+ /** Pretty-print best-effort; devolve o original se não for JSON válido. */
17
+ export function formatJson(text: string): string {
18
+ try {
19
+ return JSON.stringify(JSON.parse(text), null, 2);
20
+ } catch {
21
+ return text;
22
+ }
23
+ }
24
+
25
+ /** Tokeniza o JSON preservando o texto original (inclusive espaços/quebras). */
26
+ export function highlightJson(text: string): JsonToken[] {
27
+ const tokens: JsonToken[] = [];
28
+ let last = 0;
29
+ for (const match of text.matchAll(TOKEN)) {
30
+ const index = match.index ?? 0;
31
+ if (index > last) {
32
+ tokens.push({ text: text.slice(last, index), type: "plain" });
33
+ }
34
+ if (match[1] !== undefined) {
35
+ tokens.push({
36
+ text: match[1],
37
+ type: match[2] !== undefined ? "key" : "string",
38
+ });
39
+ if (match[2] !== undefined) {
40
+ tokens.push({ text: match[2], type: "punct" });
41
+ }
42
+ } else if (match[3] !== undefined) {
43
+ tokens.push({ text: match[3], type: "number" });
44
+ } else if (match[4] !== undefined) {
45
+ tokens.push({ text: match[4], type: "literal" });
46
+ } else if (match[5] !== undefined) {
47
+ tokens.push({ text: match[5], type: "punct" });
48
+ }
49
+ last = index + match[0].length;
50
+ }
51
+ if (last < text.length) {
52
+ tokens.push({ text: text.slice(last), type: "plain" });
53
+ }
54
+ return tokens;
55
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Markdown mínimo do campo `action` (cliente).
3
+ *
4
+ * Suporta o suficiente para instruções curtas: parágrafos, quebras de linha,
5
+ * listas, blockquote, blocos de código e formatação inline (`code`, **bold**,
6
+ * *itálico*, links). Sem lib externa e sem HTML cru.
7
+ */
8
+
9
+ export type MarkdownBlock =
10
+ | { type: "paragraph"; text: string }
11
+ | { type: "list"; ordered: boolean; items: string[] }
12
+ | { type: "code"; language: string | null; code: string }
13
+ | { type: "quote"; text: string };
14
+
15
+ export type InlineToken =
16
+ | { type: "text"; text: string }
17
+ | { type: "code"; text: string }
18
+ | { type: "strong"; text: string }
19
+ | { type: "em"; text: string }
20
+ | { type: "link"; text: string; url: string };
21
+
22
+ const FENCE = /^\s*```(\S*)\s*$/;
23
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
24
+ const ORDERED = /^\s*\d+\.\s+(.*)$/;
25
+ const QUOTE = /^\s*>\s?(.*)$/;
26
+
27
+ function isBlockStart(line: string): boolean {
28
+ return (
29
+ FENCE.test(line) || BULLET.test(line) || ORDERED.test(line) || QUOTE.test(line)
30
+ );
31
+ }
32
+
33
+ /** Divide o texto em blocos simples (sem aninhamento profundo). */
34
+ export function parseMarkdown(text: string): MarkdownBlock[] {
35
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
36
+ const blocks: MarkdownBlock[] = [];
37
+ let index = 0;
38
+
39
+ while (index < lines.length) {
40
+ const line = lines[index];
41
+ if (line.trim().length === 0) {
42
+ index++;
43
+ continue;
44
+ }
45
+
46
+ const fence = FENCE.exec(line);
47
+ if (fence) {
48
+ const language = fence[1] || null;
49
+ const code: string[] = [];
50
+ index++;
51
+ while (index < lines.length && !FENCE.test(lines[index])) {
52
+ code.push(lines[index]);
53
+ index++;
54
+ }
55
+ if (index < lines.length) index++;
56
+ blocks.push({ type: "code", language, code: code.join("\n") });
57
+ continue;
58
+ }
59
+
60
+ if (BULLET.test(line)) {
61
+ const items: string[] = [];
62
+ while (index < lines.length) {
63
+ const item = BULLET.exec(lines[index]);
64
+ if (!item) break;
65
+ items.push(item[1]);
66
+ index++;
67
+ }
68
+ blocks.push({ type: "list", ordered: false, items });
69
+ continue;
70
+ }
71
+
72
+ if (ORDERED.test(line)) {
73
+ const items: string[] = [];
74
+ while (index < lines.length) {
75
+ const item = ORDERED.exec(lines[index]);
76
+ if (!item) break;
77
+ items.push(item[1]);
78
+ index++;
79
+ }
80
+ blocks.push({ type: "list", ordered: true, items });
81
+ continue;
82
+ }
83
+
84
+ if (QUOTE.test(line)) {
85
+ const quote: string[] = [];
86
+ while (index < lines.length) {
87
+ const item = QUOTE.exec(lines[index]);
88
+ if (!item) break;
89
+ quote.push(item[1]);
90
+ index++;
91
+ }
92
+ blocks.push({ type: "quote", text: quote.join("\n") });
93
+ continue;
94
+ }
95
+
96
+ const paragraph: string[] = [line];
97
+ index++;
98
+ while (
99
+ index < lines.length &&
100
+ lines[index].trim().length > 0 &&
101
+ !isBlockStart(lines[index])
102
+ ) {
103
+ paragraph.push(lines[index]);
104
+ index++;
105
+ }
106
+ blocks.push({ type: "paragraph", text: paragraph.join("\n") });
107
+ }
108
+
109
+ return blocks;
110
+ }
111
+
112
+ /** Tokeniza formatação inline preservando o texto e a ordem dos marcadores. */
113
+ export function parseInline(text: string): InlineToken[] {
114
+ const tokens: InlineToken[] = [];
115
+ // Ordem importa: code/bold antes de itálico; `_` exige fronteira de palavra.
116
+ const pattern =
117
+ /`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|(?<![\w])_[^_\n]+_(?![\w])|\[[^\]\n]+\]\([^)\s]+\)/g;
118
+ let last = 0;
119
+ for (const match of text.matchAll(pattern)) {
120
+ const start = match.index ?? 0;
121
+ if (start > last) {
122
+ tokens.push({ type: "text", text: text.slice(last, start) });
123
+ }
124
+ const raw = match[0];
125
+ if (raw.startsWith("`")) {
126
+ tokens.push({ type: "code", text: raw.slice(1, -1) });
127
+ } else if (raw.startsWith("**") || raw.startsWith("__")) {
128
+ tokens.push({ type: "strong", text: raw.slice(2, -2) });
129
+ } else if (raw.startsWith("*") || raw.startsWith("_")) {
130
+ tokens.push({ type: "em", text: raw.slice(1, -1) });
131
+ } else {
132
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(raw);
133
+ if (link) {
134
+ tokens.push({ type: "link", text: link[1], url: link[2] });
135
+ } else {
136
+ tokens.push({ type: "text", text: raw });
137
+ }
138
+ }
139
+ last = start + raw.length;
140
+ }
141
+ if (last < text.length) {
142
+ tokens.push({ type: "text", text: text.slice(last) });
143
+ }
144
+ return tokens;
145
+ }
146
+
147
+ /** URL segura para links de markdown (evita `javascript:` e afins). */
148
+ export function safeUrl(url: string): string | null {
149
+ const value = url.trim();
150
+ if (/^(https?:|mailto:)/i.test(value)) return value;
151
+ if (value.startsWith("/") && !value.startsWith("//")) return value;
152
+ return null;
153
+ }
@@ -50,6 +50,29 @@ 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
+ | "domain-review"
65
+ | "verify"
66
+ | "other";
67
+
68
+ /** Metadados de artefato retornados pela API (sem conteúdo). */
69
+ export type ArtifactMeta = {
70
+ name: string;
71
+ kind: ArtifactKind;
72
+ contentType: string;
73
+ updatedAt: string;
74
+ };
75
+
53
76
  export type RunSummary = {
54
77
  slug: string;
55
78
  branch: string;
@@ -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; }
@@ -369,13 +404,35 @@ 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; }
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
+ .action-text a { color:var(--accent-strong); text-decoration:underline; text-underline-offset:2px; }
418
+ .action-text strong { color:#fff; font-weight:600; }
419
+ .action-text em { color:#e9e4dc; }
420
+ .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; }
376
421
  .decision-tags-block { display:flex; align-items:center; gap:8px; }
377
422
  .evidence { margin:0; padding: 16px; white-space:break-spaces; font-size:12px; line-height:1.6; color:#cdc7bf;
378
423
  background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
424
+ .evidence-block { display:flex; flex-direction:column; gap:6px; }
425
+ .evidence-head { display:flex; justify-content:flex-end; }
426
+ .code-lang { font-family:var(--font-mono); font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
427
+ .code-evidence { white-space:pre; overflow:auto; }
428
+ .tok-keyword { color:#c792ea; }
429
+ .tok-string { color:#9ddc8a; }
430
+ .tok-comment { color:#6d6862; font-style:italic; }
431
+ .tok-number { color:#8fc7ff; }
432
+ .tok-type { color:var(--accent-strong); }
433
+ .tok-function { color:#82aaff; }
434
+ .tok-property { color:#e6c07b; }
435
+ .tok-punct { color:#8d8a85; }
379
436
  .tags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin-left: auto; }
380
437
  .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
438
  .tag { display:inline-flex; align-items:center; gap:6px; padding:3px 9px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
@@ -398,11 +455,34 @@ code, pre, .mono { font-family:var(--font-mono); }
398
455
  font-family:var(--font-mono); font-size:11px; color:var(--muted); }
399
456
  .comment-status.saved { color:var(--success); }
400
457
  .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;
458
+ .artifact-layout { display:grid; grid-template-columns:minmax(190px,250px) minmax(0,1fr); gap:16px; align-items:start; }
459
+ .artifact-list { display:flex; flex-direction:column; gap:14px; max-height:72vh; overflow:auto; padding:2px 4px 2px 0; }
460
+ .artifact-group { display:flex; flex-direction:column; gap:4px; }
461
+ .artifact-group-label { margin:0 0 2px; font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
462
+ .artifact-item { display:flex; flex-direction:column; gap:2px; text-align:left; cursor:pointer; font:inherit; color:var(--muted);
463
+ padding:8px 10px; background:transparent; border:1px solid transparent; border-radius:10px;
464
+ transition:background .15s ease, border-color .15s ease, color .15s ease; }
465
+ .artifact-item:hover { color:var(--fg); background:var(--surface-2); border-color:var(--border); }
466
+ .artifact-item.active { color:var(--fg); background:var(--accent-soft); border-color:var(--accent-line); }
467
+ .artifact-item-name { font-family:var(--font-mono); font-size:11.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
468
+ .artifact-item-kind { font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
469
+ .artifact-item.active .artifact-item-kind { color:var(--accent); }
470
+ .artifact-pane { min-width:0; }
471
+ .artifact-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:12px; }
472
+ .artifact-title { margin:0 0 5px; font-family:var(--font-mono); font-size:13px; font-weight:600; word-break:break-word; }
473
+ .artifact-desc { margin:0; max-width:78ch; color:var(--muted); font-size:12.5px; line-height:1.55; }
474
+ .artifact-kind-badge { flex:none; display:inline-flex; align-items:center; padding:3px 9px; font-size:10px; font-weight:700;
475
+ letter-spacing:.08em; text-transform:uppercase; color:var(--accent); background:var(--accent-soft);
476
+ border:1px solid var(--accent-line); border-radius:999px; }
477
+ .artifact-view { margin:0; padding:14px 16px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
405
478
  background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
479
+ .json-view { white-space:pre; }
480
+ .json-key { color:var(--accent-strong); }
481
+ .json-string { color:#9ddc8a; }
482
+ .json-number { color:#8fc7ff; }
483
+ .json-literal { color:#d7a6ff; }
484
+ .json-punct { color:#8d8a85; }
485
+ @media (max-width:900px) { .artifact-layout { grid-template-columns:1fr; } .artifact-list { max-height:240px; } }
406
486
  .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
487
  color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); border-radius:999px; text-decoration:none; }
408
488
  .sent-tag:hover { color:#fff; }