@payconductor/houndsman 0.7.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 (54) 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/worktree-command.ts +118 -0
  8. package/src/cli/help.ts +12 -2
  9. package/src/cli/index.ts +14 -4
  10. package/src/core/ast/service.ts +7 -2
  11. package/src/core/brief.ts +1 -0
  12. package/src/core/commands/context.ts +9 -1
  13. package/src/core/commands/findings.ts +11 -5
  14. package/src/core/commands/review.ts +13 -0
  15. package/src/core/commands/scaffold.ts +4 -2
  16. package/src/core/config.ts +13 -0
  17. package/src/core/defaults.ts +7 -0
  18. package/src/core/engine/houndsman-engine.ts +26 -3
  19. package/src/core/engine/review-scope.ts +17 -22
  20. package/src/core/engine/review-session.ts +25 -2
  21. package/src/core/engine/review-workspace.ts +204 -0
  22. package/src/core/jev/jev-detector.ts +10 -2
  23. package/src/core/orchestrator.ts +62 -13
  24. package/src/core/project-config.ts +12 -0
  25. package/src/core/services/git-repository.ts +46 -0
  26. package/src/core/services/pr-resolver.ts +147 -0
  27. package/src/core/services/worktree-manager.ts +269 -0
  28. package/src/core/storage/file/file-storage.ts +14 -1
  29. package/src/core/storage/paths.ts +30 -1
  30. package/src/core/storage/sqlite/mappers.ts +4 -0
  31. package/src/core/storage/sqlite/schema.ts +13 -3
  32. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  33. package/src/core/storage/types.ts +12 -0
  34. package/src/core/storage/work-area.ts +20 -1
  35. package/src/core/types.ts +2 -0
  36. package/src/harness/hunt/hunt-options.ts +17 -0
  37. package/src/harness/hunt/hunt-session.ts +7 -20
  38. package/src/index.ts +31 -0
  39. package/src/server/client/App.tsx +49 -11
  40. package/src/server/client/DeleteRunModal.tsx +163 -0
  41. package/src/server/client/ResumePanel.tsx +8 -8
  42. package/src/server/client/Sidebar.tsx +56 -1
  43. package/src/server/client/icons.tsx +10 -0
  44. package/src/server/client/utils/types.ts +26 -0
  45. package/src/server/index.ts +97 -4
  46. package/src/server/pr-inbox.ts +15 -4
  47. package/src/server/run-repository.ts +103 -2
  48. package/src/skill/README.md +11 -3
  49. package/src/skill/SKILL.md +44 -10
  50. package/src/skill/hunt.md +33 -7
  51. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  52. package/src/tests/cli.test.ts +2 -1
  53. package/src/tests/server.test.ts +38 -0
  54. package/src/tests/worktree.test.ts +369 -0
@@ -24,6 +24,7 @@ import type {
24
24
  CheckoutResponse,
25
25
  PrListItem,
26
26
  PrListResponse,
27
+ PrWorktreeResponse,
27
28
  RunSummary,
28
29
  WorktreeState,
29
30
  } from "./utils/types";
@@ -89,9 +90,11 @@ function PrItem({
89
90
  worktree,
90
91
  busy,
91
92
  confirming,
93
+ worktreeBusy,
92
94
  onConfirm,
93
95
  onCancel,
94
96
  onCheckout,
97
+ onCreateWorktree,
95
98
  onSelectRun,
96
99
  }: {
97
100
  pr: PrListItem;
@@ -99,9 +102,11 @@ function PrItem({
99
102
  worktree: WorktreeState;
100
103
  busy: boolean;
101
104
  confirming: boolean;
105
+ worktreeBusy: boolean;
102
106
  onConfirm: (number: number) => void;
103
107
  onCancel: () => void;
104
108
  onCheckout: (pr: PrListItem) => void;
109
+ onCreateWorktree: (pr: PrListItem) => void;
105
110
  onSelectRun: (slug: string) => void;
106
111
  }) {
107
112
  const isCurrent = currentBranch === pr.headRefName;
@@ -130,6 +135,11 @@ function PrItem({
130
135
  <span>· {pr.author}</span>
131
136
  </p>
132
137
  {review && <p class={`pr-review pr-review-${pr.reviewDecision}`}>{review}</p>}
138
+ {pr.worktreePath && (
139
+ <p class="pr-worktree" title={pr.worktreePath}>
140
+ Worktree: <code>{pr.worktreePath}</code>
141
+ </p>
142
+ )}
133
143
  <div class="pr-actions">
134
144
  {runSlug && (
135
145
  <button
@@ -141,6 +151,21 @@ function PrItem({
141
151
  Abrir run
142
152
  </button>
143
153
  )}
154
+ {!pr.worktreePath && (
155
+ <button
156
+ type="button"
157
+ class="pr-btn"
158
+ disabled={busy || worktreeBusy}
159
+ title={`Criar um worktree dedicado para ${pr.headRefName} (revisão paralela)`}
160
+ onClick={() => onCreateWorktree(pr)}
161
+ >
162
+ {worktreeBusy ? (
163
+ <Spinner size={12} class="icon spin" />
164
+ ) : (
165
+ "Worktree"
166
+ )}
167
+ </button>
168
+ )}
144
169
  {pr.url && (
145
170
  <a
146
171
  class="pr-btn"
@@ -209,6 +234,7 @@ export function Sidebar({
209
234
  const [error, setError] = useState("");
210
235
  const [notice, setNotice] = useState("");
211
236
  const [busy, setBusy] = useState<number | null>(null);
237
+ const [worktreeBusy, setWorktreeBusy] = useState<number | null>(null);
212
238
  const [confirming, setConfirming] = useState<number | null>(null);
213
239
  const requested = useRef(false);
214
240
 
@@ -284,7 +310,34 @@ export function Sidebar({
284
310
  },
285
311
  [loadPrs, onRefreshRuns, onSelect],
286
312
  );
287
-
313
+ const createWorktree = useCallback(
314
+ async (pr: PrListItem) => {
315
+ setWorktreeBusy(pr.number);
316
+ setError("");
317
+ setNotice("");
318
+ try {
319
+ const result = await api<PrWorktreeResponse>("/api/prs/worktree", {
320
+ method: "POST",
321
+ headers: { "content-type": "application/json" },
322
+ body: JSON.stringify({ number: pr.number }),
323
+ });
324
+ if (!result.ok) {
325
+ setError(result.detail);
326
+ return;
327
+ }
328
+ setNotice(
329
+ `Worktree de #${pr.number} em ${result.path}. ` +
330
+ `Rode \`houndsman --pr ${pr.number}\` para revisar em paralelo.`,
331
+ );
332
+ await loadPrs();
333
+ } catch (err) {
334
+ setError(String(err));
335
+ } finally {
336
+ setWorktreeBusy(null);
337
+ }
338
+ },
339
+ [loadPrs],
340
+ );
288
341
  return (
289
342
  <aside class="sidebar">
290
343
  <div class="brand">
@@ -383,9 +436,11 @@ export function Sidebar({
383
436
  worktree={data.worktree}
384
437
  busy={busy !== null}
385
438
  confirming={confirming === pr.number}
439
+ worktreeBusy={worktreeBusy === pr.number}
386
440
  onConfirm={setConfirming}
387
441
  onCancel={() => setConfirming(null)}
388
442
  onCheckout={checkout}
443
+ onCreateWorktree={createWorktree}
389
444
  onSelectRun={onSelect}
390
445
  />
391
446
  ))}
@@ -120,6 +120,16 @@ export const Alert = (p: IconProps) => (
120
120
  </Svg>
121
121
  );
122
122
 
123
+ /** Lixeira — excluir a run. */
124
+ export const Trash = (p: IconProps) => (
125
+ <Svg {...p}>
126
+ <path d="M4 7h16" />
127
+ <path d="M9 7V4h6v3" />
128
+ <path d="m6.5 7 1 13h9l1-13" />
129
+ <path d="M10 11v6M14 11v6" />
130
+ </Svg>
131
+ );
132
+
123
133
  export const ListIcon = (p: IconProps) => (
124
134
  <Svg {...p}>
125
135
  <path d="M9 6h12M9 12h12M9 18h12" />
@@ -83,6 +83,9 @@ export type ArtifactMeta = {
83
83
  export type RunSummary = {
84
84
  slug: string;
85
85
  branch: string;
86
+ /** Worktree onde a run foi executada (ausente = clone principal). */
87
+ worktreePath?: string;
88
+ prNumber?: number;
86
89
  updatedAt?: string;
87
90
  startedAt?: string;
88
91
  /** Último sinal de vida de uma execução ativa (revisão/correção). */
@@ -126,6 +129,8 @@ export type PrListItem = {
126
129
  reviewDecision?: string;
127
130
  url?: string;
128
131
  runSlug?: string;
132
+ /** Worktree ativo da PR (revisão paralela). */
133
+ worktreePath?: string;
129
134
  };
130
135
 
131
136
  export type PrListResponse = {
@@ -145,6 +150,27 @@ export type CheckoutResponse =
145
150
  changes?: string[];
146
151
  };
147
152
 
153
+ /** Resultado de `DELETE /api/runs/:slug`. */
154
+ export type DeleteRunResponse = {
155
+ deleted: boolean;
156
+ worktreeRequested: boolean;
157
+ worktreeRemoved: boolean;
158
+ worktreePath?: string;
159
+ warning?: string;
160
+ };
161
+
162
+ export type PrWorktreeResponse =
163
+ | {
164
+ ok: true;
165
+ number: number;
166
+ branch: string;
167
+ path: string;
168
+ head: string;
169
+ created: boolean;
170
+ warning?: string;
171
+ }
172
+ | { ok: false; reason: string; detail: string };
173
+
148
174
  /** Comentário normalizado da PR (thread ou geral). */
149
175
  export type PrCommentView = {
150
176
  id: number;
@@ -17,7 +17,10 @@ import {
17
17
  } from "../core/github-review";
18
18
  import { logger } from "../core/logging";
19
19
  import { GitRepository } from "../core/services/git-repository";
20
+ import { PrResolver } from "../core/services/pr-resolver";
21
+ import { WorktreeManager } from "../core/services/worktree-manager";
20
22
  import { createStorage } from "../core/storage/factory";
23
+ import { resolveWorktreesRoot } from "../core/storage/paths";
21
24
  import { resolveRepoIdentity } from "../core/storage/repo-identity";
22
25
  import type { RepoIdentity, StorageProvider } from "../core/storage/types";
23
26
  import { buildClientBundle } from "./client-build";
@@ -94,6 +97,8 @@ async function route(
94
97
  ghRunner: GhRunner,
95
98
  prs: PrInbox,
96
99
  editor: EditorContext,
100
+ worktrees: WorktreeManager,
101
+ git: GitRepository,
97
102
  ): Promise<Response> {
98
103
  const url = new URL(request.url);
99
104
  const path = url.pathname;
@@ -108,7 +113,61 @@ async function route(
108
113
  if (path === "/api/health") return json({ ok: true });
109
114
  if (path === "/api/editor" && request.method === "GET") return json(editor);
110
115
  if (path === "/api/prs" && request.method === "GET") {
111
- return json(prs.list(runs.branchIndex()));
116
+ return json(prs.list(runs.workspaceIndex()));
117
+ }
118
+ if (path === "/api/worktrees" && request.method === "GET") {
119
+ return json({
120
+ worktrees: worktrees.managed().map((entry) => ({
121
+ path: entry.path,
122
+ branch: entry.branch?.replace(/^refs\/heads\//, "") ?? null,
123
+ head: entry.head ?? null,
124
+ prunable: entry.prunable,
125
+ })),
126
+ });
127
+ }
128
+ if (path === "/api/prs/worktree" && request.method === "POST") {
129
+ const body = (await request.json().catch(() => ({}))) as { number?: unknown };
130
+ const number = Number(body.number);
131
+ if (!Number.isInteger(number) || number <= 0) {
132
+ return json(
133
+ { ok: false, reason: "invalid", detail: "Número de PR inválido." },
134
+ 400,
135
+ );
136
+ }
137
+ const resolver = new PrResolver(ghRunner, git);
138
+ const resolved = resolver.resolve(number);
139
+ if (!resolved.ok) {
140
+ return json({ ok: false, reason: resolved.reason, detail: resolved.detail });
141
+ }
142
+ const refs = resolver.prepareRefs(resolved.pr);
143
+ try {
144
+ const worktree = worktrees.ensure({
145
+ branch: resolved.pr.headRefName,
146
+ ref: refs.head,
147
+ });
148
+ return json({
149
+ ok: true,
150
+ number,
151
+ branch: resolved.pr.headRefName,
152
+ path: worktree.path,
153
+ head: worktree.head,
154
+ created: worktree.created,
155
+ warning: refs.warning,
156
+ });
157
+ } catch (error) {
158
+ return json({
159
+ ok: false,
160
+ reason: "git",
161
+ detail: error instanceof Error ? error.message : String(error),
162
+ });
163
+ }
164
+ }
165
+ const worktreeMatch = path.match(/^\/api\/worktrees\/(.+)$/);
166
+ if (worktreeMatch && request.method === "DELETE") {
167
+ const branch = decodeURIComponent(worktreeMatch[1]);
168
+ const force = url.searchParams.get("force") === "1";
169
+ const result = worktrees.remove(branch, { force });
170
+ return json(result, result.removed ? 200 : 409);
112
171
  }
113
172
  if (path === "/api/prs/checkout" && request.method === "POST") {
114
173
  const body = (await request.json().catch(() => ({}))) as { number?: unknown };
@@ -132,6 +191,14 @@ async function route(
132
191
  const rest = match[2] ?? "";
133
192
  if (!runs.exists(slug)) return notFound(`run '${slug}' não encontrada`);
134
193
 
194
+ if (rest === "" && request.method === "DELETE") {
195
+ const removeWorktree = ["1", "true"].includes(
196
+ url.searchParams.get("worktree") ?? "",
197
+ );
198
+ const result = runs.delete(slug, { removeWorktree });
199
+ return result ? json(result) : notFound(`run '${slug}' não encontrada`);
200
+ }
201
+
135
202
  if (rest === "" || rest === "/") return json(runs.detail(slug));
136
203
 
137
204
  if (rest === "/comments" && request.method === "GET") {
@@ -195,25 +262,44 @@ export class HoundsmanServer {
195
262
  private readonly ghRunner: GhRunner;
196
263
  private readonly prs: PrInbox;
197
264
  private readonly editor: EditorContext;
265
+ private readonly worktrees: WorktreeManager;
266
+ private readonly git: GitRepository;
198
267
 
199
268
  constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
200
269
  const repo = deps.repo ?? resolveRepoIdentity();
201
270
  this.ghRunner = deps.ghRunner ?? defaultGhRunner;
271
+ this.git = new GitRepository(deps.gitRunner ?? createGitRunner(repo.root));
272
+ this.prs = new PrInbox(this.ghRunner, this.git);
273
+ this.worktrees = new WorktreeManager(
274
+ new GitRepository(createGitRunner(repo.root)),
275
+ {
276
+ repoKey: repo.key,
277
+ repoRoot: repo.root,
278
+ baseDir: resolveWorktreesRoot(config.worktrees?.location, repo.root),
279
+ },
280
+ );
202
281
  this.runs = new RunRepository(
203
282
  deps.storage ?? createStorage(config),
204
283
  repo,
205
284
  this.ghRunner,
285
+ this.worktrees,
206
286
  );
207
287
  this.editor =
208
288
  deps.editor ?? resolveEditor(deps.editorPreference, process.env, repo.root);
209
- const git = new GitRepository(deps.gitRunner ?? createGitRunner(repo.root));
210
- this.prs = new PrInbox(this.ghRunner, git);
211
289
  }
212
290
 
213
291
  /** Handler de requisições (testável sem abrir porta). */
214
292
  readonly handler = async (request: Request): Promise<Response> => {
215
293
  try {
216
- return await route(request, this.runs, this.ghRunner, this.prs, this.editor);
294
+ return await route(
295
+ request,
296
+ this.runs,
297
+ this.ghRunner,
298
+ this.prs,
299
+ this.editor,
300
+ this.worktrees,
301
+ this.git,
302
+ );
217
303
  } catch (error) {
218
304
  return json(
219
305
  { error: error instanceof Error ? error.message : String(error) },
@@ -423,6 +509,13 @@ code, pre, .mono { font-family:var(--font-mono); tab-size:4; }
423
509
  transition:background .15s ease, border-color .15s ease, color .15s ease; }
424
510
  .btn:hover { background:#202027; border-color:var(--accent-line); }
425
511
  .btn:disabled { opacity:.55; cursor:not-allowed; }
512
+ .btn.danger { color:#ffb3b3; background:rgba(255,86,86,.12); border-color:rgba(255,86,86,.4); }
513
+ .btn.danger:hover { background:rgba(255,86,86,.2); border-color:rgba(255,86,86,.62); }
514
+ .modal-check { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; font-size:12.5px; line-height:1.55;
515
+ color:var(--fg); background:var(--surface-2); border:1px solid var(--border); border-radius:12px; cursor:pointer; }
516
+ .modal-check input { margin:3px 0 0; flex:none; accent-color:var(--accent); }
517
+ .modal-check code { font-family:var(--font-mono); font-size:11.5px; color:var(--muted); word-break:break-all; }
518
+ .modal-warn { margin:0; font-size:12px; line-height:1.55; color:var(--muted); }
426
519
  .icon.spin { animation:spin .8s linear infinite; }
427
520
  .content { width:100%; max-width:1180px; margin:0 auto; padding:22px 24px 64px; }
428
521
  .page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; margin-bottom:16px; }
@@ -19,6 +19,13 @@ export type WorktreeState = {
19
19
  sample: string[];
20
20
  };
21
21
 
22
+ /** Run associada à branch de uma PR. */
23
+ export type PrRunInfo = {
24
+ slug: string;
25
+ worktreePath?: string;
26
+ prNumber?: number;
27
+ };
28
+
22
29
  /** PR aberta exibida na sidebar. */
23
30
  export type PrListItem = {
24
31
  number: number;
@@ -32,6 +39,8 @@ export type PrListItem = {
32
39
  url?: string;
33
40
  /** Run local correspondente à branch, quando existir. */
34
41
  runSlug?: string;
42
+ /** Worktree ativo da PR (revisão paralela). */
43
+ worktreePath?: string;
35
44
  };
36
45
 
37
46
  /** Resposta de `GET /api/prs`. */
@@ -70,11 +79,12 @@ type GhPrListItem = {
70
79
 
71
80
  function toItem(
72
81
  raw: GhPrListItem,
73
- runSlugs: ReadonlyMap<string, string>,
82
+ runs: ReadonlyMap<string, PrRunInfo>,
74
83
  ): PrListItem | null {
75
84
  const number = Number(raw.number);
76
85
  const headRefName = raw.headRefName?.trim();
77
86
  if (!Number.isFinite(number) || number <= 0 || !headRefName) return null;
87
+ const run = runs.get(headRefName);
78
88
  return {
79
89
  number,
80
90
  title: raw.title?.trim() || `PR #${number}`,
@@ -85,7 +95,8 @@ function toItem(
85
95
  isDraft: Boolean(raw.isDraft),
86
96
  reviewDecision: raw.reviewDecision?.trim() || undefined,
87
97
  url: raw.url,
88
- runSlug: runSlugs.get(headRefName),
98
+ runSlug: run?.slug,
99
+ worktreePath: run?.worktreePath,
89
100
  };
90
101
  }
91
102
 
@@ -115,7 +126,7 @@ export class PrInbox {
115
126
  }
116
127
 
117
128
  /** Lista as PRs abertas (limite 50) e o estado local do repositório. */
118
- list(runSlugs: ReadonlyMap<string, string> = new Map()): PrListResponse {
129
+ list(runs: ReadonlyMap<string, PrRunInfo> = new Map()): PrListResponse {
119
130
  const currentBranch = this.git.currentBranch();
120
131
  const worktree = this.worktree();
121
132
  const result = this.ghRunner([
@@ -162,7 +173,7 @@ export class PrInbox {
162
173
  }
163
174
 
164
175
  const prs = (parsed as GhPrListItem[])
165
- .map((item) => toItem(item, runSlugs))
176
+ .map((item) => toItem(item, runs))
166
177
  .filter((item): item is PrListItem => item !== null)
167
178
  .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
168
179
 
@@ -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,
@@ -35,7 +36,10 @@ import {
35
36
  import { pingFindingsStatus } from "../core/run-status";
36
37
  import { systemClock } from "../core/services/clock";
37
38
  import { defaultGhRunner } from "../core/services/gh-client";
39
+ import type { WorktreeManager } from "../core/services/worktree-manager";
40
+ import { workRoot } from "../core/storage/paths";
38
41
  import type { RepoIdentity, ReviewRecord, StorageProvider } from "../core/storage/types";
42
+ import { ReviewWorkArea } from "../core/storage/work-area";
39
43
  import type { Severity } from "../core/types";
40
44
  import { isSlug } from "./artifacts";
41
45
  import { fetchPrComments, type PrCommentsResponse } from "./pr-comments";
@@ -44,6 +48,10 @@ import { fetchPrComments, type PrCommentsResponse } from "./pr-comments";
44
48
  export type RunListItem = {
45
49
  slug: string;
46
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;
47
55
  updatedAt?: string;
48
56
  startedAt?: string;
49
57
  heartbeatAt?: string;
@@ -55,6 +63,10 @@ export type RunListItem = {
55
63
  export type RunDetail = {
56
64
  slug: string;
57
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;
58
70
  status: ReviewRecord | null;
59
71
  manifest: unknown;
60
72
  summary: FindingsSummary;
@@ -63,6 +75,18 @@ export type RunDetail = {
63
75
  deleted: string[];
64
76
  };
65
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
+
66
90
  /** Resultado do envio dos findings à PR. */
67
91
  export type SubmitResult =
68
92
  | { available: false; reason: string }
@@ -81,15 +105,18 @@ export class RunRepository {
81
105
  private readonly storage: StorageProvider;
82
106
  private readonly repo: RepoIdentity;
83
107
  private readonly ghRunner: GhRunner;
108
+ private readonly worktrees?: WorktreeManager;
84
109
 
85
110
  constructor(
86
111
  storage: StorageProvider,
87
112
  repo: RepoIdentity,
88
113
  ghRunner: GhRunner = defaultGhRunner,
114
+ worktrees?: WorktreeManager,
89
115
  ) {
90
116
  this.storage = storage;
91
117
  this.repo = repo;
92
118
  this.ghRunner = ghRunner;
119
+ this.worktrees = worktrees;
93
120
  }
94
121
 
95
122
  /** `true` se o slug aponta para uma run do repositório atual. */
@@ -104,6 +131,8 @@ export class RunRepository {
104
131
  return {
105
132
  slug: review.slug,
106
133
  branch: review.branch,
134
+ worktreePath: review.worktreePath,
135
+ prNumber: review.prNumber,
107
136
  updatedAt: review.updatedAt,
108
137
  startedAt: review.startedAt,
109
138
  heartbeatAt: review.heartbeatAt,
@@ -122,6 +151,23 @@ export class RunRepository {
122
151
  );
123
152
  }
124
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
+
125
171
  /** Detalhe da run (null se o slug for inválido). */
126
172
  detail(slug: string): RunDetail | null {
127
173
  const review = this.findBySlug(slug);
@@ -135,6 +181,8 @@ export class RunRepository {
135
181
  return {
136
182
  slug: review.slug,
137
183
  branch: review.branch,
184
+ worktreePath: review.worktreePath,
185
+ prNumber: review.prNumber,
138
186
  status: review,
139
187
  manifest: this.readArtifactJson(review, "manifest.json"),
140
188
  summary: summarize(state),
@@ -144,6 +192,49 @@ export class RunRepository {
144
192
  };
145
193
  }
146
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
+
147
238
  /** Aplica `VACUUM`/`optimize` no backend (chamado no start do server). */
148
239
  maintain(): void {
149
240
  this.storage.maintain();
@@ -171,6 +262,12 @@ export class RunRepository {
171
262
  return this.storage.readFindings(review.id) ?? emptyState(review.branch);
172
263
  }
173
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
+
174
271
  private readArtifactJson<T>(review: ReviewRecord, name: string): T | null {
175
272
  const artifact = this.storage.readArtifact(review.id, name);
176
273
  if (!artifact) return null;
@@ -252,7 +349,7 @@ export class RunRepository {
252
349
  const review = this.findBySlug(slug);
253
350
  if (!review) return null;
254
351
  const state = this.loadState(review);
255
- const result = syncState(state, fileReader(process.cwd()));
352
+ const result = syncState(state, fileReader(this.rootOf(review)));
256
353
  this.storage.writeFindings(review.id, state);
257
354
  pingFindingsStatus(this.storage, review.id, state);
258
355
  return { result, summary: summarize(state) };
@@ -336,5 +433,9 @@ function toClientFinding(
336
433
  finding: FindingState,
337
434
  review: ReviewRecord,
338
435
  ): FindingState & { absFile: string } {
339
- 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) };
340
441
  }
@@ -39,13 +39,20 @@ Um único skill (`/houndsman`), quatro capabilities (detalhadas em `hunt.md`, `t
39
39
  | 2. Revisar | *(subagentes)* | skill | `findings/<watchdog>.json` (um por watchdog) |
40
40
  | 3. Finalizar | `bun run houndsman --finalize` | skill | `review.md` |
41
41
 
42
+ > **Alvo = PR** (ex.: “revise a PR 456”): troque os passos por `houndsman context --pr 456`,
43
+ > `houndsman --pr 456` e `houndsman --pr 456 --finalize`. O prepare cria um **worktree**
44
+ > detached em `<repo>/.houndsman/worktrees/<repo>/<branch>`; os briefs trazem
45
+ > `**Worktree:**` e os subagentes devem ler essa árvore (nunca o clone principal).
46
+
42
47
  ## Comandos
43
48
 
44
49
  | Comando | Função |
45
50
  |---|---|
46
- | `houndsman context` | Passo 0 — contexto da PR via `gh` |
51
+ | `houndsman context` | Passo 0 — contexto da PR via `gh` (`--pr <n>`/`--worktree <path>`) |
47
52
  | `houndsman` | Passo 1 — preparar (gera os briefs) |
48
- | `houndsman --finalize` | Passo 3 — gerar o relatório |
53
+ | `houndsman --pr <n>` | Passo 1 em worktree dedicado (revisões paralelas) |
54
+ | `houndsman --finalize` | Passo 3 — gerar o relatório (`--pr <n>` reusa o worktree) |
55
+ | `houndsman worktree <ação>` | `list`/`add`/`remove`/`prune`/`path` dos worktrees |
49
56
  | `houndsman watchdog <id>` | Contexto de um watchdog (`--json`, `--rules`) |
50
57
  | `houndsman findings <sub>` | Triagem/correção dos findings (`list`/`set`/`done`/`sync`/`batch`/`verify`/...) |
51
58
  | `houndsman server` | UI local de triagem (Bun.serve + Preact) |
@@ -57,7 +64,8 @@ Um único skill (`/houndsman`), quatro capabilities (detalhadas em `hunt.md`, `t
57
64
  ### Flags
58
65
 
59
66
  `--config <path>` · `--base <branch>` (default `main`) · `--head <ref>` (default `HEAD`) ·
60
- `--branch <nome>` · `--clean` · `--full` · `--no-jev` · `--fail-on-blocking` (no `--finalize`).
67
+ `--branch <nome>` · `--pr <n>` · `--worktree <path>` · `--clean` · `--full` · `--no-jev` ·
68
+ `--fail-on-blocking` (no `--finalize`).
61
69
 
62
70
  ## Artefatos
63
71