@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
@@ -9,16 +9,16 @@
9
9
  */
10
10
 
11
11
  import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
12
- import { api } from "./api";
13
- import { EXEC_STATES, SEVERITY_LABELS, SEVERITY_ORDER } from "./constants";
14
- import { ExternalLink, Send, Spinner } from "./icons";
12
+ import { GitHub, Send, Spinner } from "./icons";
13
+ import { api } from "./utils/api";
14
+ import { EXEC_STATES, SEVERITY_LABELS, SEVERITY_ORDER } from "./utils/constants";
15
15
  import type {
16
16
  ExecState,
17
17
  Severity,
18
18
  SubmitOutcome,
19
19
  SubmitPreview,
20
20
  SubmitResponse,
21
- } from "./types";
21
+ } from "./utils/types";
22
22
 
23
23
  const EXEC_LABELS: Record<ExecState, string> = {
24
24
  pending: "Pendente",
@@ -333,6 +333,27 @@ export function SubmitModal({
333
333
  arquivo
334
334
  </span>
335
335
  )}
336
+ {candidate.sent &&
337
+ (candidate.sentUrl ? (
338
+ <a
339
+ class="sent-tag gh"
340
+ href={candidate.sentUrl}
341
+ target="_blank"
342
+ rel="noreferrer"
343
+ title="Já enviado ao GitHub — abrir comentário"
344
+ >
345
+ <GitHub size={12} />
346
+ enviado
347
+ </a>
348
+ ) : (
349
+ <span
350
+ class="sent-tag gh"
351
+ title="Já enviado ao GitHub"
352
+ >
353
+ <GitHub size={12} />
354
+ enviado
355
+ </span>
356
+ ))}
336
357
  </div>
337
358
  ))}
338
359
  </div>
@@ -389,22 +410,44 @@ export function SubmitModal({
389
410
  /** Resumo do resultado do envio. */
390
411
  function ResultView({ outcome }: { outcome: SubmitOutcome }) {
391
412
  const generalUrl = outcome.general?.url;
413
+ const review = outcome.review;
414
+ const reviewUrl = review?.url;
392
415
  return (
393
416
  <>
394
417
  <div class="submit-summary">
395
418
  Enviados <b>{outcome.posted.length}</b> · falhas{" "}
396
419
  <b>{outcome.failed.length}</b> · pulados <b>{outcome.skipped.length}</b>.
397
420
  </div>
421
+ {review && (
422
+ <p class="submit-empty">
423
+ {review.created
424
+ ? review.finalized
425
+ ? `Revisão #${review.id} finalizada (${review.event}).`
426
+ : `Revisão #${review.id} criada, mas não finalizada.`
427
+ : `Comentários adicionados à revisão pendente #${review.id}.`}{" "}
428
+ {reviewUrl && (
429
+ <a
430
+ class="sent-tag gh"
431
+ href={reviewUrl}
432
+ target="_blank"
433
+ rel="noreferrer"
434
+ >
435
+ <GitHub size={12} /> abrir
436
+ </a>
437
+ )}
438
+ {review.error && <> ❌ {review.error}</>}
439
+ </p>
440
+ )}
398
441
  {generalUrl && (
399
442
  <p class="submit-empty">
400
443
  Comentário geral:{" "}
401
444
  <a
402
- class="sent-tag"
445
+ class="sent-tag gh"
403
446
  href={generalUrl}
404
447
  target="_blank"
405
448
  rel="noreferrer"
406
449
  >
407
- <ExternalLink size={12} /> abrir
450
+ <GitHub size={12} /> abrir
408
451
  </a>
409
452
  </p>
410
453
  )}
@@ -3,7 +3,7 @@
3
3
  * progresso da correção (feitos ÷ total).
4
4
  */
5
5
 
6
- import type { Summary } from "./types";
6
+ import type { Summary } from "./utils/types";
7
7
 
8
8
  function Kpi({
9
9
  label,
@@ -51,6 +51,14 @@ export const ExternalLink = (p: IconProps) => (
51
51
  </Svg>
52
52
  );
53
53
 
54
+ /** Marca do GitHub (contorno) — usada em achados já publicados na PR. */
55
+ export const GitHub = (p: IconProps) => (
56
+ <Svg {...p}>
57
+ <path d="M9 19c-4.3 1.3-4.3-2.2-6-2.7" />
58
+ <path d="M15 22v-3.9a3.4 3.4 0 0 0-.9-2.6c3-.3 6.1-1.5 6.1-6.8a5.3 5.3 0 0 0-1.4-3.7 4.9 4.9 0 0 0-.1-3.6s-1.1-.4-3.7 1.4a12.7 12.7 0 0 0-6.8 0C5.6 1 4.5 1.4 4.5 1.4a4.9 4.9 0 0 0-.1 3.6A5.3 5.3 0 0 0 3 8.7c0 5.2 3.1 6.4 6.1 6.8a3.4 3.4 0 0 0-.9 2.6V22" />
59
+ </Svg>
60
+ );
61
+
54
62
  export const Sparkles = (p: IconProps) => (
55
63
  <Svg {...p}>
56
64
  <path d="M11 3.5l1.7 4.3 4.3 1.7-4.3 1.7L11 15.5 9.3 11.2 5 9.5l4.3-1.7z" />
@@ -126,6 +134,15 @@ export const Layers = (p: IconProps) => (
126
134
  </Svg>
127
135
  );
128
136
 
137
+ /** Documento com linhas — aba de resumo. */
138
+ export const FileText = (p: IconProps) => (
139
+ <Svg {...p}>
140
+ <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
141
+ <path d="M14 3v5h5" />
142
+ <path d="M9 13h6M9 17h4" />
143
+ </Svg>
144
+ );
145
+
129
146
  /** Olho — "quem auditou o trecho" (watchdogs envolvidos). */
130
147
  export const Watch = (p: IconProps) => (
131
148
  <Svg {...p}>
@@ -39,15 +39,22 @@ export const RUN_STATUS_LABELS: Record<RunStatus, string> = {
39
39
  fix_done: "Correção pronta",
40
40
  };
41
41
 
42
- /** Limiar de inatividade para exibir timeout (display-only; não é status). */
42
+ /** Limiar de inatividade da correção para exibir timeout (display-only). */
43
43
  export const STATUS_TIMEOUT_MS = 10 * 60 * 1000;
44
44
 
45
+ /** Revisão despacha subagentes em ondas longas — janela maior que a correção. */
46
+ export const REVIEW_TIMEOUT_MS = 30 * 60 * 1000;
47
+
45
48
  /** Intervalo do polling da UI (o badge conta o tempo desde a última sync). */
46
49
  export const AUTO_REFRESH_MS = 10_000;
47
50
 
48
51
  export type RunStatusView = { kind: string; label: string; timeout: boolean };
49
52
 
50
- /** View do status de uma run — label; vira timeout quando ativo e sem ping recente. */
53
+ /**
54
+ * View do status de uma run — timeout só existe quando há um heartbeat de
55
+ * execução (`reviewing`/`fixing`). `updatedAt` muda com triagem/sync e não
56
+ * serve para provar que a skill está rodando.
57
+ */
51
58
  export function describeRunStatus(
52
59
  run: RunSummary,
53
60
  now: number = Date.now(),
@@ -55,8 +62,16 @@ export function describeRunStatus(
55
62
  const status = run.status;
56
63
  if (!status) return null;
57
64
  const active = status === "reviewing" || status === "fixing";
58
- const updated = run.updatedAt ? Date.parse(run.updatedAt) : Number.NaN;
59
- if (active && Number.isFinite(updated) && now - updated > STATUS_TIMEOUT_MS) {
65
+ if (!active) {
66
+ return { kind: status, label: RUN_STATUS_LABELS[status], timeout: false };
67
+ }
68
+
69
+ // Sem heartbeat não há execução conhecida: evita o falso "timeout".
70
+ const heartbeat = run.heartbeatAt ? Date.parse(run.heartbeatAt) : Number.NaN;
71
+ if (!Number.isFinite(heartbeat)) return null;
72
+
73
+ const timeoutMs = status === "reviewing" ? REVIEW_TIMEOUT_MS : STATUS_TIMEOUT_MS;
74
+ if (now - heartbeat > timeoutMs) {
60
75
  return {
61
76
  kind: "timeout",
62
77
  label: status === "reviewing" ? "Timeout na revisão" : "Timeout na correção",
@@ -69,6 +84,7 @@ export function describeRunStatus(
69
84
  /** Ordem canônica dos tipos de artefato na lista da UI. */
70
85
  const KIND_ORDER: ArtifactKind[] = [
71
86
  "review",
87
+ "resume",
72
88
  "domain-review",
73
89
  "verify",
74
90
  "brief",
@@ -86,6 +102,7 @@ const KIND_ORDER: ArtifactKind[] = [
86
102
  const GROUP_BY_KIND: Record<ArtifactKind, { key: string; label: string; order: number }> =
87
103
  {
88
104
  review: { key: "report", label: "Relatórios", order: 0 },
105
+ resume: { key: "report", label: "Relatórios", order: 0 },
89
106
  "domain-review": { key: "report", label: "Relatórios", order: 0 },
90
107
  verify: { key: "verify", label: "Verificação", order: 1 },
91
108
  brief: { key: "brief", label: "Briefs (auditoria)", order: 2 },
@@ -102,6 +119,7 @@ const GROUP_BY_KIND: Record<ArtifactKind, { key: string; label: string; order: n
102
119
  /** Rótulo curto do tipo (badge do item). */
103
120
  export const ARTIFACT_KIND_LABELS: Record<ArtifactKind, string> = {
104
121
  review: "relatório",
122
+ resume: "resumo",
105
123
  "domain-review": "domínio",
106
124
  verify: "verify",
107
125
  brief: "brief",
@@ -127,6 +145,8 @@ export function describeArtifact(meta: ArtifactMeta): string {
127
145
  switch (meta.kind) {
128
146
  case "review":
129
147
  return "Relatório consolidado da revisão — o entregável.";
148
+ case "resume":
149
+ return "Resumo final da revisão: visão de negócio e pontos técnicos diretos (leitura humana).";
130
150
  case "domain-review":
131
151
  return "Relatório isolado do domínio: achados daquele watchdog.";
132
152
  case "verify":
@@ -156,6 +176,14 @@ export function isJsonArtifact(meta: ArtifactMeta | undefined): boolean {
156
176
  return meta.contentType.includes("json") || meta.name.toLowerCase().endsWith(".json");
157
177
  }
158
178
 
179
+ /** `true` se o conteúdo deve ser renderizado como markdown. */
180
+ export function isMarkdownArtifact(meta: ArtifactMeta | undefined): boolean {
181
+ if (!meta) return false;
182
+ return (
183
+ meta.contentType.includes("markdown") || meta.name.toLowerCase().endsWith(".md")
184
+ );
185
+ }
186
+
159
187
  /** Agrupa os artefatos para a navegação lateral (ordem estável). */
160
188
  export function groupArtifacts(
161
189
  items: ArtifactMeta[],
@@ -190,8 +218,9 @@ export function groupArtifacts(
190
218
 
191
219
  /** Legenda única de cada aba (explica o que a aba é; mostrada quando selecionada). */
192
220
  export const TAB_CAPTIONS = {
221
+ resume: "Resumo principal da revisão: o que muda em termos de negócio, números diretos (arquivos, churn, domínios) e os pontos técnicos que exigem ação.",
193
222
  findings:
194
223
  "Triagem dos achados da revisão: decida Executar, Ignorar ou deixe Pendente — o comentário orienta o auto-fix. Agente: houndsman findings list --decision pending --json · findings done <id>.",
195
224
  artifacts:
196
- "Artefatos persistidos da run: relatórios, briefs de auditoria, manifesto, classificação, Jev, contexto da PR e verificação. JSON com destaque de sintaxe.",
225
+ "Artefatos persistidos da run: relatórios, briefs de auditoria, manifesto, classificação, Jev, contexto da PR e verificação. JSON com destaque de sintaxe; markdown renderizado com highlight de código.",
197
226
  } as const;
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Markdown mínimo do cliente (campo `action` do finding e artefatos).
3
+ *
4
+ * Suporta o suficiente para instruções curtas e relatórios: parágrafos,
5
+ * quebras de linha, listas, blockquote, títulos, tabelas, blocos de código e
6
+ * formatação inline (`code`, **bold**, *itálico*, links). Sem lib externa e
7
+ * sem HTML cru.
8
+ */
9
+
10
+ /** Alinhamento de coluna em tabela markdown. */
11
+ export type MarkdownAlign = "left" | "center" | "right";
12
+
13
+ export type MarkdownBlock =
14
+ | { type: "paragraph"; text: string }
15
+ | { type: "list"; ordered: boolean; items: string[] }
16
+ | { type: "code"; language: string | null; code: string }
17
+ | { type: "quote"; text: string }
18
+ | { type: "heading"; level: number; text: string }
19
+ | {
20
+ type: "table";
21
+ align: (MarkdownAlign | undefined)[];
22
+ header: string[];
23
+ rows: string[][];
24
+ }
25
+ | { type: "hr" };
26
+
27
+ export type InlineToken =
28
+ | { type: "text"; text: string }
29
+ | { type: "code"; text: string }
30
+ | { type: "strong"; text: string }
31
+ | { type: "em"; text: string }
32
+ | { type: "link"; text: string; url: string }
33
+ | { type: "path"; text: string };
34
+
35
+ const FENCE = /^\s*```(\S*)\s*$/;
36
+ const BULLET = /^\s*[-*+]\s+(.*)$/;
37
+ const ORDERED = /^\s*\d+\.\s+(.*)$/;
38
+ const QUOTE = /^\s*>\s?(.*)$/;
39
+ const HEADING = /^(#{1,6})\s+(.*)$/;
40
+ const HR = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
41
+ const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$/;
42
+
43
+ function isBlockStart(line: string): boolean {
44
+ return (
45
+ FENCE.test(line) ||
46
+ HEADING.test(line) ||
47
+ HR.test(line) ||
48
+ BULLET.test(line) ||
49
+ ORDERED.test(line) ||
50
+ QUOTE.test(line)
51
+ );
52
+ }
53
+
54
+ /** Quebra uma linha de tabela em células (remove os pipes das pontas). */
55
+ function splitTableRow(line: string): string[] {
56
+ return line
57
+ .trim()
58
+ .replace(/^\|/, "")
59
+ .replace(/\|$/, "")
60
+ .split("|")
61
+ .map((cell) => cell.trim());
62
+ }
63
+
64
+ /** `true` quando há um cabeçalho seguido da linha separadora de tabela. */
65
+ function isTableStart(lines: string[], index: number): boolean {
66
+ if (index + 1 >= lines.length) return false;
67
+ if (!lines[index].includes("|")) return false;
68
+ return TABLE_SEP.test(lines[index + 1]);
69
+ }
70
+
71
+ /** Alinhamentos declarados na linha separadora (`:---`, `:---:`, `---:`). */
72
+ function parseAlignments(line: string): (MarkdownAlign | undefined)[] {
73
+ return splitTableRow(line).map((cell) => {
74
+ const left = cell.startsWith(":");
75
+ const right = cell.endsWith(":");
76
+ if (left && right) return "center";
77
+ if (right) return "right";
78
+ if (left) return "left";
79
+ return undefined;
80
+ });
81
+ }
82
+
83
+ /** Célula numérica ou razão (`12`, `3/4`) — colunas assim vão para a direita. */
84
+ const NUMERIC_CELL = /^[\d\s/]+$/;
85
+
86
+ /** Resolve o alinhamento: explícito > coluna só de números/barra > padrão. */
87
+ function resolveAlignments(
88
+ align: (MarkdownAlign | undefined)[],
89
+ rows: string[][],
90
+ ): (MarkdownAlign | undefined)[] {
91
+ return align.map((value, column) => {
92
+ if (value) return value;
93
+ let filled = 0;
94
+ for (const row of rows) {
95
+ const cell = (row[column] ?? "").trim();
96
+ if (cell.length === 0) continue;
97
+ if (!NUMERIC_CELL.test(cell) || !/\d/.test(cell)) return undefined;
98
+ filled++;
99
+ }
100
+ return filled > 0 ? "right" : undefined;
101
+ });
102
+ }
103
+
104
+ /** Divide o texto em blocos simples (sem aninhamento profundo). */
105
+ export function parseMarkdown(text: string): MarkdownBlock[] {
106
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
107
+ const blocks: MarkdownBlock[] = [];
108
+ let index = 0;
109
+
110
+ while (index < lines.length) {
111
+ const line = lines[index];
112
+ if (line.trim().length === 0) {
113
+ index++;
114
+ continue;
115
+ }
116
+
117
+ const fence = FENCE.exec(line);
118
+ if (fence) {
119
+ const language = fence[1] || null;
120
+ const code: string[] = [];
121
+ index++;
122
+ while (index < lines.length && !FENCE.test(lines[index])) {
123
+ code.push(lines[index]);
124
+ index++;
125
+ }
126
+ if (index < lines.length) index++;
127
+ blocks.push({ type: "code", language, code: code.join("\n") });
128
+ continue;
129
+ }
130
+
131
+ const heading = HEADING.exec(line);
132
+ if (heading) {
133
+ blocks.push({
134
+ type: "heading",
135
+ level: heading[1].length,
136
+ text: heading[2].trim(),
137
+ });
138
+ index++;
139
+ continue;
140
+ }
141
+
142
+ if (HR.test(line)) {
143
+ blocks.push({ type: "hr" });
144
+ index++;
145
+ continue;
146
+ }
147
+
148
+ if (isTableStart(lines, index)) {
149
+ const header = splitTableRow(line);
150
+ const align = parseAlignments(lines[index + 1]);
151
+ const rows: string[][] = [];
152
+ index += 2;
153
+ while (
154
+ index < lines.length &&
155
+ lines[index].trim().length > 0 &&
156
+ lines[index].includes("|")
157
+ ) {
158
+ rows.push(splitTableRow(lines[index]));
159
+ index++;
160
+ }
161
+ blocks.push({
162
+ type: "table",
163
+ align: resolveAlignments(align, rows),
164
+ header,
165
+ rows,
166
+ });
167
+ continue;
168
+ }
169
+
170
+ if (BULLET.test(line)) {
171
+ const items: string[] = [];
172
+ while (index < lines.length) {
173
+ const item = BULLET.exec(lines[index]);
174
+ if (!item) break;
175
+ items.push(item[1]);
176
+ index++;
177
+ }
178
+ blocks.push({ type: "list", ordered: false, items });
179
+ continue;
180
+ }
181
+
182
+ if (ORDERED.test(line)) {
183
+ const items: string[] = [];
184
+ while (index < lines.length) {
185
+ const item = ORDERED.exec(lines[index]);
186
+ if (!item) break;
187
+ items.push(item[1]);
188
+ index++;
189
+ }
190
+ blocks.push({ type: "list", ordered: true, items });
191
+ continue;
192
+ }
193
+
194
+ if (QUOTE.test(line)) {
195
+ const quote: string[] = [];
196
+ while (index < lines.length) {
197
+ const item = QUOTE.exec(lines[index]);
198
+ if (!item) break;
199
+ quote.push(item[1]);
200
+ index++;
201
+ }
202
+ blocks.push({ type: "quote", text: quote.join("\n") });
203
+ continue;
204
+ }
205
+
206
+ const paragraph: string[] = [line];
207
+ index++;
208
+ while (
209
+ index < lines.length &&
210
+ lines[index].trim().length > 0 &&
211
+ !isBlockStart(lines[index]) &&
212
+ !isTableStart(lines, index)
213
+ ) {
214
+ paragraph.push(lines[index]);
215
+ index++;
216
+ }
217
+ blocks.push({ type: "paragraph", text: paragraph.join("\n") });
218
+ }
219
+
220
+ return blocks;
221
+ }
222
+
223
+ /** Blocos de uma seção `## <título>` (sem o heading); vazio se ausente. */
224
+ export function sectionBlocks(text: string, title: string): MarkdownBlock[] {
225
+ const wanted = title.trim().toLowerCase();
226
+ const blocks: MarkdownBlock[] = [];
227
+ let inside = false;
228
+ for (const block of parseMarkdown(text)) {
229
+ if (block.type === "heading") {
230
+ if (inside) break;
231
+ inside = block.level >= 2 && block.text.trim().toLowerCase() === wanted;
232
+ continue;
233
+ }
234
+ if (inside) blocks.push(block);
235
+ }
236
+ return blocks;
237
+ }
238
+
239
+ /** Extensão reconhecida como arquivo (evita destacar texto comum). */
240
+ const FILE_EXTENSION =
241
+ /\.(?:[cm]?[jt]sx?|json|md|ya?ml|toml|css|scss|html?|py|go|rs|java|kt|cs|php|rb|sh|sql|txt|lock)$/i;
242
+
243
+ /** Caminho/arquivo dentro de um texto corrido (`src/foo.ts`, `review.md`). */
244
+ const PATH_TOKEN =
245
+ /(?:[\w.-]+[\\/])*[\w-]+\.(?:[cm]?[jt]sx?|json|md|ya?ml|toml|css|scss|html?|py|go|rs|java|kt|cs|php|rb|sh|sql|txt|lock)\b/g;
246
+
247
+ /** `true` se o texto parece um caminho/arquivo (sem espaços e com extensão). */
248
+ export function isFilePath(value: string): boolean {
249
+ const text = value.trim();
250
+ if (!text || /\s/.test(text)) return false;
251
+ if (/^(?:https?:|mailto:|#)/i.test(text)) return false;
252
+ const base = text.split(/[\\/]/).pop() ?? text;
253
+ if (FILE_EXTENSION.test(base)) return true;
254
+ return (
255
+ text.startsWith("./") ||
256
+ text.startsWith("../") ||
257
+ text.startsWith("/") ||
258
+ /^[A-Za-z]:[\\/]/.test(text)
259
+ );
260
+ }
261
+
262
+ /** Quebra texto corrido em tokens, destacando caminhos de arquivo. */
263
+ function pushTextTokens(tokens: InlineToken[], text: string): void {
264
+ let last = 0;
265
+ for (const match of text.matchAll(PATH_TOKEN)) {
266
+ const index = match.index ?? 0;
267
+ if (index > last) {
268
+ tokens.push({ type: "text", text: text.slice(last, index) });
269
+ }
270
+ tokens.push({ type: "path", text: match[0] });
271
+ last = index + match[0].length;
272
+ }
273
+ if (last < text.length) {
274
+ tokens.push({ type: "text", text: text.slice(last) });
275
+ }
276
+ }
277
+
278
+ /** Tokeniza formatação inline preservando o texto e a ordem dos marcadores. */
279
+ export function parseInline(text: string): InlineToken[] {
280
+ const tokens: InlineToken[] = [];
281
+ // Ordem importa: code/bold antes de itálico; `_` exige fronteira de palavra.
282
+ const pattern =
283
+ /`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|(?<![\w])_[^_\n]+_(?![\w])|\[[^\]\n]+\]\([^)\s]+\)/g;
284
+ let last = 0;
285
+ for (const match of text.matchAll(pattern)) {
286
+ const start = match.index ?? 0;
287
+ if (start > last) {
288
+ pushTextTokens(tokens, text.slice(last, start));
289
+ }
290
+ const raw = match[0];
291
+ if (raw.startsWith("`")) {
292
+ tokens.push({ type: "code", text: raw.slice(1, -1) });
293
+ } else if (raw.startsWith("**") || raw.startsWith("__")) {
294
+ tokens.push({ type: "strong", text: raw.slice(2, -2) });
295
+ } else if (raw.startsWith("*") || raw.startsWith("_")) {
296
+ tokens.push({ type: "em", text: raw.slice(1, -1) });
297
+ } else {
298
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(raw);
299
+ if (link) {
300
+ tokens.push({ type: "link", text: link[1], url: link[2] });
301
+ } else {
302
+ pushTextTokens(tokens, raw);
303
+ }
304
+ }
305
+ last = start + raw.length;
306
+ }
307
+ if (last < text.length) {
308
+ pushTextTokens(tokens, text.slice(last));
309
+ }
310
+ return tokens;
311
+ }
312
+
313
+ /** URL segura para links de markdown (evita `javascript:` e afins). */
314
+ export function safeUrl(url: string): string | null {
315
+ const value = url.trim();
316
+ if (/^(https?:|mailto:)/i.test(value)) return value;
317
+ if (value.startsWith("/") && !value.startsWith("//")) return value;
318
+ return null;
319
+ }
@@ -61,6 +61,7 @@ export type ArtifactKind =
61
61
  | "context-summary"
62
62
  | "finding-report"
63
63
  | "review"
64
+ | "resume"
64
65
  | "domain-review"
65
66
  | "verify"
66
67
  | "other";
@@ -77,6 +78,9 @@ export type RunSummary = {
77
78
  slug: string;
78
79
  branch: string;
79
80
  updatedAt?: string;
81
+ startedAt?: string;
82
+ /** Último sinal de vida de uma execução ativa (revisão/correção). */
83
+ heartbeatAt?: string;
80
84
  status?: RunStatus | null;
81
85
  summary: Summary;
82
86
  };
@@ -87,6 +91,52 @@ export type FindingsResponse = {
87
91
  summary: Summary;
88
92
  };
89
93
 
94
+ /** Manifesto da run (subset consumido pela aba de resumo). */
95
+ export type RunManifest = {
96
+ branch?: string;
97
+ base?: string;
98
+ head?: string;
99
+ generatedAt?: string;
100
+ churn?: { additions: number; deletions: number };
101
+ watchdogs?: {
102
+ id: string;
103
+ name: string;
104
+ files: string[];
105
+ overflow: number;
106
+ status: "pending" | "done";
107
+ }[];
108
+ context?: {
109
+ summarized: boolean;
110
+ source?: {
111
+ number?: number;
112
+ title?: string;
113
+ url?: string;
114
+ base?: string;
115
+ head?: string;
116
+ } | null;
117
+ };
118
+ excludedCount?: number;
119
+ deleted?: string[];
120
+ unclassified?: string[];
121
+ };
122
+
123
+ /** Detalhe da run (`GET /api/runs/:slug`). */
124
+ export type RunDetail = {
125
+ slug: string;
126
+ branch: string;
127
+ manifest: RunManifest | null;
128
+ watchdogs: string[];
129
+ unclassified: string[];
130
+ deleted: string[];
131
+ };
132
+
133
+ /** `classification.json` — só o necessário para os cards do resumo. */
134
+ export type ClassificationArtifact = {
135
+ files?: Record<string, { status: string; oldPath?: string }>;
136
+ churn?: Record<string, { additions: number; deletions: number }>;
137
+ watchdogs?: Record<string, string[]>;
138
+ };
139
+
90
140
  /** Candidato ao envio da revisão (espelha `core/github-review.ts`). */
91
141
  export type SubmitCandidate = {
92
142
  id: string;
@@ -134,6 +184,14 @@ export type SubmitOutcome = {
134
184
  failed: { id: string; reason: string }[];
135
185
  skipped: { id: string; reason: string }[];
136
186
  general?: { commentId: number; url?: string; ids: string[] };
187
+ review?: {
188
+ id?: number;
189
+ url?: string;
190
+ created: boolean;
191
+ finalized: boolean;
192
+ event?: "COMMENT" | "REQUEST_CHANGES";
193
+ error?: string;
194
+ };
137
195
  };
138
196
 
139
197
  export type SubmitResponse = {