@payconductor/houndsman 0.4.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 (58) hide show
  1. package/README.md +12 -38
  2. package/package.json +1 -1
  3. package/src/cli/commands/status-command.ts +46 -0
  4. package/src/cli/help.ts +1 -0
  5. package/src/cli/index.ts +3 -0
  6. package/src/core/commands/findings.ts +61 -5
  7. package/src/core/commands/status.ts +63 -0
  8. package/src/core/engine/review-scope.ts +26 -8
  9. package/src/core/findings-state.ts +21 -4
  10. package/src/core/github-review.ts +620 -57
  11. package/src/core/orchestrator.ts +98 -8
  12. package/src/core/paths.ts +3 -0
  13. package/src/core/renderers/resume-renderer.ts +151 -0
  14. package/src/core/report-builder.ts +7 -4
  15. package/src/core/run-status.ts +39 -5
  16. package/src/core/storage/file/file-storage.ts +2 -0
  17. package/src/core/storage/sqlite/mappers.ts +2 -0
  18. package/src/core/storage/sqlite/schema.ts +9 -1
  19. package/src/core/storage/sqlite/sqlite-storage.ts +5 -1
  20. package/src/core/storage/types.ts +9 -0
  21. package/src/server/client/App.tsx +26 -9
  22. package/src/server/client/ArtifactsPanel.tsx +12 -5
  23. package/src/server/client/AutoRefresh.tsx +1 -1
  24. package/src/server/client/Filters.tsx +7 -2
  25. package/src/server/client/FindingCard.tsx +14 -10
  26. package/src/server/client/FindingsList.tsx +2 -2
  27. package/src/server/client/MarkdownText.tsx +92 -12
  28. package/src/server/client/ResumePanel.tsx +368 -0
  29. package/src/server/client/RunList.tsx +3 -3
  30. package/src/server/client/SubmitModal.tsx +49 -6
  31. package/src/server/client/SummaryBar.tsx +1 -1
  32. package/src/server/client/icons.tsx +17 -0
  33. package/src/server/client/{constants.ts → utils/constants.ts} +34 -5
  34. package/src/server/client/utils/markdown.ts +319 -0
  35. package/src/server/client/{types.ts → utils/types.ts} +58 -0
  36. package/src/server/index.ts +63 -5
  37. package/src/server/run-repository.ts +9 -0
  38. package/src/skill/example-review.md +22 -14
  39. package/src/skill/hunt.md +31 -17
  40. package/src/skill/verify.md +2 -1
  41. package/src/tests/__snapshots__/baseline.test.ts.snap +32 -30
  42. package/src/tests/baseline.test.ts +3 -0
  43. package/src/tests/code-highlight.test.ts +1 -1
  44. package/src/tests/findings-state.test.ts +50 -2
  45. package/src/tests/github-review.test.ts +409 -60
  46. package/src/tests/markdown.test.ts +61 -1
  47. package/src/tests/review-resume.test.ts +101 -0
  48. package/src/tests/review-scope.test.ts +302 -0
  49. package/src/tests/review-scripts.test.ts +73 -0
  50. package/src/tests/run-status-view.test.ts +94 -0
  51. package/src/tests/run-status.test.ts +53 -0
  52. package/src/tests/server.test.ts +76 -21
  53. package/src/tests/storage.test.ts +63 -0
  54. package/src/server/client/markdown.ts +0 -153
  55. /package/src/server/client/{api.ts → utils/api.ts} +0 -0
  56. /package/src/server/client/{code-highlight.ts → utils/code-highlight.ts} +0 -0
  57. /package/src/server/client/{format.ts → utils/format.ts} +0 -0
  58. /package/src/server/client/{json-highlight.ts → utils/json-highlight.ts} +0 -0
@@ -30,6 +30,7 @@ import { ReviewScopeResolver, type RunClassification } from "./engine/review-sco
30
30
  import type { BudgetSelection, ClassificationResult } from "./file-classifier";
31
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,
@@ -39,12 +40,18 @@ import {
39
40
  parseCandidates,
40
41
  serializeCandidates,
41
42
  } from "./jev";
42
- import { displayPath, OUTPUT_FORMAT_PATH, REVIEW_FILE } from "./paths";
43
+ import {
44
+ displayPath,
45
+ OUTPUT_FORMAT_PATH,
46
+ REVIEW_FILE,
47
+ REVIEW_RESUME_FILE,
48
+ } from "./paths";
43
49
  import { currentBranch } from "./pr-context";
44
50
  import { ProjectConfig } from "./project-config";
45
51
  import { BriefRenderer } from "./renderers/brief-renderer";
46
52
  import { ManifestWriter } from "./renderers/manifest-writer";
47
53
  import { ReportRenderer } from "./renderers/report-renderer";
54
+ import { renderReviewResume } from "./renderers/resume-renderer";
48
55
  import {
49
56
  type ContextSummary,
50
57
  type DomainReport,
@@ -312,19 +319,36 @@ export async function prepare(
312
319
  if (opts.clean) ctx.work.cleanFindings();
313
320
  ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
314
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
+ }
344
+
315
345
  // Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
316
346
  writeRunStatus(ctx.storage, ctx.review.id, {
317
347
  status: "reviewing",
318
348
  resetStartedAt: true,
319
349
  });
320
350
 
321
- // Marca o commit revisado e decide entre review completo e incremental.
322
- const headSha = resolveSha(opts.head);
323
- const previous = ctx.storage.readFindings(ctx.review.id);
324
- const { classification, diff } = new ReviewScopeResolver(opts, config).resolve(
325
- previous,
326
- headSha,
327
- );
351
+ const { classification, diff } = scope;
328
352
  ctx.storage.writeArtifact(ctx.review.id, {
329
353
  name: "classification.json",
330
354
  kind: "classification",
@@ -510,6 +534,27 @@ export function finalize(
510
534
  config: HoundsmanConfig,
511
535
  ctx: ReviewContext,
512
536
  ): FinalizeResult {
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
+
513
558
  const classification = readClassification(ctx);
514
559
  const activeIds = Object.keys(classification.watchdogs);
515
560
 
@@ -546,12 +591,44 @@ export function finalize(
546
591
  branch,
547
592
  readFile: fileReader(process.cwd()),
548
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
+ ]),
549
599
  now,
550
600
  headSha: classification.headSha,
551
601
  reviewed: classification.headSha
552
602
  ? { headSha: classification.headSha, at: now }
553
603
  : undefined,
554
604
  });
605
+
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
+
555
632
  ctx.storage.writeFindings(ctx.review.id, state);
556
633
  ctx.storage.updateReview(ctx.review.id, {
557
634
  base: opts.base,
@@ -585,6 +662,19 @@ export function finalize(
585
662
  kind: "review",
586
663
  content: `${incrementalNote(classification)}${markdown}\n`,
587
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
+ });
588
678
 
589
679
  const location = ctx.storage.location(ctx.review.id);
590
680
  messages.push(
package/src/core/paths.ts CHANGED
@@ -52,3 +52,6 @@ export const VERIFY_FILE = "verify.md";
52
52
 
53
53
  /** Relatório consolidado da review (artefato `review.md`). */
54
54
  export const REVIEW_FILE = "review.md";
55
+
56
+ /** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
57
+ export const REVIEW_RESUME_FILE = "review-resume.md";
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Resumo final da revisão (`review-resume.md`).
3
+ *
4
+ * Artefato somente-leitura para humanos: não entra em briefs, não é lido pelos
5
+ * watchdogs e não participa de nenhuma etapa seguinte. Une o contexto de
6
+ * negócio da PR aos números técnicos diretos da revisão, com limite de
7
+ * `RESUME_MAX_CHARS`.
8
+ */
9
+
10
+ import type {
11
+ ContextSummary,
12
+ MergedFinding,
13
+ WatchdogNameResolver,
14
+ } from "../report-builder";
15
+ import { systemClock } from "../services/clock";
16
+
17
+ /** Teto de caracteres do artefato (inclui cabeçalho e quebras de linha). */
18
+ export const RESUME_MAX_CHARS = 2500;
19
+
20
+ /** Dados de classificação usados no resumo. */
21
+ export type ResumeClassification = {
22
+ files: Record<string, { status: string }>;
23
+ watchdogs: Record<string, string[]>;
24
+ };
25
+
26
+ export type ReviewResumeInput = {
27
+ branch: string;
28
+ /** `YYYY-MM-DD`; default: data corrente do sistema. */
29
+ date?: string;
30
+ context?: ContextSummary | null;
31
+ classification: ResumeClassification;
32
+ findings: readonly MergedFinding[];
33
+ nameOf?: WatchdogNameResolver;
34
+ };
35
+
36
+ const identityName: WatchdogNameResolver = (id) => id;
37
+
38
+ const STATUS_BY_SEVERITY: Record<"blocking" | "attention" | "clean", string> = {
39
+ blocking: "❌ Mudanças necessárias",
40
+ attention: "⚠️ Aprovado com ressalvas",
41
+ clean: "✅ Aprovado",
42
+ };
43
+
44
+ /** Corta o texto em `max` caracteres, sinalizando com reticências. */
45
+ function truncate(value: string, max: number): string {
46
+ const text = value.trim().replace(/\s+/g, " ");
47
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
48
+ }
49
+
50
+ function plural(count: number, singular: string, plural: string): string {
51
+ return `${count} ${count === 1 ? singular : plural}`;
52
+ }
53
+
54
+ /** Resumo de negócio: contexto da PR; sem ele, o alcance da branch. */
55
+ function businessSection(input: ReviewResumeInput): string[] {
56
+ const context = input.context;
57
+ const hasContext =
58
+ context?.available === true &&
59
+ Boolean(
60
+ context.objective ||
61
+ context.motivation ||
62
+ (context.changeSummary && context.changeSummary.length > 0),
63
+ );
64
+ const lines = ["## Resumo de negócio", ""];
65
+
66
+ if (!hasContext) {
67
+ const domainCount = Object.keys(input.classification.watchdogs).length;
68
+ lines.push(
69
+ `A branch \`${input.branch}\` altera ${plural(
70
+ Object.keys(input.classification.files).length,
71
+ "arquivo",
72
+ "arquivos",
73
+ )} em ${plural(domainCount, "domínio", "domínios")} de revisão.`,
74
+ "Sem resumo de contexto da PR disponível.",
75
+ "",
76
+ );
77
+ return lines;
78
+ }
79
+
80
+ if (context.objective) lines.push(context.objective, "");
81
+ if (context.motivation) lines.push(`**Por quê:** ${context.motivation}`, "");
82
+ if (context.changeSummary && context.changeSummary.length > 0) {
83
+ for (const item of context.changeSummary.slice(0, 4)) {
84
+ lines.push(`- ${truncate(item, 180)}`);
85
+ }
86
+ lines.push("");
87
+ }
88
+ return lines;
89
+ }
90
+
91
+ /** Achados que exigem ação, em formato direto (máximo 5). */
92
+ function technicalSection(
93
+ findings: readonly MergedFinding[],
94
+ nameOf: WatchdogNameResolver,
95
+ ): string[] {
96
+ const relevant = findings
97
+ .filter(
98
+ (finding) =>
99
+ finding.severity === "blocking" || finding.severity === "attention",
100
+ )
101
+ .slice(0, 5);
102
+ const lines = ["## Pontos técnicos", ""];
103
+ if (relevant.length === 0) {
104
+ lines.push("- Nenhum bloqueio ou ponto de atenção.", "");
105
+ return lines;
106
+ }
107
+ for (const finding of relevant) {
108
+ const location = finding.line ? `${finding.file}:${finding.line}` : finding.file;
109
+ const watchdogs = finding.watchdogs.map((id) => nameOf(id)).join(", ");
110
+ lines.push(
111
+ `- \`${location}\` — ${truncate(finding.message, 130)}${watchdogs ? ` _(${watchdogs})_` : ""}`,
112
+ );
113
+ }
114
+ lines.push("");
115
+ return lines;
116
+ }
117
+
118
+ /** Garante o teto de caracteres cortando em uma quebra de linha. */
119
+ function clamp(markdown: string, max: number): string {
120
+ if (markdown.length <= max) return markdown;
121
+ const head = markdown.slice(0, max - 2);
122
+ const cut = head.lastIndexOf("\n");
123
+ return `${(cut > 0 ? head.slice(0, cut) : head).trimEnd()}\n…`;
124
+ }
125
+
126
+ /**
127
+ * Renderiza o resumo final. Determinístico e sem chamadas externas: o texto
128
+ * nasce do contexto já disponível e dos findings consolidados.
129
+ */
130
+ export function renderReviewResume(input: ReviewResumeInput): string {
131
+ const nameOf = input.nameOf ?? identityName;
132
+ const blockers = input.findings.filter((f) => f.severity === "blocking").length;
133
+ const attention = input.findings.filter((f) => f.severity === "attention").length;
134
+ const status =
135
+ blockers > 0
136
+ ? STATUS_BY_SEVERITY.blocking
137
+ : attention > 0
138
+ ? STATUS_BY_SEVERITY.attention
139
+ : STATUS_BY_SEVERITY.clean;
140
+
141
+ const lines = [
142
+ `# Resumo da revisão — ${input.branch}`,
143
+ "",
144
+ `**Data:** ${input.date ?? systemClock().slice(0, 10)} · **Status:** ${status}`,
145
+ "",
146
+ ...businessSection(input),
147
+ ...technicalSection(input.findings, nameOf),
148
+ ];
149
+
150
+ return clamp(`${lines.join("\n").trimEnd()}\n`, RESUME_MAX_CHARS);
151
+ }
@@ -294,6 +294,7 @@ function renderConclusion(counts: Record<Severity, number>): string[] {
294
294
  "",
295
295
  `**Status:** ${status}`,
296
296
  `**Resumo:** ${counts.blocking} violações bloqueantes, ${counts.attention} pontos de atenção, ${counts.suggestion} sugestões e ${counts.nit} nits.`,
297
+ "",
297
298
  ];
298
299
  }
299
300
 
@@ -453,6 +454,12 @@ export function renderReport(
453
454
 
454
455
  if (context) lines.push(...renderContext(context));
455
456
 
457
+ // Sumário primeiro: quem lê o review.md vê domínios, cobertura e conclusão
458
+ // antes dos pontos específicos.
459
+ lines.push(...renderDomainIndex(domains));
460
+ lines.push(...renderCoverage(reports, coverage, nameOf));
461
+ lines.push(...renderConclusion(counts));
462
+
456
463
  lines.push(...renderFindingsSections(merged, nameOf));
457
464
 
458
465
  if (merged.length === 0) {
@@ -463,9 +470,5 @@ export function renderReport(
463
470
  lines.push(...renderUnconfirmedCandidates(candidates, merged));
464
471
  }
465
472
 
466
- lines.push(...renderDomainIndex(domains));
467
- lines.push(...renderCoverage(reports, coverage, nameOf));
468
- lines.push(...renderConclusion(counts));
469
-
470
473
  return lines.join("\n");
471
474
  }
@@ -4,9 +4,9 @@
4
4
  * Diz à UI em que pé está uma run: `reviewing` (revisando) → `review_done`
5
5
  * (revisão finalizada) → `fixing` (corrigindo) → `fix_done` (correção finalizada).
6
6
  *
7
- * Toda ação que muda o estado faz um **ping** no `updatedAt`. Timeout é
8
- * **display-only** (a UI compara `updatedAt` com o agora) — não é um status
9
- * persistido.
7
+ * Toda ação que muda o estado pinga o `updatedAt`. Execuções ativas têm um
8
+ * `heartbeatAt` próprio (renovado por `status ping` ou pela correção); a UI usa
9
+ * esse campo — não o `updatedAt` — para detectar timeout (display-only).
10
10
  */
11
11
 
12
12
  import type { FindingsStateFile } from "./findings-state";
@@ -24,6 +24,11 @@ export const RUN_STATUSES: readonly RunStatus[] = [
24
24
  "fix_done",
25
25
  ];
26
26
 
27
+ /** `true` para status de execução (recebem heartbeat e podem dar timeout). */
28
+ export function isActiveRunStatus(status: RunStatus | null): boolean {
29
+ return status === "reviewing" || status === "fixing";
30
+ }
31
+
27
32
  /** Lê o status atual (null se a review não existir). */
28
33
  export function readRunStatus(
29
34
  storage: StorageProvider,
@@ -36,6 +41,11 @@ export type WriteRunStatusOptions = {
36
41
  status: RunStatus;
37
42
  /** Reinicia o `startedAt` — usado no início de uma nova revisão. */
38
43
  resetStartedAt?: boolean;
44
+ /**
45
+ * Marca a execução como viva agora. Implícito no início de um ciclo
46
+ * (`resetStartedAt`); pings explícitos usam `touchRunStatus`.
47
+ */
48
+ touch?: boolean;
39
49
  now?: string;
40
50
  };
41
51
 
@@ -45,15 +55,35 @@ export function writeRunStatus(
45
55
  reviewId: string,
46
56
  options: WriteRunStatusOptions,
47
57
  ): ReviewRecord {
58
+ const now = options.now ?? systemClock();
59
+ const active = isActiveRunStatus(options.status);
60
+ const touch = options.touch ?? options.resetStartedAt ?? false;
48
61
  const updated = storage.updateReview(reviewId, {
49
62
  status: options.status,
50
63
  resetStartedAt: options.resetStartedAt,
51
- updatedAt: options.now ?? systemClock(),
64
+ heartbeatAt: active && touch ? now : undefined,
65
+ resetHeartbeat: !active,
66
+ updatedAt: now,
52
67
  });
53
68
  if (!updated) throw new Error(`Review '${reviewId}' não encontrada.`);
54
69
  return updated;
55
70
  }
56
71
 
72
+ /**
73
+ * Heartbeat explícito de uma execução em andamento (skill viva).
74
+ * Só age em `reviewing`/`fixing`; status terminal é devolvido sem alteração.
75
+ */
76
+ export function touchRunStatus(
77
+ storage: StorageProvider,
78
+ reviewId: string,
79
+ now: string = systemClock(),
80
+ ): ReviewRecord {
81
+ const review = storage.getReview(reviewId);
82
+ if (!review) throw new Error(`Review '${reviewId}' não encontrada.`);
83
+ if (!isActiveRunStatus(review.status)) return review;
84
+ return storage.updateReview(reviewId, { heartbeatAt: now, updatedAt: now }) ?? review;
85
+ }
86
+
57
87
  /**
58
88
  * Deriva o status após uma mudança nos findings (triagem/correção).
59
89
  * `fixing` exige correção em andamento (`in_progress`) ou retomada de um fix;
@@ -87,7 +117,11 @@ export function pingFindingsStatus(
87
117
  state: FindingsStateFile,
88
118
  ): ReviewRecord {
89
119
  const previous = storage.getReview(reviewId)?.status ?? null;
120
+ const status = statusAfterFindingsChange(previous, state);
90
121
  return writeRunStatus(storage, reviewId, {
91
- status: statusAfterFindingsChange(previous, state),
122
+ status,
123
+ // Só a correção em andamento é uma execução: triagem/reviewing não
124
+ // devem renovar o heartbeat (era a origem do "timeout falso").
125
+ touch: status === "fixing",
92
126
  });
93
127
  }
@@ -102,6 +102,8 @@ export class FileStorageProvider implements StorageProvider {
102
102
  if (patch.status !== undefined) updated.status = patch.status;
103
103
  if (patch.resetStartedAt) updated.startedAt = now;
104
104
  else if (patch.startedAt !== undefined) updated.startedAt = patch.startedAt;
105
+ if (patch.resetHeartbeat) updated.heartbeatAt = undefined;
106
+ else if (patch.heartbeatAt !== undefined) updated.heartbeatAt = patch.heartbeatAt;
105
107
  updated.updatedAt = patch.updatedAt ?? now;
106
108
  return this.writeReview(updated);
107
109
  }
@@ -30,6 +30,7 @@ export type ReviewRow = {
30
30
  reviewed_at: string | null;
31
31
  status: string | null;
32
32
  started_at: string | null;
33
+ heartbeat_at: string | null;
33
34
  created_at: string;
34
35
  updated_at: string;
35
36
  };
@@ -96,6 +97,7 @@ export function reviewFromRow(row: ReviewRow): ReviewRecord {
96
97
  reviewedAt: row.reviewed_at ?? undefined,
97
98
  status: (row.status as ReviewRecord["status"]) ?? null,
98
99
  startedAt: row.started_at ?? undefined,
100
+ heartbeatAt: row.heartbeat_at ?? undefined,
99
101
  createdAt: row.created_at,
100
102
  updatedAt: row.updated_at,
101
103
  };
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { Database } from "bun:sqlite";
9
9
 
10
- export const SCHEMA_VERSION = 1;
10
+ export const SCHEMA_VERSION = 2;
11
11
 
12
12
  const DDL = `
13
13
  CREATE TABLE IF NOT EXISTS reviews (
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS reviews (
24
24
  reviewed_at TEXT,
25
25
  status TEXT,
26
26
  started_at TEXT,
27
+ heartbeat_at TEXT,
27
28
  created_at TEXT NOT NULL,
28
29
  updated_at TEXT NOT NULL,
29
30
  UNIQUE(repo_key, branch)
@@ -103,6 +104,13 @@ export function migrate(db: Database, path: string): void {
103
104
 
104
105
  db.transaction(() => {
105
106
  db.exec(DDL);
107
+ // v2: heartbeat explícito da execução (bancos v1 não tinham a coluna).
108
+ const columns = db.query("PRAGMA table_info(reviews)").all() as {
109
+ name: string;
110
+ }[];
111
+ if (!columns.some((column) => column.name === "heartbeat_at")) {
112
+ db.exec("ALTER TABLE reviews ADD COLUMN heartbeat_at TEXT");
113
+ }
106
114
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
107
115
  })();
108
116
  }
@@ -33,7 +33,7 @@ import { migrate } from "./schema";
33
33
 
34
34
  /** Colunas mutáveis de review (camel → snake). */
35
35
  const REVIEW_PATCH_COLUMNS: Record<
36
- Exclude<keyof ReviewPatch, "resetStartedAt">,
36
+ Exclude<keyof ReviewPatch, "resetStartedAt" | "resetHeartbeat">,
37
37
  string
38
38
  > = {
39
39
  branch: "branch",
@@ -45,6 +45,7 @@ const REVIEW_PATCH_COLUMNS: Record<
45
45
  reviewedAt: "reviewed_at",
46
46
  status: "status",
47
47
  startedAt: "started_at",
48
+ heartbeatAt: "heartbeat_at",
48
49
  updatedAt: "updated_at",
49
50
  };
50
51
 
@@ -159,6 +160,9 @@ export class SQLiteStorageProvider implements StorageProvider {
159
160
  sets.push("started_at = ?");
160
161
  params.push(now);
161
162
  }
163
+ if (patch.resetHeartbeat) {
164
+ sets.push("heartbeat_at = NULL");
165
+ }
162
166
  if (patch.updatedAt === undefined) {
163
167
  sets.push("updated_at = ?");
164
168
  params.push(now);
@@ -36,6 +36,11 @@ export type ReviewRecord = {
36
36
  reviewedAt?: string;
37
37
  status: ReviewStatus | null;
38
38
  startedAt?: string;
39
+ /**
40
+ * Último sinal de vida de uma execução ativa (`reviewing`/`fixing`).
41
+ * Diferente de `updatedAt`: não muda com triagem/sync — só com a execução.
42
+ */
43
+ heartbeatAt?: string;
39
44
  createdAt: string;
40
45
  updatedAt: string;
41
46
  };
@@ -53,11 +58,14 @@ export type ReviewPatch = Partial<
53
58
  | "reviewedAt"
54
59
  | "status"
55
60
  | "startedAt"
61
+ | "heartbeatAt"
56
62
  | "updatedAt"
57
63
  >
58
64
  > & {
59
65
  /** Reinicia o `startedAt` no início de um novo ciclo de revisão. */
60
66
  resetStartedAt?: boolean;
67
+ /** Limpa o heartbeat (execução terminou). */
68
+ resetHeartbeat?: boolean;
61
69
  };
62
70
 
63
71
  /** Entrada de `getOrCreateReview`. */
@@ -81,6 +89,7 @@ export type ArtifactKind =
81
89
  | "context-summary"
82
90
  | "finding-report"
83
91
  | "review"
92
+ | "resume"
84
93
  | "domain-review"
85
94
  | "verify"
86
95
  | "other";
@@ -1,25 +1,31 @@
1
1
  /**
2
2
  * App: layout do servidor — sidebar de execuções, header fixo (abas segmentadas,
3
- * auto-refresh e reancoragem), KPIs, filtros e os painéis de findings/artefatos.
4
- * Concentra o estado e as chamadas à API.
3
+ * auto-refresh e reancoragem), KPIs, filtros e os painéis de resumo/findings/
4
+ * artefatos. Concentra o estado e as chamadas à API.
5
5
  */
6
6
 
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
8
8
  import { ArtifactsPanel } from "./ArtifactsPanel";
9
9
  import { AutoRefresh } from "./AutoRefresh";
10
- import { api, computeSummary } from "./api";
11
- import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./constants";
12
10
  import { Filters, type FiltersState } from "./Filters";
13
11
  import { FindingsList } from "./FindingsList";
14
- import { Layers, ListIcon, Refresh, Send } from "./icons";
12
+ import { FileText, Layers, ListIcon, Refresh, Send } from "./icons";
13
+ import { ResumePanel } from "./ResumePanel";
15
14
  import { RunList } from "./RunList";
16
15
  import { SubmitModal } from "./SubmitModal";
17
16
  import { SummaryBar } from "./SummaryBar";
18
- import type { Finding, FindingsResponse, RunSummary } from "./types";
17
+ import { api, computeSummary } from "./utils/api";
18
+ import { AUTO_REFRESH_MS, TAB_CAPTIONS } from "./utils/constants";
19
+ import type { Finding, FindingsResponse, RunSummary } from "./utils/types";
19
20
 
20
- type Tab = "findings" | "artifacts";
21
+ type Tab = "resume" | "findings" | "artifacts";
21
22
 
22
23
  const TABS: { id: Tab; label: string; hint: string }[] = [
24
+ {
25
+ id: "resume",
26
+ label: "Resumo",
27
+ hint: "Visão principal da revisão: negócio, números e pontos técnicos",
28
+ },
23
29
  {
24
30
  id: "findings",
25
31
  label: "Findings",
@@ -36,7 +42,7 @@ export function App() {
36
42
  const [runs, setRuns] = useState<RunSummary[]>([]);
37
43
  const [slug, setSlug] = useState<string | null>(null);
38
44
  const [data, setData] = useState<FindingsResponse | null>(null);
39
- const [tab, setTab] = useState<Tab>("findings");
45
+ const [tab, setTab] = useState<Tab>("resume");
40
46
  const [filters, setFilters] = useState<FiltersState>({
41
47
  query: "",
42
48
  decision: "all",
@@ -200,7 +206,9 @@ export function App() {
200
206
  class={`seg-btn${tab === item.id ? " active" : ""}`}
201
207
  onClick={() => setTab(item.id)}
202
208
  >
203
- {item.id === "findings" ? (
209
+ {item.id === "resume" ? (
210
+ <FileText size={15} />
211
+ ) : item.id === "findings" ? (
204
212
  <ListIcon size={15} />
205
213
  ) : (
206
214
  <Layers size={15} />
@@ -254,6 +262,15 @@ export function App() {
254
262
  </div>
255
263
  {error && <p class="error">{error}</p>}
256
264
 
265
+ {slug && tab === "resume" && data && (
266
+ <ResumePanel
267
+ slug={slug}
268
+ branch={branch}
269
+ findings={data.findings}
270
+ summary={summary}
271
+ onOpenArtifacts={() => setTab("artifacts")}
272
+ />
273
+ )}
257
274
  {slug && tab === "findings" && data && (
258
275
  <>
259
276
  <SummaryBar summary={summary} />
@@ -1,20 +1,23 @@
1
1
  /**
2
2
  * Painel de artefatos: lista todos os artefatos persistidos da run e renderiza
3
- * o conteúdo bruto. JSON ganha destaque de sintaxe; briefs e relatórios são
3
+ * o conteúdo bruto. JSON ganha destaque de sintaxe; markdown é renderizado com
4
+ * o mesmo parser/highlight do restante da UI. Briefs e relatórios ficam
4
5
  * auditáveis pela mesma navegação.
5
6
  */
6
7
 
7
8
  import { useEffect, useMemo, useState } from "preact/hooks";
8
- import { api } from "./api";
9
+ import { MarkdownText } from "./MarkdownText";
10
+ import { api } from "./utils/api";
9
11
  import {
10
12
  ARTIFACT_KIND_LABELS,
11
13
  describeArtifact,
12
14
  displayArtifactName,
13
15
  groupArtifacts,
14
16
  isJsonArtifact,
15
- } from "./constants";
16
- import { formatJson, highlightJson } from "./json-highlight";
17
- import type { ArtifactMeta } from "./types";
17
+ isMarkdownArtifact,
18
+ } from "./utils/constants";
19
+ import { formatJson, highlightJson } from "./utils/json-highlight";
20
+ import type { ArtifactMeta } from "./utils/types";
18
21
 
19
22
  /** Prefere o relatório consolidado; senão, o primeiro artefato disponível. */
20
23
  function pickDefault(items: ArtifactMeta[]): string {
@@ -149,6 +152,10 @@ export function ArtifactsPanel({ slug }: { slug: string }) {
149
152
  ) : selected ? (
150
153
  isJsonArtifact(selected) ? (
151
154
  <JsonView content={content} />
155
+ ) : isMarkdownArtifact(selected) ? (
156
+ <div class="artifact-view artifact-markdown">
157
+ <MarkdownText text={content} />
158
+ </div>
152
159
  ) : (
153
160
  <pre class="artifact-view">{content || "—"}</pre>
154
161
  )
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { useEffect, useState } from "preact/hooks";
9
- import { formatElapsed } from "./format";
9
+ import { formatElapsed } from "./utils/format";
10
10
 
11
11
  export function AutoRefresh({
12
12
  lastSync,