@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
package/README.md CHANGED
@@ -26,8 +26,8 @@ diferente das PRs. O motor só coordena a caçada — as regras são problema se
26
26
 
27
27
  - **Bun** — runtime obrigatório.
28
28
  - **git** — o diff usa `git diff <base>...<head>`.
29
- - Opcionais: **`gh`** (contexto da PR) e **`TYPESAFE_API_KEY`** (pré-análise Jev). A revisão
30
- funciona normalmente sem eles; o `houndsman doctor` valida a credencial Jev quando presente.
29
+ - **gh** (Opcional) — Obtém contexto da PR e dispara comentários para o GitHub
30
+ - **`TYPESAFE_API_KEY`** (Opcional) — Env a ser informada na raiz, usada em pré-analise com JEV
31
31
 
32
32
  ### 2. Instale o CLI (global)
33
33
 
@@ -95,62 +95,38 @@ nenhum script à mão.
95
95
 
96
96
  O skill tem **4 capabilities**, todas em `/houndsman`:
97
97
 
98
- - **`hunt`** — revisa a PR (default: `/houndsman` = `/houndsman hunt`).
98
+ - **`hunt`** — revisa a PR.
99
99
  - **`train`** — mantém/atualiza as regras dos watchdogs.
100
- - **`fix`** — corrige os findings marcados (`/houndsman fix`).
101
- - **`verify`** — confere se as violações foram resolvidas (`/houndsman verify`), sem re-revisar.
100
+ - **`fix`** — corrige os findings marcados.
101
+ - **`verify`** — confere se as violações foram resolvidas, sem re-revisar.
102
102
 
103
- - Revisão normal: `/houndsman` ou `/houndsman hunt` (a branch é detectada; base default `main`).
104
103
  - Revisar outra branch: `/houndsman hunt feature/minha-branch`.
105
- - Validar e triar na UI: `houndsman server --open` — marque os achados a executar (`execute`).
106
- - Manual (sem skill): `houndsman context` → `houndsman --base main` → *(subagentes)* →
107
- `houndsman --finalize`.
108
- - CI: `houndsman --finalize --fail-on-blocking` (exit 1 se houver bloqueantes).
104
+ - Validar na UI: `houndsman server --open` — marque os findings a executar (`execute`).
109
105
 
110
106
  ---
111
107
 
112
- ## Triando os findings (opcional)
108
+ ## Validando os findings (opcional)
113
109
 
114
- Depois do `--finalize`, cada finding tem um `id` curto e um `fingerprint` de **conteúdo**
115
- no storage global. Você decide o que fazer com cada um — pela
116
- **UI local** ou pelo **agente** (auto-fix) — sem abrir o JSON à mão:
110
+ Você decide o que fazer com cada finding pela **UI local**, informando se quer `Executar` ou `Ignorar`
117
111
 
118
112
  ```bash
119
113
  bun run houndsman server --open # UI: triar, comentar, marcar feito, ler artefatos
120
114
  ```
121
115
 
122
- - **decision:** `pending` · `execute` · `ignore` (+ comentário).
123
- - **execState:** `pending` · `in_progress` · `done` · `stale`.
124
-
125
- **Auto-fix pelo agente** — as linhas mudam depois do fix, e o motor reancora por conteúdo:
126
-
127
- ```bash
128
- bun run houndsman findings batch --json # grupo atual (agrupamento automático) + outros
129
- # ... o agente corrige apenas esse grupo ...
130
- bun run houndsman findings sync # reancora por conteúdo
131
- bun run houndsman findings done <id>
132
- bun run houndsman findings verify # confere o que resolveu (barato, sem re-revisar)
133
- ```
134
-
135
- > A partir da 2ª revisão o `hunt` é **incremental**: revisa só os arquivos que mudaram desde
136
- > o último `--finalize` e **carrega** os findings dos arquivos inalterados. `--full` força
137
- > o review completo.
138
-
139
- > A identidade do finding é o **conteúdo** (fingerprint), não a linha: itens deslocados
140
- > pelo fix não se perdem. O estado mora no storage global (`<HOUNDSMAN_HOME>/houndsman.db`).
116
+ > A partir da 2ª revisão o `hunt` é **incremental**: revisa apenas os arquivos que mudaram desde
117
+ > a última revisão. `--full` força o review completo.
141
118
 
142
119
  ---
143
120
 
144
121
  ## Mantendo a configuração
145
122
 
146
123
  - **Persistência** → por default, SQLite global em `<HOUNDSMAN_HOME>/houndsman.db`
147
- (`HOUNDSMAN_HOME` sobrescreve o diretório de dados). Para voltar ao backend de arquivos,
148
- troque só o provider — a interface `StorageProvider` é a mesma:
124
+ (`HOUNDSMAN_HOME` sobrescreve o diretório de dados). Caso queira storage base em arquivos, na config, troque provider para `file`:
149
125
 
150
126
  ```ts
151
127
  export const houndsmanConfig = {
152
128
  // ...
153
- storage: { provider: "file", path: "./houndsman-runs" },
129
+ storage: { provider: "file", path: "./houndsman/runs" },
154
130
  };
155
131
  ```
156
132
 
@@ -165,8 +141,6 @@ bun run houndsman findings verify # confere o que resolveu (barato, se
165
141
 
166
142
  O `update` reescreve **só** `.houndsman/core/**` e `.houndsman/.gitignore`. Seus arquivos
167
143
  (`watchdogs/`, `exclusions.ts`, `doc-mappings.ts`, `houndsman.config.ts`) nunca são tocados.
168
- - **Recomeçar a camada do zero** → `houndsman setup --force` (sobrescreve só os arquivos do
169
- usuário; sem `--force` o `setup` é idempotente).
170
144
 
171
145
  ---
172
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payconductor/houndsman",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Orquestrador de revisão de Pull Requests: classifica o diff, despacha watchdogs especializados por domínio e consolida o relatório.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,46 @@
1
+ /** Comando `status` — mostra o status da run ou renova o heartbeat (`status ping`). */
2
+
3
+ import { pingStatus, readStatus } from "../../core/commands/status";
4
+ import { ArgParser } from "../arg-parser";
5
+ import type { CliContext, Command } from "./command";
6
+
7
+ export class StatusCommand implements Command {
8
+ readonly name = "status";
9
+
10
+ async run(args: string[], context: CliContext): Promise<number> {
11
+ const parser = ArgParser.from(args);
12
+ const action = parser.command();
13
+ const branch = parser.value("--branch");
14
+
15
+ if (action && action !== "ping" && action !== "show") {
16
+ context.err(
17
+ `Ação desconhecida em 'status': '${action}'. Use 'status' ou 'status ping'.`,
18
+ );
19
+ return 1;
20
+ }
21
+
22
+ const engine = await context.engine();
23
+ const result =
24
+ action === "ping"
25
+ ? pingStatus(engine.storage, engine.repo, branch)
26
+ : readStatus(engine.storage, engine.repo, branch);
27
+
28
+ if (action === "ping") {
29
+ context.out(
30
+ result.heartbeatAt
31
+ ? `Heartbeat atualizado — ${result.branch} · ${result.status ?? "sem status"} · ${result.heartbeatAt}`
32
+ : `Sem execução ativa — ${result.branch} · ${result.status ?? "sem status"}.`,
33
+ );
34
+ } else {
35
+ context.out(
36
+ [
37
+ `Branch: ${result.branch}`,
38
+ `Status: ${result.status ?? "—"}`,
39
+ `Heartbeat: ${result.heartbeatAt ?? "—"}`,
40
+ `Atualizado: ${result.updatedAt}`,
41
+ ].join("\n"),
42
+ );
43
+ }
44
+ return 0;
45
+ }
46
+ }
package/src/cli/help.ts CHANGED
@@ -6,6 +6,7 @@ Comandos:
6
6
  --finalize Consolida os findings em review.md
7
7
  context Coleta o contexto da PR via gh (Passo 0)
8
8
  findings <sub> Triagem/correção dos findings (list · show · set · done · reset · sync · summary · batch · verify · submit · path)
9
+ status [ping] Mostra o status da run (ping renova o heartbeat da execução)
9
10
  server Sobe a UI local de triagem (--port, --host, --open)
10
11
  setup Cria a configuração inicial do projeto (config + skills)
11
12
  update Sincroniza os arquivos auto-gerados (.houndsman/core)
package/src/cli/index.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * --finalize Passo 3 — consolidar os findings em review.md
16
16
  * context Passo 0 — coletar o contexto da PR via `gh`
17
17
  * findings <sub> Triagem/correção dos findings (list/set/done/sync/batch/verify/submit/...)
18
+ * status [ping] Mostra o status da run; `ping` renova o heartbeat da execução
18
19
  * server Sobe a UI local de triagem (Bun.serve)
19
20
  * setup Cria a configuração inicial do projeto (config + skills)
20
21
  * update Sincroniza os arquivos auto-gerados (.houndsman/core)
@@ -38,6 +39,7 @@ import { CommandRegistry } from "./commands/registry";
38
39
  import { ReviewCommand } from "./commands/review-command";
39
40
  import { ServerCommand } from "./commands/server-command";
40
41
  import { SetupCommand, TestCommand, UpdateCommand } from "./commands/setup-command";
42
+ import { StatusCommand } from "./commands/status-command";
41
43
  import { WatchdogCommand } from "./commands/watchdog-command";
42
44
  import { HELP } from "./help";
43
45
 
@@ -53,6 +55,7 @@ function buildRegistry(): CommandRegistry {
53
55
  .register(new HelpCommand())
54
56
  .register(new ContextCommand())
55
57
  .register(new FindingsCommand())
58
+ .register(new StatusCommand())
56
59
  .register(new WatchdogCommand())
57
60
  .register(new ServerCommand())
58
61
  .register(new SetupCommand())
@@ -36,6 +36,7 @@ import {
36
36
  type SubmitPreview,
37
37
  type SubmitSelection,
38
38
  submitFindings,
39
+ syncResolvedThreads,
39
40
  } from "../github-review";
40
41
  import { VERIFY_FILE } from "../paths";
41
42
  import { currentBranch } from "../pr-context";
@@ -214,6 +215,25 @@ function pingStatus(ctx: CommandContext, state: FindingsStateFile): void {
214
215
  pingFindingsStatus(ctx.storage, ctx.review.id, state);
215
216
  }
216
217
 
218
+ /**
219
+ * Resolve no GitHub as threads dos findings recém-resolvidos localmente.
220
+ * Fail-open (`gh` ausente/sem PR ⇒ só a mensagem). Devolve a linha para o
221
+ * output ou `null` quando não há vínculo remoto pendente.
222
+ */
223
+ function syncThreadsOutput(ctx: CommandContext, ids: readonly string[]): string | null {
224
+ if (ids.length === 0) return null;
225
+ const state = loadState(ctx);
226
+ const outcome = syncResolvedThreads({ state, ids });
227
+ if (outcome.checked === 0) return null;
228
+ if (outcome.updated.length > 0) ctx.storage.writeFindings(ctx.review.id, state);
229
+ if (!outcome.available) {
230
+ return `GitHub: threads não sincronizadas (${outcome.reason}).`;
231
+ }
232
+ const failures =
233
+ outcome.failed.length > 0 ? `, ${outcome.failed.length} falha(s)` : "";
234
+ return `GitHub: ${outcome.resolved.length} thread(s) resolvida(s), ${outcome.skipped.length} já resolvida(s)/pulada(s)${failures}.`;
235
+ }
236
+
217
237
  /** Aplica uma atualização a um finding e persiste. */
218
238
  function updateOne(
219
239
  ctx: CommandContext,
@@ -309,10 +329,17 @@ function execute(
309
329
  }
310
330
  const finding = updateOne(ctx, input, update);
311
331
  pingStatus(ctx, loadState(ctx));
332
+ const threads =
333
+ update.execState === "done" ? syncThreadsOutput(ctx, [finding.id]) : null;
312
334
  return {
313
335
  output: parsed.bools.has("--json")
314
336
  ? JSON.stringify(finding, null, 2)
315
- : `Atualizado: \`${finding.id}\` → ${finding.decision}/${finding.execState}.`,
337
+ : [
338
+ `Atualizado: \`${finding.id}\` → ${finding.decision}/${finding.execState}.`,
339
+ threads,
340
+ ]
341
+ .filter(Boolean)
342
+ .join("\n"),
316
343
  exitCode: 0,
317
344
  };
318
345
  }
@@ -320,8 +347,17 @@ function execute(
320
347
  const ids = requireIds(parsed, "done");
321
348
  const updated = updateMany(ctx, ids, { execState: "done" });
322
349
  pingStatus(ctx, loadState(ctx));
350
+ const threads = syncThreadsOutput(
351
+ ctx,
352
+ updated.map((finding) => finding.id),
353
+ );
323
354
  return {
324
- output: `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
355
+ output: [
356
+ `Marcados como done: ${updated.map((f) => f.id).join(", ")}.`,
357
+ threads,
358
+ ]
359
+ .filter(Boolean)
360
+ .join("\n"),
325
361
  exitCode: 0,
326
362
  };
327
363
  }
@@ -511,6 +547,9 @@ async function executeVerify(
511
547
  pingStatus(ctx, current);
512
548
  }
513
549
 
550
+ // Resolvidos localmente ⇒ resolve as conversas correspondentes no PR.
551
+ const threads = syncThreadsOutput(ctx, applied);
552
+
514
553
  const output = parsed.bools.has("--json")
515
554
  ? JSON.stringify(
516
555
  {
@@ -534,6 +573,7 @@ async function executeVerify(
534
573
  parsed.bools.has("--apply")
535
574
  ? `Aplicados (done): ${applied.length > 0 ? applied.join(", ") : "—"}.`
536
575
  : "",
576
+ threads,
537
577
  ]
538
578
  .filter(Boolean)
539
579
  .join("\n");
@@ -603,6 +643,21 @@ function renderSubmitOutcome(outcome: SubmitOutcome, json: boolean): string {
603
643
  "",
604
644
  `Enviados: ${outcome.posted.length} · Falhas: ${outcome.failed.length} · Pulados: ${outcome.skipped.length}`,
605
645
  ];
646
+ const review = outcome.review;
647
+ if (review?.id !== undefined) {
648
+ const target = review.url ? ` — ${review.url}` : "";
649
+ if (review.created) {
650
+ const status = review.finalized
651
+ ? `finalizada (${review.event})`
652
+ : "criada, mas não finalizada";
653
+ lines.push(`Revisão #${review.id} ${status}${target}`);
654
+ } else {
655
+ lines.push(
656
+ `Comentários adicionados à revisão pendente #${review.id}${target}`,
657
+ );
658
+ }
659
+ }
660
+ if (review?.error) lines.push(`Revisão: ❌ ${review.error}`);
606
661
  if (outcome.general) {
607
662
  lines.push(
608
663
  `Comentário geral: ${outcome.general.url ?? outcome.general.commentId} (${outcome.general.ids.length})`,
@@ -615,8 +670,9 @@ function renderSubmitOutcome(outcome: SubmitOutcome, json: boolean): string {
615
670
  }
616
671
 
617
672
  /**
618
- * Subcomando `findings submit` — envia os findings selecionados como comentários
619
- * na PR (via `gh`). `--dry-run` mostra a prévia sem postar. Assíncrono (gh).
673
+ * Subcomando `findings submit` — envia os findings selecionados numa revisão da
674
+ * PR (via `gh`): reaproveita a revisão pendente ou cria/finaliza uma nova.
675
+ * `--dry-run` mostra a prévia sem postar. Assíncrono (gh).
620
676
  */
621
677
  export async function runSubmitCommand(
622
678
  config: HoundsmanConfig,
@@ -664,6 +720,6 @@ async function executeSubmit(
664
720
 
665
721
  return {
666
722
  output: renderSubmitOutcome(outcome, json),
667
- exitCode: outcome.failed.length > 0 ? 1 : 0,
723
+ exitCode: outcome.failed.length > 0 || Boolean(outcome.review?.error) ? 1 : 0,
668
724
  };
669
725
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Status da run (biblioteca) — leitura e heartbeat para a UI.
3
+ *
4
+ * `status ping` é o sinal de vida de uma skill em execução (revisão ou
5
+ * correção). O heartbeat é separado de `updatedAt`: triagem/sync atualizam o
6
+ * segundo, mas não fingem que existe uma execução em andamento.
7
+ */
8
+
9
+ import { currentBranch } from "../pr-context";
10
+ import { type RunStatus, touchRunStatus } from "../run-status";
11
+ import type { RepoIdentity, ReviewRecord, StorageProvider } from "../storage/types";
12
+
13
+ export type StatusResult = {
14
+ branch: string;
15
+ status: RunStatus | null;
16
+ startedAt?: string;
17
+ heartbeatAt?: string;
18
+ updatedAt: string;
19
+ };
20
+
21
+ function toResult(review: ReviewRecord): StatusResult {
22
+ return {
23
+ branch: review.branch,
24
+ status: review.status,
25
+ startedAt: review.startedAt,
26
+ heartbeatAt: review.heartbeatAt,
27
+ updatedAt: review.updatedAt,
28
+ };
29
+ }
30
+
31
+ /** Resolve a review da branch (explícita ou a atual do git). */
32
+ function findReview(
33
+ storage: StorageProvider,
34
+ repo: RepoIdentity,
35
+ branch?: string,
36
+ ): ReviewRecord {
37
+ const target = branch ?? currentBranch();
38
+ const review = storage.findReview(repo.key, target);
39
+ if (!review) {
40
+ throw new Error(`Nenhuma revisão para '${target}'.`);
41
+ }
42
+ return review;
43
+ }
44
+
45
+ /** Lê o status atual da run. */
46
+ export function readStatus(
47
+ storage: StorageProvider,
48
+ repo: RepoIdentity,
49
+ branch?: string,
50
+ ): StatusResult {
51
+ return toResult(findReview(storage, repo, branch));
52
+ }
53
+
54
+ /** Renova o heartbeat de uma execução ativa (`reviewing`/`fixing`). */
55
+ export function pingStatus(
56
+ storage: StorageProvider,
57
+ repo: RepoIdentity,
58
+ branch?: string,
59
+ now?: string,
60
+ ): StatusResult {
61
+ const review = findReview(storage, repo, branch);
62
+ return toResult(touchRunStatus(storage, review.id, now));
63
+ }
@@ -34,12 +34,21 @@ export type RunClassification = ClassificationResult & {
34
34
  carryover?: MergedFinding[];
35
35
  };
36
36
 
37
- /** Resultado da resolução de escopo. */
38
- export type ReviewScope = {
39
- classification: RunClassification;
40
- diff: DiffSource;
41
- reviewedFrom?: string;
42
- };
37
+ /** Resultado da resolução de escopo: revisar ou nada a fazer. */
38
+ export type ReviewScope =
39
+ | {
40
+ /** O `HEAD` é o mesmo commit já revisado — não há o que re-revisar. */
41
+ kind: "noop";
42
+ headSha: string;
43
+ /** Commit já coberto pelo review anterior. */
44
+ reviewedFrom: string;
45
+ }
46
+ | {
47
+ kind: "review";
48
+ classification: RunClassification;
49
+ diff: DiffSource;
50
+ reviewedFrom?: string;
51
+ };
43
52
 
44
53
  /** Decide entre review completo e incremental. */
45
54
  export class ReviewScopeResolver {
@@ -52,20 +61,29 @@ export class ReviewScopeResolver {
52
61
  }
53
62
 
54
63
  /**
64
+ * No-op quando o `HEAD` é exatamente o commit já revisado (sem
65
+ * `--full`/`--clean`): re-revisar a PR inteira seria desperdício, e o
66
+ * relatório anterior continua válido.
67
+ *
55
68
  * Incremental só quando há um `reviewed.headSha` anterior, o `HEAD` atual o
56
69
  * sucede (sem rebase) e não veio `--full`/`--clean`.
57
70
  */
58
71
  resolve(previous: FindingsStateFile | null, headSha: string): ReviewScope {
59
72
  const prevSha = previous?.reviewed?.headSha;
73
+
74
+ if (!this.options.full && !this.options.clean && prevSha === headSha) {
75
+ return { kind: "noop", headSha, reviewedFrom: headSha };
76
+ }
77
+
60
78
  if (
61
79
  this.options.full ||
62
80
  this.options.clean ||
63
81
  !prevSha ||
64
- prevSha === headSha ||
65
82
  !isAncestor(prevSha, headSha)
66
83
  ) {
67
84
  const diff = collectDiff(this.options.base, this.options.head);
68
85
  return {
86
+ kind: "review",
69
87
  diff,
70
88
  classification: {
71
89
  ...this.classifier
@@ -90,6 +108,6 @@ export class ReviewScopeResolver {
90
108
  reviewedFrom: prevSha,
91
109
  carryover: buildCarryover(previous, changedPaths),
92
110
  };
93
- return { classification, diff, reviewedFrom: prevSha };
111
+ return { kind: "review", classification, diff, reviewedFrom: prevSha };
94
112
  }
95
113
  }
@@ -76,6 +76,10 @@ export type GitHubLink = {
76
76
  pr: number;
77
77
  /** Quando foi enviado (ISO). */
78
78
  at: string;
79
+ /** Node ID da thread de revisão (GraphQL) — base para resolver a conversa. */
80
+ threadId?: string;
81
+ /** Quando a thread foi marcada como resolvida no GitHub (ISO). */
82
+ resolvedAt?: string;
79
83
  };
80
84
 
81
85
  /** Marco da revisão que originou o estado: commit revisado + quando. */
@@ -283,6 +287,12 @@ export type BuildStateOptions = {
283
287
  reviewed?: FindingsReviewed;
284
288
  /** SHA usado para carimbar `foundAtSha` de findings novos/re-reportados. */
285
289
  headSha?: string;
290
+ /**
291
+ * Arquivos efetivamente revisados neste ciclo (união de `reviewedFiles`).
292
+ * Quando presente, só findings ausentes desses arquivos viram `done` — o que
293
+ * ficou fora do orçamento/skipped não pode ser dado como resolvido.
294
+ */
295
+ reviewedPaths?: ReadonlySet<string>;
286
296
  now?: string;
287
297
  };
288
298
 
@@ -303,14 +313,21 @@ export function buildStateFromFindings(
303
313
 
304
314
  const result: Record<string, FindingState> = {};
305
315
  // 1. Preserva o estado anterior (marca como ausente até provar o contrário).
316
+ const reviewedPaths = options.reviewedPaths;
306
317
  for (const prev of Object.values(previous?.findings ?? {})) {
318
+ // Ausente do novo relatório ⇒ corrigido manualmente entre revisões (ou
319
+ // some junto com o trecho): vira `done`. `ignore` é decisão explícita de
320
+ // não corrigir — preserva o estado original. Arquivo fora da revisão
321
+ // (orçamento/skipped) também preserva: ausência não prova resolução.
322
+ const wasReviewed = !reviewedPaths || reviewedPaths.has(prev.file);
323
+ const resolvedByReview =
324
+ wasReviewed && prev.present && prev.decision !== "ignore";
325
+ const queuedAndGone =
326
+ wasReviewed && prev.decision === "execute" && prev.execState === "pending";
307
327
  result[prev.id] = {
308
328
  ...prev,
309
329
  present: false,
310
- execState:
311
- prev.execState === "pending" && prev.decision === "execute"
312
- ? "done"
313
- : prev.execState,
330
+ execState: resolvedByReview || queuedAndGone ? "done" : prev.execState,
314
331
  updatedAt: now,
315
332
  };
316
333
  }