@payconductor/houndsman 0.6.0 → 0.8.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 (71) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/server-command.ts +7 -1
  8. package/src/cli/commands/worktree-command.ts +118 -0
  9. package/src/cli/help.ts +13 -3
  10. package/src/cli/index.ts +14 -4
  11. package/src/core/ast/service.ts +7 -2
  12. package/src/core/brief.ts +1 -0
  13. package/src/core/commands/context.ts +9 -1
  14. package/src/core/commands/findings.ts +17 -6
  15. package/src/core/commands/review.ts +13 -0
  16. package/src/core/commands/scaffold.ts +4 -2
  17. package/src/core/config.ts +13 -0
  18. package/src/core/defaults.ts +8 -1
  19. package/src/core/engine/houndsman-engine.ts +26 -3
  20. package/src/core/engine/review-scope.ts +17 -22
  21. package/src/core/engine/review-session.ts +25 -2
  22. package/src/core/engine/review-workspace.ts +204 -0
  23. package/src/core/git-runner.ts +25 -13
  24. package/src/core/github-review.ts +10 -7
  25. package/src/core/jev/jev-detector.ts +10 -2
  26. package/src/core/orchestrator.ts +62 -13
  27. package/src/core/project-config.ts +12 -0
  28. package/src/core/services/gh-client.ts +38 -22
  29. package/src/core/services/git-repository.ts +46 -0
  30. package/src/core/services/pr-resolver.ts +147 -0
  31. package/src/core/services/worktree-manager.ts +269 -0
  32. package/src/core/storage/file/file-storage.ts +14 -1
  33. package/src/core/storage/paths.ts +30 -1
  34. package/src/core/storage/sqlite/mappers.ts +4 -0
  35. package/src/core/storage/sqlite/schema.ts +13 -3
  36. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  37. package/src/core/storage/types.ts +12 -0
  38. package/src/core/storage/work-area.ts +20 -1
  39. package/src/core/types.ts +2 -0
  40. package/src/harness/hunt/hunt-options.ts +17 -0
  41. package/src/harness/hunt/hunt-session.ts +7 -20
  42. package/src/index.ts +31 -0
  43. package/src/server/client/App.tsx +86 -22
  44. package/src/server/client/CommentsPanel.tsx +255 -0
  45. package/src/server/client/DeleteRunModal.tsx +163 -0
  46. package/src/server/client/FindingCard.tsx +12 -4
  47. package/src/server/client/FindingsList.tsx +4 -1
  48. package/src/server/client/ResumePanel.tsx +8 -8
  49. package/src/server/client/Sidebar.tsx +452 -0
  50. package/src/server/client/SubmitModal.tsx +61 -5
  51. package/src/server/client/icons.tsx +29 -0
  52. package/src/server/client/index.tsx +1 -1
  53. package/src/server/client/utils/constants.ts +2 -0
  54. package/src/server/client/utils/format.ts +47 -2
  55. package/src/server/client/utils/types.ts +108 -0
  56. package/src/server/editor.ts +129 -0
  57. package/src/server/index.ts +205 -3
  58. package/src/server/pr-comments.ts +256 -0
  59. package/src/server/pr-inbox.ts +213 -0
  60. package/src/server/run-repository.ts +136 -3
  61. package/src/skill/README.md +11 -3
  62. package/src/skill/SKILL.md +44 -10
  63. package/src/skill/hunt.md +33 -7
  64. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  65. package/src/tests/ai-core.test.ts +2 -2
  66. package/src/tests/cli.test.ts +35 -1
  67. package/src/tests/github-review.test.ts +9 -0
  68. package/src/tests/server-editor.test.ts +100 -0
  69. package/src/tests/server.test.ts +333 -2
  70. package/src/tests/worktree.test.ts +369 -0
  71. package/src/server/client/RunList.tsx +0 -75
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Comentários de uma Pull Request (servidor) — threads de revisão + comentários
3
+ * gerais, lidos via `gh api`.
4
+ *
5
+ * A aba é lazy: só chama o GitHub quando o cliente pede. O resultado fica em
6
+ * cache por branch (TTL de 60s); `refresh` ignora o cache. Tudo fail-open: sem
7
+ * PR/gh/login, devolve `available:false` e a UI mostra o motivo.
8
+ */
9
+
10
+ import { resolve } from "node:path";
11
+ import { resolvePrTarget } from "../core/github-review";
12
+ import { systemClock } from "../core/services/clock";
13
+ import { describeGhError, type GhRunner } from "../core/services/gh-client";
14
+
15
+ /** Cache do payload já processado (por raiz do repositório + branch). */
16
+ const CACHE_TTL_MS = 60_000;
17
+ const cache = new Map<string, { expiresAt: number; value: PrCommentsResponse }>();
18
+
19
+ /** Limpa o cache (testes e shutdown). */
20
+ export function clearPrCommentsCache(): void {
21
+ cache.clear();
22
+ }
23
+
24
+ /** Comentário já normalizado para a UI. */
25
+ export type PrCommentView = {
26
+ id: number;
27
+ author: string;
28
+ authorType: string;
29
+ body: string;
30
+ createdAt?: string;
31
+ url?: string;
32
+ path?: string;
33
+ line: number | null;
34
+ absFile?: string;
35
+ replyTo?: number | null;
36
+ };
37
+
38
+ /** Thread de revisão (comentário raiz + respostas). */
39
+ export type PrCommentThread = {
40
+ id: number;
41
+ path: string;
42
+ line: number | null;
43
+ absFile: string;
44
+ comments: PrCommentView[];
45
+ };
46
+
47
+ /** Resposta de `GET /api/runs/:slug/comments`. */
48
+ export type PrCommentsResponse = {
49
+ available: boolean;
50
+ reason?: string;
51
+ target?: { number: number; repo: string; url?: string };
52
+ fetchedAt: string;
53
+ threads: PrCommentThread[];
54
+ issueComments: PrCommentView[];
55
+ counts: { threads: number; inline: number; issue: number };
56
+ };
57
+
58
+ type GhInlineComment = {
59
+ id?: number;
60
+ body?: string;
61
+ path?: string;
62
+ line?: number | null;
63
+ original_line?: number | null;
64
+ created_at?: string;
65
+ html_url?: string;
66
+ in_reply_to_id?: number | null;
67
+ user?: { login?: string; type?: string };
68
+ };
69
+
70
+ type GhIssueComment = {
71
+ id?: number;
72
+ body?: string;
73
+ created_at?: string;
74
+ html_url?: string;
75
+ user?: { login?: string; type?: string };
76
+ };
77
+
78
+ type ApiList = { ok: true; items: unknown[] } | { ok: false; reason: string };
79
+
80
+ function unavailable(
81
+ reason: string,
82
+ target?: PrCommentsResponse["target"],
83
+ ): PrCommentsResponse {
84
+ return {
85
+ available: false,
86
+ reason,
87
+ target,
88
+ fetchedAt: systemClock(),
89
+ threads: [],
90
+ issueComments: [],
91
+ counts: { threads: 0, inline: 0, issue: 0 },
92
+ };
93
+ }
94
+
95
+ /** Lista paginada via `gh api`; `--slurp` junta as páginas num único array. */
96
+ function ghApiList(runner: GhRunner, path: string): ApiList {
97
+ let result = runner(["api", "--paginate", "--slurp", path]);
98
+ if (!result.ok && /unknown flag|unknown shorthand|slurp/i.test(result.stderr)) {
99
+ result = runner(["api", path]);
100
+ }
101
+ if (!result.ok) return { ok: false, reason: describeGhError(result) };
102
+ try {
103
+ const parsed = JSON.parse(result.stdout);
104
+ if (Array.isArray(parsed)) {
105
+ // Dependendo da versão do gh, `--slurp` pode devolver `[[...], [...]]`
106
+ // (um array por página) ou um array já achatado.
107
+ const items: unknown[] = [];
108
+ for (const item of parsed) {
109
+ if (Array.isArray(item)) items.push(...item);
110
+ else items.push(item);
111
+ }
112
+ return { ok: true, items };
113
+ }
114
+ return { ok: false, reason: "Resposta inesperada do gh (esperava uma lista)." };
115
+ } catch {
116
+ return { ok: false, reason: "Resposta inválida do gh (JSON malformado)." };
117
+ }
118
+ }
119
+
120
+ function toView(
121
+ raw: GhInlineComment | GhIssueComment,
122
+ repoRoot: string,
123
+ ): PrCommentView | null {
124
+ const id = Number(raw.id);
125
+ const body = raw.body?.trim() ?? "";
126
+ if (!Number.isFinite(id) || body.length === 0) return null;
127
+ const path = "path" in raw ? raw.path : undefined;
128
+ const line = "line" in raw ? (raw.line ?? raw.original_line ?? null) : null;
129
+ return {
130
+ id,
131
+ author: raw.user?.login ?? "desconhecido",
132
+ authorType: raw.user?.type ?? "User",
133
+ body,
134
+ createdAt: raw.created_at,
135
+ url: raw.html_url,
136
+ path,
137
+ line,
138
+ absFile: path ? resolve(repoRoot, path) : undefined,
139
+ replyTo: "in_reply_to_id" in raw ? (raw.in_reply_to_id ?? null) : null,
140
+ };
141
+ }
142
+
143
+ /** Agrupa comentários inline em threads (raiz + respostas). */
144
+ function buildThreads(
145
+ items: unknown[],
146
+ repoRoot: string,
147
+ ): {
148
+ threads: PrCommentThread[];
149
+ inline: number;
150
+ } {
151
+ const raws = (items as GhInlineComment[])
152
+ .filter((raw) => raw && typeof raw === "object")
153
+ .sort(
154
+ (a, b) =>
155
+ (a.created_at ?? "").localeCompare(b.created_at ?? "") ||
156
+ Number(a.id ?? 0) - Number(b.id ?? 0),
157
+ );
158
+
159
+ const views: PrCommentView[] = [];
160
+ const parent = new Map<number, number | null>();
161
+ for (const raw of raws) {
162
+ const view = toView(raw, repoRoot);
163
+ if (!view) continue;
164
+ views.push(view);
165
+ parent.set(view.id, raw.in_reply_to_id ?? null);
166
+ }
167
+
168
+ const rootOf = (id: number): number => {
169
+ let current = id;
170
+ const seen = new Set<number>();
171
+ for (;;) {
172
+ const next = parent.get(current);
173
+ if (!next || seen.has(current)) return current;
174
+ seen.add(current);
175
+ current = next;
176
+ }
177
+ };
178
+
179
+ const byRoot = new Map<number, PrCommentThread>();
180
+ for (const view of views) {
181
+ const rootId = rootOf(view.id);
182
+ const existing = byRoot.get(rootId);
183
+ if (existing) {
184
+ existing.comments.push(view);
185
+ continue;
186
+ }
187
+ const path = view.path ?? "";
188
+ byRoot.set(rootId, {
189
+ id: rootId,
190
+ path,
191
+ line: view.line,
192
+ absFile: view.absFile ?? (path ? resolve(repoRoot, path) : ""),
193
+ comments: [view],
194
+ });
195
+ }
196
+
197
+ const threads = [...byRoot.values()].sort(
198
+ (a, b) =>
199
+ a.path.localeCompare(b.path) ||
200
+ (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER) ||
201
+ a.id - b.id,
202
+ );
203
+ return { threads, inline: views.length };
204
+ }
205
+
206
+ /** Lê e agrupa os comentários da PR da branch (com cache de 60s). */
207
+ export function fetchPrComments(options: {
208
+ runner: GhRunner;
209
+ branch: string;
210
+ repoRoot: string;
211
+ refresh?: boolean;
212
+ now?: number;
213
+ }): PrCommentsResponse {
214
+ const { runner, branch, repoRoot, refresh = false } = options;
215
+ const now = options.now ?? Date.now();
216
+ const key = `${resolve(repoRoot)}\0${branch}`;
217
+ const cached = cache.get(key);
218
+ if (!refresh && cached && cached.expiresAt > now) return cached.value;
219
+
220
+ const target = resolvePrTarget(runner, branch);
221
+ if (!target.ok) return unavailable(target.reason);
222
+
223
+ const pr = {
224
+ number: target.target.number,
225
+ repo: target.target.repo,
226
+ url: target.target.url,
227
+ };
228
+ const inline = ghApiList(runner, `repos/${pr.repo}/pulls/${pr.number}/comments`);
229
+ if (!inline.ok) return unavailable(inline.reason, pr);
230
+
231
+ const issue = ghApiList(runner, `repos/${pr.repo}/issues/${pr.number}/comments`);
232
+ if (!issue.ok) return unavailable(issue.reason, pr);
233
+
234
+ const { threads, inline: inlineCount } = buildThreads(inline.items, repoRoot);
235
+ const issueComments = (issue.items as GhIssueComment[])
236
+ .map((raw) => toView(raw, repoRoot))
237
+ .filter((view): view is PrCommentView => view !== null)
238
+ .sort(
239
+ (a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "") || a.id - b.id,
240
+ );
241
+
242
+ const value: PrCommentsResponse = {
243
+ available: true,
244
+ target: pr,
245
+ fetchedAt: systemClock(),
246
+ threads,
247
+ issueComments,
248
+ counts: {
249
+ threads: threads.length,
250
+ inline: inlineCount,
251
+ issue: issueComments.length,
252
+ },
253
+ };
254
+ cache.set(key, { expiresAt: now + CACHE_TTL_MS, value });
255
+ return value;
256
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Caixa de entrada de Pull Requests do repositório atual (servidor).
3
+ *
4
+ * Lista as PRs abertas via `gh` e executa o checkout somente quando a árvore
5
+ * de trabalho está limpa. Fail-open: sem `gh` (ou sem git), devolve o motivo e
6
+ * a UI degrada sem quebrar as demais abas.
7
+ */
8
+
9
+ import { describeGhError, type GhRunner } from "../core/services/gh-client";
10
+ import type { GitRepository } from "../core/services/git-repository";
11
+
12
+ /** Estado da árvore de trabalho (base do bloqueio do checkout). */
13
+ export type WorktreeState = {
14
+ /** `false` quando o `git status` falhou. */
15
+ available: boolean;
16
+ clean: boolean;
17
+ changes: number;
18
+ /** Amostra dos caminhos alterados (limitada para a UI). */
19
+ sample: string[];
20
+ };
21
+
22
+ /** Run associada à branch de uma PR. */
23
+ export type PrRunInfo = {
24
+ slug: string;
25
+ worktreePath?: string;
26
+ prNumber?: number;
27
+ };
28
+
29
+ /** PR aberta exibida na sidebar. */
30
+ export type PrListItem = {
31
+ number: number;
32
+ title: string;
33
+ headRefName: string;
34
+ baseRefName: string;
35
+ author: string;
36
+ updatedAt?: string;
37
+ isDraft: boolean;
38
+ reviewDecision?: string;
39
+ url?: string;
40
+ /** Run local correspondente à branch, quando existir. */
41
+ runSlug?: string;
42
+ /** Worktree ativo da PR (revisão paralela). */
43
+ worktreePath?: string;
44
+ };
45
+
46
+ /** Resposta de `GET /api/prs`. */
47
+ export type PrListResponse = {
48
+ available: boolean;
49
+ reason?: string;
50
+ currentBranch: string | null;
51
+ worktree: WorktreeState;
52
+ prs: PrListItem[];
53
+ };
54
+
55
+ /** Resultado do checkout de uma PR. */
56
+ export type CheckoutResponse =
57
+ | { ok: true; number: number; branch: string | null }
58
+ | {
59
+ ok: false;
60
+ reason: "dirty" | "git" | "gh";
61
+ detail: string;
62
+ changes?: string[];
63
+ };
64
+
65
+ const PR_LIST_FIELDS =
66
+ "number,title,headRefName,baseRefName,author,updatedAt,isDraft,reviewDecision,url";
67
+
68
+ type GhPrListItem = {
69
+ number?: number;
70
+ title?: string;
71
+ headRefName?: string;
72
+ baseRefName?: string;
73
+ author?: { login?: string; name?: string };
74
+ updatedAt?: string;
75
+ isDraft?: boolean;
76
+ reviewDecision?: string;
77
+ url?: string;
78
+ };
79
+
80
+ function toItem(
81
+ raw: GhPrListItem,
82
+ runs: ReadonlyMap<string, PrRunInfo>,
83
+ ): PrListItem | null {
84
+ const number = Number(raw.number);
85
+ const headRefName = raw.headRefName?.trim();
86
+ if (!Number.isFinite(number) || number <= 0 || !headRefName) return null;
87
+ const run = runs.get(headRefName);
88
+ return {
89
+ number,
90
+ title: raw.title?.trim() || `PR #${number}`,
91
+ headRefName,
92
+ baseRefName: raw.baseRefName?.trim() || "",
93
+ author: raw.author?.login ?? raw.author?.name ?? "desconhecido",
94
+ updatedAt: raw.updatedAt,
95
+ isDraft: Boolean(raw.isDraft),
96
+ reviewDecision: raw.reviewDecision?.trim() || undefined,
97
+ url: raw.url,
98
+ runSlug: run?.slug,
99
+ worktreePath: run?.worktreePath,
100
+ };
101
+ }
102
+
103
+ /** Acesso às PRs do repositório atual. */
104
+ export class PrInbox {
105
+ constructor(
106
+ private readonly ghRunner: GhRunner,
107
+ private readonly git: GitRepository,
108
+ ) {}
109
+
110
+ /** Estado da árvore de trabalho (`git status --porcelain`). */
111
+ worktree(): WorktreeState {
112
+ const result = this.git.tryRun(["status", "--porcelain"]);
113
+ if (!result.ok) {
114
+ return { available: false, clean: false, changes: 0, sample: [] };
115
+ }
116
+ const lines = result.stdout
117
+ .split("\n")
118
+ .map((line) => line.trimEnd())
119
+ .filter((line) => line.length > 0);
120
+ return {
121
+ available: true,
122
+ clean: lines.length === 0,
123
+ changes: lines.length,
124
+ sample: lines.slice(0, 20),
125
+ };
126
+ }
127
+
128
+ /** Lista as PRs abertas (limite 50) e o estado local do repositório. */
129
+ list(runs: ReadonlyMap<string, PrRunInfo> = new Map()): PrListResponse {
130
+ const currentBranch = this.git.currentBranch();
131
+ const worktree = this.worktree();
132
+ const result = this.ghRunner([
133
+ "pr",
134
+ "list",
135
+ "--state",
136
+ "open",
137
+ "--limit",
138
+ "50",
139
+ "--json",
140
+ PR_LIST_FIELDS,
141
+ ]);
142
+
143
+ if (!result.ok) {
144
+ return {
145
+ available: false,
146
+ reason: describeGhError(result),
147
+ currentBranch,
148
+ worktree,
149
+ prs: [],
150
+ };
151
+ }
152
+
153
+ let parsed: unknown;
154
+ try {
155
+ parsed = JSON.parse(result.stdout);
156
+ } catch {
157
+ return {
158
+ available: false,
159
+ reason: "Resposta inválida do gh (JSON malformado).",
160
+ currentBranch,
161
+ worktree,
162
+ prs: [],
163
+ };
164
+ }
165
+ if (!Array.isArray(parsed)) {
166
+ return {
167
+ available: false,
168
+ reason: "Resposta inesperada do gh (esperava uma lista de PRs).",
169
+ currentBranch,
170
+ worktree,
171
+ prs: [],
172
+ };
173
+ }
174
+
175
+ const prs = (parsed as GhPrListItem[])
176
+ .map((item) => toItem(item, runs))
177
+ .filter((item): item is PrListItem => item !== null)
178
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
179
+
180
+ return { available: true, currentBranch, worktree, prs };
181
+ }
182
+
183
+ /**
184
+ * Faz checkout da PR via `gh pr checkout`.
185
+ *
186
+ * A árvore precisa estar limpa: alterações locais não commitadas bloqueiam a
187
+ * operação para não descartar trabalho do usuário.
188
+ */
189
+ checkout(number: number): CheckoutResponse {
190
+ const worktree = this.worktree();
191
+ if (!worktree.available) {
192
+ return {
193
+ ok: false,
194
+ reason: "git",
195
+ detail: "Não foi possível ler o estado do git.",
196
+ };
197
+ }
198
+ if (!worktree.clean) {
199
+ return {
200
+ ok: false,
201
+ reason: "dirty",
202
+ detail: `${worktree.changes} alteração(ões) não commitada(s).`,
203
+ changes: worktree.sample,
204
+ };
205
+ }
206
+
207
+ const result = this.ghRunner(["pr", "checkout", String(number)]);
208
+ if (!result.ok) {
209
+ return { ok: false, reason: "gh", detail: describeGhError(result) };
210
+ }
211
+ return { ok: true, number, branch: this.git.currentBranch() };
212
+ }
213
+ }
@@ -5,6 +5,7 @@
5
5
  * O slug continua sendo o branch-slug (estável nas URLs).
6
6
  */
7
7
 
8
+ import { existsSync } from "node:fs";
8
9
  import { resolve } from "node:path";
9
10
  import {
10
11
  applyFindingUpdate,
@@ -34,14 +35,23 @@ import {
34
35
  } from "../core/github-review";
35
36
  import { pingFindingsStatus } from "../core/run-status";
36
37
  import { systemClock } from "../core/services/clock";
38
+ import { defaultGhRunner } from "../core/services/gh-client";
39
+ import type { WorktreeManager } from "../core/services/worktree-manager";
40
+ import { workRoot } from "../core/storage/paths";
37
41
  import type { RepoIdentity, ReviewRecord, StorageProvider } from "../core/storage/types";
42
+ import { ReviewWorkArea } from "../core/storage/work-area";
38
43
  import type { Severity } from "../core/types";
39
44
  import { isSlug } from "./artifacts";
45
+ import { fetchPrComments, type PrCommentsResponse } from "./pr-comments";
40
46
 
41
47
  /** Item da lista de execuções (sidebar) — já ordenado do mais recente ao antigo. */
42
48
  export type RunListItem = {
43
49
  slug: string;
44
50
  branch: string;
51
+ /** Worktree onde a run foi executada (ausente = clone principal). */
52
+ worktreePath?: string;
53
+ /** PR revisada (quando iniciada via `--pr`). */
54
+ prNumber?: number;
45
55
  updatedAt?: string;
46
56
  startedAt?: string;
47
57
  heartbeatAt?: string;
@@ -53,6 +63,10 @@ export type RunListItem = {
53
63
  export type RunDetail = {
54
64
  slug: string;
55
65
  branch: string;
66
+ /** Worktree onde a run foi executada (ausente = clone principal). */
67
+ worktreePath?: string;
68
+ /** PR revisada (quando iniciada via `--pr`). */
69
+ prNumber?: number;
56
70
  status: ReviewRecord | null;
57
71
  manifest: unknown;
58
72
  summary: FindingsSummary;
@@ -61,6 +75,18 @@ export type RunDetail = {
61
75
  deleted: string[];
62
76
  };
63
77
 
78
+ /** Resultado da exclusão de uma run. */
79
+ export type DeleteRunResult = {
80
+ deleted: boolean;
81
+ /** O usuário pediu para remover o worktree. */
82
+ worktreeRequested: boolean;
83
+ /** O worktree foi de fato removido. */
84
+ worktreeRemoved: boolean;
85
+ worktreePath?: string;
86
+ /** Aviso quando o worktree não pôde ser removido. */
87
+ warning?: string;
88
+ };
89
+
64
90
  /** Resultado do envio dos findings à PR. */
65
91
  export type SubmitResult =
66
92
  | { available: false; reason: string }
@@ -78,10 +104,19 @@ export type ArtifactListItem = {
78
104
  export class RunRepository {
79
105
  private readonly storage: StorageProvider;
80
106
  private readonly repo: RepoIdentity;
107
+ private readonly ghRunner: GhRunner;
108
+ private readonly worktrees?: WorktreeManager;
81
109
 
82
- constructor(storage: StorageProvider, repo: RepoIdentity) {
110
+ constructor(
111
+ storage: StorageProvider,
112
+ repo: RepoIdentity,
113
+ ghRunner: GhRunner = defaultGhRunner,
114
+ worktrees?: WorktreeManager,
115
+ ) {
83
116
  this.storage = storage;
84
117
  this.repo = repo;
118
+ this.ghRunner = ghRunner;
119
+ this.worktrees = worktrees;
85
120
  }
86
121
 
87
122
  /** `true` se o slug aponta para uma run do repositório atual. */
@@ -96,6 +131,8 @@ export class RunRepository {
96
131
  return {
97
132
  slug: review.slug,
98
133
  branch: review.branch,
134
+ worktreePath: review.worktreePath,
135
+ prNumber: review.prNumber,
99
136
  updatedAt: review.updatedAt,
100
137
  startedAt: review.startedAt,
101
138
  heartbeatAt: review.heartbeatAt,
@@ -105,6 +142,32 @@ export class RunRepository {
105
142
  });
106
143
  }
107
144
 
145
+ /** Mapa branch → slug das runs (usado para casar PRs com execuções). */
146
+ branchIndex(): Map<string, string> {
147
+ return new Map(
148
+ this.storage
149
+ .listReviews(this.repo.key)
150
+ .map((review) => [review.branch, review.slug]),
151
+ );
152
+ }
153
+
154
+ /** Mapa branch → run + worktree (sidebar de PRs). */
155
+ workspaceIndex(): Map<
156
+ string,
157
+ { slug: string; worktreePath?: string; prNumber?: number }
158
+ > {
159
+ return new Map(
160
+ this.storage.listReviews(this.repo.key).map((review) => [
161
+ review.branch,
162
+ {
163
+ slug: review.slug,
164
+ worktreePath: review.worktreePath,
165
+ prNumber: review.prNumber,
166
+ },
167
+ ]),
168
+ );
169
+ }
170
+
108
171
  /** Detalhe da run (null se o slug for inválido). */
109
172
  detail(slug: string): RunDetail | null {
110
173
  const review = this.findBySlug(slug);
@@ -118,6 +181,8 @@ export class RunRepository {
118
181
  return {
119
182
  slug: review.slug,
120
183
  branch: review.branch,
184
+ worktreePath: review.worktreePath,
185
+ prNumber: review.prNumber,
121
186
  status: review,
122
187
  manifest: this.readArtifactJson(review, "manifest.json"),
123
188
  summary: summarize(state),
@@ -127,6 +192,49 @@ export class RunRepository {
127
192
  };
128
193
  }
129
194
 
195
+ /**
196
+ * Apaga a run por completo: staging local, findings, histórico, artefatos e a
197
+ * review. Opcionalmente remove o worktree (com `--force`, pois a run foi
198
+ * confirmada para exclusão).
199
+ */
200
+ delete(
201
+ slug: string,
202
+ options: { removeWorktree?: boolean } = {},
203
+ ): DeleteRunResult | null {
204
+ const review = this.findBySlug(slug);
205
+ if (!review) return null;
206
+
207
+ const worktreePath = review.worktreePath;
208
+ const removeWorktree = options.removeWorktree === true && Boolean(worktreePath);
209
+ let worktreeRemoved = false;
210
+ let warning: string | undefined;
211
+
212
+ if (removeWorktree && worktreePath) {
213
+ if (this.worktrees) {
214
+ const result = this.worktrees.remove(worktreePath, { force: true });
215
+ worktreeRemoved = result.removed;
216
+ if (!result.removed) {
217
+ warning = result.detail ?? "worktree não removido";
218
+ }
219
+ } else {
220
+ warning = "worktree não gerenciado por este servidor";
221
+ }
222
+ }
223
+
224
+ // Staging atual (dentro do worktree quando existe) + legado no home.
225
+ new ReviewWorkArea(review).clean();
226
+ new ReviewWorkArea(review, workRoot()).clean();
227
+
228
+ this.storage.deleteReview(review.id);
229
+ return {
230
+ deleted: true,
231
+ worktreeRequested: options.removeWorktree === true,
232
+ worktreeRemoved,
233
+ worktreePath,
234
+ warning,
235
+ };
236
+ }
237
+
130
238
  /** Aplica `VACUUM`/`optimize` no backend (chamado no start do server). */
131
239
  maintain(): void {
132
240
  this.storage.maintain();
@@ -154,6 +262,12 @@ export class RunRepository {
154
262
  return this.storage.readFindings(review.id) ?? emptyState(review.branch);
155
263
  }
156
264
 
265
+ /** Raiz dos arquivos da review: worktree vivo ou clone principal. */
266
+ private rootOf(review: ReviewRecord): string {
267
+ const worktree = review.worktreePath;
268
+ return worktree && existsSync(worktree) ? worktree : this.repo.root;
269
+ }
270
+
157
271
  private readArtifactJson<T>(review: ReviewRecord, name: string): T | null {
158
272
  const artifact = this.storage.readArtifact(review.id, name);
159
273
  if (!artifact) return null;
@@ -235,7 +349,7 @@ export class RunRepository {
235
349
  const review = this.findBySlug(slug);
236
350
  if (!review) return null;
237
351
  const state = this.loadState(review);
238
- const result = syncState(state, fileReader(process.cwd()));
352
+ const result = syncState(state, fileReader(this.rootOf(review)));
239
353
  this.storage.writeFindings(review.id, state);
240
354
  pingFindingsStatus(this.storage, review.id, state);
241
355
  return { result, summary: summarize(state) };
@@ -273,6 +387,21 @@ export class RunRepository {
273
387
  return { available: true, target: target.target, outcome };
274
388
  }
275
389
 
390
+ /** Comentários da PR da run (null se o slug for inválido). */
391
+ comments(
392
+ slug: string,
393
+ options: { refresh?: boolean } = {},
394
+ ): PrCommentsResponse | null {
395
+ const review = this.findBySlug(slug);
396
+ if (!review) return null;
397
+ return fetchPrComments({
398
+ runner: this.ghRunner,
399
+ branch: review.branch,
400
+ repoRoot: review.repoRoot,
401
+ refresh: options.refresh,
402
+ });
403
+ }
404
+
276
405
  /** Lista os artefatos da run (sem conteúdo). */
277
406
  artifacts(slug: string): ArtifactListItem[] | null {
278
407
  const review = this.findBySlug(slug);
@@ -304,5 +433,9 @@ function toClientFinding(
304
433
  finding: FindingState,
305
434
  review: ReviewRecord,
306
435
  ): FindingState & { absFile: string } {
307
- return { ...finding, absFile: resolve(review.repoRoot, finding.file) };
436
+ const root =
437
+ review.worktreePath && existsSync(review.worktreePath)
438
+ ? review.worktreePath
439
+ : review.repoRoot;
440
+ return { ...finding, absFile: resolve(root, finding.file) };
308
441
  }