@payconductor/houndsman 0.0.0-stage → 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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -3
  3. package/assets/houndsman.png +0 -0
  4. package/assets/logo-legacy.png +0 -0
  5. package/assets/logo.png +0 -0
  6. package/package.json +64 -5
  7. package/src/cli/index.ts +290 -0
  8. package/src/core/assets.ts +28 -0
  9. package/src/core/brief.ts +185 -0
  10. package/src/core/commands/context.ts +39 -0
  11. package/src/core/commands/findings.ts +390 -0
  12. package/src/core/commands/review.ts +50 -0
  13. package/src/core/commands/scaffold.ts +393 -0
  14. package/src/core/commands/setup.ts +75 -0
  15. package/src/core/commands/test.ts +20 -0
  16. package/src/core/commands/update.ts +30 -0
  17. package/src/core/commands/watchdog.ts +105 -0
  18. package/src/core/config-loader.ts +87 -0
  19. package/src/core/config.ts +101 -0
  20. package/src/core/diff-source.ts +140 -0
  21. package/src/core/doc-discoverer.ts +33 -0
  22. package/src/core/file-classifier.ts +295 -0
  23. package/src/core/finding-anchor.ts +147 -0
  24. package/src/core/findings-state.ts +538 -0
  25. package/src/core/jev/index.ts +12 -0
  26. package/src/core/jev/jev-candidates.ts +105 -0
  27. package/src/core/jev/jev-client.ts +160 -0
  28. package/src/core/jev/jev-detector.ts +205 -0
  29. package/src/core/jev/jev-questions.ts +92 -0
  30. package/src/core/jev/jev-types.ts +91 -0
  31. package/src/core/orchestrator.ts +596 -0
  32. package/src/core/paths.ts +33 -0
  33. package/src/core/pr-context.ts +271 -0
  34. package/src/core/registry-check.ts +160 -0
  35. package/src/core/report-builder.ts +461 -0
  36. package/src/core/types.ts +147 -0
  37. package/src/core/watchdog-registry.ts +43 -0
  38. package/src/index.ts +132 -0
  39. package/src/server/artifacts.ts +66 -0
  40. package/src/server/client/App.tsx +158 -0
  41. package/src/server/client/ArtifactsPanel.tsx +48 -0
  42. package/src/server/client/Filters.tsx +83 -0
  43. package/src/server/client/FindingCard.tsx +122 -0
  44. package/src/server/client/FindingsList.tsx +54 -0
  45. package/src/server/client/RunList.tsx +43 -0
  46. package/src/server/client/SummaryBar.tsx +21 -0
  47. package/src/server/client/api.ts +37 -0
  48. package/src/server/client/constants.ts +63 -0
  49. package/src/server/client/index.tsx +12 -0
  50. package/src/server/client/types.ts +48 -0
  51. package/src/server/client-build.ts +47 -0
  52. package/src/server/index.ts +365 -0
  53. package/src/skill/AUDIT-PROTOCOL.md +96 -0
  54. package/src/skill/OUTPUT-FORMAT.md +70 -0
  55. package/src/skill/README.md +124 -0
  56. package/src/skill/SETUP.md +101 -0
  57. package/src/skill/example-review.md +58 -0
  58. package/src/skill/houndsman-update.md +251 -0
  59. package/src/skill/houndsman.md +316 -0
  60. package/src/tests/findings-anchor.test.ts +120 -0
  61. package/src/tests/findings-commands.test.ts +226 -0
  62. package/src/tests/findings-state.test.ts +274 -0
  63. package/src/tests/fixtures/fake-project.ts +64 -0
  64. package/src/tests/review-scripts.test.ts +1150 -0
  65. package/src/tests/server.test.ts +212 -0
@@ -0,0 +1,596 @@
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 {
21
+ type HoundsmanConfig,
22
+ resolveJevConcurrency,
23
+ resolveMaxFilesPerBrief,
24
+ } from "./config";
25
+ import { collectDiff, type DiffSource } from "./diff-source";
26
+ import { discoverDocs } from "./doc-discoverer";
27
+ import {
28
+ type BudgetSelection,
29
+ type ClassificationResult,
30
+ classifyRange,
31
+ selectByBudget,
32
+ } from "./file-classifier";
33
+ import { buildStateFromFindings, FindingsStateStore, fileReader } from "./findings-state";
34
+ import {
35
+ type JevCandidate,
36
+ type JevCandidatesFile,
37
+ JevCandidatesStore,
38
+ JevClient,
39
+ JevDetector,
40
+ } from "./jev";
41
+ import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
42
+ import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
43
+ import {
44
+ type ContextSummary,
45
+ type DomainReport,
46
+ dedupeFindings,
47
+ parseWatchdogReport,
48
+ renderDomainReport,
49
+ renderReport,
50
+ sanitizeBranch,
51
+ type WatchdogReport,
52
+ } from "./report-builder";
53
+ import type { WatchdogMeta } from "./types";
54
+ import { findWatchdog } from "./watchdog-registry";
55
+
56
+ export type ReviewOptions = {
57
+ base: string;
58
+ head: string;
59
+ branch?: string;
60
+ dir: string;
61
+ finalize: boolean;
62
+ failOnBlocking: boolean;
63
+ clean: boolean;
64
+ noJev: boolean;
65
+ };
66
+
67
+ /** Valores default das opções (o CLI sobrepõe com as flags). */
68
+ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOptions {
69
+ return {
70
+ base: "main",
71
+ head: "HEAD",
72
+ dir: artifactDir,
73
+ finalize: false,
74
+ failOnBlocking: false,
75
+ clean: false,
76
+ noJev: false,
77
+ };
78
+ }
79
+
80
+ /** Caminho relativo à raiz, sempre com `/` (portável/legível). */
81
+ function relFromRoot(absPath: string): string {
82
+ return relative(process.cwd(), absPath).replace(/\\/g, "/");
83
+ }
84
+
85
+ type Paths = {
86
+ slug: string;
87
+ outDir: string;
88
+ briefsDir: string;
89
+ findingsDir: string;
90
+ };
91
+
92
+ function paths(opts: ReviewOptions, branch: string): Paths {
93
+ const slug = sanitizeBranch(branch);
94
+ const outDir = join(opts.dir, slug);
95
+ return {
96
+ slug,
97
+ outDir,
98
+ briefsDir: join(outDir, "briefs"),
99
+ findingsDir: join(outDir, "findings"),
100
+ };
101
+ }
102
+
103
+ function prepareDirs(dirs: Paths, clean: boolean): void {
104
+ mkdirSync(dirs.briefsDir, { recursive: true });
105
+ mkdirSync(dirs.findingsDir, { recursive: true });
106
+ if (clean) {
107
+ rmSync(dirs.findingsDir, { recursive: true, force: true });
108
+ mkdirSync(dirs.findingsDir, { recursive: true });
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Findings
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function readFindings(
117
+ findingsDir: string,
118
+ activeIds: string[],
119
+ ): { reports: WatchdogReport[]; pending: string[] } {
120
+ const reports: WatchdogReport[] = [];
121
+ const pending: string[] = [];
122
+ for (const id of activeIds) {
123
+ const path = join(findingsDir, `${id}.json`);
124
+ if (!existsSync(path)) {
125
+ pending.push(id);
126
+ continue;
127
+ }
128
+ try {
129
+ const raw = JSON.parse(readFileSync(path, "utf8"));
130
+ const report = parseWatchdogReport(raw, path);
131
+ if (report) reports.push(report);
132
+ else pending.push(id);
133
+ } catch (err) {
134
+ console.error(
135
+ `[houndsman] ${relFromRoot(path)} inválido: ${err instanceof Error ? err.message : err}`,
136
+ );
137
+ pending.push(id);
138
+ }
139
+ }
140
+ return { reports, pending };
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Manifesto
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function contextManifest(
148
+ opts: ReviewOptions,
149
+ branch: string,
150
+ ): { summarized: boolean; source: ContextSummary["source"] | null } {
151
+ const summary = readContextSummary(contextPaths(opts.dir, branch).summary);
152
+ if (!summary) return { summarized: false, source: null };
153
+ return { summarized: true, source: summary.source ?? null };
154
+ }
155
+
156
+ function writeManifest(
157
+ outDir: string,
158
+ opts: ReviewOptions,
159
+ branch: string,
160
+ classification: ClassificationResult,
161
+ activeIds: string[],
162
+ pending: string[],
163
+ jev: JevCandidatesFile,
164
+ config: HoundsmanConfig,
165
+ ): void {
166
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
167
+ let filesOverBudget = 0;
168
+ const watchdogs = activeIds.map((id) => {
169
+ const selection = budgetFor(classification, id, maxFilesPerBrief);
170
+ filesOverBudget += selection.overflow.length;
171
+ return {
172
+ id,
173
+ name: findWatchdog(config, id)?.name ?? id,
174
+ files: selection.selected,
175
+ overflow: selection.overflow.length,
176
+ brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
177
+ findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
178
+ status: pending.includes(id) ? "pending" : "done",
179
+ };
180
+ });
181
+
182
+ const manifest = {
183
+ branch,
184
+ slug: sanitizeBranch(branch),
185
+ base: opts.base,
186
+ head: opts.head,
187
+ generatedAt: new Date().toISOString(),
188
+ outputFormat: OUTPUT_FORMAT_PATH,
189
+ budget: { maxFilesPerBrief, filesOverBudget },
190
+ churn: churnTotals(classification),
191
+ watchdogs,
192
+ context: contextManifest(opts, branch),
193
+ jev: jev.available
194
+ ? {
195
+ available: true,
196
+ model: jev.model,
197
+ threshold: jev.threshold,
198
+ analyzedFiles: jev.analyzedFiles,
199
+ candidates: jev.candidates.length,
200
+ skipped: jev.skipped?.length ?? 0,
201
+ }
202
+ : { available: false, reason: jev.reason },
203
+ deleted: classification.deleted,
204
+ unclassified: classification.unclassified,
205
+ excludedCount: classification.excludedCount,
206
+ };
207
+ writeFileSync(
208
+ join(outDir, "manifest.json"),
209
+ `${JSON.stringify(manifest, null, 2)}\n`,
210
+ );
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Orçamento (triage de PRs grandes)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Aplica o orçamento aos arquivos de um watchdog. */
218
+ function budgetFor(
219
+ classification: ClassificationResult,
220
+ id: string,
221
+ maxFiles: number,
222
+ ): BudgetSelection {
223
+ return selectByBudget(
224
+ classification.watchdogs[id] ?? [],
225
+ classification.churn,
226
+ maxFiles,
227
+ );
228
+ }
229
+
230
+ /** Totais de churn do diff (métricas do manifesto). */
231
+ function churnTotals(classification: ClassificationResult): {
232
+ additions: number;
233
+ deletions: number;
234
+ } {
235
+ let additions = 0;
236
+ let deletions = 0;
237
+ for (const entry of Object.values(classification.churn ?? {})) {
238
+ additions += entry.additions;
239
+ deletions += entry.deletions;
240
+ }
241
+ return { additions, deletions };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Briefs
246
+ // ---------------------------------------------------------------------------
247
+
248
+ function writeBriefs(
249
+ activeIds: string[],
250
+ classification: ClassificationResult,
251
+ dirs: Paths,
252
+ branch: string,
253
+ context: ContextSummary | null,
254
+ candidates: JevCandidate[],
255
+ config: HoundsmanConfig,
256
+ ): void {
257
+ const docs = discoverDocs(Object.keys(classification.files), config);
258
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
259
+ for (const id of activeIds) {
260
+ const def = findWatchdog(config, id);
261
+ if (!def) continue;
262
+ const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
263
+ const brief = renderBrief(def, {
264
+ watchdogId: id,
265
+ files: selected,
266
+ statuses: classification.files,
267
+ churn: classification.churn,
268
+ overflow,
269
+ deleted: classification.deleted,
270
+ branch,
271
+ outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
272
+ outputFormatPath: OUTPUT_FORMAT_PATH,
273
+ docs,
274
+ context,
275
+ candidates: candidates.filter((c) => c.watchdogId === id),
276
+ });
277
+ writeFileSync(join(dirs.briefsDir, `${id}.md`), `${brief}\n`);
278
+ }
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Passo 1 — preparar
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /**
286
+ * Resolve os candidatos Jev (Passo 1.5). Sempre grava `candidates.json`.
287
+ * Falha-aberto: sem chave / erro / `--no-jev` → artefato `available:false`.
288
+ */
289
+ async function resolveCandidates(
290
+ opts: ReviewOptions,
291
+ classification: ClassificationResult,
292
+ dirs: Paths,
293
+ config: HoundsmanConfig,
294
+ diff: DiffSource,
295
+ ): Promise<JevCandidatesFile> {
296
+ const store = new JevCandidatesStore(
297
+ join(dirs.outDir, "candidates.json"),
298
+ config.watchdogIds,
299
+ );
300
+ const save = (data: JevCandidatesFile): JevCandidatesFile => {
301
+ store.write(data);
302
+ return data;
303
+ };
304
+
305
+ if (opts.noJev) {
306
+ return save(JevCandidatesStore.unavailable("desativado (--no-jev)"));
307
+ }
308
+
309
+ const client = new JevClient();
310
+ if (!client.isConfigured()) {
311
+ return save(JevCandidatesStore.unavailable("TYPESAFE_API_KEY não configurada"));
312
+ }
313
+
314
+ try {
315
+ const detector = new JevDetector(
316
+ client,
317
+ config.watchdogs as Record<string, WatchdogMeta>,
318
+ );
319
+ const result = await detector.detect(classification, opts.base, opts.head, {
320
+ diff,
321
+ concurrency: resolveJevConcurrency(config),
322
+ });
323
+ return save({
324
+ available: true,
325
+ model: result.model,
326
+ threshold: JevCandidatesStore.THRESHOLD,
327
+ analyzedFiles: result.analyzedFiles,
328
+ candidates: result.candidates,
329
+ skipped: result.skipped.length > 0 ? result.skipped : undefined,
330
+ });
331
+ } catch (error) {
332
+ return save(
333
+ JevCandidatesStore.unavailable(
334
+ error instanceof Error ? error.message : String(error),
335
+ ),
336
+ );
337
+ }
338
+ }
339
+
340
+ function printJevSummary(jev: JevCandidatesFile): void {
341
+ if (!jev.available) {
342
+ console.log(`Jev: pulado (${jev.reason}).`);
343
+ return;
344
+ }
345
+ const skipped = jev.skipped?.length ?? 0;
346
+ console.log(
347
+ `Jev: ${jev.candidates.length} candidato(s) em ${jev.analyzedFiles ?? 0} arquivo(s)` +
348
+ (skipped > 0 ? `, ${skipped} com falha.` : "."),
349
+ );
350
+ }
351
+
352
+ function printDiscovery(
353
+ activeIds: string[],
354
+ classification: ClassificationResult,
355
+ briefsDir: string,
356
+ ): void {
357
+ console.log(
358
+ `Discovery: ${activeIds.length} watchdog(s) ativado(s), ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
359
+ );
360
+ console.log(`Briefs: ${relFromRoot(briefsDir)}/`);
361
+ for (const file of classification.deleted) {
362
+ console.log(`Deletado (breaking change): ${file}`);
363
+ }
364
+ }
365
+
366
+ /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
367
+ function printBudget(
368
+ classification: ClassificationResult,
369
+ config: HoundsmanConfig,
370
+ ): void {
371
+ const maxFiles = resolveMaxFilesPerBrief(config);
372
+ let overflow = 0;
373
+ for (const id of Object.keys(classification.watchdogs)) {
374
+ overflow += budgetFor(classification, id, maxFiles).overflow.length;
375
+ }
376
+ if (overflow === 0) return;
377
+ console.log(
378
+ `Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
379
+ );
380
+ }
381
+
382
+ /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
383
+ export async function prepare(
384
+ opts: ReviewOptions,
385
+ branch: string,
386
+ config: HoundsmanConfig,
387
+ dirs: Paths = paths(opts, branch),
388
+ ): Promise<void> {
389
+ prepareDirs(dirs, opts.clean);
390
+
391
+ // Um único diff para toda a execução (churn + patch) — antes: 1 spawn por arquivo.
392
+ const diff = collectDiff(opts.base, opts.head);
393
+ const classification = classifyRange(opts.base, opts.head, config, diff);
394
+ writeFileSync(
395
+ join(dirs.outDir, "classification.json"),
396
+ `${JSON.stringify(classification, null, 2)}\n`,
397
+ );
398
+
399
+ const jev = await resolveCandidates(opts, classification, dirs, config, diff);
400
+
401
+ const activeIds = Object.keys(classification.watchdogs);
402
+ if (Object.keys(classification.files).length === 0) {
403
+ console.log(`Nenhuma alteração entre ${opts.base}...${opts.head}.`);
404
+ return;
405
+ }
406
+ if (activeIds.length === 0) {
407
+ console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
408
+ return;
409
+ }
410
+
411
+ const context = readContextSummary(contextPaths(opts.dir, branch).summary);
412
+
413
+ writeBriefs(activeIds, classification, dirs, branch, context, jev.candidates, config);
414
+ writeManifest(
415
+ dirs.outDir,
416
+ opts,
417
+ branch,
418
+ classification,
419
+ activeIds,
420
+ activeIds,
421
+ jev,
422
+ config,
423
+ );
424
+ printDiscovery(activeIds, classification, dirs.briefsDir);
425
+ printBudget(classification, config);
426
+ printJevSummary(jev);
427
+
428
+ if (context) {
429
+ console.log("Contexto da PR resumido e injetado nos briefs.");
430
+ } else {
431
+ console.log(
432
+ "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
433
+ );
434
+ }
435
+
436
+ console.log(
437
+ `\nPróximo passo: despachar os sub-agentes (um por brief em ${relFromRoot(dirs.briefsDir)}/).`,
438
+ );
439
+ console.log("Quando os findings estiverem gravados: bun run houndsman --finalize");
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Passo 2 — finalizar
444
+ // ---------------------------------------------------------------------------
445
+
446
+ /** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
447
+ function readClassification(classificationPath: string): ClassificationResult {
448
+ if (!existsSync(classificationPath)) {
449
+ throw new Error(
450
+ `Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
451
+ );
452
+ }
453
+ const parsed = JSON.parse(
454
+ readFileSync(classificationPath, "utf8"),
455
+ ) as ClassificationResult;
456
+ // Compatível com classification.json gerado antes do churn existir.
457
+ parsed.churn ??= {};
458
+ return parsed;
459
+ }
460
+
461
+ /**
462
+ * Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
463
+ * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
464
+ */
465
+ function writeDomainReports(
466
+ outDir: string,
467
+ branch: string,
468
+ reports: WatchdogReport[],
469
+ classification: ClassificationResult,
470
+ context: ContextSummary | null,
471
+ nameOf: (id: string) => string,
472
+ ): DomainReport[] {
473
+ const domains: DomainReport[] = [];
474
+ for (const report of reports) {
475
+ const findings = dedupeFindings([report]);
476
+ if (findings.length === 0) continue;
477
+ const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
478
+ const expected = classification.watchdogs[report.watchdogId] ?? [];
479
+ writeFileSync(
480
+ join(outDir, file),
481
+ `${renderDomainReport(
482
+ branch,
483
+ report,
484
+ findings,
485
+ expected.length,
486
+ context,
487
+ nameOf,
488
+ )}\n`,
489
+ );
490
+ domains.push({
491
+ id: report.watchdogId,
492
+ name: nameOf(report.watchdogId),
493
+ file,
494
+ findings: findings.length,
495
+ reviewedFiles: report.reviewedFiles.length,
496
+ expectedFiles: expected.length,
497
+ });
498
+ }
499
+ return domains;
500
+ }
501
+
502
+ export type FinalizeResult = {
503
+ reportPath: string;
504
+ blockers: number;
505
+ attention: number;
506
+ };
507
+
508
+ /**
509
+ * Passo 2 — consolida os findings em review.md.
510
+ * Lança se houver findings pendentes (o CLI decide o exit code).
511
+ */
512
+ export function finalize(
513
+ opts: ReviewOptions,
514
+ branch: string,
515
+ config: HoundsmanConfig,
516
+ dirs: Paths = paths(opts, branch),
517
+ ): FinalizeResult {
518
+ const classification = readClassification(join(dirs.outDir, "classification.json"));
519
+ const activeIds = Object.keys(classification.watchdogs);
520
+
521
+ const { reports, pending } = readFindings(dirs.findingsDir, activeIds);
522
+ const jev =
523
+ new JevCandidatesStore(
524
+ join(dirs.outDir, "candidates.json"),
525
+ config.watchdogIds,
526
+ ).read() ?? JevCandidatesStore.unavailable("candidates.json ausente");
527
+ writeManifest(
528
+ dirs.outDir,
529
+ opts,
530
+ branch,
531
+ classification,
532
+ activeIds,
533
+ pending,
534
+ jev,
535
+ config,
536
+ );
537
+
538
+ if (pending.length > 0) {
539
+ throw new Error(
540
+ `Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
541
+ `Cada watchdog deve gravar ${relFromRoot(dirs.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
542
+ );
543
+ }
544
+
545
+ const merged = dedupeFindings(reports);
546
+
547
+ // Estado de triagem: preserva o que já existia (casando por fingerprint) e
548
+ // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
549
+ // recalculada por `houndsman findings sync` após edições.
550
+ const store = FindingsStateStore.at(opts.dir, branch);
551
+ const state = buildStateFromFindings(merged, {
552
+ branch,
553
+ readFile: fileReader(process.cwd()),
554
+ previous: store.read(),
555
+ });
556
+ store.write(state);
557
+ console.log(
558
+ `Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
559
+ );
560
+
561
+ const context = readContextSummary(contextPaths(opts.dir, branch).summary);
562
+ const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
563
+ const domains = writeDomainReports(
564
+ dirs.outDir,
565
+ branch,
566
+ reports,
567
+ classification,
568
+ context,
569
+ nameOf,
570
+ );
571
+ const markdown = renderReport(
572
+ branch,
573
+ reports,
574
+ merged,
575
+ { classified: classification.watchdogs },
576
+ context,
577
+ jev.available ? jev.candidates : undefined,
578
+ nameOf,
579
+ domains,
580
+ );
581
+ const reportPath = join(dirs.outDir, "review.md");
582
+ writeFileSync(reportPath, `${markdown}\n`);
583
+
584
+ const blockers = merged.filter((f) => f.severity === "blocking").length;
585
+ const attention = merged.filter((f) => f.severity === "attention").length;
586
+ console.log(
587
+ `Relatório: ${relFromRoot(reportPath)} — ${blockers} bloqueante(s), ${attention} atenção.`,
588
+ );
589
+
590
+ return { reportPath, blockers, attention };
591
+ }
592
+
593
+ /** Resolve a branch (opção explícita ou a atual do git). */
594
+ export function resolveBranch(opts: ReviewOptions): string {
595
+ return opts.branch ?? currentBranch();
596
+ }
@@ -0,0 +1,33 @@
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}`;
31
+
32
+ /** Estado de triagem dos findings, dentro da pasta da run (`<slug>/state.json`). */
33
+ export const STATE_FILE = "state.json";