@payconductor/houndsman 0.0.0-stage → 0.1.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -3
  3. package/assets/houndsman.png +0 -0
  4. package/package.json +63 -5
  5. package/src/cli/index.ts +210 -0
  6. package/src/core/assets.ts +20 -0
  7. package/src/core/brief.ts +162 -0
  8. package/src/core/commands/context.ts +39 -0
  9. package/src/core/commands/review.ts +50 -0
  10. package/src/core/commands/scaffold.ts +373 -0
  11. package/src/core/commands/setup.ts +75 -0
  12. package/src/core/commands/test.ts +20 -0
  13. package/src/core/commands/update.ts +30 -0
  14. package/src/core/commands/watchdog.ts +105 -0
  15. package/src/core/config-loader.ts +87 -0
  16. package/src/core/config.ts +59 -0
  17. package/src/core/doc-discoverer.ts +33 -0
  18. package/src/core/file-classifier.ts +247 -0
  19. package/src/core/jev/index.ts +12 -0
  20. package/src/core/jev/jev-candidates.ts +105 -0
  21. package/src/core/jev/jev-client.ts +99 -0
  22. package/src/core/jev/jev-detector.ts +203 -0
  23. package/src/core/jev/jev-questions.ts +92 -0
  24. package/src/core/jev/jev-types.ts +91 -0
  25. package/src/core/orchestrator.ts +444 -0
  26. package/src/core/paths.ts +30 -0
  27. package/src/core/pr-context.ts +271 -0
  28. package/src/core/registry-check.ts +145 -0
  29. package/src/core/report-builder.ts +357 -0
  30. package/src/core/types.ts +137 -0
  31. package/src/core/watchdog-registry.ts +43 -0
  32. package/src/index.ts +56 -0
  33. package/src/skill/AUDIT-PROTOCOL.md +96 -0
  34. package/src/skill/OUTPUT-FORMAT.md +70 -0
  35. package/src/skill/README.md +116 -0
  36. package/src/skill/SETUP.md +99 -0
  37. package/src/skill/example-review.md +58 -0
  38. package/src/skill/houndsman-update.md +251 -0
  39. package/src/skill/houndsman.md +239 -0
  40. package/src/tests/fixtures/fake-project.ts +64 -0
  41. package/src/tests/review-scripts.test.ts +851 -0
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Orquestração do houndsman (biblioteca): preparar (classificação + briefs +
3
+ * manifesto + Jev) e finalizar (findings → dedupe → relatório).
4
+ *
5
+ * Genérico: recebe o `HoundsmanConfig` por parâmetro (injeção explícita).
6
+ * Entrypoints: `cli/index.ts` (`houndsman`). Aqui não há `process.argv`.
7
+ *
8
+ * Artefatos (em `opts.dir`, default `HoundsmanConfig.artifactDir/<branch-slug>/`):
9
+ * classification.json watchdogs ativados por arquivo + status (A/M/D/R)
10
+ * candidates.json candidatos da pré-análise Jev
11
+ * briefs/<id>.md instruções prontas para cada sub-agente
12
+ * findings/<id>.json saída dos sub-agentes (schema skill/OUTPUT-FORMAT.md)
13
+ * manifest.json resumo do que foi ativado
14
+ * review.md relatório final (gerado no finalize)
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
+ import { join, relative } from "node:path";
19
+ import { renderBrief } from "./brief";
20
+ import type { HoundsmanConfig } from "./config";
21
+ import { discoverDocs } from "./doc-discoverer";
22
+ import { type ClassificationResult, classifyRange } from "./file-classifier";
23
+ import {
24
+ type JevCandidate,
25
+ type JevCandidatesFile,
26
+ JevCandidatesStore,
27
+ JevClient,
28
+ JevDetector,
29
+ } from "./jev";
30
+ import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
31
+ import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
32
+ import {
33
+ type ContextSummary,
34
+ dedupeFindings,
35
+ parseWatchdogReport,
36
+ renderReport,
37
+ sanitizeBranch,
38
+ type WatchdogReport,
39
+ } from "./report-builder";
40
+ import type { WatchdogMeta } from "./types";
41
+ import { findWatchdog } from "./watchdog-registry";
42
+
43
+ export type ReviewOptions = {
44
+ base: string;
45
+ head: string;
46
+ branch?: string;
47
+ dir: string;
48
+ finalize: boolean;
49
+ failOnBlocking: boolean;
50
+ clean: boolean;
51
+ noJev: boolean;
52
+ };
53
+
54
+ /** Valores default das opções (o CLI sobrepõe com as flags). */
55
+ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOptions {
56
+ return {
57
+ base: "main",
58
+ head: "HEAD",
59
+ dir: artifactDir,
60
+ finalize: false,
61
+ failOnBlocking: false,
62
+ clean: false,
63
+ noJev: false,
64
+ };
65
+ }
66
+
67
+ /** Caminho relativo à raiz, sempre com `/` (portável/legível). */
68
+ function relFromRoot(absPath: string): string {
69
+ return relative(process.cwd(), absPath).replace(/\\/g, "/");
70
+ }
71
+
72
+ type Paths = {
73
+ slug: string;
74
+ outDir: string;
75
+ briefsDir: string;
76
+ findingsDir: string;
77
+ };
78
+
79
+ function paths(opts: ReviewOptions, branch: string): Paths {
80
+ const slug = sanitizeBranch(branch);
81
+ const outDir = join(opts.dir, slug);
82
+ return {
83
+ slug,
84
+ outDir,
85
+ briefsDir: join(outDir, "briefs"),
86
+ findingsDir: join(outDir, "findings"),
87
+ };
88
+ }
89
+
90
+ function prepareDirs(dirs: Paths, clean: boolean): void {
91
+ mkdirSync(dirs.briefsDir, { recursive: true });
92
+ mkdirSync(dirs.findingsDir, { recursive: true });
93
+ if (clean) {
94
+ rmSync(dirs.findingsDir, { recursive: true, force: true });
95
+ mkdirSync(dirs.findingsDir, { recursive: true });
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Findings
101
+ // ---------------------------------------------------------------------------
102
+
103
+ function readFindings(
104
+ findingsDir: string,
105
+ activeIds: string[],
106
+ ): { reports: WatchdogReport[]; pending: string[] } {
107
+ const reports: WatchdogReport[] = [];
108
+ const pending: string[] = [];
109
+ for (const id of activeIds) {
110
+ const path = join(findingsDir, `${id}.json`);
111
+ if (!existsSync(path)) {
112
+ pending.push(id);
113
+ continue;
114
+ }
115
+ try {
116
+ const raw = JSON.parse(readFileSync(path, "utf8"));
117
+ const report = parseWatchdogReport(raw, path);
118
+ if (report) reports.push(report);
119
+ else pending.push(id);
120
+ } catch (err) {
121
+ console.error(
122
+ `[houndsman] ${relFromRoot(path)} inválido: ${err instanceof Error ? err.message : err}`,
123
+ );
124
+ pending.push(id);
125
+ }
126
+ }
127
+ return { reports, pending };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Manifesto
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function contextManifest(
135
+ opts: ReviewOptions,
136
+ branch: string,
137
+ ): { summarized: boolean; source: ContextSummary["source"] | null } {
138
+ const summary = readContextSummary(contextPaths(opts.dir, branch).summary);
139
+ if (!summary) return { summarized: false, source: null };
140
+ return { summarized: true, source: summary.source ?? null };
141
+ }
142
+
143
+ function writeManifest(
144
+ outDir: string,
145
+ opts: ReviewOptions,
146
+ branch: string,
147
+ classification: ClassificationResult,
148
+ activeIds: string[],
149
+ pending: string[],
150
+ jev: JevCandidatesFile,
151
+ config: HoundsmanConfig,
152
+ ): void {
153
+ const manifest = {
154
+ branch,
155
+ slug: sanitizeBranch(branch),
156
+ base: opts.base,
157
+ head: opts.head,
158
+ generatedAt: new Date().toISOString(),
159
+ outputFormat: OUTPUT_FORMAT_PATH,
160
+ watchdogs: activeIds.map((id) => ({
161
+ id,
162
+ name: findWatchdog(config, id)?.name ?? id,
163
+ files: classification.watchdogs[id] ?? [],
164
+ brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
165
+ findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
166
+ status: pending.includes(id) ? "pending" : "done",
167
+ })),
168
+ context: contextManifest(opts, branch),
169
+ jev: jev.available
170
+ ? {
171
+ available: true,
172
+ model: jev.model,
173
+ threshold: jev.threshold,
174
+ analyzedFiles: jev.analyzedFiles,
175
+ candidates: jev.candidates.length,
176
+ skipped: jev.skipped?.length ?? 0,
177
+ }
178
+ : { available: false, reason: jev.reason },
179
+ deleted: classification.deleted,
180
+ unclassified: classification.unclassified,
181
+ excludedCount: classification.excludedCount,
182
+ };
183
+ writeFileSync(
184
+ join(outDir, "manifest.json"),
185
+ `${JSON.stringify(manifest, null, 2)}\n`,
186
+ );
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Briefs
191
+ // ---------------------------------------------------------------------------
192
+
193
+ function writeBriefs(
194
+ activeIds: string[],
195
+ classification: ClassificationResult,
196
+ dirs: Paths,
197
+ branch: string,
198
+ context: ContextSummary | null,
199
+ candidates: JevCandidate[],
200
+ config: HoundsmanConfig,
201
+ ): void {
202
+ const docs = discoverDocs(Object.keys(classification.files), config);
203
+ for (const id of activeIds) {
204
+ const def = findWatchdog(config, id);
205
+ if (!def) continue;
206
+ const files = classification.watchdogs[id] ?? [];
207
+ const brief = renderBrief(def, {
208
+ watchdogId: id,
209
+ files,
210
+ statuses: classification.files,
211
+ deleted: classification.deleted,
212
+ branch,
213
+ outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
214
+ outputFormatPath: OUTPUT_FORMAT_PATH,
215
+ docs,
216
+ context,
217
+ candidates: candidates.filter((c) => c.watchdogId === id),
218
+ });
219
+ writeFileSync(join(dirs.briefsDir, `${id}.md`), `${brief}\n`);
220
+ }
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Passo 1 — preparar
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /**
228
+ * Resolve os candidatos Jev (Passo 1.5). Sempre grava `candidates.json`.
229
+ * Falha-aberto: sem chave / erro / `--no-jev` → artefato `available:false`.
230
+ */
231
+ async function resolveCandidates(
232
+ opts: ReviewOptions,
233
+ classification: ClassificationResult,
234
+ dirs: Paths,
235
+ config: HoundsmanConfig,
236
+ ): Promise<JevCandidatesFile> {
237
+ const store = new JevCandidatesStore(
238
+ join(dirs.outDir, "candidates.json"),
239
+ config.watchdogIds,
240
+ );
241
+ const save = (data: JevCandidatesFile): JevCandidatesFile => {
242
+ store.write(data);
243
+ return data;
244
+ };
245
+
246
+ if (opts.noJev) {
247
+ return save(JevCandidatesStore.unavailable("desativado (--no-jev)"));
248
+ }
249
+
250
+ const client = new JevClient();
251
+ if (!client.isConfigured()) {
252
+ return save(JevCandidatesStore.unavailable("TYPESAFE_API_KEY não configurada"));
253
+ }
254
+
255
+ try {
256
+ const detector = new JevDetector(
257
+ client,
258
+ config.watchdogs as Record<string, WatchdogMeta>,
259
+ );
260
+ const result = await detector.detect(classification, opts.base, opts.head);
261
+ return save({
262
+ available: true,
263
+ model: result.model,
264
+ threshold: JevCandidatesStore.THRESHOLD,
265
+ analyzedFiles: result.analyzedFiles,
266
+ candidates: result.candidates,
267
+ skipped: result.skipped.length > 0 ? result.skipped : undefined,
268
+ });
269
+ } catch (error) {
270
+ return save(
271
+ JevCandidatesStore.unavailable(
272
+ error instanceof Error ? error.message : String(error),
273
+ ),
274
+ );
275
+ }
276
+ }
277
+
278
+ function printJevSummary(jev: JevCandidatesFile): void {
279
+ if (!jev.available) {
280
+ console.log(`Jev: pulado (${jev.reason}).`);
281
+ return;
282
+ }
283
+ const skipped = jev.skipped?.length ?? 0;
284
+ console.log(
285
+ `Jev: ${jev.candidates.length} candidato(s) em ${jev.analyzedFiles ?? 0} arquivo(s)` +
286
+ (skipped > 0 ? `, ${skipped} com falha.` : "."),
287
+ );
288
+ }
289
+
290
+ function printDiscovery(
291
+ activeIds: string[],
292
+ classification: ClassificationResult,
293
+ briefsDir: string,
294
+ ): void {
295
+ console.log(
296
+ `Discovery: ${activeIds.length} watchdog(s) ativado(s), ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
297
+ );
298
+ console.log(`Briefs: ${relFromRoot(briefsDir)}/`);
299
+ for (const file of classification.deleted) {
300
+ console.log(`Deletado (breaking change): ${file}`);
301
+ }
302
+ }
303
+
304
+ /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
305
+ export async function prepare(
306
+ opts: ReviewOptions,
307
+ branch: string,
308
+ config: HoundsmanConfig,
309
+ dirs: Paths = paths(opts, branch),
310
+ ): Promise<void> {
311
+ prepareDirs(dirs, opts.clean);
312
+
313
+ const classification = classifyRange(opts.base, opts.head, config);
314
+ writeFileSync(
315
+ join(dirs.outDir, "classification.json"),
316
+ `${JSON.stringify(classification, null, 2)}\n`,
317
+ );
318
+
319
+ const jev = await resolveCandidates(opts, classification, dirs, config);
320
+
321
+ const activeIds = Object.keys(classification.watchdogs);
322
+ if (Object.keys(classification.files).length === 0) {
323
+ console.log(`Nenhuma alteração entre ${opts.base}...${opts.head}.`);
324
+ return;
325
+ }
326
+ if (activeIds.length === 0) {
327
+ console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
328
+ return;
329
+ }
330
+
331
+ const context = readContextSummary(contextPaths(opts.dir, branch).summary);
332
+
333
+ writeBriefs(activeIds, classification, dirs, branch, context, jev.candidates, config);
334
+ writeManifest(
335
+ dirs.outDir,
336
+ opts,
337
+ branch,
338
+ classification,
339
+ activeIds,
340
+ activeIds,
341
+ jev,
342
+ config,
343
+ );
344
+ printDiscovery(activeIds, classification, dirs.briefsDir);
345
+ printJevSummary(jev);
346
+
347
+ if (context) {
348
+ console.log("Contexto da PR resumido e injetado nos briefs.");
349
+ } else {
350
+ console.log(
351
+ "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
352
+ );
353
+ }
354
+
355
+ console.log(
356
+ `\nPróximo passo: despachar os sub-agentes (um por brief em ${relFromRoot(dirs.briefsDir)}/).`,
357
+ );
358
+ console.log("Quando os findings estiverem gravados: bun run houndsman --finalize");
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Passo 2 — finalizar
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
366
+ function readClassification(classificationPath: string): ClassificationResult {
367
+ if (!existsSync(classificationPath)) {
368
+ throw new Error(
369
+ `Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
370
+ );
371
+ }
372
+ return JSON.parse(readFileSync(classificationPath, "utf8"));
373
+ }
374
+
375
+ export type FinalizeResult = {
376
+ reportPath: string;
377
+ blockers: number;
378
+ attention: number;
379
+ };
380
+
381
+ /**
382
+ * Passo 2 — consolida os findings em review.md.
383
+ * Lança se houver findings pendentes (o CLI decide o exit code).
384
+ */
385
+ export function finalize(
386
+ opts: ReviewOptions,
387
+ branch: string,
388
+ config: HoundsmanConfig,
389
+ dirs: Paths = paths(opts, branch),
390
+ ): FinalizeResult {
391
+ const classification = readClassification(join(dirs.outDir, "classification.json"));
392
+ const activeIds = Object.keys(classification.watchdogs);
393
+
394
+ const { reports, pending } = readFindings(dirs.findingsDir, activeIds);
395
+ const jev =
396
+ new JevCandidatesStore(
397
+ join(dirs.outDir, "candidates.json"),
398
+ config.watchdogIds,
399
+ ).read() ?? JevCandidatesStore.unavailable("candidates.json ausente");
400
+ writeManifest(
401
+ dirs.outDir,
402
+ opts,
403
+ branch,
404
+ classification,
405
+ activeIds,
406
+ pending,
407
+ jev,
408
+ config,
409
+ );
410
+
411
+ if (pending.length > 0) {
412
+ throw new Error(
413
+ `Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
414
+ `Cada watchdog deve gravar ${relFromRoot(dirs.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
415
+ );
416
+ }
417
+
418
+ const merged = dedupeFindings(reports);
419
+ const context = readContextSummary(contextPaths(opts.dir, branch).summary);
420
+ const markdown = renderReport(
421
+ branch,
422
+ reports,
423
+ merged,
424
+ { classified: classification.watchdogs },
425
+ context,
426
+ jev.available ? jev.candidates : undefined,
427
+ (id) => findWatchdog(config, id)?.name ?? id,
428
+ );
429
+ const reportPath = join(dirs.outDir, "review.md");
430
+ writeFileSync(reportPath, `${markdown}\n`);
431
+
432
+ const blockers = merged.filter((f) => f.severity === "blocking").length;
433
+ const attention = merged.filter((f) => f.severity === "attention").length;
434
+ console.log(
435
+ `Relatório: ${relFromRoot(reportPath)} — ${blockers} bloqueante(s), ${attention} atenção.`,
436
+ );
437
+
438
+ return { reportPath, blockers, attention };
439
+ }
440
+
441
+ /** Resolve a branch (opção explícita ou a atual do git). */
442
+ export function resolveBranch(opts: ReviewOptions): string {
443
+ return opts.branch ?? currentBranch();
444
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Caminhos do houndsman (biblioteca): raiz relativa para logs + layout fixo da
3
+ * camada de projeto (`.houndsman/`).
4
+ *
5
+ * A camada gerada tem local fixo, então nada aqui é configurável pelo projeto:
6
+ * o CLI deriva os artefatos e o schema de output destas constantes.
7
+ */
8
+
9
+ import { relative } from "node:path";
10
+ import { OUTPUT_FORMAT_FILE } from "./assets";
11
+
12
+ /** Caminho relativo ao cwd, sempre com `/` (portável/legível em logs). */
13
+ export function relFromRoot(absPath: string): string {
14
+ return relative(process.cwd(), absPath).replace(/\\/g, "/");
15
+ }
16
+
17
+ /** Diretório da camada de projeto (regras + artefatos). */
18
+ export const PROJECT_DIR = ".houndsman";
19
+
20
+ /** Subdiretório dos arquivos auto-gerados (sincronizados por `update`). */
21
+ export const CORE_DIR = `${PROJECT_DIR}/core`;
22
+
23
+ /** Skill canônico (markdown) dentro da camada gerada. */
24
+ export const CORE_SKILL_DIR = `${CORE_DIR}/skill`;
25
+
26
+ /** Artefatos da revisão (briefs, findings, review.md) — default de `artifactDir`. */
27
+ export const ARTIFACT_DIR = `${PROJECT_DIR}/.runs`;
28
+
29
+ /** Schema de output dos watchdogs, citado nos briefs. */
30
+ export const OUTPUT_FORMAT_PATH = `${CORE_SKILL_DIR}/${OUTPUT_FORMAT_FILE}`;