@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.
- package/README.md +18 -6
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/help.ts +7 -3
- package/src/cli/index.ts +2 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +155 -120
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +32 -71
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +175 -128
- package/src/core/paths.ts +23 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +28 -53
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +269 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +160 -0
- package/src/core/storage/sqlite/schema.ts +108 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
- package/src/core/storage/types.ts +143 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/ArtifactsPanel.tsx +137 -23
- package/src/server/client/FindingCard.tsx +37 -6
- package/src/server/client/MarkdownText.tsx +101 -0
- package/src/server/client/SubmitModal.tsx +9 -4
- package/src/server/client/code-highlight.ts +422 -0
- package/src/server/client/constants.ts +130 -24
- package/src/server/client/json-highlight.ts +55 -0
- package/src/server/client/markdown.ts +153 -0
- package/src/server/client/types.ts +23 -0
- package/src/server/index.ts +95 -15
- package/src/server/run-repository.ts +144 -136
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +27 -18
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +5 -5
- package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
- package/src/tests/baseline.test.ts +76 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +6 -3
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +89 -0
- package/src/tests/review-scripts.test.ts +8 -5
- package/src/tests/run-status.test.ts +33 -23
- package/src/tests/server.test.ts +108 -75
- 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 {
|
|
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
|
-
/**
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
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
|
|
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;
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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 = {
|
|
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
|
|
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, {
|
|
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:
|
|
373
|
-
.action-icon { flex:none; display:inline-block; margin-
|
|
374
|
-
.action-
|
|
375
|
-
.action-
|
|
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-
|
|
402
|
-
.artifact-
|
|
403
|
-
.artifact-
|
|
404
|
-
.artifact-
|
|
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; }
|