@payconductor/houndsman 0.3.0 → 0.5.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 (91) hide show
  1. package/README.md +25 -39
  2. package/package.json +1 -1
  3. package/src/cli/commands/context-command.ts +11 -9
  4. package/src/cli/commands/doctor-command.ts +5 -0
  5. package/src/cli/commands/findings-command.ts +13 -3
  6. package/src/cli/commands/review-command.ts +1 -2
  7. package/src/cli/commands/status-command.ts +46 -0
  8. package/src/cli/help.ts +8 -3
  9. package/src/cli/index.ts +5 -2
  10. package/src/core/commands/context.ts +29 -13
  11. package/src/core/commands/findings.ts +216 -125
  12. package/src/core/commands/review.ts +7 -8
  13. package/src/core/commands/scaffold.ts +15 -16
  14. package/src/core/commands/status.ts +63 -0
  15. package/src/core/config.ts +16 -7
  16. package/src/core/engine/houndsman-engine.ts +15 -1
  17. package/src/core/engine/review-scope.ts +26 -8
  18. package/src/core/engine/review-session.ts +37 -9
  19. package/src/core/findings-state.ts +53 -75
  20. package/src/core/github-review.ts +620 -57
  21. package/src/core/jev/jev-candidates.ts +49 -35
  22. package/src/core/orchestrator.ts +271 -134
  23. package/src/core/paths.ts +26 -17
  24. package/src/core/pr-context.ts +19 -5
  25. package/src/core/project-config.ts +4 -5
  26. package/src/core/renderers/manifest-writer.ts +29 -18
  27. package/src/core/renderers/resume-renderer.ts +151 -0
  28. package/src/core/report-builder.ts +7 -4
  29. package/src/core/run-layout.ts +4 -104
  30. package/src/core/run-status.ts +59 -50
  31. package/src/core/services/git-repository.ts +16 -0
  32. package/src/core/services/project-files.ts +13 -1
  33. package/src/core/storage/factory.ts +24 -0
  34. package/src/core/storage/file/file-storage.ts +271 -0
  35. package/src/core/storage/paths.ts +64 -0
  36. package/src/core/storage/repo-identity.ts +70 -0
  37. package/src/core/storage/sqlite/mappers.ts +162 -0
  38. package/src/core/storage/sqlite/schema.ts +116 -0
  39. package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
  40. package/src/core/storage/types.ts +152 -0
  41. package/src/core/storage/work-area.ts +191 -0
  42. package/src/index.ts +27 -6
  43. package/src/server/artifacts.ts +8 -61
  44. package/src/server/client/App.tsx +26 -9
  45. package/src/server/client/ArtifactsPanel.tsx +144 -23
  46. package/src/server/client/AutoRefresh.tsx +1 -1
  47. package/src/server/client/Filters.tsx +7 -2
  48. package/src/server/client/FindingCard.tsx +50 -15
  49. package/src/server/client/FindingsList.tsx +2 -2
  50. package/src/server/client/MarkdownText.tsx +181 -0
  51. package/src/server/client/ResumePanel.tsx +368 -0
  52. package/src/server/client/RunList.tsx +3 -3
  53. package/src/server/client/SubmitModal.tsx +58 -10
  54. package/src/server/client/SummaryBar.tsx +1 -1
  55. package/src/server/client/icons.tsx +17 -0
  56. package/src/server/client/utils/code-highlight.ts +422 -0
  57. package/src/server/client/utils/constants.ts +226 -0
  58. package/src/server/client/utils/json-highlight.ts +55 -0
  59. package/src/server/client/utils/markdown.ts +319 -0
  60. package/src/server/client/{types.ts → utils/types.ts} +81 -0
  61. package/src/server/index.ts +154 -16
  62. package/src/server/run-repository.ts +153 -136
  63. package/src/skill/OUTPUT-FORMAT.md +6 -4
  64. package/src/skill/README.md +22 -14
  65. package/src/skill/SETUP.md +4 -4
  66. package/src/skill/example-review.md +22 -14
  67. package/src/skill/fix.md +5 -5
  68. package/src/skill/hunt.md +55 -32
  69. package/src/skill/train.md +1 -1
  70. package/src/skill/verify.md +7 -6
  71. package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
  72. package/src/tests/baseline.test.ts +79 -34
  73. package/src/tests/cli.test.ts +40 -19
  74. package/src/tests/code-highlight.test.ts +123 -0
  75. package/src/tests/domain.test.ts +1 -1
  76. package/src/tests/findings-commands.test.ts +153 -131
  77. package/src/tests/findings-state.test.ts +56 -5
  78. package/src/tests/fixtures/fake-project.ts +0 -1
  79. package/src/tests/github-review.test.ts +409 -60
  80. package/src/tests/infrastructure.test.ts +33 -38
  81. package/src/tests/markdown.test.ts +149 -0
  82. package/src/tests/review-resume.test.ts +101 -0
  83. package/src/tests/review-scope.test.ts +302 -0
  84. package/src/tests/review-scripts.test.ts +81 -5
  85. package/src/tests/run-status-view.test.ts +94 -0
  86. package/src/tests/run-status.test.ts +86 -23
  87. package/src/tests/server.test.ts +184 -96
  88. package/src/tests/storage.test.ts +449 -0
  89. package/src/server/client/constants.ts +0 -91
  90. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  91. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
@@ -5,17 +5,17 @@
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 } from "node:path";
19
19
  import {
20
20
  type HoundsmanConfig,
21
21
  resolveJevConcurrency,
@@ -28,32 +28,42 @@ import { Watchdog } from "./domain/watchdog";
28
28
  import { mergeCarryover } from "./engine/carryover";
29
29
  import { ReviewScopeResolver, type RunClassification } from "./engine/review-scope";
30
30
  import type { BudgetSelection, ClassificationResult } from "./file-classifier";
31
- import { buildStateFromFindings, FindingsStateStore, fileReader } from "./findings-state";
31
+ import { buildStateFromFindings, fileReader } from "./findings-state";
32
32
  import { resolveSha } from "./git-revision";
33
+ import { syncResolvedThreads } from "./github-review";
33
34
  import {
34
35
  type JevCandidate,
35
36
  type JevCandidatesFile,
36
37
  JevCandidatesStore,
37
38
  JevClient,
38
39
  JevDetector,
40
+ parseCandidates,
41
+ serializeCandidates,
39
42
  } from "./jev";
40
- import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH, relFromRoot } from "./paths";
41
- import { currentBranch, readContextSummary } from "./pr-context";
43
+ import {
44
+ displayPath,
45
+ OUTPUT_FORMAT_PATH,
46
+ REVIEW_FILE,
47
+ REVIEW_RESUME_FILE,
48
+ } from "./paths";
49
+ import { currentBranch } from "./pr-context";
42
50
  import { ProjectConfig } from "./project-config";
43
51
  import { BriefRenderer } from "./renderers/brief-renderer";
44
52
  import { ManifestWriter } from "./renderers/manifest-writer";
45
53
  import { ReportRenderer } from "./renderers/report-renderer";
54
+ import { renderReviewResume } from "./renderers/resume-renderer";
46
55
  import {
47
56
  type ContextSummary,
48
57
  type DomainReport,
49
58
  dedupeFindings,
50
- parseWatchdogReport,
59
+ parseContextSummary,
51
60
  sanitizeBranch,
52
61
  type WatchdogReport,
53
62
  } from "./report-builder";
54
- import { RunLayout } from "./run-layout";
55
63
  import { writeRunStatus } from "./run-status";
56
64
  import { systemClock } from "./services/clock";
65
+ import type { ReviewRecord, StorageProvider } from "./storage/types";
66
+ import { ReviewWorkArea } from "./storage/work-area";
57
67
  import type { WatchdogMeta } from "./types";
58
68
  import { findWatchdog } from "./watchdog-registry";
59
69
 
@@ -61,7 +71,6 @@ export type ReviewOptions = {
61
71
  base: string;
62
72
  head: string;
63
73
  branch?: string;
64
- dir: string;
65
74
  finalize: boolean;
66
75
  failOnBlocking: boolean;
67
76
  clean: boolean;
@@ -71,11 +80,10 @@ export type ReviewOptions = {
71
80
  };
72
81
 
73
82
  /** Valores default das opções (o CLI sobrepõe com as flags). */
74
- export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOptions {
83
+ export function defaultOptions(): ReviewOptions {
75
84
  return {
76
85
  base: "main",
77
86
  head: "HEAD",
78
- dir: artifactDir,
79
87
  finalize: false,
80
88
  failOnBlocking: false,
81
89
  clean: false,
@@ -84,20 +92,19 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
84
92
  };
85
93
  }
86
94
 
87
- /** Layout de uma run (paths de briefs/findings/artefatos). */
88
- type Paths = RunLayout;
95
+ /** Contexto de uma run: storage + review + staging. */
96
+ export type ReviewContext = {
97
+ storage: StorageProvider;
98
+ review: ReviewRecord;
99
+ work: ReviewWorkArea;
100
+ };
89
101
 
90
- function paths(opts: ReviewOptions, branch: string): Paths {
91
- return new RunLayout(opts.dir, branch);
92
- }
93
-
94
- function prepareDirs(dirs: Paths, clean: boolean): void {
95
- mkdirSync(dirs.briefsDir, { recursive: true });
96
- mkdirSync(dirs.findingsDir, { recursive: true });
97
- if (clean) {
98
- rmSync(dirs.findingsDir, { recursive: true, force: true });
99
- mkdirSync(dirs.findingsDir, { recursive: true });
100
- }
102
+ /** Cria o contexto de uma review existente. */
103
+ export function reviewContext(
104
+ storage: StorageProvider,
105
+ review: ReviewRecord,
106
+ ): ReviewContext {
107
+ return { storage, review, work: new ReviewWorkArea(review) };
101
108
  }
102
109
 
103
110
  // ---------------------------------------------------------------------------
@@ -120,40 +127,6 @@ function incrementalNote(classification: RunClassification): string {
120
127
  );
121
128
  }
122
129
 
123
- // ---------------------------------------------------------------------------
124
- // Findings
125
- // ---------------------------------------------------------------------------
126
-
127
- function readFindings(
128
- findingsDir: string,
129
- activeIds: string[],
130
- ): { reports: WatchdogReport[]; pending: string[]; warnings: string[] } {
131
- const reports: WatchdogReport[] = [];
132
- const pending: string[] = [];
133
- const warnings: string[] = [];
134
- for (const id of activeIds) {
135
- const path = join(findingsDir, `${id}.json`);
136
- if (!existsSync(path)) {
137
- pending.push(id);
138
- continue;
139
- }
140
- try {
141
- const raw = JSON.parse(readFileSync(path, "utf8"));
142
- const report = parseWatchdogReport(raw, path, (message) =>
143
- warnings.push(message),
144
- );
145
- if (report) reports.push(report);
146
- else pending.push(id);
147
- } catch (err) {
148
- warnings.push(
149
- `[houndsman] ${relFromRoot(path)} inválido: ${err instanceof Error ? err.message : err}`,
150
- );
151
- pending.push(id);
152
- }
153
- }
154
- return { reports, pending, warnings };
155
- }
156
-
157
130
  // ---------------------------------------------------------------------------
158
131
  // Orçamento (triage de PRs grandes)
159
132
  // ---------------------------------------------------------------------------
@@ -169,7 +142,7 @@ function budgetFor(
169
142
 
170
143
  /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
171
144
  function writeManifest(
172
- layout: RunLayout,
145
+ ctx: ReviewContext,
173
146
  config: HoundsmanConfig,
174
147
  opts: ReviewOptions,
175
148
  classification: RunClassification,
@@ -177,12 +150,13 @@ function writeManifest(
177
150
  pending: string[],
178
151
  jev: JevCandidatesFile,
179
152
  ): void {
180
- new ManifestWriter(layout, new ProjectConfig(config), opts).write(
181
- classification,
182
- activeIds,
183
- pending,
184
- jev,
185
- );
153
+ new ManifestWriter(
154
+ ctx.storage,
155
+ ctx.review,
156
+ ctx.work,
157
+ new ProjectConfig(config),
158
+ opts,
159
+ ).write(classification, activeIds, pending, jev);
186
160
  }
187
161
 
188
162
  // ---------------------------------------------------------------------------
@@ -192,12 +166,13 @@ function writeManifest(
192
166
  function writeBriefs(
193
167
  activeIds: string[],
194
168
  classification: ClassificationResult,
195
- dirs: Paths,
169
+ ctx: ReviewContext,
196
170
  branch: string,
197
171
  context: ContextSummary | null,
198
172
  candidates: JevCandidate[],
199
173
  config: HoundsmanConfig,
200
174
  ): void {
175
+ const work = ctx.work;
201
176
  const docs = discoverDocs(Object.keys(classification.files), config);
202
177
  const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
203
178
  const renderer = new BriefRenderer();
@@ -213,13 +188,20 @@ function writeBriefs(
213
188
  overflow,
214
189
  deleted: classification.deleted,
215
190
  branch,
216
- outputPath: relFromRoot(dirs.findingFile(id)),
191
+ outputPath: work.findingFile(id),
217
192
  outputFormatPath: OUTPUT_FORMAT_PATH,
218
193
  docs,
219
194
  context,
220
195
  candidates: candidates.filter((c) => c.watchdogId === id),
221
196
  });
222
- writeFileSync(dirs.briefFile(id), `${brief}\n`);
197
+ const content = `${brief}\n`;
198
+ work.writeBrief(id, content);
199
+ // Auditoria: o brief também fica no storage (o staging é limpo no finalize).
200
+ ctx.storage.writeArtifact(ctx.review.id, {
201
+ name: `briefs/${id}.md`,
202
+ kind: "brief",
203
+ content,
204
+ });
223
205
  }
224
206
  }
225
207
 
@@ -234,13 +216,16 @@ function writeBriefs(
234
216
  async function resolveCandidates(
235
217
  opts: ReviewOptions,
236
218
  classification: ClassificationResult,
237
- dirs: Paths,
219
+ ctx: ReviewContext,
238
220
  config: HoundsmanConfig,
239
221
  diff: DiffSource,
240
222
  ): Promise<JevCandidatesFile> {
241
- const store = new JevCandidatesStore(dirs.candidatesFile, config.watchdogIds);
242
223
  const save = (data: JevCandidatesFile): JevCandidatesFile => {
243
- store.write(data);
224
+ ctx.storage.writeArtifact(ctx.review.id, {
225
+ name: "candidates.json",
226
+ kind: "candidates",
227
+ content: serializeCandidates(data),
228
+ });
244
229
  return data;
245
230
  };
246
231
 
@@ -295,7 +280,7 @@ function discoveryMessages(
295
280
  ): string[] {
296
281
  const messages = [
297
282
  `Discovery: ${activeIds.length} watchdog(s) ativado(s), ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
298
- `Briefs: ${relFromRoot(briefsDir)}/`,
283
+ `Briefs: ${displayPath(briefsDir)}/`,
299
284
  ];
300
285
  for (const file of classification.deleted) {
301
286
  messages.push(`Deletado (breaking change): ${file}`);
@@ -327,31 +312,54 @@ export async function prepare(
327
312
  opts: ReviewOptions,
328
313
  branch: string,
329
314
  config: HoundsmanConfig,
330
- dirs: Paths = paths(opts, branch),
315
+ ctx: ReviewContext,
331
316
  ): Promise<PrepareResult> {
332
317
  const messages: string[] = [];
333
- prepareDirs(dirs, opts.clean);
318
+ ctx.work.ensure();
319
+ if (opts.clean) ctx.work.cleanFindings();
320
+ ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
321
+
322
+ // Marca o commit revisado e decide entre completo, incremental ou no-op.
323
+ const headSha = resolveSha(opts.head);
324
+ const previous = ctx.storage.readFindings(ctx.review.id);
325
+ const scope = new ReviewScopeResolver(opts, config).resolve(previous, headSha);
326
+
327
+ // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
328
+ if (scope.kind === "noop") {
329
+ const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
330
+ // Ciclo em andamento continua válido (os findings já despachados devem
331
+ // ser finalizados); fora isso, o relatório anterior é a resposta final.
332
+ const pendingCycle = status === "reviewing";
333
+ if (!pendingCycle) {
334
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
335
+ }
336
+ messages.push(
337
+ `Nenhuma alteração desde a última revisão (${shortSha(scope.reviewedFrom)}).`,
338
+ pendingCycle
339
+ ? "Nada novo a preparar — o ciclo em andamento continua; rode `--finalize` quando os findings estiverem gravados."
340
+ : "Nada a revisar — o relatório anterior foi mantido. Use `--full` para re-revisar.",
341
+ );
342
+ return { messages };
343
+ }
334
344
 
335
345
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
336
- writeRunStatus(dirs.outDir, { branch, status: "reviewing", resetStartedAt: true });
346
+ writeRunStatus(ctx.storage, ctx.review.id, {
347
+ status: "reviewing",
348
+ resetStartedAt: true,
349
+ });
337
350
 
338
- // Marca o commit revisado e decide entre review completo e incremental.
339
- const headSha = resolveSha(opts.head);
340
- const previous = FindingsStateStore.at(opts.dir, branch).read();
341
- const { classification, diff } = new ReviewScopeResolver(opts, config).resolve(
342
- previous,
343
- headSha,
344
- );
345
- writeFileSync(
346
- dirs.classificationFile,
347
- `${JSON.stringify(classification, null, 2)}\n`,
348
- );
351
+ const { classification, diff } = scope;
352
+ ctx.storage.writeArtifact(ctx.review.id, {
353
+ name: "classification.json",
354
+ kind: "classification",
355
+ content: `${JSON.stringify(classification, null, 2)}\n`,
356
+ });
349
357
 
350
- const jev = await resolveCandidates(opts, classification, dirs, config, diff);
358
+ const jev = await resolveCandidates(opts, classification, ctx, config, diff);
351
359
 
352
360
  const activeIds = Object.keys(classification.watchdogs);
353
361
  if (Object.keys(classification.files).length === 0) {
354
- writeRunStatus(dirs.outDir, { branch, status: "review_done" });
362
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
355
363
  messages.push(
356
364
  classification.mode === "incremental"
357
365
  ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
@@ -360,7 +368,7 @@ export async function prepare(
360
368
  return { messages };
361
369
  }
362
370
  if (activeIds.length === 0) {
363
- writeRunStatus(dirs.outDir, { branch, status: "review_done" });
371
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
364
372
  messages.push(
365
373
  "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
366
374
  );
@@ -372,13 +380,14 @@ export async function prepare(
372
380
  );
373
381
  }
374
382
 
375
- const context = readContextSummary(dirs.contextPaths.summary);
383
+ const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
384
+ persistContext(ctx);
376
385
 
377
- writeBriefs(activeIds, classification, dirs, branch, context, jev.candidates, config);
378
- writeManifest(dirs, config, opts, classification, activeIds, activeIds, jev);
386
+ writeBriefs(activeIds, classification, ctx, branch, context, jev.candidates, config);
387
+ writeManifest(ctx, config, opts, classification, activeIds, activeIds, jev);
379
388
 
380
389
  messages.push(
381
- ...discoveryMessages(activeIds, classification, dirs.briefsDir),
390
+ ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
382
391
  ...budgetMessage(classification, config),
383
392
  ...jevSummary(jev),
384
393
  );
@@ -392,28 +401,78 @@ export async function prepare(
392
401
  }
393
402
 
394
403
  messages.push(
395
- `\nPróximo passo: despachar os sub-agentes (um por brief em ${relFromRoot(dirs.briefsDir)}/).`,
404
+ `\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
396
405
  "Quando os findings estiverem gravados: bun run houndsman --finalize",
397
406
  );
398
407
 
399
408
  return { messages };
400
409
  }
410
+ /** Persiste o contexto da PR (bruto + resumo) como artefatos, quando existirem. */
411
+ function persistContext(ctx: ReviewContext): void {
412
+ const raw = ctx.work.readContextRaw();
413
+ if (raw !== null) {
414
+ ctx.storage.writeArtifact(ctx.review.id, {
415
+ name: "context.json",
416
+ kind: "context",
417
+ content: raw,
418
+ });
419
+ }
420
+ const summary = ctx.work.readContextSummaryRaw();
421
+ if (summary !== null) {
422
+ ctx.storage.writeArtifact(ctx.review.id, {
423
+ name: "context-summary.json",
424
+ kind: "context-summary",
425
+ content: summary,
426
+ });
427
+ }
428
+ const brief = ctx.work.readContextBriefRaw();
429
+ if (brief !== null) {
430
+ ctx.storage.writeArtifact(ctx.review.id, {
431
+ name: "context-brief.md",
432
+ kind: "context-brief",
433
+ content: brief,
434
+ });
435
+ }
436
+ }
401
437
 
402
438
  // ---------------------------------------------------------------------------
403
439
  // Passo 2 — finalizar
404
440
  // ---------------------------------------------------------------------------
405
441
 
406
- /** Lê o classification.json gravado pelo passo de preparação (lança se ausente). */
407
- function readClassification(classificationPath: string): RunClassification {
408
- if (!existsSync(classificationPath)) {
442
+ /** Lê o `classification.json` persistido pelo prepare (lança se ausente). */
443
+ function readClassification(ctx: ReviewContext): RunClassification {
444
+ const artifact = ctx.storage.readArtifact(ctx.review.id, "classification.json");
445
+ if (!artifact) {
409
446
  throw new Error(
410
- `Não encontrei ${relFromRoot(classificationPath)}. Rode \`bun run houndsman\` (preparar) primeiro.`,
447
+ `Não encontrei a classificação da review. Rode \`bun run houndsman\` (preparar) primeiro.`,
448
+ );
449
+ }
450
+ return JSON.parse(artifact.content) as RunClassification;
451
+ }
452
+
453
+ /** Lê os candidatos Jev persistidos (unavailable se ausente/inválido). */
454
+ function readCandidates(ctx: ReviewContext, config: HoundsmanConfig): JevCandidatesFile {
455
+ const artifact = ctx.storage.readArtifact(ctx.review.id, "candidates.json");
456
+ if (!artifact) return JevCandidatesStore.unavailable("candidates.json ausente");
457
+ try {
458
+ return (
459
+ parseCandidates(JSON.parse(artifact.content), config.watchdogIds) ??
460
+ JevCandidatesStore.unavailable("candidates.json inválido")
411
461
  );
462
+ } catch {
463
+ return JevCandidatesStore.unavailable("candidates.json inválido");
464
+ }
465
+ }
466
+
467
+ /** Resumo do contexto persistido (usado quando o staging já foi limpo). */
468
+ function readPersistedContextSummary(ctx: ReviewContext): ContextSummary | null {
469
+ const artifact = ctx.storage.readArtifact(ctx.review.id, "context-summary.json");
470
+ if (!artifact) return null;
471
+ try {
472
+ return parseContextSummary(JSON.parse(artifact.content));
473
+ } catch {
474
+ return null;
412
475
  }
413
- const parsed = JSON.parse(
414
- readFileSync(classificationPath, "utf8"),
415
- ) as RunClassification;
416
- return parsed;
417
476
  }
418
477
 
419
478
  /**
@@ -421,7 +480,7 @@ function readClassification(classificationPath: string): RunClassification {
421
480
  * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
422
481
  */
423
482
  function writeDomainReports(
424
- layout: RunLayout,
483
+ ctx: ReviewContext,
425
484
  branch: string,
426
485
  reports: WatchdogReport[],
427
486
  classification: ClassificationResult,
@@ -435,16 +494,17 @@ function writeDomainReports(
435
494
  if (findings.length === 0) continue;
436
495
  const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
437
496
  const expected = classification.watchdogs[report.watchdogId] ?? [];
438
- writeFileSync(
439
- layout.file(file),
440
- `${renderer.renderDomain(
497
+ ctx.storage.writeArtifact(ctx.review.id, {
498
+ name: file,
499
+ kind: "domain-review",
500
+ content: `${renderer.renderDomain(
441
501
  branch,
442
502
  report,
443
503
  findings,
444
504
  expected.length,
445
505
  context,
446
506
  )}\n`,
447
- );
507
+ });
448
508
  domains.push({
449
509
  id: report.watchdogId,
450
510
  name: nameOf(report.watchdogId),
@@ -472,23 +532,42 @@ export function finalize(
472
532
  opts: ReviewOptions,
473
533
  branch: string,
474
534
  config: HoundsmanConfig,
475
- dirs: Paths = paths(opts, branch),
535
+ ctx: ReviewContext,
476
536
  ): FinalizeResult {
477
- const classification = readClassification(dirs.classificationFile);
537
+ // `--finalize` só consolida um ciclo preparado (status `reviewing`). Sem esta
538
+ // guarda, um prepare no-op seguido de `--finalize` reescreveria o relatório
539
+ // anterior com a classificação antiga (perdendo os relatórios por domínio).
540
+ // O `ctx.review` é um snapshot: relê o status atual do storage.
541
+ const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
542
+ if (status !== "reviewing") {
543
+ const state = ctx.storage.readFindings(ctx.review.id);
544
+ const present = Object.values(state?.findings ?? {}).filter(
545
+ (finding) => finding.present,
546
+ );
547
+ return {
548
+ reportPath: ctx.storage.location(ctx.review.id),
549
+ blockers: present.filter((f) => f.severity === "blocking").length,
550
+ attention: present.filter((f) => f.severity === "attention").length,
551
+ messages: [
552
+ `Nada a finalizar: nenhum ciclo de revisão pendente (status: ${status ?? "sem status"}).`,
553
+ "Se nada mudou desde a última revisão, o relatório anterior foi mantido; use `--full` para re-revisar.",
554
+ ],
555
+ };
556
+ }
557
+
558
+ const classification = readClassification(ctx);
478
559
  const activeIds = Object.keys(classification.watchdogs);
479
560
 
480
561
  const messages: string[] = [];
481
- const { reports, pending, warnings } = readFindings(dirs.findingsDir, activeIds);
562
+ const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
482
563
  messages.push(...warnings);
483
- const jev =
484
- new JevCandidatesStore(dirs.candidatesFile, config.watchdogIds).read() ??
485
- JevCandidatesStore.unavailable("candidates.json ausente");
486
- writeManifest(dirs, config, opts, classification, activeIds, pending, jev);
564
+ const jev = readCandidates(ctx, config);
565
+ writeManifest(ctx, config, opts, classification, activeIds, pending, jev);
487
566
 
488
567
  if (pending.length > 0) {
489
568
  throw new Error(
490
569
  `Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
491
- `Cada watchdog deve gravar ${relFromRoot(dirs.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
570
+ `Cada watchdog deve gravar ${displayPath(ctx.work.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
492
571
  );
493
572
  }
494
573
 
@@ -508,27 +587,61 @@ export function finalize(
508
587
  // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
509
588
  // recalculada por `houndsman findings sync` após edições. O commit revisado
510
589
  // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
511
- const store = FindingsStateStore.at(opts.dir, branch);
512
590
  const state = buildStateFromFindings(merged, {
513
591
  branch,
514
592
  readFile: fileReader(process.cwd()),
515
- previous: store.read(),
593
+ previous: ctx.storage.readFindings(ctx.review.id),
594
+ reviewedPaths: new Set([
595
+ ...reports.flatMap((report) => report.reviewedFiles),
596
+ // Arquivo deletado: o achado deixou de existir junto com ele.
597
+ ...classification.deleted,
598
+ ]),
516
599
  now,
517
600
  headSha: classification.headSha,
518
601
  reviewed: classification.headSha
519
602
  ? { headSha: classification.headSha, at: now }
520
603
  : undefined,
521
604
  });
522
- store.write(state);
523
- messages.push(
524
- `Triagem: ${relFromRoot(store.filePath)} — ${Object.keys(state.findings).length} finding(s).`,
525
- );
526
- writeRunStatus(dirs.outDir, { branch, status: "review_done" });
527
605
 
528
- const context = readContextSummary(dirs.contextPaths.summary);
606
+ // Findings corrigidos entre revisões (ausentes + done) têm a conversa do PR
607
+ // resolvida automaticamente; as já resolvidas no GitHub são puladas. Só
608
+ // toca o `gh` quando há vínculo remoto pendente (fail-open).
609
+ const threadIds = Object.values(state.findings)
610
+ .filter(
611
+ (finding) =>
612
+ !finding.present &&
613
+ finding.execState === "done" &&
614
+ finding.github &&
615
+ finding.github.kind !== "comment" &&
616
+ !finding.github.resolvedAt,
617
+ )
618
+ .map((finding) => finding.id);
619
+ if (threadIds.length > 0) {
620
+ const sync = syncResolvedThreads({ state, ids: threadIds, now });
621
+ if (!sync.available) {
622
+ messages.push(`GitHub: threads não sincronizadas (${sync.reason}).`);
623
+ } else if (sync.checked > 0) {
624
+ const failures =
625
+ sync.failed.length > 0 ? `, ${sync.failed.length} falha(s)` : "";
626
+ messages.push(
627
+ `GitHub: ${sync.resolved.length} thread(s) resolvida(s), ${sync.skipped.length} já resolvida(s)/pulada(s)${failures}.`,
628
+ );
629
+ }
630
+ }
631
+
632
+ ctx.storage.writeFindings(ctx.review.id, state);
633
+ ctx.storage.updateReview(ctx.review.id, {
634
+ base: opts.base,
635
+ head: opts.head,
636
+ headSha: classification.headSha,
637
+ });
638
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
639
+
640
+ persistContext(ctx);
641
+ const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
529
642
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
530
643
  const domains = writeDomainReports(
531
- dirs,
644
+ ctx,
532
645
  branch,
533
646
  reports,
534
647
  classification,
@@ -544,16 +657,40 @@ export function finalize(
544
657
  jev.available ? jev.candidates : undefined,
545
658
  domains,
546
659
  );
547
- const reportPath = dirs.reviewFile;
548
- writeFileSync(reportPath, `${incrementalNote(classification)}${markdown}\n`);
660
+ ctx.storage.writeArtifact(ctx.review.id, {
661
+ name: REVIEW_FILE,
662
+ kind: "review",
663
+ content: `${incrementalNote(classification)}${markdown}\n`,
664
+ });
665
+ // Resumo final para leitura humana — não participa de nenhuma etapa seguinte.
666
+ ctx.storage.writeArtifact(ctx.review.id, {
667
+ name: REVIEW_RESUME_FILE,
668
+ kind: "resume",
669
+ content: renderReviewResume({
670
+ branch,
671
+ date: now.slice(0, 10),
672
+ context,
673
+ classification,
674
+ findings: merged,
675
+ nameOf,
676
+ }),
677
+ });
678
+
679
+ const location = ctx.storage.location(ctx.review.id);
680
+ messages.push(
681
+ `Triagem: ${location} — ${Object.keys(state.findings).length} finding(s).`,
682
+ );
549
683
 
550
684
  const blockers = merged.filter((f) => f.severity === "blocking").length;
551
685
  const attention = merged.filter((f) => f.severity === "attention").length;
552
686
  messages.push(
553
- `Relatório: ${relFromRoot(reportPath)} — ${blockers} bloqueante(s), ${attention} atenção.`,
687
+ `Relatório: ${location} — ${blockers} bloqueante(s), ${attention} atenção.`,
554
688
  );
555
689
 
556
- return { reportPath, blockers, attention, messages };
690
+ // O staging já cumpriu o papel (briefs/findings/contexto estão no storage).
691
+ ctx.work.clean();
692
+
693
+ return { reportPath: location, blockers, attention, messages };
557
694
  }
558
695
 
559
696
  /** Resolve a branch (opção explícita ou a atual do git). */