@payconductor/houndsman 0.1.0 → 0.1.1
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 +39 -78
- package/assets/logo-legacy.png +0 -0
- package/assets/logo.png +0 -0
- package/package.json +3 -2
- package/src/cli/index.ts +89 -9
- package/src/core/assets.ts +8 -0
- package/src/core/brief.ts +25 -2
- package/src/core/commands/findings.ts +390 -0
- package/src/core/commands/scaffold.ts +20 -0
- package/src/core/config.ts +42 -0
- package/src/core/diff-source.ts +140 -0
- package/src/core/file-classifier.ts +50 -2
- package/src/core/finding-anchor.ts +147 -0
- package/src/core/findings-state.ts +538 -0
- package/src/core/jev/jev-client.ts +72 -11
- package/src/core/jev/jev-detector.ts +21 -19
- package/src/core/orchestrator.ts +169 -17
- package/src/core/paths.ts +3 -0
- package/src/core/registry-check.ts +15 -0
- package/src/core/report-builder.ts +113 -9
- package/src/core/types.ts +10 -0
- package/src/index.ts +78 -2
- package/src/server/artifacts.ts +66 -0
- package/src/server/client/App.tsx +158 -0
- package/src/server/client/ArtifactsPanel.tsx +48 -0
- package/src/server/client/Filters.tsx +83 -0
- package/src/server/client/FindingCard.tsx +122 -0
- package/src/server/client/FindingsList.tsx +54 -0
- package/src/server/client/RunList.tsx +43 -0
- package/src/server/client/SummaryBar.tsx +21 -0
- package/src/server/client/api.ts +37 -0
- package/src/server/client/constants.ts +63 -0
- package/src/server/client/index.tsx +12 -0
- package/src/server/client/types.ts +48 -0
- package/src/server/client-build.ts +47 -0
- package/src/server/index.ts +365 -0
- package/src/skill/README.md +14 -6
- package/src/skill/SETUP.md +5 -3
- package/src/skill/houndsman-update.md +1 -1
- package/src/skill/houndsman.md +87 -10
- package/src/tests/findings-anchor.test.ts +120 -0
- package/src/tests/findings-commands.test.ts +226 -0
- package/src/tests/findings-state.test.ts +274 -0
- package/src/tests/review-scripts.test.ts +299 -0
- package/src/tests/server.test.ts +212 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barra de resumo da triagem (total, presentes e contagens por decisão/execução).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DECISION_LABELS, DECISIONS } from "./constants";
|
|
6
|
+
import type { Summary } from "./types";
|
|
7
|
+
|
|
8
|
+
export function SummaryBar({ summary }: { summary: Summary }) {
|
|
9
|
+
return (
|
|
10
|
+
<div class="summary-bar">
|
|
11
|
+
<span>Total: {summary.total}</span>
|
|
12
|
+
<span>Presentes: {summary.present}</span>
|
|
13
|
+
{DECISIONS.filter((decision) => decision !== "pending").map((decision) => (
|
|
14
|
+
<span key={decision}>
|
|
15
|
+
{DECISION_LABELS[decision]}: {summary.byDecision[decision] ?? 0}
|
|
16
|
+
</span>
|
|
17
|
+
))}
|
|
18
|
+
<span>Feitos: {summary.byExecState.done ?? 0}</span>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers de acesso à API local do servidor.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DECISIONS, EXEC_STATES, SEVERITY_ORDER } from "./constants";
|
|
6
|
+
import type { Decision, ExecState, Finding, Severity, Summary } from "./types";
|
|
7
|
+
|
|
8
|
+
/** `fetch` JSON: lança com o corpo do erro quando a resposta não é 2xx. */
|
|
9
|
+
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|
10
|
+
const res = await fetch(path, init);
|
|
11
|
+
if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
|
|
12
|
+
return (await res.json()) as T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Recalcula o resumo a partir dos findings atuais (contadores vivos na UI). */
|
|
16
|
+
export function computeSummary(findings: Finding[]): Summary {
|
|
17
|
+
const byDecision = Object.fromEntries(DECISIONS.map((d) => [d, 0])) as Record<
|
|
18
|
+
Decision,
|
|
19
|
+
number
|
|
20
|
+
>;
|
|
21
|
+
const byExecState = Object.fromEntries(EXEC_STATES.map((e) => [e, 0])) as Record<
|
|
22
|
+
ExecState,
|
|
23
|
+
number
|
|
24
|
+
>;
|
|
25
|
+
const bySeverity = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0])) as Record<
|
|
26
|
+
Severity,
|
|
27
|
+
number
|
|
28
|
+
>;
|
|
29
|
+
let present = 0;
|
|
30
|
+
for (const finding of findings) {
|
|
31
|
+
if (finding.present) present++;
|
|
32
|
+
byDecision[finding.decision]++;
|
|
33
|
+
byExecState[finding.execState]++;
|
|
34
|
+
bySeverity[finding.severity]++;
|
|
35
|
+
}
|
|
36
|
+
return { total: findings.length, present, byDecision, byExecState, bySeverity };
|
|
37
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constantes de apresentação do cliente (labels, ordem, emojis).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Decision, ExecState, Severity } from "./types";
|
|
6
|
+
|
|
7
|
+
export const SEVERITY_EMOJI: Record<Severity, string> = {
|
|
8
|
+
blocking: "❌",
|
|
9
|
+
attention: "⚠️",
|
|
10
|
+
suggestion: "💡",
|
|
11
|
+
nit: "🔎",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const SEVERITY_ORDER: Severity[] = ["blocking", "attention", "suggestion", "nit"];
|
|
15
|
+
|
|
16
|
+
export const DECISIONS: Decision[] = ["pending", "execute", "ignore"];
|
|
17
|
+
|
|
18
|
+
export const DECISION_LABELS: Record<Decision, string> = {
|
|
19
|
+
pending: "Pendente",
|
|
20
|
+
execute: "Executar",
|
|
21
|
+
ignore: "Ignorar",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const EXEC_STATES: ExecState[] = ["pending", "in_progress", "done", "stale"];
|
|
25
|
+
|
|
26
|
+
export const EXEC_LABELS: Record<ExecState, string> = {
|
|
27
|
+
pending: "exec: pendente",
|
|
28
|
+
in_progress: "exec: em andamento",
|
|
29
|
+
done: "exec: feito",
|
|
30
|
+
stale: "exec: stale",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Artefatos que podem ser lidos na aba "Artefatos". */
|
|
34
|
+
export const ARTIFACTS = [
|
|
35
|
+
"manifest.json",
|
|
36
|
+
"classification.json",
|
|
37
|
+
"candidates.json",
|
|
38
|
+
"context-summary.json",
|
|
39
|
+
"review.md",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export type ArtifactName = (typeof ARTIFACTS)[number];
|
|
43
|
+
|
|
44
|
+
/** Descrição de cada artefato (mostrada ao selecioná-lo na aba). */
|
|
45
|
+
export const ARTIFACT_INFO: Record<ArtifactName, string> = {
|
|
46
|
+
"manifest.json":
|
|
47
|
+
"Resumo da execução: watchdogs ativos, arquivos, orçamento, churn, Jev e contexto da PR.",
|
|
48
|
+
"classification.json":
|
|
49
|
+
"Classificação do diff: watchdogs ativados por arquivo, status (A/M/D/R) e churn por arquivo.",
|
|
50
|
+
"candidates.json":
|
|
51
|
+
"Indícios da pré-análise Jev (regra + arquivo + confiança) — não são achados.",
|
|
52
|
+
"context-summary.json":
|
|
53
|
+
"Resumo da PR (objetivo, motivação e alterações) gerado no Passo 0.",
|
|
54
|
+
"review.md": "Relatório consolidado da revisão — o entregável.",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Legenda única de cada aba (explica o que a aba é; mostrada quando selecionada). */
|
|
58
|
+
export const TAB_CAPTIONS = {
|
|
59
|
+
findings:
|
|
60
|
+
"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>.",
|
|
61
|
+
artifacts:
|
|
62
|
+
"Artefatos brutos da run: manifesto, classificação, candidatos Jev, contexto da PR e o relatório consolidado (review.md).",
|
|
63
|
+
} as const;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry do cliente (Preact): monta o `<App />` no `#app`.
|
|
3
|
+
*
|
|
4
|
+
* Bundle gerado em memória por `Bun.build` (`client-build.ts`). Os componentes
|
|
5
|
+
* vivem em arquivos irmãos nesta pasta (`App.tsx`, `RunList.tsx`, ...).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { render } from "preact";
|
|
9
|
+
import { App } from "./App";
|
|
10
|
+
|
|
11
|
+
const root = document.getElementById("app");
|
|
12
|
+
if (root) render(<App />, root);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tipos compartilhados do cliente (espelham `src/core/findings-state.ts`).
|
|
3
|
+
*
|
|
4
|
+
* São um espelho minimalista do contrato da API — evita importar o núcleo
|
|
5
|
+
* (Node) no bundle do navegador.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Severity = "blocking" | "attention" | "suggestion" | "nit";
|
|
9
|
+
export type Decision = "pending" | "execute" | "ignore";
|
|
10
|
+
export type ExecState = "pending" | "in_progress" | "done" | "stale";
|
|
11
|
+
|
|
12
|
+
export type Finding = {
|
|
13
|
+
id: string;
|
|
14
|
+
rule: string;
|
|
15
|
+
severity: Severity;
|
|
16
|
+
file: string;
|
|
17
|
+
line: number | null;
|
|
18
|
+
message: string;
|
|
19
|
+
action: string;
|
|
20
|
+
evidence?: string;
|
|
21
|
+
count: number;
|
|
22
|
+
present: boolean;
|
|
23
|
+
decision: Decision;
|
|
24
|
+
comment?: string;
|
|
25
|
+
execState: ExecState;
|
|
26
|
+
watchdogIds: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type Summary = {
|
|
30
|
+
total: number;
|
|
31
|
+
present: number;
|
|
32
|
+
byDecision: Record<Decision, number>;
|
|
33
|
+
byExecState: Record<ExecState, number>;
|
|
34
|
+
bySeverity: Record<Severity, number>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RunSummary = {
|
|
38
|
+
slug: string;
|
|
39
|
+
branch: string;
|
|
40
|
+
updatedAt?: string;
|
|
41
|
+
summary: Summary;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type FindingsResponse = {
|
|
45
|
+
branch: string;
|
|
46
|
+
findings: Finding[];
|
|
47
|
+
summary: Summary;
|
|
48
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empacota o cliente Preact em memória (Bun.build) — sem build step no pacote.
|
|
3
|
+
*
|
|
4
|
+
* Sem cache por padrão: o bundle é refeito quando algum arquivo do cliente muda
|
|
5
|
+
* (comparação por mtime), então editar a UI não exige reiniciar o servidor. O
|
|
6
|
+
* navegador também não cacheia (`cache-control: no-store` + `?v=` na URL).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
let cached: string | null = null;
|
|
13
|
+
let cachedStamp = -1;
|
|
14
|
+
|
|
15
|
+
/** Maior mtime entre os arquivos do cliente (recursivo). */
|
|
16
|
+
function sourceStamp(dir: string): number {
|
|
17
|
+
let stamp = 0;
|
|
18
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const full = join(dir, entry.name);
|
|
20
|
+
stamp = Math.max(
|
|
21
|
+
stamp,
|
|
22
|
+
entry.isDirectory() ? sourceStamp(full) : statSync(full).mtimeMs,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return stamp;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bundle ESM do cliente (target browser). Lança se o build falhar. */
|
|
29
|
+
export async function buildClientBundle(): Promise<string> {
|
|
30
|
+
const sourceDir = join(import.meta.dir, "client");
|
|
31
|
+
const stamp = sourceStamp(sourceDir);
|
|
32
|
+
if (cached !== null && stamp === cachedStamp) return cached;
|
|
33
|
+
|
|
34
|
+
const result = await Bun.build({
|
|
35
|
+
entrypoints: [join(sourceDir, "index.tsx")],
|
|
36
|
+
target: "browser",
|
|
37
|
+
format: "esm",
|
|
38
|
+
jsx: { runtime: "automatic", importSource: "preact" },
|
|
39
|
+
});
|
|
40
|
+
if (!result.success || result.outputs.length === 0) {
|
|
41
|
+
const details = (result.logs ?? []).map((log) => log.message).join("\n");
|
|
42
|
+
throw new Error(`Falha ao empacotar o cliente do houndsman.\n${details}`);
|
|
43
|
+
}
|
|
44
|
+
cached = await result.outputs[0].text();
|
|
45
|
+
cachedStamp = stamp;
|
|
46
|
+
return cached;
|
|
47
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Servidor local do houndsman (`Bun.serve`) — UI de triagem + artefatos.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { mediaPath } from "../core/assets";
|
|
11
|
+
import { type HoundsmanConfig, resolveArtifactDir } from "../core/config";
|
|
12
|
+
import {
|
|
13
|
+
applyFindingUpdate,
|
|
14
|
+
buildStateFromRunDir,
|
|
15
|
+
emptyState,
|
|
16
|
+
type FindingDecision,
|
|
17
|
+
type FindingExecState,
|
|
18
|
+
type FindingsFilter,
|
|
19
|
+
type FindingsStateFile,
|
|
20
|
+
FindingsStateStore,
|
|
21
|
+
type FindingUpdate,
|
|
22
|
+
fileReader,
|
|
23
|
+
filterFindings,
|
|
24
|
+
reorder,
|
|
25
|
+
type SyncResult,
|
|
26
|
+
summarize,
|
|
27
|
+
syncState,
|
|
28
|
+
} from "../core/findings-state";
|
|
29
|
+
import { STATE_FILE } from "../core/paths";
|
|
30
|
+
import type { Severity } from "../core/types";
|
|
31
|
+
import { listRunSlugs, readRunFile, readRunJson, runDirForSlug } from "./artifacts";
|
|
32
|
+
import { buildClientBundle } from "./client-build";
|
|
33
|
+
|
|
34
|
+
type BunServer = ReturnType<typeof Bun.serve>;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Helpers de resposta
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function json(data: unknown, status = 200): Response {
|
|
41
|
+
return new Response(JSON.stringify(data), {
|
|
42
|
+
status,
|
|
43
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function text(body: string, contentType: string, status = 200): Response {
|
|
48
|
+
return new Response(body, { status, headers: { "content-type": contentType } });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resposta sem cache — o servidor é local; o navegador sempre pega a versão nova. */
|
|
52
|
+
function noStore(body: string, contentType: string): Response {
|
|
53
|
+
return new Response(body, {
|
|
54
|
+
headers: {
|
|
55
|
+
"content-type": contentType,
|
|
56
|
+
"cache-control": "no-store, no-cache, must-revalidate",
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function notFound(detail = "não encontrado"): Response {
|
|
62
|
+
return json({ error: detail }, 404);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Estado / dados
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function buildForRun(dir: string, slug: string): FindingsStateFile | null {
|
|
70
|
+
const manifest = readRunJson<{ branch?: string }>(dir, "manifest.json");
|
|
71
|
+
return buildStateFromRunDir(dir, manifest?.branch ?? slug, fileReader(process.cwd()));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Lê o estado da run. Se ausente, reconstrói dos artefatos e PERSISTE — os ids
|
|
76
|
+
* são aleatórios, então precisam ser estáveis entre requisições.
|
|
77
|
+
*/
|
|
78
|
+
function loadState(dir: string, slug: string): FindingsStateFile {
|
|
79
|
+
const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
|
|
80
|
+
const existing = store.read();
|
|
81
|
+
if (existing) return existing;
|
|
82
|
+
const built = buildForRun(dir, slug);
|
|
83
|
+
if (built) {
|
|
84
|
+
store.write(built);
|
|
85
|
+
return built;
|
|
86
|
+
}
|
|
87
|
+
return emptyState(slug);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function listRuns(artifactDir: string): unknown[] {
|
|
91
|
+
return listRunSlugs(artifactDir).map((slug) => {
|
|
92
|
+
const dir = join(artifactDir, slug);
|
|
93
|
+
const state = loadState(dir, slug);
|
|
94
|
+
const manifest = readRunJson<{ branch?: string; generatedAt?: string }>(
|
|
95
|
+
dir,
|
|
96
|
+
"manifest.json",
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
slug,
|
|
100
|
+
branch: state.branch || manifest?.branch || slug,
|
|
101
|
+
updatedAt: state.updatedAt,
|
|
102
|
+
summary: summarize(state),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function runDetail(dir: string, slug: string): unknown {
|
|
108
|
+
const state = loadState(dir, slug);
|
|
109
|
+
const classification = readRunJson<{
|
|
110
|
+
watchdogs?: Record<string, string[]>;
|
|
111
|
+
unclassified?: string[];
|
|
112
|
+
deleted?: string[];
|
|
113
|
+
}>(dir, "classification.json");
|
|
114
|
+
return {
|
|
115
|
+
slug,
|
|
116
|
+
branch: state.branch || slug,
|
|
117
|
+
manifest: readRunJson(dir, "manifest.json"),
|
|
118
|
+
summary: summarize(state),
|
|
119
|
+
watchdogs: Object.keys(classification?.watchdogs ?? {}),
|
|
120
|
+
unclassified: classification?.unclassified ?? [],
|
|
121
|
+
deleted: classification?.deleted ?? [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findingsFor(dir: string, slug: string, params: URLSearchParams): unknown {
|
|
126
|
+
const state = loadState(dir, slug);
|
|
127
|
+
const filter: FindingsFilter = {
|
|
128
|
+
decision: (params.get("decision") as FindingDecision) || undefined,
|
|
129
|
+
execState: (params.get("exec") as FindingExecState) || undefined,
|
|
130
|
+
severity: (params.get("severity") as Severity) || undefined,
|
|
131
|
+
watchdog: params.get("watchdog") || undefined,
|
|
132
|
+
file: params.get("file") || undefined,
|
|
133
|
+
includeAbsent: params.get("all") === "1",
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
branch: state.branch,
|
|
137
|
+
findings: filterFindings(state, filter),
|
|
138
|
+
summary: summarize(state),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function updateFinding(
|
|
143
|
+
dir: string,
|
|
144
|
+
slug: string,
|
|
145
|
+
id: string,
|
|
146
|
+
request: Request,
|
|
147
|
+
): Promise<Response> {
|
|
148
|
+
const body = (await request.json().catch(() => ({}))) as FindingUpdate;
|
|
149
|
+
const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
|
|
150
|
+
const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
|
|
151
|
+
const finding = state.findings[id];
|
|
152
|
+
if (!finding) return notFound(`finding '${id}' não encontrado`);
|
|
153
|
+
applyFindingUpdate(finding, body);
|
|
154
|
+
reorder(state);
|
|
155
|
+
store.write(state);
|
|
156
|
+
return json({ finding });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function syncRun(dir: string, slug: string): Response {
|
|
160
|
+
const store = new FindingsStateStore(join(dir, STATE_FILE), slug);
|
|
161
|
+
const state = store.read() ?? buildForRun(dir, slug) ?? emptyState(slug);
|
|
162
|
+
const result: SyncResult = syncState(state, fileReader(process.cwd()));
|
|
163
|
+
store.write(state);
|
|
164
|
+
return json({ result, summary: summarize(state) });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function serveArtifact(dir: string, name: string): Response {
|
|
168
|
+
if (!/\.(json|md)$/i.test(name)) return notFound("tipo de artefato não servido");
|
|
169
|
+
const content = readRunFile(dir, name);
|
|
170
|
+
if (content === null) return notFound(`artefato '${name}' não encontrado`);
|
|
171
|
+
const type = name.toLowerCase().endsWith(".json")
|
|
172
|
+
? "application/json; charset=utf-8"
|
|
173
|
+
: "text/markdown; charset=utf-8";
|
|
174
|
+
return text(content, type);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Rotas
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
const API_RUN = /^\/api\/runs\/([^/]+)(\/.*)?$/;
|
|
182
|
+
const LOGO_FILE = "logo.png";
|
|
183
|
+
|
|
184
|
+
/** Serve a logo do pacote (`assets/logo.png`). */
|
|
185
|
+
async function serveLogo(): Promise<Response> {
|
|
186
|
+
const file = Bun.file(mediaPath(LOGO_FILE));
|
|
187
|
+
if (!(await file.exists())) return notFound("logo não encontrada");
|
|
188
|
+
return new Response(file, {
|
|
189
|
+
headers: { "cache-control": "no-store" },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function route(request: Request, artifactDir: string): Promise<Response> {
|
|
194
|
+
const url = new URL(request.url);
|
|
195
|
+
const path = url.pathname;
|
|
196
|
+
|
|
197
|
+
if (path === "/" || path === "/index.html") {
|
|
198
|
+
return noStore(renderShell(), "text/html; charset=utf-8");
|
|
199
|
+
}
|
|
200
|
+
if (path === "/assets/app.js") {
|
|
201
|
+
return noStore(await buildClientBundle(), "text/javascript; charset=utf-8");
|
|
202
|
+
}
|
|
203
|
+
if (path === `/assets/${LOGO_FILE}`) return serveLogo();
|
|
204
|
+
if (path === "/api/health") return json({ ok: true });
|
|
205
|
+
|
|
206
|
+
if (path === "/api/runs" && request.method === "GET") {
|
|
207
|
+
return json({ runs: listRuns(artifactDir) });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const match = path.match(API_RUN);
|
|
211
|
+
if (!match) return notFound();
|
|
212
|
+
const slug = decodeURIComponent(match[1]);
|
|
213
|
+
const rest = match[2] ?? "";
|
|
214
|
+
const dir = runDirForSlug(artifactDir, slug);
|
|
215
|
+
if (!dir) return notFound(`run '${slug}' não encontrada`);
|
|
216
|
+
|
|
217
|
+
if (rest === "" || rest === "/") return json(runDetail(dir, slug));
|
|
218
|
+
|
|
219
|
+
if (rest === "/findings" && request.method === "GET") {
|
|
220
|
+
return json(findingsFor(dir, slug, url.searchParams));
|
|
221
|
+
}
|
|
222
|
+
if (rest === "/sync" && request.method === "POST") return syncRun(dir, slug);
|
|
223
|
+
|
|
224
|
+
const findingMatch = rest.match(/^\/findings\/([^/]+)$/);
|
|
225
|
+
if (findingMatch) {
|
|
226
|
+
const id = decodeURIComponent(findingMatch[1]);
|
|
227
|
+
if (request.method === "GET") {
|
|
228
|
+
const state = loadState(dir, slug);
|
|
229
|
+
const finding = state.findings[id];
|
|
230
|
+
return finding
|
|
231
|
+
? json({ finding })
|
|
232
|
+
: notFound(`finding '${id}' não encontrado`);
|
|
233
|
+
}
|
|
234
|
+
if (request.method === "PATCH" || request.method === "POST") {
|
|
235
|
+
return updateFinding(dir, slug, id, request);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const artifactMatch = rest.match(/^\/artifacts\/(.+)$/);
|
|
240
|
+
if (artifactMatch && request.method === "GET") {
|
|
241
|
+
return serveArtifact(dir, decodeURIComponent(artifactMatch[1]));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return notFound();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Cria o handler de requisições (testável sem abrir porta). */
|
|
248
|
+
export function createRequestHandler(
|
|
249
|
+
config: HoundsmanConfig,
|
|
250
|
+
): (request: Request) => Promise<Response> {
|
|
251
|
+
const artifactDir = resolveArtifactDir(config);
|
|
252
|
+
return async (request) => {
|
|
253
|
+
try {
|
|
254
|
+
return await route(request, artifactDir);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return json(
|
|
257
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
258
|
+
500,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export type ServerOptions = {
|
|
265
|
+
config: HoundsmanConfig;
|
|
266
|
+
port?: number;
|
|
267
|
+
host?: string;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** Sobe o servidor e devolve a instância + URL. */
|
|
271
|
+
export function startServer(options: ServerOptions): { server: BunServer; url: string } {
|
|
272
|
+
const host = options.host ?? "127.0.0.1";
|
|
273
|
+
const port = options.port ?? 7331;
|
|
274
|
+
const server = Bun.serve({
|
|
275
|
+
hostname: host,
|
|
276
|
+
port,
|
|
277
|
+
fetch: createRequestHandler(options.config),
|
|
278
|
+
});
|
|
279
|
+
return { server, url: `http://${host}:${server.port}` };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Shell (HTML + CSS)
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
const STYLES = `
|
|
287
|
+
:root { color-scheme: dark; --bg:#161616; --panel:#1e1e1e; --border:#2f2f2f; --fg:#e6e6e6; --muted:#9aa0a6; --accent:#4c8dff; }
|
|
288
|
+
* { box-sizing: border-box; }
|
|
289
|
+
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; background:var(--bg); color:var(--fg); }
|
|
290
|
+
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
291
|
+
.app { display:flex; min-height:100vh; }
|
|
292
|
+
.sidebar { width:240px; border-right:1px solid var(--border); background:var(--panel); padding:16px; }
|
|
293
|
+
.brand { display:flex; align-items:center; gap:10px; margin-bottom:10px; }
|
|
294
|
+
.brand .logo { width:40px; height:40px; object-fit:contain; border-radius:8px; background:#111; border:1px solid var(--border); }
|
|
295
|
+
.sidebar h1 { font-size:16px; margin:0; }
|
|
296
|
+
.sub { color:var(--muted); font-size:12px; margin:2px 0 12px; }
|
|
297
|
+
.brand .sub { margin:0; }
|
|
298
|
+
.runs { display:flex; flex-direction:column; gap:4px; }
|
|
299
|
+
.run { text-align:left; background:transparent; border:1px solid transparent; border-radius:6px; color:var(--fg); padding:8px; cursor:pointer; display:flex; flex-direction:column; gap:2px; }
|
|
300
|
+
.run:hover { background:#262626; }
|
|
301
|
+
.run.active { border-color:var(--accent); background:#22304d; }
|
|
302
|
+
.run-branch { font-weight:600; font-size:13px; }
|
|
303
|
+
.run-meta { color:var(--muted); font-size:11px; }
|
|
304
|
+
.main { flex:1; padding:16px 20px; max-width:980px; }
|
|
305
|
+
.topbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
|
|
306
|
+
.tabs { display:flex; gap:6px; flex-wrap:wrap; }
|
|
307
|
+
.btn { background:#262626; color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:5px 10px; font-size:12px; cursor:pointer; }
|
|
308
|
+
.btn:hover { border-color:var(--accent); }
|
|
309
|
+
.btn.active { background:#22304d; border-color:var(--accent); }
|
|
310
|
+
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
|
311
|
+
.summary-bar { display:flex; gap:14px; flex-wrap:wrap; color:var(--muted); font-size:12px; border:1px solid var(--border); border-radius:6px; padding:8px 10px; margin-bottom:10px; }
|
|
312
|
+
.filters { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
|
|
313
|
+
.filters input[type=text], .filters input:not([type]), .filters select { background:#1b1b1b; color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:6px 8px; font-size:12px; }
|
|
314
|
+
.filters input:not([type]) { min-width:240px; }
|
|
315
|
+
.toggle { color:var(--muted); font-size:12px; display:flex; align-items:center; gap:6px; }
|
|
316
|
+
.caption { color:var(--muted); font-size:12px; line-height:1.5; margin:0 0 12px; }
|
|
317
|
+
.caption code { background:#242424; padding:1px 5px; border-radius:4px; }
|
|
318
|
+
.sev-group h2 { font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); }
|
|
319
|
+
.sev-group h2 .n { color:#666; }
|
|
320
|
+
.card { background:var(--panel); border:1px solid var(--border); border-left:3px solid var(--border); border-radius:8px; padding:12px 14px; margin-bottom:10px; }
|
|
321
|
+
.card.sev-blocking { border-left-color:#e5484d; }
|
|
322
|
+
.card.sev-attention { border-left-color:#e3a008; }
|
|
323
|
+
.card.sev-suggestion { border-left-color:#4c8dff; }
|
|
324
|
+
.card.sev-nit { border-left-color:#666; }
|
|
325
|
+
.card.absent { opacity:.55; }
|
|
326
|
+
.card-head { display:flex; align-items:center; gap:10px; font-size:12px; color:var(--muted); }
|
|
327
|
+
.card-head .loc { color:var(--fg); }
|
|
328
|
+
.card-head .spacer { flex:1; }
|
|
329
|
+
.card-head .id { color:#7c7c7c; }
|
|
330
|
+
.rule { font-size:14px; margin:8px 0 4px; }
|
|
331
|
+
.msg { margin:2px 0; font-size:13px; }
|
|
332
|
+
.action { margin:2px 0; font-size:13px; color:#cfcfcf; }
|
|
333
|
+
.evidence { background:#111; border:1px solid var(--border); border-radius:6px; padding:8px; font-size:12px; overflow:auto; }
|
|
334
|
+
.card-foot { display:flex; gap:12px; color:var(--muted); font-size:11px; margin:6px 0; }
|
|
335
|
+
.decisions { display:flex; gap:6px; flex-wrap:wrap; margin:6px 0; }
|
|
336
|
+
.dec-execute.active { background:#153b21; border-color:#2ea043; }
|
|
337
|
+
.dec-ignore.active { background:#3a2323; border-color:#e5484d; }
|
|
338
|
+
.comment-row textarea { width:100%; min-height:44px; background:#1b1b1b; color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:6px 8px; font-size:12px; }
|
|
339
|
+
.comment-actions { display:flex; gap:6px; margin-top:6px; }
|
|
340
|
+
.btn.done.active { background:#153b21; border-color:#2ea043; }
|
|
341
|
+
.artifacts .tabs { margin-bottom:10px; }
|
|
342
|
+
.artifact-desc { color:var(--muted); font-size:12px; line-height:1.5; margin:0 0 10px; }
|
|
343
|
+
.artifact-view { background:#111; border:1px solid var(--border); border-radius:6px; padding:12px; font-size:12px; overflow:auto; max-height:70vh; }
|
|
344
|
+
.error { color:#ff7b72; font-size:13px; }
|
|
345
|
+
.empty { color:var(--muted); font-size:13px; }
|
|
346
|
+
`;
|
|
347
|
+
|
|
348
|
+
/** Id de build por processo — muda a URL do bundle a cada reinício do servidor. */
|
|
349
|
+
const BUILD_ID = Date.now().toString(36);
|
|
350
|
+
|
|
351
|
+
function renderShell(): string {
|
|
352
|
+
return `<!doctype html>
|
|
353
|
+
<html lang="pt-BR">
|
|
354
|
+
<head>
|
|
355
|
+
<meta charset="utf-8" />
|
|
356
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
357
|
+
<title>Houndsman — Revisões</title>
|
|
358
|
+
<style>${STYLES}</style>
|
|
359
|
+
</head>
|
|
360
|
+
<body>
|
|
361
|
+
<div id="app"></div>
|
|
362
|
+
<script type="module" src="/assets/app.js?v=${BUILD_ID}"></script>
|
|
363
|
+
</body>
|
|
364
|
+
</html>`;
|
|
365
|
+
}
|
package/src/skill/README.md
CHANGED
|
@@ -6,6 +6,12 @@ Revisão automatizada de Pull Requests — CLI independente + camada de configur
|
|
|
6
6
|
|
|
7
7
|
- **houndsman** — o orquestrador/treinador (CLI + motor).
|
|
8
8
|
- **watchdog** — cada agente especialista por domínio que audita o diff.
|
|
9
|
+
- **brief** — instruções prontas para um watchdog (o prompt do subagente).
|
|
10
|
+
- **findings** — os achados que o subagente grava em JSON (único output consumido).
|
|
11
|
+
- **candidato (Jev)** — indício automático; **não** é achado (o watchdog confirma ou refuta).
|
|
12
|
+
- **churn** — linhas adicionadas/removidas por arquivo; usado para priorizar a leitura.
|
|
13
|
+
- **budget / orçamento** — teto de arquivos por brief; o excedente ("fora do orçamento")
|
|
14
|
+
não é revisado, mas fica registrado em `skipped`.
|
|
9
15
|
- **review/revisão** — o processo de revisão.
|
|
10
16
|
|
|
11
17
|
## Quem executa o quê
|
|
@@ -31,7 +37,7 @@ Revisão automatizada de Pull Requests — CLI independente + camada de configur
|
|
|
31
37
|
| `houndsman watchdog <id>` | Contexto de um watchdog (`--json`, `--rules`) |
|
|
32
38
|
| `houndsman setup` | Cria a configuração inicial do projeto |
|
|
33
39
|
| `houndsman update` | Sincroniza os arquivos auto-gerados (`.houndsman/core`) |
|
|
34
|
-
| `houndsman doctor` | Valida a configuração
|
|
40
|
+
| `houndsman doctor` | Valida a configuração, o ambiente e a credencial Jev (anti-drift) |
|
|
35
41
|
| `houndsman test` | Roda a suíte de testes da lib |
|
|
36
42
|
|
|
37
43
|
### Flags
|
|
@@ -45,14 +51,15 @@ Tudo em `.houndsman/.runs/<branch-slug>/` (default do `artifactDir`, `.houndsman
|
|
|
45
51
|
|
|
46
52
|
| Arquivo | Conteúdo |
|
|
47
53
|
|---|---|
|
|
48
|
-
| `classification.json` | Watchdogs ativados + status (A/M/D/R) |
|
|
54
|
+
| `classification.json` | Watchdogs ativados + status (A/M/D/R) + churn |
|
|
49
55
|
| `candidates.json` | Candidatos da pré-análise Jev |
|
|
50
56
|
| `context.json` | Payload bruto da PR via `gh` (ou marcador de indisponível) |
|
|
51
57
|
| `context-summary.json` | Resumo do subagente (objetivo/motivação/alterações) |
|
|
52
|
-
| `briefs/<watchdog>.md` | Instruções prontas para cada sub-agente |
|
|
53
|
-
| `manifest.json` | Resumo (watchdogs, arquivos, deletados, status, contexto) |
|
|
58
|
+
| `briefs/<watchdog>.md` | Instruções prontas para cada sub-agente (arquivos por risco) |
|
|
59
|
+
| `manifest.json` | Resumo (watchdogs, arquivos, deletados, status, contexto, orçamento, churn) |
|
|
54
60
|
| `findings/<watchdog>.json` | Saída dos sub-agentes (schema `OUTPUT-FORMAT.md`) |
|
|
55
|
-
| `review.md` | Relatório final |
|
|
61
|
+
| `review.md` | Relatório final (achados agrupados por arquivo) |
|
|
62
|
+
| `review.<watchdog>.md` | Relatório por domínio (só quando há achados) |
|
|
56
63
|
|
|
57
64
|
## Estrutura
|
|
58
65
|
|
|
@@ -72,7 +79,8 @@ src/
|
|
|
72
79
|
│ ├── orchestrator.ts # preparar/finalizar
|
|
73
80
|
│ ├── brief.ts # defaultRenderBrief + PROCEDURE
|
|
74
81
|
│ ├── pr-context.ts # contexto da PR via gh
|
|
75
|
-
│ ├── file-classifier.ts # classificação (via `classify` dos watchdogs)
|
|
82
|
+
│ ├── file-classifier.ts # classificação (via `classify` dos watchdogs) + churn
|
|
83
|
+
│ ├── diff-source.ts # diff único (patch + --numstat) p/ churn/orçamento
|
|
76
84
|
│ ├── doc-discoverer.ts # descoberta de docs
|
|
77
85
|
│ ├── report-builder.ts # dedupe/render do relatório
|
|
78
86
|
│ ├── registry-check.ts # validações anti-drift (config-driven)
|
package/src/skill/SETUP.md
CHANGED
|
@@ -7,8 +7,8 @@ no seu projeto é **autocontida** — não exige nenhuma dependência local do p
|
|
|
7
7
|
|
|
8
8
|
- **Bun** — o CLI e o motor usam a runtime Bun (execução de TS, `Bun.spawnSync`).
|
|
9
9
|
- **git** — o diff usa `git diff <base>...<head> --name-status`.
|
|
10
|
-
- Opcional: **`gh`** (contexto da PR) e **`TYPESAFE_API_KEY`** (pré-análise Jev).
|
|
11
|
-
|
|
10
|
+
- Opcional: **`gh`** (contexto da PR) e **`TYPESAFE_API_KEY`** (pré-análise Jev). O pipeline
|
|
11
|
+
segue normalmente sem eles.
|
|
12
12
|
|
|
13
13
|
## Instalação (global)
|
|
14
14
|
|
|
@@ -59,7 +59,7 @@ bun run houndsman update # reescreve .houndsman/core/** e .houndsman/.gitig
|
|
|
59
59
|
## Verificar
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
bun run houndsman doctor # valida config + ambiente
|
|
62
|
+
bun run houndsman doctor # valida config + ambiente + credencial Jev
|
|
63
63
|
bun run houndsman test # suíte da lib (project-agnóstica)
|
|
64
64
|
bun run houndsman --base main # prepara briefs do diff atual
|
|
65
65
|
```
|
|
@@ -76,6 +76,8 @@ Definido em `src/core/config.ts` (pacote) e espelhado em `.houndsman/core/types.
|
|
|
76
76
|
| `exclusions` | Arquivos que nunca entram na revisão. |
|
|
77
77
|
| `docMappings` | Path → documentação de contexto. |
|
|
78
78
|
| `alwaysIncludeDocs` | Docs sempre presentes nos briefs (default `["AGENTS.md"]`). |
|
|
79
|
+
| `budgets` | **Opcional** — teto de arquivos por brief (`maxFilesPerBrief`, default `40`); o excedente vira "fora do orçamento" (registrar em `skipped`). |
|
|
80
|
+
| `concurrency` | **Opcional** — chamadas Jev simultâneas (`jev`, default `5`). |
|
|
79
81
|
|
|
80
82
|
> O motor **não contém** ids, paths ou regras do projeto — tudo chega por `HoundsmanConfig`,
|
|
81
83
|
> carregado em runtime a partir de `houndsman.config.ts` (ou `--config <path>`).
|