@payconductor/houndsman 0.1.0 → 0.1.3

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 (61) hide show
  1. package/README.md +68 -89
  2. package/assets/logo-legacy.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/package.json +3 -2
  5. package/src/cli/index.ts +101 -10
  6. package/src/core/assets.ts +11 -0
  7. package/src/core/brief.ts +27 -5
  8. package/src/core/commands/findings.ts +618 -0
  9. package/src/core/commands/review.ts +2 -0
  10. package/src/core/commands/scaffold.ts +33 -44
  11. package/src/core/config.ts +42 -0
  12. package/src/core/diff-source.ts +140 -0
  13. package/src/core/file-classifier.ts +50 -2
  14. package/src/core/finding-anchor.ts +147 -0
  15. package/src/core/findings-state.ts +556 -0
  16. package/src/core/fix-batch.ts +236 -0
  17. package/src/core/git-revision.ts +105 -0
  18. package/src/core/jev/index.ts +1 -0
  19. package/src/core/jev/jev-client.ts +72 -11
  20. package/src/core/jev/jev-detector.ts +21 -19
  21. package/src/core/jev/jev-verify.ts +178 -0
  22. package/src/core/orchestrator.ts +362 -23
  23. package/src/core/paths.ts +9 -0
  24. package/src/core/registry-check.ts +15 -0
  25. package/src/core/report-builder.ts +118 -9
  26. package/src/core/run-status.ts +123 -0
  27. package/src/core/types.ts +10 -0
  28. package/src/core/verify.ts +175 -0
  29. package/src/core/watchdog-registry.ts +1 -2
  30. package/src/index.ts +78 -2
  31. package/src/server/artifacts.ts +67 -0
  32. package/src/server/client/App.tsx +158 -0
  33. package/src/server/client/ArtifactsPanel.tsx +48 -0
  34. package/src/server/client/Filters.tsx +83 -0
  35. package/src/server/client/FindingCard.tsx +122 -0
  36. package/src/server/client/FindingsList.tsx +54 -0
  37. package/src/server/client/RunList.tsx +52 -0
  38. package/src/server/client/SummaryBar.tsx +21 -0
  39. package/src/server/client/api.ts +37 -0
  40. package/src/server/client/constants.ts +94 -0
  41. package/src/server/client/index.tsx +12 -0
  42. package/src/server/client/types.ts +52 -0
  43. package/src/server/client-build.ts +47 -0
  44. package/src/server/index.ts +376 -0
  45. package/src/skill/AUDIT-PROTOCOL.md +2 -2
  46. package/src/skill/README.md +43 -11
  47. package/src/skill/SETUP.md +16 -4
  48. package/src/skill/SKILL.md +48 -0
  49. package/src/skill/fix.md +206 -0
  50. package/src/skill/{houndsman.md → hunt.md} +83 -33
  51. package/src/skill/{houndsman-update.md → train.md} +12 -12
  52. package/src/skill/verify.md +70 -0
  53. package/src/tests/findings-anchor.test.ts +120 -0
  54. package/src/tests/findings-commands.test.ts +476 -0
  55. package/src/tests/findings-state.test.ts +325 -0
  56. package/src/tests/fix-batch.test.ts +257 -0
  57. package/src/tests/review-scripts.test.ts +299 -0
  58. package/src/tests/run-status.test.ts +147 -0
  59. package/src/tests/scaffold.test.ts +59 -0
  60. package/src/tests/server.test.ts +242 -0
  61. package/src/tests/verify.test.ts +170 -0
@@ -17,9 +17,27 @@
17
17
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { join, relative } from "node:path";
19
19
  import { renderBrief } from "./brief";
20
- import type { HoundsmanConfig } from "./config";
20
+ import {
21
+ type HoundsmanConfig,
22
+ resolveJevConcurrency,
23
+ resolveMaxFilesPerBrief,
24
+ } from "./config";
25
+ import { churnToRecord, collectDiff, type DiffSource } from "./diff-source";
21
26
  import { discoverDocs } from "./doc-discoverer";
22
- import { type ClassificationResult, classifyRange } from "./file-classifier";
27
+ import {
28
+ type BudgetSelection,
29
+ type ClassificationResult,
30
+ classifyEntries,
31
+ classifyRange,
32
+ selectByBudget,
33
+ } from "./file-classifier";
34
+ import {
35
+ buildStateFromFindings,
36
+ type FindingsStateFile,
37
+ FindingsStateStore,
38
+ fileReader,
39
+ } from "./findings-state";
40
+ import { collectDiffSince, diffEntries, isAncestor, resolveSha } from "./git-revision";
23
41
  import {
24
42
  type JevCandidate,
25
43
  type JevCandidatesFile,
@@ -31,13 +49,17 @@ import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
31
49
  import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
32
50
  import {
33
51
  type ContextSummary,
52
+ type DomainReport,
34
53
  dedupeFindings,
54
+ type MergedFinding,
35
55
  parseWatchdogReport,
56
+ renderDomainReport,
36
57
  renderReport,
37
58
  sanitizeBranch,
38
59
  type WatchdogReport,
39
60
  } from "./report-builder";
40
- import type { WatchdogMeta } from "./types";
61
+ import { writeRunStatus } from "./run-status";
62
+ import { severityRank, type WatchdogMeta } from "./types";
41
63
  import { findWatchdog } from "./watchdog-registry";
42
64
 
43
65
  export type ReviewOptions = {
@@ -49,6 +71,8 @@ export type ReviewOptions = {
49
71
  failOnBlocking: boolean;
50
72
  clean: boolean;
51
73
  noJev: boolean;
74
+ /** Força review completo (ignora o modo incremental). */
75
+ full: boolean;
52
76
  };
53
77
 
54
78
  /** Valores default das opções (o CLI sobrepõe com as flags). */
@@ -61,6 +85,7 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
61
85
  failOnBlocking: false,
62
86
  clean: false,
63
87
  noJev: false,
88
+ full: false,
64
89
  };
65
90
  }
66
91
 
@@ -96,6 +121,142 @@ function prepareDirs(dirs: Paths, clean: boolean): void {
96
121
  }
97
122
  }
98
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // Escopo da revisão (completo vs incremental)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Classificação enriquecida gravada no `classification.json` (modo + revisão + carry-over). */
129
+ type RunClassification = ClassificationResult & {
130
+ /** Commit revisado (head do diff). */
131
+ headSha?: string;
132
+ /** Modo do prepare: completo ou incremental (delta desde a última revisão). */
133
+ mode: "full" | "incremental";
134
+ /** Commit da última revisão (só no incremental). */
135
+ reviewedFrom?: string;
136
+ /** Findings preservados do review anterior (arquivos inalterados). */
137
+ carryover?: MergedFinding[];
138
+ };
139
+
140
+ /** SHA curto para logs (7 chars, como o git). */
141
+ function shortSha(sha: string): string {
142
+ return sha.slice(0, 7);
143
+ }
144
+
145
+ /** Chave de dedupe de finding: `(file, line, rule)` — igual ao `dedupeFindings`. */
146
+ function findingKey(finding: Pick<MergedFinding, "file" | "line" | "rule">): string {
147
+ return `${finding.file}|${finding.line ?? ""}|${finding.rule}`;
148
+ }
149
+
150
+ /** Aviso no topo do `review.md` quando a revisão foi incremental (senão vazio). */
151
+ function incrementalNote(classification: RunClassification): string {
152
+ if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
153
+ const carried = classification.carryover?.length ?? 0;
154
+ return (
155
+ `> **Revisão incremental** — desde \`${shortSha(classification.reviewedFrom)}\`; ` +
156
+ `${Object.keys(classification.files).length} arquivo(s) alterado(s), ` +
157
+ `${carried} finding(s) carregado(s) do review anterior.\n\n`
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Findings do review anterior em arquivos inalterados — carregados sem
163
+ * re-revisão (carry-over). Preserva o `foundAtSha` para o diff focado do verify.
164
+ */
165
+ function buildCarryover(
166
+ previous: FindingsStateFile | null,
167
+ changedPaths: Set<string>,
168
+ ): MergedFinding[] {
169
+ if (!previous) return [];
170
+ return Object.values(previous.findings)
171
+ .filter((finding) => finding.present && !changedPaths.has(finding.file))
172
+ .map((finding) => ({
173
+ id: finding.id,
174
+ rule: finding.rule,
175
+ severity: finding.severity,
176
+ file: finding.file,
177
+ line: finding.line ?? undefined,
178
+ message: finding.message,
179
+ action: finding.action,
180
+ evidence: finding.evidence,
181
+ foundAtSha: finding.foundAtSha,
182
+ watchdogs: [...finding.watchdogIds],
183
+ }));
184
+ }
185
+
186
+ /** Une os findings re-reportados com os carry-over (preserva ids do estado anterior). */
187
+ function mergeCarryover(
188
+ merged: MergedFinding[],
189
+ carryover: readonly MergedFinding[],
190
+ ): MergedFinding[] {
191
+ const byKey = new Map(merged.map((finding) => [findingKey(finding), finding]));
192
+ for (const carried of carryover) {
193
+ const existing = byKey.get(findingKey(carried));
194
+ if (existing) {
195
+ for (const watchdog of carried.watchdogs) {
196
+ if (!existing.watchdogs.includes(watchdog))
197
+ existing.watchdogs.push(watchdog);
198
+ }
199
+ continue;
200
+ }
201
+ byKey.set(findingKey(carried), carried);
202
+ merged.push(carried);
203
+ }
204
+ merged.sort((a, b) => {
205
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
206
+ if (bySeverity !== 0) return bySeverity;
207
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
208
+ return (a.line ?? 0) - (b.line ?? 0);
209
+ });
210
+ return merged;
211
+ }
212
+
213
+ /**
214
+ * Decide entre review completo e incremental. Incremental só quando há um
215
+ * `reviewed.headSha` anterior, o `HEAD` atual o sucede (sem rebase) e não veio
216
+ * `--full`/`--clean`. Reconstrói só o delta `prevSha..headSha` e carrega os
217
+ * findings de arquivos inalterados do estado anterior.
218
+ */
219
+ function resolveReviewScope(
220
+ opts: ReviewOptions,
221
+ previous: FindingsStateFile | null,
222
+ headSha: string,
223
+ config: HoundsmanConfig,
224
+ ): { classification: RunClassification; diff: DiffSource; reviewedFrom?: string } {
225
+ const prevSha = previous?.reviewed?.headSha;
226
+ if (
227
+ opts.full ||
228
+ opts.clean ||
229
+ !prevSha ||
230
+ prevSha === headSha ||
231
+ !isAncestor(prevSha, headSha)
232
+ ) {
233
+ const diff = collectDiff(opts.base, opts.head);
234
+ return {
235
+ diff,
236
+ classification: {
237
+ ...classifyRange(opts.base, opts.head, config, diff),
238
+ headSha,
239
+ mode: "full",
240
+ },
241
+ };
242
+ }
243
+
244
+ const range = `${prevSha}..${headSha}`;
245
+ const diff = collectDiffSince(prevSha, headSha);
246
+ const entries = diffEntries(range);
247
+ const changedPaths = new Set(entries.map((entry) => entry.path));
248
+ const classification: RunClassification = {
249
+ ...classifyEntries(entries, config, churnToRecord(diff.churn)),
250
+ base: prevSha,
251
+ head: opts.head,
252
+ headSha,
253
+ mode: "incremental",
254
+ reviewedFrom: prevSha,
255
+ carryover: buildCarryover(previous, changedPaths),
256
+ };
257
+ return { classification, diff, reviewedFrom: prevSha };
258
+ }
259
+
99
260
  // ---------------------------------------------------------------------------
100
261
  // Findings
101
262
  // ---------------------------------------------------------------------------
@@ -144,27 +305,47 @@ function writeManifest(
144
305
  outDir: string,
145
306
  opts: ReviewOptions,
146
307
  branch: string,
147
- classification: ClassificationResult,
308
+ classification: RunClassification,
148
309
  activeIds: string[],
149
310
  pending: string[],
150
311
  jev: JevCandidatesFile,
151
312
  config: HoundsmanConfig,
152
313
  ): void {
314
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
315
+ let filesOverBudget = 0;
316
+ const watchdogs = activeIds.map((id) => {
317
+ const selection = budgetFor(classification, id, maxFilesPerBrief);
318
+ filesOverBudget += selection.overflow.length;
319
+ return {
320
+ id,
321
+ name: findWatchdog(config, id)?.name ?? id,
322
+ files: selection.selected,
323
+ overflow: selection.overflow.length,
324
+ brief: relFromRoot(join(outDir, "briefs", `${id}.md`)),
325
+ findings: relFromRoot(join(outDir, "findings", `${id}.json`)),
326
+ status: pending.includes(id) ? "pending" : "done",
327
+ };
328
+ });
329
+
153
330
  const manifest = {
154
331
  branch,
155
332
  slug: sanitizeBranch(branch),
156
333
  base: opts.base,
157
334
  head: opts.head,
335
+ headSha: classification.headSha,
158
336
  generatedAt: new Date().toISOString(),
159
337
  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
- })),
338
+ budget: { maxFilesPerBrief, filesOverBudget },
339
+ churn: churnTotals(classification),
340
+ incremental:
341
+ classification.mode === "incremental"
342
+ ? {
343
+ from: classification.reviewedFrom,
344
+ changedFiles: Object.keys(classification.files).length,
345
+ carried: classification.carryover?.length ?? 0,
346
+ }
347
+ : undefined,
348
+ watchdogs,
168
349
  context: contextManifest(opts, branch),
169
350
  jev: jev.available
170
351
  ? {
@@ -186,6 +367,37 @@ function writeManifest(
186
367
  );
187
368
  }
188
369
 
370
+ // ---------------------------------------------------------------------------
371
+ // Orçamento (triage de PRs grandes)
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /** Aplica o orçamento aos arquivos de um watchdog. */
375
+ function budgetFor(
376
+ classification: ClassificationResult,
377
+ id: string,
378
+ maxFiles: number,
379
+ ): BudgetSelection {
380
+ return selectByBudget(
381
+ classification.watchdogs[id] ?? [],
382
+ classification.churn,
383
+ maxFiles,
384
+ );
385
+ }
386
+
387
+ /** Totais de churn do diff (métricas do manifesto). */
388
+ function churnTotals(classification: ClassificationResult): {
389
+ additions: number;
390
+ deletions: number;
391
+ } {
392
+ let additions = 0;
393
+ let deletions = 0;
394
+ for (const entry of Object.values(classification.churn ?? {})) {
395
+ additions += entry.additions;
396
+ deletions += entry.deletions;
397
+ }
398
+ return { additions, deletions };
399
+ }
400
+
189
401
  // ---------------------------------------------------------------------------
190
402
  // Briefs
191
403
  // ---------------------------------------------------------------------------
@@ -200,14 +412,17 @@ function writeBriefs(
200
412
  config: HoundsmanConfig,
201
413
  ): void {
202
414
  const docs = discoverDocs(Object.keys(classification.files), config);
415
+ const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
203
416
  for (const id of activeIds) {
204
417
  const def = findWatchdog(config, id);
205
418
  if (!def) continue;
206
- const files = classification.watchdogs[id] ?? [];
419
+ const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
207
420
  const brief = renderBrief(def, {
208
421
  watchdogId: id,
209
- files,
422
+ files: selected,
210
423
  statuses: classification.files,
424
+ churn: classification.churn,
425
+ overflow,
211
426
  deleted: classification.deleted,
212
427
  branch,
213
428
  outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
@@ -233,6 +448,7 @@ async function resolveCandidates(
233
448
  classification: ClassificationResult,
234
449
  dirs: Paths,
235
450
  config: HoundsmanConfig,
451
+ diff: DiffSource,
236
452
  ): Promise<JevCandidatesFile> {
237
453
  const store = new JevCandidatesStore(
238
454
  join(dirs.outDir, "candidates.json"),
@@ -257,7 +473,10 @@ async function resolveCandidates(
257
473
  client,
258
474
  config.watchdogs as Record<string, WatchdogMeta>,
259
475
  );
260
- const result = await detector.detect(classification, opts.base, opts.head);
476
+ const result = await detector.detect(classification, opts.base, opts.head, {
477
+ diff,
478
+ concurrency: resolveJevConcurrency(config),
479
+ });
261
480
  return save({
262
481
  available: true,
263
482
  model: result.model,
@@ -301,6 +520,22 @@ function printDiscovery(
301
520
  }
302
521
  }
303
522
 
523
+ /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
524
+ function printBudget(
525
+ classification: ClassificationResult,
526
+ config: HoundsmanConfig,
527
+ ): void {
528
+ const maxFiles = resolveMaxFilesPerBrief(config);
529
+ let overflow = 0;
530
+ for (const id of Object.keys(classification.watchdogs)) {
531
+ overflow += budgetFor(classification, id, maxFiles).overflow.length;
532
+ }
533
+ if (overflow === 0) return;
534
+ console.log(
535
+ `Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
536
+ );
537
+ }
538
+
304
539
  /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
305
540
  export async function prepare(
306
541
  opts: ReviewOptions,
@@ -310,23 +545,40 @@ export async function prepare(
310
545
  ): Promise<void> {
311
546
  prepareDirs(dirs, opts.clean);
312
547
 
313
- const classification = classifyRange(opts.base, opts.head, config);
548
+ // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
549
+ writeRunStatus(dirs.outDir, { branch, status: "reviewing", resetStartedAt: true });
550
+
551
+ // Marca o commit revisado e decide entre review completo e incremental.
552
+ const headSha = resolveSha(opts.head);
553
+ const previous = FindingsStateStore.at(opts.dir, branch).read();
554
+ const { classification, diff } = resolveReviewScope(opts, previous, headSha, config);
314
555
  writeFileSync(
315
556
  join(dirs.outDir, "classification.json"),
316
557
  `${JSON.stringify(classification, null, 2)}\n`,
317
558
  );
318
559
 
319
- const jev = await resolveCandidates(opts, classification, dirs, config);
560
+ const jev = await resolveCandidates(opts, classification, dirs, config, diff);
320
561
 
321
562
  const activeIds = Object.keys(classification.watchdogs);
322
563
  if (Object.keys(classification.files).length === 0) {
323
- console.log(`Nenhuma alteração entre ${opts.base}...${opts.head}.`);
564
+ writeRunStatus(dirs.outDir, { branch, status: "review_done" });
565
+ console.log(
566
+ classification.mode === "incremental"
567
+ ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
568
+ : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
569
+ );
324
570
  return;
325
571
  }
326
572
  if (activeIds.length === 0) {
573
+ writeRunStatus(dirs.outDir, { branch, status: "review_done" });
327
574
  console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
328
575
  return;
329
576
  }
577
+ if (classification.mode === "incremental") {
578
+ console.log(
579
+ `Modo incremental: ${Object.keys(classification.files).length} arquivo(s) alterado(s) desde ${shortSha(classification.reviewedFrom ?? "")}; ${classification.carryover?.length ?? 0} finding(s) carregado(s) do review anterior.`,
580
+ );
581
+ }
330
582
 
331
583
  const context = readContextSummary(contextPaths(opts.dir, branch).summary);
332
584
 
@@ -342,6 +594,7 @@ export async function prepare(
342
594
  config,
343
595
  );
344
596
  printDiscovery(activeIds, classification, dirs.briefsDir);
597
+ printBudget(classification, config);
345
598
  printJevSummary(jev);
346
599
 
347
600
  if (context) {
@@ -363,13 +616,57 @@ export async function prepare(
363
616
  // ---------------------------------------------------------------------------
364
617
 
365
618
  /** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
366
- function readClassification(classificationPath: string): ClassificationResult {
619
+ function readClassification(classificationPath: string): RunClassification {
367
620
  if (!existsSync(classificationPath)) {
368
621
  throw new Error(
369
622
  `Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
370
623
  );
371
624
  }
372
- return JSON.parse(readFileSync(classificationPath, "utf8"));
625
+ const parsed = JSON.parse(
626
+ readFileSync(classificationPath, "utf8"),
627
+ ) as RunClassification;
628
+ return parsed;
629
+ }
630
+
631
+ /**
632
+ * Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
633
+ * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
634
+ */
635
+ function writeDomainReports(
636
+ outDir: string,
637
+ branch: string,
638
+ reports: WatchdogReport[],
639
+ classification: ClassificationResult,
640
+ context: ContextSummary | null,
641
+ nameOf: (id: string) => string,
642
+ ): DomainReport[] {
643
+ const domains: DomainReport[] = [];
644
+ for (const report of reports) {
645
+ const findings = dedupeFindings([report]);
646
+ if (findings.length === 0) continue;
647
+ const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
648
+ const expected = classification.watchdogs[report.watchdogId] ?? [];
649
+ writeFileSync(
650
+ join(outDir, file),
651
+ `${renderDomainReport(
652
+ branch,
653
+ report,
654
+ findings,
655
+ expected.length,
656
+ context,
657
+ nameOf,
658
+ )}\n`,
659
+ );
660
+ domains.push({
661
+ id: report.watchdogId,
662
+ name: nameOf(report.watchdogId),
663
+ file,
664
+ findings: findings.length,
665
+ reviewedFiles: report.reviewedFiles.length,
666
+ expectedFiles: expected.length,
667
+ });
668
+ }
669
+ return domains;
373
670
  }
374
671
 
375
672
  export type FinalizeResult = {
@@ -415,8 +712,49 @@ export function finalize(
415
712
  );
416
713
  }
417
714
 
418
- const merged = dedupeFindings(reports);
715
+ const now = new Date().toISOString();
716
+ // Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
717
+ const merged = mergeCarryover(
718
+ dedupeFindings(reports),
719
+ classification.carryover ?? [],
720
+ );
721
+ if (classification.mode === "incremental") {
722
+ console.log(
723
+ `Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
724
+ );
725
+ }
726
+
727
+ // Estado de triagem: preserva o que já existia (casando por fingerprint) e
728
+ // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
729
+ // recalculada por `houndsman findings sync` após edições. O commit revisado
730
+ // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
731
+ const store = FindingsStateStore.at(opts.dir, branch);
732
+ const state = buildStateFromFindings(merged, {
733
+ branch,
734
+ readFile: fileReader(process.cwd()),
735
+ previous: store.read(),
736
+ now,
737
+ headSha: classification.headSha,
738
+ reviewed: classification.headSha
739
+ ? { headSha: classification.headSha, at: now }
740
+ : undefined,
741
+ });
742
+ store.write(state);
743
+ console.log(
744
+ `Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
745
+ );
746
+ writeRunStatus(dirs.outDir, { branch, status: "review_done" });
747
+
419
748
  const context = readContextSummary(contextPaths(opts.dir, branch).summary);
749
+ const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
750
+ const domains = writeDomainReports(
751
+ dirs.outDir,
752
+ branch,
753
+ reports,
754
+ classification,
755
+ context,
756
+ nameOf,
757
+ );
420
758
  const markdown = renderReport(
421
759
  branch,
422
760
  reports,
@@ -424,10 +762,11 @@ export function finalize(
424
762
  { classified: classification.watchdogs },
425
763
  context,
426
764
  jev.available ? jev.candidates : undefined,
427
- (id) => findWatchdog(config, id)?.name ?? id,
765
+ nameOf,
766
+ domains,
428
767
  );
429
768
  const reportPath = join(dirs.outDir, "review.md");
430
- writeFileSync(reportPath, `${markdown}\n`);
769
+ writeFileSync(reportPath, `${incrementalNote(classification)}${markdown}\n`);
431
770
 
432
771
  const blockers = merged.filter((f) => f.severity === "blocking").length;
433
772
  const attention = merged.filter((f) => f.severity === "attention").length;
package/src/core/paths.ts CHANGED
@@ -28,3 +28,12 @@ export const ARTIFACT_DIR = `${PROJECT_DIR}/.runs`;
28
28
 
29
29
  /** Schema de output dos watchdogs, citado nos briefs. */
30
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";
34
+
35
+ /** Status da run (revisão/correção), dentro da pasta da run (`<slug>/run.json`). */
36
+ export const RUN_STATUS_FILE = "run.json";
37
+
38
+ /** Relatório da verificação de findings, dentro da pasta da run (`<slug>/verify.md`). */
39
+ export const VERIFY_FILE = "verify.md";
@@ -127,6 +127,20 @@ function checkRulePolarity(config: HoundsmanConfig, warnings: string[]): void {
127
127
  }
128
128
  }
129
129
 
130
+ /** Aviso para orçamento/concorrência inválidos — o motor cai no default. */
131
+ function checkBudgets(config: HoundsmanConfig, warnings: string[]): void {
132
+ const maxFiles = config.budgets?.maxFilesPerBrief;
133
+ if (maxFiles !== undefined && maxFiles <= 0) {
134
+ warnings.push(
135
+ `budgets.maxFilesPerBrief = ${maxFiles} — não-positivo; usando o default.`,
136
+ );
137
+ }
138
+ const jev = config.concurrency?.jev;
139
+ if (jev !== undefined && jev <= 0) {
140
+ warnings.push(`concurrency.jev = ${jev} — não-positivo; usando o default.`);
141
+ }
142
+ }
143
+
130
144
  export function checkRegistry(config: HoundsmanConfig): {
131
145
  problems: string[];
132
146
  warnings: string[];
@@ -139,6 +153,7 @@ export function checkRegistry(config: HoundsmanConfig): {
139
153
  checkClassification(config, problems);
140
154
  checkRendering(config, problems);
141
155
  checkRulePolarity(config, warnings);
156
+ checkBudgets(config, warnings);
142
157
  const jevQuestions = checkJevSpecs(config, problems);
143
158
 
144
159
  return { problems, warnings, jevQuestions };