@payconductor/houndsman 0.3.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 (69) hide show
  1. package/README.md +18 -6
  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/help.ts +7 -3
  8. package/src/cli/index.ts +2 -2
  9. package/src/core/commands/context.ts +29 -13
  10. package/src/core/commands/findings.ts +155 -120
  11. package/src/core/commands/review.ts +7 -8
  12. package/src/core/commands/scaffold.ts +15 -16
  13. package/src/core/config.ts +16 -7
  14. package/src/core/engine/houndsman-engine.ts +15 -1
  15. package/src/core/engine/review-session.ts +37 -9
  16. package/src/core/findings-state.ts +32 -71
  17. package/src/core/jev/jev-candidates.ts +49 -35
  18. package/src/core/orchestrator.ts +175 -128
  19. package/src/core/paths.ts +23 -17
  20. package/src/core/pr-context.ts +19 -5
  21. package/src/core/project-config.ts +4 -5
  22. package/src/core/renderers/manifest-writer.ts +29 -18
  23. package/src/core/run-layout.ts +4 -104
  24. package/src/core/run-status.ts +28 -53
  25. package/src/core/services/git-repository.ts +16 -0
  26. package/src/core/services/project-files.ts +13 -1
  27. package/src/core/storage/factory.ts +24 -0
  28. package/src/core/storage/file/file-storage.ts +269 -0
  29. package/src/core/storage/paths.ts +64 -0
  30. package/src/core/storage/repo-identity.ts +70 -0
  31. package/src/core/storage/sqlite/mappers.ts +160 -0
  32. package/src/core/storage/sqlite/schema.ts +108 -0
  33. package/src/core/storage/sqlite/sqlite-storage.ts +427 -0
  34. package/src/core/storage/types.ts +143 -0
  35. package/src/core/storage/work-area.ts +191 -0
  36. package/src/index.ts +27 -6
  37. package/src/server/artifacts.ts +8 -61
  38. package/src/server/client/ArtifactsPanel.tsx +137 -23
  39. package/src/server/client/FindingCard.tsx +37 -6
  40. package/src/server/client/MarkdownText.tsx +101 -0
  41. package/src/server/client/SubmitModal.tsx +9 -4
  42. package/src/server/client/code-highlight.ts +422 -0
  43. package/src/server/client/constants.ts +130 -24
  44. package/src/server/client/json-highlight.ts +55 -0
  45. package/src/server/client/markdown.ts +153 -0
  46. package/src/server/client/types.ts +23 -0
  47. package/src/server/index.ts +95 -15
  48. package/src/server/run-repository.ts +144 -136
  49. package/src/skill/OUTPUT-FORMAT.md +6 -4
  50. package/src/skill/README.md +22 -14
  51. package/src/skill/SETUP.md +4 -4
  52. package/src/skill/fix.md +5 -5
  53. package/src/skill/hunt.md +27 -18
  54. package/src/skill/train.md +1 -1
  55. package/src/skill/verify.md +5 -5
  56. package/src/tests/__snapshots__/baseline.test.ts.snap +44 -33
  57. package/src/tests/baseline.test.ts +76 -34
  58. package/src/tests/cli.test.ts +40 -19
  59. package/src/tests/code-highlight.test.ts +123 -0
  60. package/src/tests/domain.test.ts +1 -1
  61. package/src/tests/findings-commands.test.ts +153 -131
  62. package/src/tests/findings-state.test.ts +6 -3
  63. package/src/tests/fixtures/fake-project.ts +0 -1
  64. package/src/tests/infrastructure.test.ts +33 -38
  65. package/src/tests/markdown.test.ts +89 -0
  66. package/src/tests/review-scripts.test.ts +8 -5
  67. package/src/tests/run-status.test.ts +33 -23
  68. package/src/tests/server.test.ts +108 -75
  69. package/src/tests/storage.test.ts +386 -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,7 +28,7 @@ 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
33
  import {
34
34
  type JevCandidate,
@@ -36,9 +36,11 @@ import {
36
36
  JevCandidatesStore,
37
37
  JevClient,
38
38
  JevDetector,
39
+ parseCandidates,
40
+ serializeCandidates,
39
41
  } from "./jev";
40
- import { ARTIFACT_DIR, OUTPUT_FORMAT_PATH, relFromRoot } from "./paths";
41
- import { currentBranch, readContextSummary } from "./pr-context";
42
+ import { displayPath, OUTPUT_FORMAT_PATH, REVIEW_FILE } from "./paths";
43
+ import { currentBranch } from "./pr-context";
42
44
  import { ProjectConfig } from "./project-config";
43
45
  import { BriefRenderer } from "./renderers/brief-renderer";
44
46
  import { ManifestWriter } from "./renderers/manifest-writer";
@@ -47,13 +49,14 @@ import {
47
49
  type ContextSummary,
48
50
  type DomainReport,
49
51
  dedupeFindings,
50
- parseWatchdogReport,
52
+ parseContextSummary,
51
53
  sanitizeBranch,
52
54
  type WatchdogReport,
53
55
  } from "./report-builder";
54
- import { RunLayout } from "./run-layout";
55
56
  import { writeRunStatus } from "./run-status";
56
57
  import { systemClock } from "./services/clock";
58
+ import type { ReviewRecord, StorageProvider } from "./storage/types";
59
+ import { ReviewWorkArea } from "./storage/work-area";
57
60
  import type { WatchdogMeta } from "./types";
58
61
  import { findWatchdog } from "./watchdog-registry";
59
62
 
@@ -61,7 +64,6 @@ export type ReviewOptions = {
61
64
  base: string;
62
65
  head: string;
63
66
  branch?: string;
64
- dir: string;
65
67
  finalize: boolean;
66
68
  failOnBlocking: boolean;
67
69
  clean: boolean;
@@ -71,11 +73,10 @@ export type ReviewOptions = {
71
73
  };
72
74
 
73
75
  /** Valores default das opções (o CLI sobrepõe com as flags). */
74
- export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOptions {
76
+ export function defaultOptions(): ReviewOptions {
75
77
  return {
76
78
  base: "main",
77
79
  head: "HEAD",
78
- dir: artifactDir,
79
80
  finalize: false,
80
81
  failOnBlocking: false,
81
82
  clean: false,
@@ -84,20 +85,19 @@ export function defaultOptions(artifactDir: string = ARTIFACT_DIR): ReviewOption
84
85
  };
85
86
  }
86
87
 
87
- /** Layout de uma run (paths de briefs/findings/artefatos). */
88
- type Paths = RunLayout;
89
-
90
- function paths(opts: ReviewOptions, branch: string): Paths {
91
- return new RunLayout(opts.dir, branch);
92
- }
88
+ /** Contexto de uma run: storage + review + staging. */
89
+ export type ReviewContext = {
90
+ storage: StorageProvider;
91
+ review: ReviewRecord;
92
+ work: ReviewWorkArea;
93
+ };
93
94
 
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
- }
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) };
101
101
  }
102
102
 
103
103
  // ---------------------------------------------------------------------------
@@ -120,40 +120,6 @@ function incrementalNote(classification: RunClassification): string {
120
120
  );
121
121
  }
122
122
 
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
123
  // ---------------------------------------------------------------------------
158
124
  // Orçamento (triage de PRs grandes)
159
125
  // ---------------------------------------------------------------------------
@@ -169,7 +135,7 @@ function budgetFor(
169
135
 
170
136
  /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
171
137
  function writeManifest(
172
- layout: RunLayout,
138
+ ctx: ReviewContext,
173
139
  config: HoundsmanConfig,
174
140
  opts: ReviewOptions,
175
141
  classification: RunClassification,
@@ -177,12 +143,13 @@ function writeManifest(
177
143
  pending: string[],
178
144
  jev: JevCandidatesFile,
179
145
  ): void {
180
- new ManifestWriter(layout, new ProjectConfig(config), opts).write(
181
- classification,
182
- activeIds,
183
- pending,
184
- jev,
185
- );
146
+ new ManifestWriter(
147
+ ctx.storage,
148
+ ctx.review,
149
+ ctx.work,
150
+ new ProjectConfig(config),
151
+ opts,
152
+ ).write(classification, activeIds, pending, jev);
186
153
  }
187
154
 
188
155
  // ---------------------------------------------------------------------------
@@ -192,12 +159,13 @@ function writeManifest(
192
159
  function writeBriefs(
193
160
  activeIds: string[],
194
161
  classification: ClassificationResult,
195
- dirs: Paths,
162
+ ctx: ReviewContext,
196
163
  branch: string,
197
164
  context: ContextSummary | null,
198
165
  candidates: JevCandidate[],
199
166
  config: HoundsmanConfig,
200
167
  ): void {
168
+ const work = ctx.work;
201
169
  const docs = discoverDocs(Object.keys(classification.files), config);
202
170
  const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
203
171
  const renderer = new BriefRenderer();
@@ -213,13 +181,20 @@ function writeBriefs(
213
181
  overflow,
214
182
  deleted: classification.deleted,
215
183
  branch,
216
- outputPath: relFromRoot(dirs.findingFile(id)),
184
+ outputPath: work.findingFile(id),
217
185
  outputFormatPath: OUTPUT_FORMAT_PATH,
218
186
  docs,
219
187
  context,
220
188
  candidates: candidates.filter((c) => c.watchdogId === id),
221
189
  });
222
- writeFileSync(dirs.briefFile(id), `${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
+ });
223
198
  }
224
199
  }
225
200
 
@@ -234,13 +209,16 @@ function writeBriefs(
234
209
  async function resolveCandidates(
235
210
  opts: ReviewOptions,
236
211
  classification: ClassificationResult,
237
- dirs: Paths,
212
+ ctx: ReviewContext,
238
213
  config: HoundsmanConfig,
239
214
  diff: DiffSource,
240
215
  ): Promise<JevCandidatesFile> {
241
- const store = new JevCandidatesStore(dirs.candidatesFile, config.watchdogIds);
242
216
  const save = (data: JevCandidatesFile): JevCandidatesFile => {
243
- store.write(data);
217
+ ctx.storage.writeArtifact(ctx.review.id, {
218
+ name: "candidates.json",
219
+ kind: "candidates",
220
+ content: serializeCandidates(data),
221
+ });
244
222
  return data;
245
223
  };
246
224
 
@@ -295,7 +273,7 @@ function discoveryMessages(
295
273
  ): string[] {
296
274
  const messages = [
297
275
  `Discovery: ${activeIds.length} watchdog(s) ativado(s), ${Object.keys(classification.files).length} arquivo(s), ${classification.deleted.length} deletado(s).`,
298
- `Briefs: ${relFromRoot(briefsDir)}/`,
276
+ `Briefs: ${displayPath(briefsDir)}/`,
299
277
  ];
300
278
  for (const file of classification.deleted) {
301
279
  messages.push(`Deletado (breaking change): ${file}`);
@@ -327,31 +305,37 @@ export async function prepare(
327
305
  opts: ReviewOptions,
328
306
  branch: string,
329
307
  config: HoundsmanConfig,
330
- dirs: Paths = paths(opts, branch),
308
+ ctx: ReviewContext,
331
309
  ): Promise<PrepareResult> {
332
310
  const messages: string[] = [];
333
- prepareDirs(dirs, opts.clean);
311
+ ctx.work.ensure();
312
+ if (opts.clean) ctx.work.cleanFindings();
313
+ ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
334
314
 
335
315
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
336
- writeRunStatus(dirs.outDir, { branch, status: "reviewing", resetStartedAt: true });
316
+ writeRunStatus(ctx.storage, ctx.review.id, {
317
+ status: "reviewing",
318
+ resetStartedAt: true,
319
+ });
337
320
 
338
321
  // Marca o commit revisado e decide entre review completo e incremental.
339
322
  const headSha = resolveSha(opts.head);
340
- const previous = FindingsStateStore.at(opts.dir, branch).read();
323
+ const previous = ctx.storage.readFindings(ctx.review.id);
341
324
  const { classification, diff } = new ReviewScopeResolver(opts, config).resolve(
342
325
  previous,
343
326
  headSha,
344
327
  );
345
- writeFileSync(
346
- dirs.classificationFile,
347
- `${JSON.stringify(classification, null, 2)}\n`,
348
- );
328
+ ctx.storage.writeArtifact(ctx.review.id, {
329
+ name: "classification.json",
330
+ kind: "classification",
331
+ content: `${JSON.stringify(classification, null, 2)}\n`,
332
+ });
349
333
 
350
- const jev = await resolveCandidates(opts, classification, dirs, config, diff);
334
+ const jev = await resolveCandidates(opts, classification, ctx, config, diff);
351
335
 
352
336
  const activeIds = Object.keys(classification.watchdogs);
353
337
  if (Object.keys(classification.files).length === 0) {
354
- writeRunStatus(dirs.outDir, { branch, status: "review_done" });
338
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
355
339
  messages.push(
356
340
  classification.mode === "incremental"
357
341
  ? `Nenhuma alteração desde a última revisão (${shortSha(classification.reviewedFrom ?? "")}).`
@@ -360,7 +344,7 @@ export async function prepare(
360
344
  return { messages };
361
345
  }
362
346
  if (activeIds.length === 0) {
363
- writeRunStatus(dirs.outDir, { branch, status: "review_done" });
347
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
364
348
  messages.push(
365
349
  "Nenhum arquivo revisável (tudo excluído, deletado ou irrelevante).",
366
350
  );
@@ -372,13 +356,14 @@ export async function prepare(
372
356
  );
373
357
  }
374
358
 
375
- const context = readContextSummary(dirs.contextPaths.summary);
359
+ const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
360
+ persistContext(ctx);
376
361
 
377
- writeBriefs(activeIds, classification, dirs, branch, context, jev.candidates, config);
378
- writeManifest(dirs, config, opts, classification, activeIds, activeIds, jev);
362
+ writeBriefs(activeIds, classification, ctx, branch, context, jev.candidates, config);
363
+ writeManifest(ctx, config, opts, classification, activeIds, activeIds, jev);
379
364
 
380
365
  messages.push(
381
- ...discoveryMessages(activeIds, classification, dirs.briefsDir),
366
+ ...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
382
367
  ...budgetMessage(classification, config),
383
368
  ...jevSummary(jev),
384
369
  );
@@ -392,28 +377,78 @@ export async function prepare(
392
377
  }
393
378
 
394
379
  messages.push(
395
- `\nPróximo passo: despachar os sub-agentes (um por brief em ${relFromRoot(dirs.briefsDir)}/).`,
380
+ `\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
396
381
  "Quando os findings estiverem gravados: bun run houndsman --finalize",
397
382
  );
398
383
 
399
384
  return { messages };
400
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
+ }
412
+ }
401
413
 
402
414
  // ---------------------------------------------------------------------------
403
415
  // Passo 2 — finalizar
404
416
  // ---------------------------------------------------------------------------
405
417
 
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)) {
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) {
409
422
  throw new Error(
410
- `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.`,
424
+ );
425
+ }
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")
411
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;
412
451
  }
413
- const parsed = JSON.parse(
414
- readFileSync(classificationPath, "utf8"),
415
- ) as RunClassification;
416
- return parsed;
417
452
  }
418
453
 
419
454
  /**
@@ -421,7 +456,7 @@ function readClassification(classificationPath: string): RunClassification {
421
456
  * Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
422
457
  */
423
458
  function writeDomainReports(
424
- layout: RunLayout,
459
+ ctx: ReviewContext,
425
460
  branch: string,
426
461
  reports: WatchdogReport[],
427
462
  classification: ClassificationResult,
@@ -435,16 +470,17 @@ function writeDomainReports(
435
470
  if (findings.length === 0) continue;
436
471
  const file = `review.${sanitizeBranch(report.watchdogId)}.md`;
437
472
  const expected = classification.watchdogs[report.watchdogId] ?? [];
438
- writeFileSync(
439
- layout.file(file),
440
- `${renderer.renderDomain(
473
+ ctx.storage.writeArtifact(ctx.review.id, {
474
+ name: file,
475
+ kind: "domain-review",
476
+ content: `${renderer.renderDomain(
441
477
  branch,
442
478
  report,
443
479
  findings,
444
480
  expected.length,
445
481
  context,
446
482
  )}\n`,
447
- );
483
+ });
448
484
  domains.push({
449
485
  id: report.watchdogId,
450
486
  name: nameOf(report.watchdogId),
@@ -472,23 +508,21 @@ export function finalize(
472
508
  opts: ReviewOptions,
473
509
  branch: string,
474
510
  config: HoundsmanConfig,
475
- dirs: Paths = paths(opts, branch),
511
+ ctx: ReviewContext,
476
512
  ): FinalizeResult {
477
- const classification = readClassification(dirs.classificationFile);
513
+ const classification = readClassification(ctx);
478
514
  const activeIds = Object.keys(classification.watchdogs);
479
515
 
480
516
  const messages: string[] = [];
481
- const { reports, pending, warnings } = readFindings(dirs.findingsDir, activeIds);
517
+ const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
482
518
  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);
519
+ const jev = readCandidates(ctx, config);
520
+ writeManifest(ctx, config, opts, classification, activeIds, pending, jev);
487
521
 
488
522
  if (pending.length > 0) {
489
523
  throw new Error(
490
524
  `Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
491
- `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}).`,
492
526
  );
493
527
  }
494
528
 
@@ -508,27 +542,29 @@ export function finalize(
508
542
  // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
509
543
  // recalculada por `houndsman findings sync` após edições. O commit revisado
510
544
  // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
511
- const store = FindingsStateStore.at(opts.dir, branch);
512
545
  const state = buildStateFromFindings(merged, {
513
546
  branch,
514
547
  readFile: fileReader(process.cwd()),
515
- previous: store.read(),
548
+ previous: ctx.storage.readFindings(ctx.review.id),
516
549
  now,
517
550
  headSha: classification.headSha,
518
551
  reviewed: classification.headSha
519
552
  ? { headSha: classification.headSha, at: now }
520
553
  : undefined,
521
554
  });
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" });
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" });
527
562
 
528
- const context = readContextSummary(dirs.contextPaths.summary);
563
+ persistContext(ctx);
564
+ const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
529
565
  const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
530
566
  const domains = writeDomainReports(
531
- dirs,
567
+ ctx,
532
568
  branch,
533
569
  reports,
534
570
  classification,
@@ -544,16 +580,27 @@ export function finalize(
544
580
  jev.available ? jev.candidates : undefined,
545
581
  domains,
546
582
  );
547
- const reportPath = dirs.reviewFile;
548
- 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
+ );
549
593
 
550
594
  const blockers = merged.filter((f) => f.severity === "blocking").length;
551
595
  const attention = merged.filter((f) => f.severity === "attention").length;
552
596
  messages.push(
553
- `Relatório: ${relFromRoot(reportPath)} — ${blockers} bloqueante(s), ${attention} atenção.`,
597
+ `Relatório: ${location} — ${blockers} bloqueante(s), ${attention} atenção.`,
554
598
  );
555
599
 
556
- return { reportPath, blockers, attention, messages };
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 };
557
604
  }
558
605
 
559
606
  /** Resolve a branch (opção explícita ou a atual do git). */
package/src/core/paths.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * Caminhos do houndsman (biblioteca): raiz relativa para logs + layout fixo da
3
3
  * camada de projeto (`.houndsman/`).
4
4
  *
5
- * A camada gerada tem local fixo, então nada aqui é configurável pelo projeto:
6
- * o CLI deriva os artefatos e o schema de output destas constantes.
5
+ * A camada gerada tem local fixo, então nada aqui é configurável pelo projeto.
6
+ * Dados de execução (reviews/findings/artefatos) vivem no storage global; aqui
7
+ * ficam só os paths da camada e os nomes dos artefatos de staging.
7
8
  */
8
9
 
9
- import { relative } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { relative, sep } from "node:path";
10
12
  import { OUTPUT_FORMAT_FILE } from "./assets";
11
13
 
12
14
  /** Caminho relativo ao cwd, sempre com `/` (portável/legível em logs). */
@@ -14,6 +16,16 @@ export function relFromRoot(absPath: string): string {
14
16
  return relative(process.cwd(), absPath).replace(/\\/g, "/");
15
17
  }
16
18
 
19
+ /** Caminho legível para mensagens: `~/...` quando sob o home, senão absoluto. */
20
+ export function displayPath(absPath: string): string {
21
+ const home = homedir();
22
+ if (absPath === home) return "~";
23
+ if (absPath.startsWith(`${home}${sep}`)) {
24
+ return `~${absPath.slice(home.length).replace(/\\/g, "/")}`;
25
+ }
26
+ return absPath.replace(/\\/g, "/");
27
+ }
28
+
17
29
  /** Diretório da camada de projeto (regras + artefatos). */
18
30
  export const PROJECT_DIR = ".houndsman";
19
31
 
@@ -23,26 +35,20 @@ export const CORE_DIR = `${PROJECT_DIR}/core`;
23
35
  /** Skill canônico (markdown) dentro da camada gerada. */
24
36
  export const CORE_SKILL_DIR = `${CORE_DIR}/skill`;
25
37
 
26
- /** Artefatos da revisão (briefs, findings, review.md) — default de `artifactDir`. */
27
- export const ARTIFACT_DIR = `${PROJECT_DIR}/.runs`;
28
-
29
38
  /** Schema de output dos watchdogs, citado nos briefs. */
30
39
  export const OUTPUT_FORMAT_PATH = `${CORE_SKILL_DIR}/${OUTPUT_FORMAT_FILE}`;
31
40
 
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";
40
-
41
- /** Contexto bruto da PR (`gh pr view`), dentro da pasta da run. */
41
+ /** Contexto bruto da PR (`gh pr view`), no staging da review. */
42
42
  export const CONTEXT_FILE = "context.json";
43
43
 
44
- /** Resumo do contexto gravado pelo subagente, dentro da pasta da run. */
44
+ /** Resumo do contexto gravado pelo subagente, no staging da review. */
45
45
  export const CONTEXT_SUMMARY_FILE = "context-summary.json";
46
46
 
47
47
  /** Brief do subagente que resume o contexto da PR. */
48
48
  export const CONTEXT_BRIEF_FILE = "context-brief.md";
49
+
50
+ /** Relatório da verificação de findings (artefato `verify.md`). */
51
+ export const VERIFY_FILE = "verify.md";
52
+
53
+ /** Relatório consolidado da review (artefato `review.md`). */
54
+ export const REVIEW_FILE = "review.md";
@@ -5,7 +5,7 @@
5
5
  * OPCIONAL: sem binário, sem login ou sem PR para a branch, grava
6
6
  * `context.json` com `{ available: false, reason }` e sai com código 0.
7
7
  *
8
- * Artefatos (em --dir, default .houndsman/.runs/<branch-slug>/):
8
+ * Artefatos no staging da review (`<HOUNDSMAN_HOME>/work/<reviewId>/`):
9
9
  * context.json payload bruto do `gh pr view` (ou marcador de indisponível)
10
10
  * context-brief.md instruções para o subagente resumir o contexto
11
11
  *
@@ -22,13 +22,19 @@ import {
22
22
  } from "./paths";
23
23
  import type { ContextSummary } from "./report-builder";
24
24
  import { parseContextSummary } from "./report-builder";
25
- import { type ContextPaths, RunLayout } from "./run-layout";
26
25
  import { systemClock } from "./services/clock";
27
26
  import { defaultGhRunner, GhClient, type GhRunner } from "./services/gh-client";
28
27
  import { GitRepository } from "./services/git-repository";
29
28
 
30
29
  export { CONTEXT_BRIEF_FILE, CONTEXT_FILE, CONTEXT_SUMMARY_FILE };
31
- export type { ContextPaths };
30
+
31
+ /** Caminhos dos artefatos de contexto da PR (staging da review). */
32
+ export type ContextPaths = {
33
+ dir: string;
34
+ context: string;
35
+ summary: string;
36
+ brief: string;
37
+ };
32
38
 
33
39
  const GH_JSON_FIELDS =
34
40
  "number,title,body,url,baseRefName,headRefName,author,commits,files";
@@ -60,8 +66,16 @@ export type PrContextRaw = {
60
66
  };
61
67
 
62
68
  /** Caminhos dos artefatos de contexto para uma branch. */
63
- export function contextPaths(dir: string, branch: string): ContextPaths {
64
- return new RunLayout(dir, branch).contextPaths;
69
+ export function contextFileNames(): {
70
+ context: string;
71
+ summary: string;
72
+ brief: string;
73
+ } {
74
+ return {
75
+ context: CONTEXT_FILE,
76
+ summary: CONTEXT_SUMMARY_FILE,
77
+ brief: CONTEXT_BRIEF_FILE,
78
+ };
65
79
  }
66
80
 
67
81
  /** Detecta a branch atual; lança se não houver (o CLI decide o exit). */