@payconductor/houndsman 0.12.0 → 0.14.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 (119) hide show
  1. package/README.md +23 -2
  2. package/package.json +1 -1
  3. package/src/cli/commands/compare-command.ts +112 -0
  4. package/src/cli/commands/context-command.ts +1 -0
  5. package/src/cli/commands/doctor-command.ts +22 -0
  6. package/src/cli/commands/findings-command.ts +11 -1
  7. package/src/cli/commands/hunt-command.ts +479 -0
  8. package/src/cli/commands/review-command.ts +58 -2
  9. package/src/cli/commands/status-command.ts +3 -2
  10. package/src/cli/help.ts +20 -3
  11. package/src/cli/index.ts +20 -3
  12. package/src/cli/progress.ts +50 -0
  13. package/src/core/ai/ai-client.ts +38 -2
  14. package/src/core/ai/ai-sdk-model-provider.ts +39 -0
  15. package/src/core/ai/features/brief-enricher.ts +1 -1
  16. package/src/core/ai/features/business-analyzer.ts +61 -0
  17. package/src/core/ai/features/business-summarizer.ts +44 -0
  18. package/src/core/ai/index.ts +14 -1
  19. package/src/core/ai/prompts/brief.ts +33 -7
  20. package/src/core/ai/prompts/business.ts +76 -0
  21. package/src/core/ai/provider-capabilities.ts +12 -0
  22. package/src/core/ai/schemas.ts +70 -2
  23. package/src/core/ai/types.ts +2 -0
  24. package/src/core/ai/usage-recorder.ts +53 -1
  25. package/src/core/brief.ts +246 -12
  26. package/src/core/builtins.ts +108 -0
  27. package/src/core/business-summary.ts +583 -0
  28. package/src/core/commands/compare.ts +233 -0
  29. package/src/core/commands/context.ts +6 -2
  30. package/src/core/commands/findings.ts +38 -30
  31. package/src/core/commands/review.ts +6 -0
  32. package/src/core/commands/scaffold.ts +38 -0
  33. package/src/core/commands/status.ts +9 -4
  34. package/src/core/compare.ts +243 -0
  35. package/src/core/concurrency.ts +30 -0
  36. package/src/core/config.ts +57 -1
  37. package/src/core/defaults.ts +63 -2
  38. package/src/core/domain/budget.ts +62 -4
  39. package/src/core/domain/classification.ts +12 -2
  40. package/src/core/engine/carryover.ts +4 -0
  41. package/src/core/engine/finalize-pipeline.ts +347 -0
  42. package/src/core/engine/prepare-pipeline.ts +1294 -0
  43. package/src/core/engine/review-context.ts +51 -0
  44. package/src/core/engine/review-options.ts +47 -0
  45. package/src/core/engine/review-scope.ts +106 -26
  46. package/src/core/engine/review-session.ts +18 -16
  47. package/src/core/engine/review-workspace.ts +14 -0
  48. package/src/core/engine/run-artifacts.ts +196 -0
  49. package/src/core/engine/run-format.ts +4 -0
  50. package/src/core/env.ts +14 -0
  51. package/src/core/findings-state.ts +13 -1
  52. package/src/core/github-review.ts +5 -2
  53. package/src/core/hunt/hunt-runner.ts +418 -0
  54. package/src/core/hunt/index.ts +61 -0
  55. package/src/core/hunt/maki-install.ts +331 -0
  56. package/src/core/hunt/maki-intent-prompt.ts +50 -0
  57. package/src/core/hunt/maki-model.ts +101 -0
  58. package/src/core/hunt/maki-prompt.ts +83 -0
  59. package/src/core/hunt/maki-release.ts +104 -0
  60. package/src/core/hunt/maki-runner.ts +276 -0
  61. package/src/core/hunt/pool.ts +8 -0
  62. package/src/core/logging/index.ts +7 -0
  63. package/src/core/logging/logger.ts +19 -0
  64. package/src/core/logging/progress.ts +366 -0
  65. package/src/core/orchestrator.ts +33 -1180
  66. package/src/core/paths.ts +6 -3
  67. package/src/core/pr-context.ts +2 -4
  68. package/src/core/prepare-report.ts +61 -0
  69. package/src/core/project-config.ts +46 -1
  70. package/src/core/propagation.ts +127 -0
  71. package/src/core/registry-check.ts +60 -21
  72. package/src/core/renderers/manifest-writer.ts +13 -0
  73. package/src/core/renderers/resume-renderer.ts +151 -11
  74. package/src/core/report-builder.ts +108 -278
  75. package/src/core/storage/file/file-storage.ts +7 -3
  76. package/src/core/storage/sqlite/mappers.ts +10 -0
  77. package/src/core/storage/sqlite/schema.ts +104 -23
  78. package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
  79. package/src/core/storage/types.ts +17 -4
  80. package/src/core/storage/work-area.ts +59 -1
  81. package/src/core/types.ts +31 -0
  82. package/src/core/watchdog-registry.ts +21 -3
  83. package/src/index.ts +78 -4
  84. package/src/server/client/ArtifactsPanel.tsx +4 -2
  85. package/src/server/client/FindingCard.tsx +21 -1
  86. package/src/server/client/RunsSection.tsx +5 -0
  87. package/src/server/client/utils/constants.ts +21 -11
  88. package/src/server/client/utils/markdown.ts +1 -1
  89. package/src/server/client/utils/types.ts +14 -2
  90. package/src/server/run-repository.ts +35 -14
  91. package/src/server/styles.css +2 -0
  92. package/src/skill/OUTPUT-FORMAT.md +36 -10
  93. package/src/skill/README.md +30 -14
  94. package/src/skill/SETUP.md +22 -1
  95. package/src/skill/SKILL.md +17 -4
  96. package/src/skill/example-resume.md +43 -0
  97. package/src/skill/hunt.md +181 -58
  98. package/src/tests/__snapshots__/baseline.test.ts.snap +206 -178
  99. package/src/tests/ai-core.test.ts +84 -1
  100. package/src/tests/ai-features.test.ts +95 -4
  101. package/src/tests/baseline.test.ts +29 -121
  102. package/src/tests/brief-scope.test.ts +250 -0
  103. package/src/tests/business-summary.test.ts +258 -0
  104. package/src/tests/cli.test.ts +1 -0
  105. package/src/tests/compare.test.ts +233 -0
  106. package/src/tests/fixtures/fake-model-provider.ts +2 -1
  107. package/src/tests/hunt-cli.test.ts +185 -0
  108. package/src/tests/hunt-install.test.ts +153 -0
  109. package/src/tests/hunt-maki.test.ts +525 -0
  110. package/src/tests/markdown.test.ts +3 -3
  111. package/src/tests/progress.test.ts +145 -0
  112. package/src/tests/propagation.test.ts +119 -0
  113. package/src/tests/review-resume.test.ts +60 -1
  114. package/src/tests/review-scope.test.ts +90 -19
  115. package/src/tests/review-scripts.test.ts +106 -218
  116. package/src/tests/server.test.ts +7 -4
  117. package/src/tests/storage.test.ts +194 -9
  118. package/src/core/renderers/report-renderer.ts +0 -66
  119. package/src/skill/example-review.md +0 -66
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Pipeline do finalize (Passo 2): findings → dedupe → estado de triagem →
3
+ * resumo final (`review-resume.md`).
4
+ *
5
+ * O contexto da run entra no construtor; sem estado ambiente.
6
+ */
7
+
8
+ import { summarizeBusiness } from "../business-summary";
9
+ import type { HoundsmanConfig } from "../config";
10
+ import { buildStateFromFindings } from "../findings-state";
11
+ import { syncResolvedThreads } from "../github-review";
12
+ import type { JevCandidatesFile } from "../jev";
13
+ import { displayPath, OUTPUT_FORMAT_PATH, REVIEW_RESUME_FILE } from "../paths";
14
+ import { ProjectConfig } from "../project-config";
15
+ import {
16
+ type ManifestAi,
17
+ type ManifestAst,
18
+ type ManifestBusiness,
19
+ ManifestWriter,
20
+ } from "../renderers/manifest-writer";
21
+ import { type ResumeDomain, renderReviewResume } from "../renderers/resume-renderer";
22
+ import { dedupeFindings, type WatchdogReport } from "../report-builder";
23
+ import { writeRunStatus } from "../run-status";
24
+ import { systemClock } from "../services/clock";
25
+ import { findWatchdog } from "../watchdog-registry";
26
+ import { mergeCarryover } from "./carryover";
27
+ import type { ReviewContext } from "./review-context";
28
+ import type { ReviewOptions } from "./review-options";
29
+ import type { RunClassification } from "./review-scope";
30
+ import { RunArtifacts } from "./run-artifacts";
31
+ import { shortSha } from "./run-format";
32
+
33
+ /** Aviso no topo do resumo quando a revisão foi incremental (senão vazio). */
34
+ function incrementalNote(classification: RunClassification): string {
35
+ if (classification.mode !== "incremental" || !classification.reviewedFrom) return "";
36
+ const carried = classification.carryover?.length ?? 0;
37
+ return (
38
+ `> **Revisão incremental** — desde \`${shortSha(classification.reviewedFrom)}\`; ` +
39
+ `${Object.keys(classification.files).length} arquivo(s) alterado(s), ` +
40
+ `${carried} finding(s) carregado(s) do review anterior.\n\n`
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Cobertura efetiva de um watchdog: só conta arquivos que estavam no escopo.
46
+ *
47
+ * `reviewedFiles` é auto-declarado pelo subagente e costuma incluir chamadores e
48
+ * arquivos de outros domínios; sem o recorte, a tabela mostrava "25/13" e
49
+ * escondia o que realmente ficou de fora.
50
+ */
51
+ export function coverageOf(
52
+ reviewedFiles: readonly string[],
53
+ expectedFiles: readonly string[],
54
+ ): { reviewed: number; expected: number } {
55
+ const expected = new Set(expectedFiles);
56
+ const reviewed = new Set(reviewedFiles.filter((file) => expected.has(file)));
57
+ return { reviewed: reviewed.size, expected: expected.size };
58
+ }
59
+
60
+ /** Arquivos do escopo que não foram revisados nem declarados em `skipped`. */
61
+ export function unaccountedFiles(
62
+ reviewedFiles: readonly string[],
63
+ skippedFiles: readonly string[],
64
+ expectedFiles: readonly string[],
65
+ ): string[] {
66
+ const accounted = new Set([...reviewedFiles, ...skippedFiles]);
67
+ return expectedFiles.filter((file) => !accounted.has(file));
68
+ }
69
+
70
+ /** Arquivos do escopo declarados em `skipped` (e não revisados). */
71
+ export function skippedInScope(
72
+ reviewedFiles: readonly string[],
73
+ skippedFiles: readonly string[],
74
+ expectedFiles: readonly string[],
75
+ ): number {
76
+ const reviewed = new Set(reviewedFiles);
77
+ const expected = new Set(expectedFiles);
78
+ return new Set(
79
+ skippedFiles.filter((file) => expected.has(file) && !reviewed.has(file)),
80
+ ).size;
81
+ }
82
+
83
+ /**
84
+ * Cobertura por domínio (achados × arquivos revisados/esperados) para a tabela
85
+ * do resumo final. Não grava relatório por domínio: os achados vivem na triagem
86
+ * (`findings`), que já é filtrável por watchdog na UI.
87
+ */
88
+ function summarizeDomains(
89
+ reports: readonly WatchdogReport[],
90
+ classification: RunClassification,
91
+ nameOf: (id: string) => string,
92
+ ): ResumeDomain[] {
93
+ return reports
94
+ .map((report) => {
95
+ const expected = classification.watchdogs[report.watchdogId] ?? [];
96
+ const coverage = coverageOf(report.reviewedFiles, expected);
97
+ return {
98
+ id: report.watchdogId,
99
+ name: nameOf(report.watchdogId),
100
+ findings: dedupeFindings([report]).length,
101
+ reviewedFiles: coverage.reviewed,
102
+ expectedFiles: coverage.expected,
103
+ skippedFiles: skippedInScope(
104
+ report.reviewedFiles,
105
+ report.skipped,
106
+ expected,
107
+ ),
108
+ };
109
+ })
110
+ .sort((a, b) => b.findings - a.findings || a.name.localeCompare(b.name));
111
+ }
112
+
113
+ export type FinalizeResult = {
114
+ reportPath: string;
115
+ blockers: number;
116
+ attention: number;
117
+ messages: string[];
118
+ };
119
+
120
+ /**
121
+ * Passo 2 — consolida os findings no resumo final (`review-resume.md`).
122
+ * Lança se houver findings pendentes (o CLI decide o exit code).
123
+ */
124
+ export class FinalizePipeline {
125
+ private readonly artifacts: RunArtifacts;
126
+
127
+ constructor(
128
+ private readonly ctx: ReviewContext,
129
+ private readonly config: HoundsmanConfig,
130
+ private readonly opts: ReviewOptions,
131
+ ) {
132
+ this.artifacts = new RunArtifacts(ctx.storage, ctx.review, ctx.work);
133
+ }
134
+
135
+ run(): FinalizeResult {
136
+ const { ctx, config, opts } = this;
137
+ const branch = ctx.review.branch;
138
+ // `--finalize` só consolida um ciclo preparado (status `reviewing`). Sem esta
139
+ // guarda, um prepare no-op seguido de `--finalize` reescreveria o resumo
140
+ // anterior com a classificação antiga (perdendo a cobertura por domínio).
141
+ // O `ctx.review` é um snapshot: relê o status atual do storage.
142
+ const status = ctx.storage.getReview(ctx.review.id)?.status ?? null;
143
+ if (status !== "reviewing") {
144
+ const state = ctx.storage.readFindings(ctx.review.id);
145
+ const present = Object.values(state?.findings ?? {}).filter(
146
+ (finding) => finding.present,
147
+ );
148
+ return {
149
+ reportPath: ctx.storage.location(ctx.review.id),
150
+ blockers: present.filter((f) => f.severity === "blocking").length,
151
+ attention: present.filter((f) => f.severity === "attention").length,
152
+ messages: [
153
+ `Nada a finalizar: nenhum ciclo de revisão pendente (status: ${status ?? "sem status"}).`,
154
+ "Se nada mudou desde a última revisão, o resumo anterior foi mantido; use `--full` para re-revisar.",
155
+ ],
156
+ };
157
+ }
158
+
159
+ const classification = this.artifacts.classification();
160
+ const activeIds = Object.keys(classification.watchdogs);
161
+
162
+ const messages: string[] = [];
163
+ const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
164
+ messages.push(...warnings);
165
+ const jev = this.artifacts.candidates(config);
166
+ const extras = this.artifacts.manifestExtras();
167
+ const business = this.artifacts.persistBusiness();
168
+ const manifestBusiness: ManifestBusiness | undefined = business
169
+ ? { source: business.source, ...summarizeBusiness(business) }
170
+ : extras.business;
171
+ this.writeManifest(
172
+ classification,
173
+ activeIds,
174
+ pending,
175
+ jev,
176
+ extras.ast,
177
+ extras.ai,
178
+ manifestBusiness,
179
+ );
180
+
181
+ if (pending.length > 0) {
182
+ throw new Error(
183
+ `Faltam ${pending.length}/${activeIds.length} findings: ${pending.join(", ")}.\n` +
184
+ `Cada watchdog deve gravar ${displayPath(ctx.work.findingsDir)}/<id>.json (schema ${OUTPUT_FORMAT_PATH}).`,
185
+ );
186
+ }
187
+
188
+ const now = systemClock();
189
+ // Findings re-reportados neste ciclo + carry-over (arquivos inalterados).
190
+ const merged = mergeCarryover(
191
+ dedupeFindings(reports),
192
+ classification.carryover ?? [],
193
+ );
194
+ if (classification.mode === "incremental") {
195
+ messages.push(
196
+ `Carry-over: ${classification.carryover?.length ?? 0} finding(s) preservado(s) de ${shortSha(classification.reviewedFrom ?? "")}.`,
197
+ );
198
+ }
199
+
200
+ // Estado de triagem: preserva o que já existia (casando por fingerprint) e
201
+ // anexa o `id` de cada finding aos itens do relatório. A linha é cache —
202
+ // recalculada por `houndsman findings sync` após edições. O commit revisado
203
+ // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
204
+ const state = buildStateFromFindings(merged, {
205
+ branch,
206
+ readFile: ctx.files.reader(),
207
+ previous: ctx.storage.readFindings(ctx.review.id),
208
+ reviewedPaths: new Set([
209
+ ...reports.flatMap((report) => report.reviewedFiles),
210
+ // Arquivo deletado: o achado deixou de existir junto com ele.
211
+ ...classification.deleted,
212
+ ]),
213
+ now,
214
+ headSha: classification.headSha,
215
+ reviewed: classification.headSha
216
+ ? { headSha: classification.headSha, at: now }
217
+ : undefined,
218
+ });
219
+
220
+ // Findings corrigidos entre revisões (ausentes + done) têm a conversa do PR
221
+ // resolvida automaticamente; as já resolvidas no GitHub são puladas. Só
222
+ // toca o `gh` quando há vínculo remoto pendente (fail-open).
223
+ const threadIds = Object.values(state.findings)
224
+ .filter(
225
+ (finding) =>
226
+ !finding.present &&
227
+ finding.execState === "done" &&
228
+ finding.github &&
229
+ finding.github.kind !== "comment" &&
230
+ !finding.github.resolvedAt,
231
+ )
232
+ .map((finding) => finding.id);
233
+ if (threadIds.length > 0) {
234
+ const sync = syncResolvedThreads({ state, ids: threadIds, now });
235
+ if (!sync.available) {
236
+ messages.push(`GitHub: threads não sincronizadas (${sync.reason}).`);
237
+ } else if (sync.checked > 0) {
238
+ const failures =
239
+ sync.failed.length > 0 ? `, ${sync.failed.length} falha(s)` : "";
240
+ messages.push(
241
+ `GitHub: ${sync.resolved.length} thread(s) resolvida(s), ${sync.skipped.length} já resolvida(s)/pulada(s)${failures}.`,
242
+ );
243
+ }
244
+ }
245
+
246
+ ctx.storage.writeFindings(ctx.review.id, state);
247
+ ctx.storage.updateReview(ctx.review.id, {
248
+ base: opts.base,
249
+ head: opts.head,
250
+ headSha: classification.headSha,
251
+ });
252
+ writeRunStatus(ctx.storage, ctx.review.id, { status: "review_done" });
253
+
254
+ this.artifacts.persistContext();
255
+ const context = ctx.work.readContextSummary() ?? this.artifacts.contextSummary();
256
+ const technical =
257
+ this.artifacts.technicalSummary() ??
258
+ this.artifacts.technicalFallback(classification);
259
+ const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
260
+ const domains = summarizeDomains(reports, classification, nameOf);
261
+ // Resumo final — único artefato humano: negócio, técnico, cobertura e
262
+ // conclusão. Os achados ficam na triagem (storage/UI), não duplicados aqui.
263
+ ctx.storage.writeArtifact(ctx.review.id, {
264
+ name: REVIEW_RESUME_FILE,
265
+ kind: "resume",
266
+ content: renderReviewResume({
267
+ branch,
268
+ date: now.slice(0, 10),
269
+ note: incrementalNote(classification),
270
+ context,
271
+ business,
272
+ classification,
273
+ findings: merged,
274
+ technical,
275
+ domains,
276
+ candidates: jev.available ? jev.candidates : undefined,
277
+ }),
278
+ });
279
+
280
+ const location = ctx.storage.location(ctx.review.id);
281
+ messages.push(
282
+ `Triagem: ${location} — ${Object.keys(state.findings).length} finding(s).`,
283
+ );
284
+
285
+ const blockers = merged.filter((f) => f.severity === "blocking").length;
286
+ const attention = merged.filter((f) => f.severity === "attention").length;
287
+ messages.push(
288
+ `Resumo: ${location} (${REVIEW_RESUME_FILE}) — ${blockers} bloqueante(s), ${attention} atenção.`,
289
+ );
290
+
291
+ // Lacuna de cobertura: arquivos do escopo que ninguém declarou ter revisado.
292
+ const gaps = reports.flatMap((report) => {
293
+ const expected = classification.watchdogs[report.watchdogId] ?? [];
294
+ const missing = unaccountedFiles(
295
+ report.reviewedFiles,
296
+ report.skipped,
297
+ expected,
298
+ );
299
+ if (missing.length === 0) return [];
300
+ return [
301
+ {
302
+ id: report.watchdogId,
303
+ missing,
304
+ coverage: coverageOf(report.reviewedFiles, expected),
305
+ },
306
+ ];
307
+ });
308
+ if (gaps.length > 0) {
309
+ const total = gaps.reduce((sum, gap) => sum + gap.missing.length, 0);
310
+ const detail = gaps
311
+ .map((gap) => {
312
+ const sample = gap.missing.slice(0, 3).join(", ");
313
+ const rest =
314
+ gap.missing.length > 3 ? ` (+${gap.missing.length - 3})` : "";
315
+ return `${nameOf(gap.id)} (${gap.coverage.reviewed}/${gap.coverage.expected}): ${sample}${rest}`;
316
+ })
317
+ .join("; ");
318
+ messages.push(
319
+ `Cobertura: ${total} arquivo(s) sem revisão declarada — ${detail}.`,
320
+ );
321
+ }
322
+
323
+ // O staging já cumpriu o papel (briefs/findings/contexto estão no storage).
324
+ ctx.work.clean();
325
+
326
+ return { reportPath: location, blockers, attention, messages };
327
+ }
328
+
329
+ /** Grava o manifesto da run (delegado ao `ManifestWriter`). */
330
+ private writeManifest(
331
+ classification: RunClassification,
332
+ activeIds: string[],
333
+ pending: string[],
334
+ jev: JevCandidatesFile,
335
+ ast?: ManifestAst,
336
+ ai?: ManifestAi,
337
+ business?: ManifestBusiness,
338
+ ): void {
339
+ new ManifestWriter(
340
+ this.ctx.storage,
341
+ this.ctx.review,
342
+ this.ctx.work,
343
+ new ProjectConfig(this.config),
344
+ this.opts,
345
+ ).write(classification, activeIds, pending, jev, ast, ai, business);
346
+ }
347
+ }