@payconductor/houndsman 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +18 -6
  2. package/package.json +1 -1
  3. package/src/cli/arg-parser.ts +8 -0
  4. package/src/cli/commands/command.ts +23 -0
  5. package/src/cli/commands/context-command.ts +40 -0
  6. package/src/cli/commands/doctor-command.ts +60 -0
  7. package/src/cli/commands/findings-command.ts +38 -0
  8. package/src/cli/commands/help-command.ts +13 -0
  9. package/src/cli/commands/registry.ts +34 -0
  10. package/src/cli/commands/review-command.ts +21 -0
  11. package/src/cli/commands/server-command.ts +37 -0
  12. package/src/cli/commands/setup-command.ts +34 -0
  13. package/src/cli/commands/watchdog-command.ts +34 -0
  14. package/src/cli/help.ts +47 -0
  15. package/src/cli/index.ts +60 -268
  16. package/src/core/arg-parser.ts +48 -0
  17. package/src/core/commands/context.ts +29 -13
  18. package/src/core/commands/findings.ts +185 -260
  19. package/src/core/commands/review.ts +21 -39
  20. package/src/core/commands/scaffold.ts +189 -80
  21. package/src/core/commands/setup.ts +16 -11
  22. package/src/core/commands/update.ts +7 -7
  23. package/src/core/commands/watchdog.ts +8 -80
  24. package/src/core/config.ts +24 -25
  25. package/src/core/defaults.ts +20 -0
  26. package/src/core/diff-source.ts +1 -13
  27. package/src/core/doc-discoverer.ts +25 -15
  28. package/src/core/domain/budget.ts +42 -0
  29. package/src/core/domain/classification.ts +94 -0
  30. package/src/core/domain/diff.ts +53 -0
  31. package/src/core/domain/watchdog.ts +96 -0
  32. package/src/core/engine/carryover.ts +67 -0
  33. package/src/core/engine/houndsman-engine.ts +65 -0
  34. package/src/core/engine/review-scope.ts +95 -0
  35. package/src/core/engine/review-session.ts +79 -0
  36. package/src/core/file-classifier.ts +11 -67
  37. package/src/core/findings-state.ts +193 -100
  38. package/src/core/git-revision.ts +14 -40
  39. package/src/core/git-runner.ts +46 -0
  40. package/src/core/github-review.ts +4 -33
  41. package/src/core/jev/jev-candidates.ts +49 -35
  42. package/src/core/orchestrator.ts +247 -421
  43. package/src/core/paths.ts +26 -11
  44. package/src/core/pr-context.ts +39 -51
  45. package/src/core/project-config.ts +56 -0
  46. package/src/core/renderers/batch-presenter.ts +40 -0
  47. package/src/core/renderers/brief-renderer.ts +23 -0
  48. package/src/core/renderers/findings-presenter.ts +74 -0
  49. package/src/core/renderers/manifest-writer.ts +123 -0
  50. package/src/core/renderers/report-renderer.ts +66 -0
  51. package/src/core/renderers/verify-presenter.ts +50 -0
  52. package/src/core/renderers/watchdog-presenter.ts +100 -0
  53. package/src/core/report-builder.ts +12 -7
  54. package/src/core/run-layout.ts +8 -0
  55. package/src/core/run-status.ts +29 -59
  56. package/src/core/services/clock.ts +12 -0
  57. package/src/core/services/file-classifier.ts +34 -0
  58. package/src/core/services/fix-batch-builder.ts +13 -0
  59. package/src/core/services/gh-client.ts +54 -0
  60. package/src/core/services/git-repository.ts +139 -0
  61. package/src/core/services/project-files.ts +91 -0
  62. package/src/core/services/verifier.ts +58 -0
  63. package/src/core/storage/factory.ts +24 -0
  64. package/src/core/storage/file/file-storage.ts +269 -0
  65. package/src/core/storage/paths.ts +64 -0
  66. package/src/core/storage/repo-identity.ts +70 -0
  67. package/src/core/storage/sqlite/mappers.ts +160 -0
  68. package/src/core/storage/sqlite/schema.ts +108 -0
  69. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  70. package/src/core/storage/types.ts +143 -0
  71. package/src/core/storage/work-area.ts +191 -0
  72. package/src/core/verify.ts +2 -1
  73. package/src/core/watchdog-registry.ts +58 -16
  74. package/src/index.ts +57 -6
  75. package/src/server/artifacts.ts +8 -61
  76. package/src/server/client/ArtifactsPanel.tsx +137 -23
  77. package/src/server/client/FindingCard.tsx +37 -6
  78. package/src/server/client/MarkdownText.tsx +101 -0
  79. package/src/server/client/SubmitModal.tsx +9 -4
  80. package/src/server/client/code-highlight.ts +422 -0
  81. package/src/server/client/constants.ts +130 -24
  82. package/src/server/client/json-highlight.ts +55 -0
  83. package/src/server/client/markdown.ts +153 -0
  84. package/src/server/client/types.ts +23 -0
  85. package/src/server/index.ts +155 -245
  86. package/src/server/run-repository.ts +299 -0
  87. package/src/skill/OUTPUT-FORMAT.md +6 -4
  88. package/src/skill/README.md +22 -14
  89. package/src/skill/SETUP.md +4 -4
  90. package/src/skill/fix.md +5 -5
  91. package/src/skill/hunt.md +27 -18
  92. package/src/skill/train.md +1 -1
  93. package/src/skill/verify.md +5 -5
  94. package/src/tests/__snapshots__/baseline.test.ts.snap +825 -0
  95. package/src/tests/baseline.test.ts +555 -493
  96. package/src/tests/cli.test.ts +40 -19
  97. package/src/tests/code-highlight.test.ts +123 -0
  98. package/src/tests/domain.test.ts +328 -0
  99. package/src/tests/findings-commands.test.ts +153 -131
  100. package/src/tests/findings-state.test.ts +6 -3
  101. package/src/tests/fixtures/fake-project.ts +0 -1
  102. package/src/tests/infrastructure.test.ts +205 -0
  103. package/src/tests/markdown.test.ts +89 -0
  104. package/src/tests/review-scripts.test.ts +8 -5
  105. package/src/tests/run-status.test.ts +33 -23
  106. package/src/tests/server.test.ts +108 -75
  107. package/src/tests/storage.test.ts +386 -0
@@ -5,68 +5,65 @@
5
5
  * Genérico: recebe o `HoundsmanConfig` por parâmetro (injeção explícita).
6
6
  * Entrypoints: `cli/index.ts` (`houndsman`). Aqui não há `process.argv`.
7
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)
8
+ * Persistência:
9
+ * storage reviews/findings/artefatos (SQLite por default)
10
+ * <home>/work/<review> staging dos subagentes
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
+ * context*.json/md contexto da PR (Passo 0)
14
+ *
15
+ * Artefatos persistidos: classification, candidates, manifest, review.md,
16
+ * review.<watchdog>.md, context e context-summary.
15
17
  */
16
18
 
17
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
- import { join, relative } from "node:path";
19
- import { renderBrief } from "./brief";
20
19
  import {
21
20
  type HoundsmanConfig,
22
21
  resolveJevConcurrency,
23
22
  resolveMaxFilesPerBrief,
24
23
  } from "./config";
25
- import { churnToRecord, collectDiff, type DiffSource } from "./diff-source";
24
+ import type { DiffSource } from "./diff-source";
26
25
  import { discoverDocs } from "./doc-discoverer";
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";
26
+ import { Classification } from "./domain/classification";
27
+ import { Watchdog } from "./domain/watchdog";
28
+ import { mergeCarryover } from "./engine/carryover";
29
+ import { ReviewScopeResolver, type RunClassification } from "./engine/review-scope";
30
+ import type { BudgetSelection, ClassificationResult } from "./file-classifier";
31
+ import { buildStateFromFindings, fileReader } from "./findings-state";
32
+ import { resolveSha } from "./git-revision";
41
33
  import {
42
34
  type JevCandidate,
43
35
  type JevCandidatesFile,
44
36
  JevCandidatesStore,
45
37
  JevClient,
46
38
  JevDetector,
39
+ parseCandidates,
40
+ serializeCandidates,
47
41
  } from "./jev";
48
- import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH } from "./paths";
49
- import { contextPaths, currentBranch, readContextSummary } from "./pr-context";
42
+ import { displayPath, OUTPUT_FORMAT_PATH, REVIEW_FILE } from "./paths";
43
+ import { currentBranch } from "./pr-context";
44
+ import { ProjectConfig } from "./project-config";
45
+ import { BriefRenderer } from "./renderers/brief-renderer";
46
+ import { ManifestWriter } from "./renderers/manifest-writer";
47
+ import { ReportRenderer } from "./renderers/report-renderer";
50
48
  import {
51
49
  type ContextSummary,
52
50
  type DomainReport,
53
51
  dedupeFindings,
54
- type MergedFinding,
55
- parseWatchdogReport,
56
- renderDomainReport,
57
- renderReport,
52
+ parseContextSummary,
58
53
  sanitizeBranch,
59
54
  type WatchdogReport,
60
55
  } from "./report-builder";
61
56
  import { writeRunStatus } from "./run-status";
62
- import { severityRank, type WatchdogMeta } from "./types";
57
+ import { systemClock } from "./services/clock";
58
+ import type { ReviewRecord, StorageProvider } from "./storage/types";
59
+ import { ReviewWorkArea } from "./storage/work-area";
60
+ import type { WatchdogMeta } from "./types";
63
61
  import { findWatchdog } from "./watchdog-registry";
64
62
 
65
63
  export type ReviewOptions = {
66
64
  base: string;
67
65
  head: string;
68
66
  branch?: string;
69
- dir: string;
70
67
  finalize: boolean;
71
68
  failOnBlocking: boolean;
72
69
  clean: boolean;
@@ -76,11 +73,10 @@ export type ReviewOptions = {
76
73
  };
77
74
 
78
75
  /** Valores default das opções (o CLI sobrepõe com as flags). */
79
- export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOptions {
76
+ export function defaultOptions(): ReviewOptions {
80
77
  return {
81
78
  base: "main",
82
79
  head: "HEAD",
83
- dir: artifactDir,
84
80
  finalize: false,
85
81
  failOnBlocking: false,
86
82
  clean: false,
@@ -89,64 +85,30 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
89
85
  };
90
86
  }
91
87
 
92
- /** Caminho relativo à raiz, sempre com `/` (portável/legível). */
93
- function relFromRoot(absPath: string): string {
94
- return relative(process.cwd(), absPath).replace(/\\/g, "/");
95
- }
96
-
97
- type Paths = {
98
- slug: string;
99
- outDir: string;
100
- briefsDir: string;
101
- findingsDir: string;
88
+ /** Contexto de uma run: storage + review + staging. */
89
+ export type ReviewContext = {
90
+ storage: StorageProvider;
91
+ review: ReviewRecord;
92
+ work: ReviewWorkArea;
102
93
  };
103
94
 
104
- function paths(opts: ReviewOptions, branch: string): Paths {
105
- const slug = sanitizeBranch(branch);
106
- const outDir = join(opts.dir, slug);
107
- return {
108
- slug,
109
- outDir,
110
- briefsDir: join(outDir, "briefs"),
111
- findingsDir: join(outDir, "findings"),
112
- };
113
- }
114
-
115
- function prepareDirs(dirs: Paths, clean: boolean): void {
116
- mkdirSync(dirs.briefsDir, { recursive: true });
117
- mkdirSync(dirs.findingsDir, { recursive: true });
118
- if (clean) {
119
- rmSync(dirs.findingsDir, { recursive: true, force: true });
120
- mkdirSync(dirs.findingsDir, { recursive: true });
121
- }
95
+ /** Cria o contexto de uma review existente. */
96
+ export function reviewContext(
97
+ storage: StorageProvider,
98
+ review: ReviewRecord,
99
+ ): ReviewContext {
100
+ return { storage, review, work: new ReviewWorkArea(review) };
122
101
  }
123
102
 
124
103
  // ---------------------------------------------------------------------------
125
104
  // Escopo da revisão (completo vs incremental)
126
105
  // ---------------------------------------------------------------------------
127
106
 
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
107
  /** SHA curto para logs (7 chars, como o git). */
141
108
  function shortSha(sha: string): string {
142
109
  return sha.slice(0, 7);
143
110
  }
144
111
 
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
112
  /** Aviso no topo do `review.md` quando a revisão foi incremental (senão vazio). */
151
113
  function incrementalNote(classification: RunClassification): string {
152
114
  if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
@@ -158,215 +120,6 @@ function incrementalNote(classification: RunClassification): string {
158
120
  );
159
121
  }
160
122
 
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
-
260
- // ---------------------------------------------------------------------------
261
- // Findings
262
- // ---------------------------------------------------------------------------
263
-
264
- function readFindings(
265
- findingsDir: string,
266
- activeIds: string[],
267
- ): { reports: WatchdogReport[]; pending: string[] } {
268
- const reports: WatchdogReport[] = [];
269
- const pending: string[] = [];
270
- for (const id of activeIds) {
271
- const path = join(findingsDir, `${id}.json`);
272
- if (!existsSync(path)) {
273
- pending.push(id);
274
- continue;
275
- }
276
- try {
277
- const raw = JSON.parse(readFileSync(path, "utf8"));
278
- const report = parseWatchdogReport(raw, path);
279
- if (report) reports.push(report);
280
- else pending.push(id);
281
- } catch (err) {
282
- console.error(
283
- `[houndsman] ${relFromRoot(path)} inválido: ${err instanceof Error ? err.message : err}`,
284
- );
285
- pending.push(id);
286
- }
287
- }
288
- return { reports, pending };
289
- }
290
-
291
- // ---------------------------------------------------------------------------
292
- // Manifesto
293
- // ---------------------------------------------------------------------------
294
-
295
- function contextManifest(
296
- opts: ReviewOptions,
297
- branch: string,
298
- ): { summarized: boolean; source: ContextSummary["source"] | null } {
299
- const summary = readContextSummary(contextPaths(opts.dir, branch).summary);
300
- if (!summary) return { summarized: false, source: null };
301
- return { summarized: true, source: summary.source ?? null };
302
- }
303
-
304
- function writeManifest(
305
- outDir: string,
306
- opts: ReviewOptions,
307
- branch: string,
308
- classification: RunClassification,
309
- activeIds: string[],
310
- pending: string[],
311
- jev: JevCandidatesFile,
312
- config: HoundsmanConfig,
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
-
330
- const manifest = {
331
- branch,
332
- slug: sanitizeBranch(branch),
333
- base: opts.base,
334
- head: opts.head,
335
- headSha: classification.headSha,
336
- generatedAt: new Date().toISOString(),
337
- outputFormat: OUTPUT_FORMAT_PATH,
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,
349
- context: contextManifest(opts, branch),
350
- jev: jev.available
351
- ? {
352
- available: true,
353
- model: jev.model,
354
- threshold: jev.threshold,
355
- analyzedFiles: jev.analyzedFiles,
356
- candidates: jev.candidates.length,
357
- skipped: jev.skipped?.length ?? 0,
358
- }
359
- : { available: false, reason: jev.reason },
360
- deleted: classification.deleted,
361
- unclassified: classification.unclassified,
362
- excludedCount: classification.excludedCount,
363
- };
364
- writeFileSync(
365
- join(outDir, "manifest.json"),
366
- `${JSON.stringify(manifest, null, 2)}\n`,
367
- );
368
- }
369
-
370
123
  // ---------------------------------------------------------------------------
371
124
  // Orçamento (triage de PRs grandes)
372
125
  // ---------------------------------------------------------------------------
@@ -377,25 +130,26 @@ function budgetFor(
377
130
  id: string,
378
131
  maxFiles: number,
379
132
  ): BudgetSelection {
380
- return selectByBudget(
381
- classification.watchdogs[id] ?? [],
382
- classification.churn,
383
- maxFiles,
384
- );
133
+ return Classification.from(classification).budgetFor(id, maxFiles);
385
134
  }
386
135
 
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 };
136
+ /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
137
+ function writeManifest(
138
+ ctx: ReviewContext,
139
+ config: HoundsmanConfig,
140
+ opts: ReviewOptions,
141
+ classification: RunClassification,
142
+ activeIds: string[],
143
+ pending: string[],
144
+ jev: JevCandidatesFile,
145
+ ): void {
146
+ new ManifestWriter(
147
+ ctx.storage,
148
+ ctx.review,
149
+ ctx.work,
150
+ new ProjectConfig(config),
151
+ opts,
152
+ ).write(classification, activeIds, pending, jev);
399
153
  }
400
154
 
401
155
  // ---------------------------------------------------------------------------
@@ -405,19 +159,21 @@ function churnTotals(classification: ClassificationResult): {
405
159
  function writeBriefs(
406
160
  activeIds: string[],
407
161
  classification: ClassificationResult,
408
- dirs: Paths,
162
+ ctx: ReviewContext,
409
163
  branch: string,
410
164
  context: ContextSummary | null,
411
165
  candidates: JevCandidate[],
412
166
  config: HoundsmanConfig,
413
167
  ): void {
168
+ const work = ctx.work;
414
169
  const docs = discoverDocs(Object.keys(classification.files), config);
415
170
  const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
171
+ const renderer = new BriefRenderer();
416
172
  for (const id of activeIds) {
417
173
  const def = findWatchdog(config, id);
418
174
  if (!def) continue;
419
175
  const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
420
- const brief = renderBrief(def, {
176
+ const brief = renderer.render(new Watchdog(def), {
421
177
  watchdogId: id,
422
178
  files: selected,
423
179
  statuses: classification.files,
@@ -425,13 +181,20 @@ function writeBriefs(
425
181
  overflow,
426
182
  deleted: classification.deleted,
427
183
  branch,
428
- outputPath: relFromRoot(join(dirs.findingsDir, `${id}.json`)),
184
+ outputPath: work.findingFile(id),
429
185
  outputFormatPath: OUTPUT_FORMAT_PATH,
430
186
  docs,
431
187
  context,
432
188
  candidates: candidates.filter((c) => c.watchdogId === id),
433
189
  });
434
- writeFileSync(join(dirs.briefsDir, `${id}.md`), `${brief}\n`);
190
+ const content = `${brief}\n`;
191
+ work.writeBrief(id, content);
192
+ // Auditoria: o brief também fica no storage (o staging é limpo no finalize).
193
+ ctx.storage.writeArtifact(ctx.review.id, {
194
+ name: `briefs/${id}.md`,
195
+ kind: "brief",
196
+ content,
197
+ });
435
198
  }
436
199
  }
437
200
 
@@ -446,16 +209,16 @@ function writeBriefs(
446
209
  async function resolveCandidates(
447
210
  opts: ReviewOptions,
448
211
  classification: ClassificationResult,
449
- dirs: Paths,
212
+ ctx: ReviewContext,
450
213
  config: HoundsmanConfig,
451
214
  diff: DiffSource,
452
215
  ): Promise<JevCandidatesFile> {
453
- const store = new JevCandidatesStore(
454
- join(dirs.outDir, "candidates.json"),
455
- config.watchdogIds,
456
- );
457
216
  const save = (data: JevCandidatesFile): JevCandidatesFile => {
458
- store.write(data);
217
+ ctx.storage.writeArtifact(ctx.review.id, {
218
+ name: "candidates.json",
219
+ kind: "candidates",
220
+ content: serializeCandidates(data),
221
+ });
459
222
  return data;
460
223
  };
461
224
 
@@ -494,138 +257,198 @@ async function resolveCandidates(
494
257
  }
495
258
  }
496
259
 
497
- function printJevSummary(jev: JevCandidatesFile): void {
498
- if (!jev.available) {
499
- console.log(`Jev: pulado (${jev.reason}).`);
500
- return;
501
- }
260
+ function jevSummary(jev: JevCandidatesFile): string[] {
261
+ if (!jev.available) return [`Jev: pulado (${jev.reason}).`];
502
262
  const skipped = jev.skipped?.length ?? 0;
503
- console.log(
263
+ return [
504
264
  `Jev: ${jev.candidates.length} candidato(s) em ${jev.analyzedFiles ?? 0} arquivo(s)` +
505
265
  (skipped > 0 ? `, ${skipped} com falha.` : "."),
506
- );
266
+ ];
507
267
  }
508
268
 
509
- function printDiscovery(
269
+ function discoveryMessages(
510
270
  activeIds: string[],
511
271
  classification: ClassificationResult,
512
272
  briefsDir: string,
513
- ): void {
514
- console.log(
273
+ ): string[] {
274
+ const messages = [
515
275
  `Discovery: ${activeIds.length} watchdog(s) ativado(s), ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
516
- );
517
- console.log(`Briefs: ${relFromRoot(briefsDir)}/`);
276
+ `Briefs: ${displayPath(briefsDir)}/`,
277
+ ];
518
278
  for (const file of classification.deleted) {
519
- console.log(`Deletado (breaking change): ${file}`);
279
+ messages.push(`Deletado (breaking change): ${file}`);
520
280
  }
281
+ return messages;
521
282
  }
522
283
 
523
284
  /** Resumo do orçamento — só quando algum arquivo ficou de fora (PR grande). */
524
- function printBudget(
285
+ function budgetMessage(
525
286
  classification: ClassificationResult,
526
287
  config: HoundsmanConfig,
527
- ): void {
288
+ ): string[] {
528
289
  const maxFiles = resolveMaxFilesPerBrief(config);
529
290
  let overflow = 0;
530
291
  for (const id of Object.keys(classification.watchdogs)) {
531
292
  overflow += budgetFor(classification, id, maxFiles).overflow.length;
532
293
  }
533
- if (overflow === 0) return;
534
- console.log(
294
+ if (overflow === 0) return [];
295
+ return [
535
296
  `Orçamento: ${maxFiles} arquivo(s)/brief — ${overflow} fora do orçamento (menor churn; registrar em skipped).`,
536
- );
297
+ ];
537
298
  }
538
299
 
300
+ /** Resultado do prepare — mensagens para o CLI imprimir. */
301
+ export type PrepareResult = { messages: string[] };
302
+
539
303
  /** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
540
304
  export async function prepare(
541
305
  opts: ReviewOptions,
542
306
  branch: string,
543
307
  config: HoundsmanConfig,
544
- dirs: Paths = paths(opts, branch),
545
- ): Promise<void> {
546
- prepareDirs(dirs, opts.clean);
308
+ ctx: ReviewContext,
309
+ ): Promise<PrepareResult> {
310
+ const messages: string[] = [];
311
+ ctx.work.ensure();
312
+ if (opts.clean) ctx.work.cleanFindings();
313
+ ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
547
314
 
548
315
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
549
- writeRunStatus(dirs.outDir, { branch, status: "reviewing", resetStartedAt: true });
316
+ writeRunStatus(ctx.storage, ctx.review.id, {
317
+ status: "reviewing",
318
+ resetStartedAt: true,
319
+ });
550
320
 
551
321
  // Marca o commit revisado e decide entre review completo e incremental.
552
322
  const headSha = resolveSha(opts.head);
553
- const previous = FindingsStateStore.at(opts.dir, branch).read();
554
- const { classification, diff } = resolveReviewScope(opts, previous, headSha, config);
555
- writeFileSync(
556
- join(dirs.outDir, "classification.json"),
557
- `${JSON.stringify(classification, null, 2)}\n`,
323
+ const previous = ctx.storage.readFindings(ctx.review.id);
324
+ const { classification, diff } = new ReviewScopeResolver(opts, config).resolve(
325
+ previous,
326
+ headSha,
558
327
  );
328
+ ctx.storage.writeArtifact(ctx.review.id, {
329
+ name: "classification.json",
330
+ kind: "classification",
331
+ content: `${JSON.stringify(classification, null, 2)}\n`,
332
+ });
559
333
 
560
- const jev = await resolveCandidates(opts, classification, dirs, config, diff);
334
+ const jev = await resolveCandidates(opts, classification, ctx, config, diff);
561
335
 
562
336
  const activeIds = Object.keys(classification.watchdogs);
563
337
  if (Object.keys(classification.files).length === 0) {
564
- writeRunStatus(dirs.outDir, { branch, status: "review_done" });
565
- console.log(
338
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
339
+ messages.push(
566
340
  classification.mode === "incremental"
567
341
  ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
568
342
  : `Nenhuma alteração entre ${opts.base}...${opts.head}.`,
569
343
  );
570
- return;
344
+ return { messages };
571
345
  }
572
346
  if (activeIds.length === 0) {
573
- writeRunStatus(dirs.outDir, { branch, status: "review_done" });
574
- console.log("Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).");
575
- return;
347
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
348
+ messages.push(
349
+ "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
350
+ );
351
+ return { messages };
576
352
  }
577
353
  if (classification.mode === "incremental") {
578
- console.log(
354
+ messages.push(
579
355
  `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
356
  );
581
357
  }
582
358
 
583
- const context = readContextSummary(contextPaths(opts.dir, branch).summary);
359
+ const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
360
+ persistContext(ctx);
584
361
 
585
- writeBriefs(activeIds, classification, dirs, branch, context, jev.candidates, config);
586
- writeManifest(
587
- dirs.outDir,
588
- opts,
589
- branch,
590
- classification,
591
- activeIds,
592
- activeIds,
593
- jev,
594
- config,
362
+ writeBriefs(activeIds, classification, ctx, branch, context, jev.candidates, config);
363
+ writeManifest(ctx, config, opts, classification, activeIds, activeIds, jev);
364
+
365
+ messages.push(
366
+ ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
367
+ ...budgetMessage(classification, config),
368
+ ...jevSummary(jev),
595
369
  );
596
- printDiscovery(activeIds, classification, dirs.briefsDir);
597
- printBudget(classification, config);
598
- printJevSummary(jev);
599
370
 
600
371
  if (context) {
601
- console.log("Contexto da PR resumido e injetado nos briefs.");
372
+ messages.push("Contexto da PR resumido e injetado nos briefs.");
602
373
  } else {
603
- console.log(
374
+ messages.push(
604
375
  "Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
605
376
  );
606
377
  }
607
378
 
608
- console.log(
609
- `\nPróximo passo: despachar os sub-agentes (um por brief em ${relFromRoot(dirs.briefsDir)}/).`,
379
+ messages.push(
380
+ `\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
381
+ "Quando os findings estiverem gravados: bun run houndsman --finalize",
610
382
  );
611
- console.log("Quando os findings estiverem gravados: bun run houndsman --finalize");
383
+
384
+ return { messages };
385
+ }
386
+ /** Persiste o contexto da PR (bruto + resumo) como artefatos, quando existirem. */
387
+ function persistContext(ctx: ReviewContext): void {
388
+ const raw = ctx.work.readContextRaw();
389
+ if (raw !== null) {
390
+ ctx.storage.writeArtifact(ctx.review.id, {
391
+ name: "context.json",
392
+ kind: "context",
393
+ content: raw,
394
+ });
395
+ }
396
+ const summary = ctx.work.readContextSummaryRaw();
397
+ if (summary !== null) {
398
+ ctx.storage.writeArtifact(ctx.review.id, {
399
+ name: "context-summary.json",
400
+ kind: "context-summary",
401
+ content: summary,
402
+ });
403
+ }
404
+ const brief = ctx.work.readContextBriefRaw();
405
+ if (brief !== null) {
406
+ ctx.storage.writeArtifact(ctx.review.id, {
407
+ name: "context-brief.md",
408
+ kind: "context-brief",
409
+ content: brief,
410
+ });
411
+ }
612
412
  }
613
413
 
614
414
  // ---------------------------------------------------------------------------
615
415
  // Passo 2 — finalizar
616
416
  // ---------------------------------------------------------------------------
617
417
 
618
- /** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
619
- function readClassification(classificationPath: string): RunClassification {
620
- if (!existsSync(classificationPath)) {
418
+ /** Lê o `classification.json` persistido pelo prepare (lança se ausente). */
419
+ function readClassification(ctx: ReviewContext): RunClassification {
420
+ const artifact = ctx.storage.readArtifact(ctx.review.id, "classification.json");
421
+ if (!artifact) {
621
422
  throw new Error(
622
- `Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
423
+ `Não encontrei a classificação da review. Rode \`bun run houndsman\` (preparar) primeiro.`,
623
424
  );
624
425
  }
625
- const parsed = JSON.parse(
626
- readFileSync(classificationPath, "utf8"),
627
- ) as RunClassification;
628
- return parsed;
426
+ return JSON.parse(artifact.content) as RunClassification;
427
+ }
428
+
429
+ /** Lê os candidatos Jev persistidos (unavailable se ausente/inválido). */
430
+ function readCandidates(ctx: ReviewContext, config: HoundsmanConfig): JevCandidatesFile {
431
+ const artifact = ctx.storage.readArtifact(ctx.review.id, "candidates.json");
432
+ if (!artifact) return JevCandidatesStore.unavailable("candidates.json ausente");
433
+ try {
434
+ return (
435
+ parseCandidates(JSON.parse(artifact.content), config.watchdogIds) ??
436
+ JevCandidatesStore.unavailable("candidates.json inválido")
437
+ );
438
+ } catch {
439
+ return JevCandidatesStore.unavailable("candidates.json inválido");
440
+ }
441
+ }
442
+
443
+ /** Resumo do contexto persistido (usado quando o staging já foi limpo). */
444
+ function readPersistedContextSummary(ctx: ReviewContext): ContextSummary | null {
445
+ const artifact = ctx.storage.readArtifact(ctx.review.id, "context-summary.json");
446
+ if (!artifact) return null;
447
+ try {
448
+ return parseContextSummary(JSON.parse(artifact.content));
449
+ } catch {
450
+ return null;
451
+ }
629
452
  }
630
453
 
631
454
  /**
@@ -633,7 +456,7 @@ function readClassification(classificationPath: string): RunClassification {
633
456
  * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
634
457
  */
635
458
  function writeDomainReports(
636
- outDir: string,
459
+ ctx: ReviewContext,
637
460
  branch: string,
638
461
  reports: WatchdogReport[],
639
462
  classification: ClassificationResult,
@@ -641,22 +464,23 @@ function writeDomainReports(
641
464
  nameOf: (id: string) => string,
642
465
  ): DomainReport[] {
643
466
  const domains: DomainReport[] = [];
467
+ const renderer = new ReportRenderer(nameOf);
644
468
  for (const report of reports) {
645
469
  const findings = dedupeFindings([report]);
646
470
  if (findings.length === 0) continue;
647
471
  const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
648
472
  const expected = classification.watchdogs[report.watchdogId] ?? [];
649
- writeFileSync(
650
- join(outDir, file),
651
- `${renderDomainReport(
473
+ ctx.storage.writeArtifact(ctx.review.id, {
474
+ name: file,
475
+ kind: "domain-review",
476
+ content: `${renderer.renderDomain(
652
477
  branch,
653
478
  report,
654
479
  findings,
655
480
  expected.length,
656
481
  context,
657
- nameOf,
658
482
  )}\n`,
659
- );
483
+ });
660
484
  domains.push({
661
485
  id: report.watchdogId,
662
486
  name: nameOf(report.watchdogId),
@@ -673,6 +497,7 @@ export type FinalizeResult = {
673
497
  reportPath: string;
674
498
  blockers: number;
675
499
  attention: number;
500
+ messages: string[];
676
501
  };
677
502
 
678
503
  /**
@@ -683,43 +508,32 @@ export function finalize(
683
508
  opts: ReviewOptions,
684
509
  branch: string,
685
510
  config: HoundsmanConfig,
686
- dirs: Paths = paths(opts, branch),
511
+ ctx: ReviewContext,
687
512
  ): FinalizeResult {
688
- const classification = readClassification(join(dirs.outDir, "classification.json"));
513
+ const classification = readClassification(ctx);
689
514
  const activeIds = Object.keys(classification.watchdogs);
690
515
 
691
- const { reports, pending } = readFindings(dirs.findingsDir, activeIds);
692
- const jev =
693
- new JevCandidatesStore(
694
- join(dirs.outDir, "candidates.json"),
695
- config.watchdogIds,
696
- ).read() ?? JevCandidatesStore.unavailable("candidates.json ausente");
697
- writeManifest(
698
- dirs.outDir,
699
- opts,
700
- branch,
701
- classification,
702
- activeIds,
703
- pending,
704
- jev,
705
- config,
706
- );
516
+ const messages: string[] = [];
517
+ const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
518
+ messages.push(...warnings);
519
+ const jev = readCandidates(ctx, config);
520
+ writeManifest(ctx, config, opts, classification, activeIds, pending, jev);
707
521
 
708
522
  if (pending.length > 0) {
709
523
  throw new Error(
710
524
  `Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
711
- `Cada watchdog deve gravar ${relFromRoot(dirs.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
525
+ `Cada watchdog deve gravar ${displayPath(ctx.work.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
712
526
  );
713
527
  }
714
528
 
715
- const now = new Date().toISOString();
529
+ const now = systemClock();
716
530
  // Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
717
531
  const merged = mergeCarryover(
718
532
  dedupeFindings(reports),
719
533
  classification.carryover ?? [],
720
534
  );
721
535
  if (classification.mode === "incremental") {
722
- console.log(
536
+ messages.push(
723
537
  `Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
724
538
  );
725
539
  }
@@ -728,53 +542,65 @@ export function finalize(
728
542
  // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
729
543
  // recalculada por `houndsman findings sync` após edições. O commit revisado
730
544
  // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
731
- const store = FindingsStateStore.at(opts.dir, branch);
732
545
  const state = buildStateFromFindings(merged, {
733
546
  branch,
734
547
  readFile: fileReader(process.cwd()),
735
- previous: store.read(),
548
+ previous: ctx.storage.readFindings(ctx.review.id),
736
549
  now,
737
550
  headSha: classification.headSha,
738
551
  reviewed: classification.headSha
739
552
  ? { headSha: classification.headSha, at: now }
740
553
  : undefined,
741
554
  });
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" });
555
+ ctx.storage.writeFindings(ctx.review.id, state);
556
+ ctx.storage.updateReview(ctx.review.id, {
557
+ base: opts.base,
558
+ head: opts.head,
559
+ headSha: classification.headSha,
560
+ });
561
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
747
562
 
748
- const context = readContextSummary(contextPaths(opts.dir, branch).summary);
563
+ persistContext(ctx);
564
+ const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
749
565
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
750
566
  const domains = writeDomainReports(
751
- dirs.outDir,
567
+ ctx,
752
568
  branch,
753
569
  reports,
754
570
  classification,
755
571
  context,
756
572
  nameOf,
757
573
  );
758
- const markdown = renderReport(
574
+ const markdown = new ReportRenderer(nameOf).render(
759
575
  branch,
760
576
  reports,
761
577
  merged,
762
578
  { classified: classification.watchdogs },
763
579
  context,
764
580
  jev.available ? jev.candidates : undefined,
765
- nameOf,
766
581
  domains,
767
582
  );
768
- const reportPath = join(dirs.outDir, "review.md");
769
- writeFileSync(reportPath, `${incrementalNote(classification)}${markdown}\n`);
583
+ ctx.storage.writeArtifact(ctx.review.id, {
584
+ name: REVIEW_FILE,
585
+ kind: "review",
586
+ content: `${incrementalNote(classification)}${markdown}\n`,
587
+ });
588
+
589
+ const location = ctx.storage.location(ctx.review.id);
590
+ messages.push(
591
+ `Triagem: ${location} — ${Object.keys(state.findings).length} finding(s).`,
592
+ );
770
593
 
771
594
  const blockers = merged.filter((f) => f.severity === "blocking").length;
772
595
  const attention = merged.filter((f) => f.severity === "attention").length;
773
- console.log(
774
- `Relatório: ${relFromRoot(reportPath)} — ${blockers} bloqueante(s), ${attention} atenção.`,
596
+ messages.push(
597
+ `Relatório: ${location} — ${blockers} bloqueante(s), ${attention} atenção.`,
775
598
  );
776
599
 
777
- return { reportPath, blockers, attention };
600
+ // O staging já cumpriu o papel (briefs/findings/contexto estão no storage).
601
+ ctx.work.clean();
602
+
603
+ return { reportPath: location, blockers, attention, messages };
778
604
  }
779
605
 
780
606
  /** Resolve a branch (opção explícita ou a atual do git). */