@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,269 @@
1
+ /**
2
+ * Worktrees gerenciados pelo houndsman (biblioteca).
3
+ *
4
+ * Um worktree por branch revisada, criado em **detached HEAD** no commit da PR.
5
+ * Detached evita o conflito "branch já checada" com o clone principal e é o
6
+ * modo correto para revisão (somente leitura). Vive fora do projeto, em
7
+ * `<HOUNDSMAN_HOME>/worktrees/<repoSlug>/<branchSlug>`.
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { createGitRunner } from "../git-runner";
14
+ import { sanitizeBranch } from "../report-builder";
15
+ import { worktreesRoot } from "../storage/paths";
16
+ import { GitRepository } from "./git-repository";
17
+
18
+ /** Entrada de `git worktree list --porcelain`. */
19
+ export type WorktreeEntry = {
20
+ path: string;
21
+ head?: string;
22
+ /** Ref completo (`refs/heads/...`) quando não detached. */
23
+ branch?: string;
24
+ detached: boolean;
25
+ bare: boolean;
26
+ prunable: boolean;
27
+ locked: boolean;
28
+ };
29
+
30
+ /** Entrada de criação de worktree. */
31
+ export type EnsureWorktreeInput = {
32
+ branch: string;
33
+ /** Ref/SHA que o worktree deve apontar. */
34
+ ref: string;
35
+ };
36
+
37
+ /** Resultado da criação/reuso. */
38
+ export type EnsureWorktreeResult = {
39
+ path: string;
40
+ /** Commit efetivamente checado (detached). */
41
+ head: string;
42
+ /** `true` quando o diretório foi criado agora. */
43
+ created: boolean;
44
+ /** `true` quando já existia apontando para o commit pedido. */
45
+ reused: boolean;
46
+ };
47
+
48
+ /** Resultado da remoção. */
49
+ export type RemoveWorktreeResult = {
50
+ removed: boolean;
51
+ detail?: string;
52
+ };
53
+
54
+ /** Opções do gerenciador. */
55
+ export type WorktreeManagerOptions = {
56
+ repoKey: string;
57
+ /** Raiz do clone principal. */
58
+ repoRoot: string;
59
+ /**
60
+ * Raiz onde os diretórios por repositório são criados.
61
+ * Default: `<HOUNDSMAN_HOME>/worktrees` (o CLI normalmente passa
62
+ * `resolveWorktreesRoot(config.worktrees?.location, repoRoot)`).
63
+ */
64
+ baseDir?: string;
65
+ };
66
+
67
+ /** Slug estável e legível para o diretório do repositório. */
68
+ export function repoSlug(repoKey: string): string {
69
+ const clean = repoKey
70
+ .replace(/^(remote|path):/, "")
71
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
72
+ .replace(/^-+|-+$/g, "")
73
+ .toLowerCase();
74
+ const hash = createHash("sha1").update(repoKey).digest("hex").slice(0, 8);
75
+ return `${clean.slice(-60) || "repo"}-${hash}`;
76
+ }
77
+
78
+ /** Normaliza um path para comparação (Windows é case-insensitive). */
79
+ function normalizePath(path: string): string {
80
+ const absolute = resolve(path).replace(/[\\/]+$/, "");
81
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
82
+ }
83
+
84
+ /** `true` se `path` está dentro de `root`. */
85
+ function isInside(root: string, path: string): boolean {
86
+ const rel = relative(resolve(root), resolve(path));
87
+ return rel === "" || (!rel.startsWith("..") && !rel.split(sep).includes(".."));
88
+ }
89
+
90
+ /** Fatia a saída de `git worktree list --porcelain` em entradas. */
91
+ export function parseWorktreeList(raw: string): WorktreeEntry[] {
92
+ const entries: WorktreeEntry[] = [];
93
+ let current: WorktreeEntry | null = null;
94
+
95
+ const flush = (): void => {
96
+ if (current) entries.push(current);
97
+ current = null;
98
+ };
99
+
100
+ for (const line of raw.split("\n")) {
101
+ if (line.startsWith("worktree ")) {
102
+ flush();
103
+ current = {
104
+ path: line.slice("worktree ".length).trim(),
105
+ detached: false,
106
+ bare: false,
107
+ prunable: false,
108
+ locked: false,
109
+ };
110
+ continue;
111
+ }
112
+ if (!current) continue;
113
+ if (line.startsWith("HEAD ")) current.head = line.slice(5).trim();
114
+ else if (line.startsWith("branch ")) current.branch = line.slice(7).trim();
115
+ else if (line === "detached") current.detached = true;
116
+ else if (line === "bare") current.bare = true;
117
+ else if (line.startsWith("prunable")) current.prunable = true;
118
+ else if (line.startsWith("locked")) current.locked = true;
119
+ }
120
+ flush();
121
+ return entries;
122
+ }
123
+
124
+ /** Gerencia os worktrees de um repositório. */
125
+ export class WorktreeManager {
126
+ private readonly git: GitRepository;
127
+ private readonly repoKey: string;
128
+ private readonly rootDir: string;
129
+
130
+ constructor(git: GitRepository, options: WorktreeManagerOptions) {
131
+ this.git = git;
132
+ this.repoKey = options.repoKey;
133
+ this.rootDir = join(
134
+ options.baseDir ?? worktreesRoot(),
135
+ repoSlug(options.repoKey),
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Garante o diretório gerenciado e um `.gitignore` local (`*`), para que
141
+ * worktrees dentro do repositório nunca sujem `git status` — mesmo que a
142
+ * camada `.houndsman/.gitignore` ainda não tenha sido atualizada.
143
+ */
144
+ private ensureRoot(): void {
145
+ mkdirSync(this.rootDir, { recursive: true });
146
+ const ignore = join(this.rootDir, ".gitignore");
147
+ if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
148
+ }
149
+
150
+ /** Diretório-base dos worktrees deste repositório. */
151
+ get baseDir(): string {
152
+ return this.rootDir;
153
+ }
154
+
155
+ /** Caminho determinístico do worktree de uma branch. */
156
+ pathFor(branch: string): string {
157
+ const slug = sanitizeBranch(branch) || "branch";
158
+ return join(this.rootDir, slug);
159
+ }
160
+
161
+ /** Todos os worktrees registrados no repositório. */
162
+ list(): WorktreeEntry[] {
163
+ return parseWorktreeList(this.git.worktreeListRaw());
164
+ }
165
+
166
+ /** Apenas os worktrees sob o diretório gerenciado. */
167
+ managed(): WorktreeEntry[] {
168
+ return this.list().filter((entry) => isInside(this.rootDir, entry.path));
169
+ }
170
+
171
+ /** Worktree registrado para uma branch (se existir). */
172
+ find(branch: string): WorktreeEntry | null {
173
+ const target = normalizePath(this.pathFor(branch));
174
+ return this.list().find((entry) => normalizePath(entry.path) === target) ?? null;
175
+ }
176
+
177
+ /**
178
+ * Garante o worktree da branch no commit `ref`.
179
+ *
180
+ * Reusa quando o HEAD já é o commit pedido; tenta `checkout --detach` quando
181
+ * existe e está em outro commit; recria quando o diretório está quebrado.
182
+ */
183
+ ensure(input: EnsureWorktreeInput): EnsureWorktreeResult {
184
+ this.ensureRoot();
185
+ const path = this.pathFor(input.branch);
186
+ const target = this.git.resolveSha(input.ref);
187
+ const existing = this.list().find(
188
+ (entry) => normalizePath(entry.path) === normalizePath(path),
189
+ );
190
+
191
+ if (existing) {
192
+ if (existing.head === target) {
193
+ return { path, head: target, created: false, reused: true };
194
+ }
195
+ const checkout = new GitRepository(createGitRunner(path)).tryRun([
196
+ "checkout",
197
+ "--detach",
198
+ target,
199
+ ]);
200
+ if (checkout.ok) {
201
+ return { path, head: target, created: false, reused: true };
202
+ }
203
+ // Worktree quebrado/inacessível: remove e recria do zero.
204
+ this.git.removeWorktree(path, true);
205
+ rmSync(path, { recursive: true, force: true });
206
+ } else if (existsSync(path)) {
207
+ // Diretório sobrou de um prune/remoção manual.
208
+ if (!isInside(this.rootDir, path)) {
209
+ throw new Error(`Worktree fora da raiz gerenciada: ${path}`);
210
+ }
211
+ rmSync(path, { recursive: true, force: true });
212
+ }
213
+
214
+ mkdirSync(dirname(path), { recursive: true });
215
+ try {
216
+ this.git.addWorktree(path, target);
217
+ } catch (error) {
218
+ // Corrida com outro processo ou metadado órfão: pruna e tenta 1x.
219
+ this.git.pruneWorktrees();
220
+ try {
221
+ this.git.addWorktree(path, target);
222
+ } catch (retry) {
223
+ const detail = retry instanceof Error ? retry.message : String(retry);
224
+ const original = error instanceof Error ? error.message : String(error);
225
+ throw new Error(
226
+ `Falha ao criar worktree em ${path}: ${detail || original}`,
227
+ );
228
+ }
229
+ }
230
+ return { path, head: target, created: true, reused: false };
231
+ }
232
+
233
+ /** Remove o worktree de uma branch (ou um path absoluto). */
234
+ remove(
235
+ branchOrPath: string,
236
+ options: { force?: boolean } = {},
237
+ ): RemoveWorktreeResult {
238
+ const path = isAbsolute(branchOrPath) ? branchOrPath : this.pathFor(branchOrPath);
239
+ const entry = this.list().find(
240
+ (worktree) => normalizePath(worktree.path) === normalizePath(path),
241
+ );
242
+ if (!entry) {
243
+ if (existsSync(path) && isInside(this.rootDir, path)) {
244
+ rmSync(path, { recursive: true, force: true });
245
+ }
246
+ return { removed: false, detail: "worktree não registrado" };
247
+ }
248
+
249
+ const result = this.git.removeWorktree(path, options.force ?? false);
250
+ if (result.ok) return { removed: true };
251
+
252
+ if (options.force) {
253
+ rmSync(path, { recursive: true, force: true });
254
+ this.git.pruneWorktrees();
255
+ return { removed: true };
256
+ }
257
+ return { removed: false, detail: result.stderr.trim() };
258
+ }
259
+
260
+ /** Limpa metadados de worktrees que não existem mais. */
261
+ prune(): string {
262
+ return this.git.pruneWorktrees().stdout.trim();
263
+ }
264
+
265
+ /** Nome legível do repositório gerenciado (diagnóstico). */
266
+ get name(): string {
267
+ return basename(this.rootDir) || this.repoKey;
268
+ }
269
+ }
@@ -43,12 +43,17 @@ export class FileStorageProvider implements StorageProvider {
43
43
  if (existing) {
44
44
  if (
45
45
  existing.repoRoot !== input.repo.root ||
46
- existing.repoRemote !== input.repo.remote
46
+ existing.repoRemote !== input.repo.remote ||
47
+ (input.worktreePath !== undefined &&
48
+ existing.worktreePath !== input.worktreePath) ||
49
+ (input.prNumber !== undefined && existing.prNumber !== input.prNumber)
47
50
  ) {
48
51
  return this.writeReview({
49
52
  ...existing,
50
53
  repoRoot: input.repo.root,
51
54
  repoRemote: input.repo.remote,
55
+ worktreePath: input.worktreePath ?? existing.worktreePath,
56
+ prNumber: input.prNumber ?? existing.prNumber,
52
57
  });
53
58
  }
54
59
  return existing;
@@ -64,6 +69,8 @@ export class FileStorageProvider implements StorageProvider {
64
69
  slug: input.slug,
65
70
  base: input.base,
66
71
  head: input.head,
72
+ worktreePath: input.worktreePath,
73
+ prNumber: input.prNumber,
67
74
  status: null,
68
75
  createdAt: now,
69
76
  updatedAt: now,
@@ -99,11 +106,17 @@ export class FileStorageProvider implements StorageProvider {
99
106
  if (patch.headSha !== undefined) updated.headSha = patch.headSha;
100
107
  if (patch.reviewedSha !== undefined) updated.reviewedSha = patch.reviewedSha;
101
108
  if (patch.reviewedAt !== undefined) updated.reviewedAt = patch.reviewedAt;
109
+ if (patch.worktreePath !== undefined) updated.worktreePath = patch.worktreePath;
110
+ if (patch.prNumber !== undefined) updated.prNumber = patch.prNumber;
102
111
  if (patch.status !== undefined) updated.status = patch.status;
103
112
  if (patch.resetStartedAt) updated.startedAt = now;
104
113
  else if (patch.startedAt !== undefined) updated.startedAt = patch.startedAt;
105
114
  if (patch.resetHeartbeat) updated.heartbeatAt = undefined;
106
115
  else if (patch.heartbeatAt !== undefined) updated.heartbeatAt = patch.heartbeatAt;
116
+ if (patch.resetWorkspace) {
117
+ updated.worktreePath = undefined;
118
+ updated.prNumber = undefined;
119
+ }
107
120
  updated.updatedAt = patch.updatedAt ?? now;
108
121
  return this.writeReview(updated);
109
122
  }
@@ -7,9 +7,11 @@
7
7
  */
8
8
 
9
9
  import { homedir } from "node:os";
10
- import { join, resolve } from "node:path";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
11
  import type { HoundsmanConfig } from "../config";
12
+ import { DEFAULT_WORKTREES_LOCATION } from "../defaults";
12
13
  import { ENV_NAMES, env } from "../env";
14
+ import { PROJECT_DIR } from "../paths";
13
15
 
14
16
  export const HOUNDSMAN_HOME_ENV = ENV_NAMES.home;
15
17
 
@@ -52,6 +54,33 @@ export function workRoot(): string {
52
54
  return join(houndsmanHome(), "work");
53
55
  }
54
56
 
57
+ /** Raiz global dos worktrees (`worktrees.location: "home"`). */
58
+ export function worktreesRoot(): string {
59
+ return join(houndsmanHome(), "worktrees");
60
+ }
61
+
62
+ /** Diretório de um worktree gerenciado. */
63
+ export function worktreeDir(repoSlug: string, branchSlug: string): string {
64
+ return join(worktreesRoot(), repoSlug, branchSlug);
65
+ }
66
+
67
+ /**
68
+ * Raiz efetiva dos worktrees de um repositório.
69
+ *
70
+ * `workspace` (default) mantém tudo em `<repo>/.houndsman/worktrees` — dentro
71
+ * da pasta aberta no editor, o que evita prompts de permissão de agentes.
72
+ * `home` usa o diretório global; outros valores são caminhos do usuário.
73
+ */
74
+ export function resolveWorktreesRoot(
75
+ location: string | undefined,
76
+ repoRoot: string,
77
+ ): string {
78
+ const value = location?.trim() || DEFAULT_WORKTREES_LOCATION;
79
+ if (value === "home") return worktreesRoot();
80
+ if (value === "workspace") return join(repoRoot, PROJECT_DIR, "worktrees");
81
+ return isAbsolute(value) ? value : resolve(repoRoot, value);
82
+ }
83
+
55
84
  /** Referência legível do storage configurado (diagnóstico). */
56
85
  export function storageLocation(config: HoundsmanConfig): string {
57
86
  return config.storage?.provider === "file"
@@ -31,6 +31,8 @@ export type ReviewRow = {
31
31
  status: string | null;
32
32
  started_at: string | null;
33
33
  heartbeat_at: string | null;
34
+ worktree_path: string | null;
35
+ pr_number: number | null;
34
36
  created_at: string;
35
37
  updated_at: string;
36
38
  };
@@ -95,6 +97,8 @@ export function reviewFromRow(row: ReviewRow): ReviewRecord {
95
97
  headSha: row.head_sha ?? undefined,
96
98
  reviewedSha: row.reviewed_sha ?? undefined,
97
99
  reviewedAt: row.reviewed_at ?? undefined,
100
+ worktreePath: row.worktree_path ?? undefined,
101
+ prNumber: row.pr_number ?? undefined,
98
102
  status: (row.status as ReviewRecord["status"]) ?? null,
99
103
  startedAt: row.started_at ?? undefined,
100
104
  heartbeatAt: row.heartbeat_at ?? undefined,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { Database } from "bun:sqlite";
9
9
 
10
- export const SCHEMA_VERSION = 2;
10
+ export const SCHEMA_VERSION = 3;
11
11
 
12
12
  const DDL = `
13
13
  CREATE TABLE IF NOT EXISTS reviews (
@@ -25,6 +25,8 @@ CREATE TABLE IF NOT EXISTS reviews (
25
25
  status TEXT,
26
26
  started_at TEXT,
27
27
  heartbeat_at TEXT,
28
+ worktree_path TEXT,
29
+ pr_number INTEGER,
28
30
  created_at TEXT NOT NULL,
29
31
  updated_at TEXT NOT NULL,
30
32
  UNIQUE(repo_key, branch)
@@ -105,11 +107,19 @@ export function migrate(db: Database, path: string): void {
105
107
  db.transaction(() => {
106
108
  db.exec(DDL);
107
109
  // v2: heartbeat explícito da execução (bancos v1 não tinham a coluna).
110
+ // v3: worktree/pr da revisão (revisões paralelas).
108
111
  const columns = db.query("PRAGMA table_info(reviews)").all() as {
109
112
  name: string;
110
113
  }[];
111
- if (!columns.some((column) => column.name === "heartbeat_at")) {
112
- db.exec("ALTER TABLE reviews ADD COLUMN heartbeat_at TEXT");
114
+ const missing: [string, string][] = [
115
+ ["heartbeat_at", "TEXT"],
116
+ ["worktree_path", "TEXT"],
117
+ ["pr_number", "INTEGER"],
118
+ ];
119
+ for (const [name, type] of missing) {
120
+ if (!columns.some((column) => column.name === name)) {
121
+ db.exec(`ALTER TABLE reviews ADD COLUMN ${name} ${type}`);
122
+ }
113
123
  }
114
124
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
115
125
  })();
@@ -33,7 +33,7 @@ import { migrate } from "./schema";
33
33
 
34
34
  /** Colunas mutáveis de review (camel → snake). */
35
35
  const REVIEW_PATCH_COLUMNS: Record<
36
- Exclude<keyof ReviewPatch, "resetStartedAt" | "resetHeartbeat">,
36
+ Exclude<keyof ReviewPatch, "resetStartedAt" | "resetHeartbeat" | "resetWorkspace">,
37
37
  string
38
38
  > = {
39
39
  branch: "branch",
@@ -43,6 +43,8 @@ const REVIEW_PATCH_COLUMNS: Record<
43
43
  headSha: "head_sha",
44
44
  reviewedSha: "reviewed_sha",
45
45
  reviewedAt: "reviewed_at",
46
+ worktreePath: "worktree_path",
47
+ prNumber: "pr_number",
46
48
  status: "status",
47
49
  startedAt: "started_at",
48
50
  heartbeatAt: "heartbeat_at",
@@ -70,13 +72,24 @@ export class SQLiteStorageProvider implements StorageProvider {
70
72
  const existing = this.findReview(input.repo.key, input.branch);
71
73
  if (existing) {
72
74
  // O repo pode ter sido movido; a identidade continua a mesma.
73
- if (
75
+ const moved =
74
76
  existing.repoRoot !== input.repo.root ||
75
- existing.repoRemote !== input.repo.remote
76
- ) {
77
+ existing.repoRemote !== input.repo.remote;
78
+ const workspace =
79
+ (input.worktreePath !== undefined &&
80
+ existing.worktreePath !== input.worktreePath) ||
81
+ (input.prNumber !== undefined && existing.prNumber !== input.prNumber);
82
+ if (moved || workspace) {
77
83
  this.db.run(
78
- "UPDATE reviews SET repo_root = ?, repo_remote = ? WHERE id = ?",
79
- [input.repo.root, input.repo.remote ?? null, existing.id],
84
+ `UPDATE reviews SET repo_root = ?, repo_remote = ?, worktree_path = ?,
85
+ pr_number = ? WHERE id = ?`,
86
+ [
87
+ input.repo.root,
88
+ input.repo.remote ?? null,
89
+ input.worktreePath ?? existing.worktreePath ?? null,
90
+ input.prNumber ?? existing.prNumber ?? null,
91
+ existing.id,
92
+ ],
80
93
  );
81
94
  }
82
95
  return this.requireReview(existing.id);
@@ -92,6 +105,8 @@ export class SQLiteStorageProvider implements StorageProvider {
92
105
  slug: input.slug,
93
106
  base: input.base,
94
107
  head: input.head,
108
+ worktreePath: input.worktreePath,
109
+ prNumber: input.prNumber,
95
110
  status: null,
96
111
  createdAt: now,
97
112
  updatedAt: now,
@@ -99,8 +114,8 @@ export class SQLiteStorageProvider implements StorageProvider {
99
114
  this.db.run(
100
115
  `INSERT INTO reviews
101
116
  (id, repo_key, repo_root, repo_remote, branch, slug, base, head,
102
- created_at, updated_at)
103
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
117
+ worktree_path, pr_number, created_at, updated_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
104
119
  [
105
120
  record.id,
106
121
  record.repoKey,
@@ -110,6 +125,8 @@ export class SQLiteStorageProvider implements StorageProvider {
110
125
  record.slug,
111
126
  record.base ?? null,
112
127
  record.head ?? null,
128
+ record.worktreePath ?? null,
129
+ record.prNumber ?? null,
113
130
  record.createdAt,
114
131
  record.updatedAt,
115
132
  ],
@@ -163,6 +180,9 @@ export class SQLiteStorageProvider implements StorageProvider {
163
180
  if (patch.resetHeartbeat) {
164
181
  sets.push("heartbeat_at = NULL");
165
182
  }
183
+ if (patch.resetWorkspace) {
184
+ sets.push("worktree_path = NULL", "pr_number = NULL");
185
+ }
166
186
  if (patch.updatedAt === undefined) {
167
187
  sets.push("updated_at = ?");
168
188
  params.push(now);
@@ -34,6 +34,10 @@ export type ReviewRecord = {
34
34
  headSha?: string;
35
35
  reviewedSha?: string;
36
36
  reviewedAt?: string;
37
+ /** Worktree onde a revisão roda (ausente = clone principal). */
38
+ worktreePath?: string;
39
+ /** Número da PR revisada (quando iniciada via `--pr`). */
40
+ prNumber?: number;
37
41
  status: ReviewStatus | null;
38
42
  startedAt?: string;
39
43
  /**
@@ -56,6 +60,8 @@ export type ReviewPatch = Partial<
56
60
  | "headSha"
57
61
  | "reviewedSha"
58
62
  | "reviewedAt"
63
+ | "worktreePath"
64
+ | "prNumber"
59
65
  | "status"
60
66
  | "startedAt"
61
67
  | "heartbeatAt"
@@ -66,6 +72,8 @@ export type ReviewPatch = Partial<
66
72
  resetStartedAt?: boolean;
67
73
  /** Limpa o heartbeat (execução terminou). */
68
74
  resetHeartbeat?: boolean;
75
+ /** Limpa `worktreePath`/`prNumber` (review voltou ao clone principal). */
76
+ resetWorkspace?: boolean;
69
77
  };
70
78
 
71
79
  /** Entrada de `getOrCreateReview`. */
@@ -75,6 +83,10 @@ export type CreateReviewInput = {
75
83
  slug: string;
76
84
  base?: string;
77
85
  head?: string;
86
+ /** Worktree onde a revisão roda (ausente = clone principal). */
87
+ worktreePath?: string;
88
+ /** Número da PR revisada (quando iniciada via `--pr`). */
89
+ prNumber?: number;
78
90
  now?: string;
79
91
  };
80
92
 
@@ -14,6 +14,7 @@ import {
14
14
  CONTEXT_FILE,
15
15
  CONTEXT_SUMMARY_FILE,
16
16
  displayPath,
17
+ PROJECT_DIR,
17
18
  } from "../paths";
18
19
  import type { ContextPaths } from "../pr-context";
19
20
  import {
@@ -35,12 +36,26 @@ export type FindingsIngest = {
35
36
  warnings: string[];
36
37
  };
37
38
 
39
+ /**
40
+ * Raiz do staging da review.
41
+ *
42
+ * Com worktree, tudo (briefs/findings/contexto) fica dentro dele — assim os
43
+ * agentes leem e escrevem dentro do workspace aberto no editor, sem prompts de
44
+ * permissão. Sem worktree, mantém o diretório global do houndsman.
45
+ */
46
+ export function reviewWorkBase(review: ReviewRecord): string {
47
+ const worktree = review.worktreePath;
48
+ return worktree && existsSync(worktree)
49
+ ? join(worktree, PROJECT_DIR, "work")
50
+ : workRoot();
51
+ }
52
+
38
53
  /** Staging de uma review (briefs/findings/contexto). */
39
54
  export class ReviewWorkArea {
40
55
  readonly review: ReviewRecord;
41
56
  readonly root: string;
42
57
 
43
- constructor(review: ReviewRecord, base: string = workRoot()) {
58
+ constructor(review: ReviewRecord, base: string = reviewWorkBase(review)) {
44
59
  this.review = review;
45
60
  this.root = join(base, review.id);
46
61
  }
@@ -86,6 +101,10 @@ export class ReviewWorkArea {
86
101
  /** Garante os diretórios; `clean` recria a área do zero. */
87
102
  ensure(clean = false): void {
88
103
  if (clean) this.clean();
104
+ mkdirSync(this.root, { recursive: true });
105
+ // Staging nunca deve sujar `git status` (especialmente dentro do worktree).
106
+ const ignore = join(this.root, ".gitignore");
107
+ if (!existsSync(ignore)) writeFileSync(ignore, "*\n");
89
108
  mkdirSync(this.briefsDir, { recursive: true });
90
109
  mkdirSync(this.findingsDir, { recursive: true });
91
110
  }
package/src/core/types.ts CHANGED
@@ -135,6 +135,8 @@ export type WatchdogRenderArgs = {
135
135
  branch: string;
136
136
  outputPath: string;
137
137
  outputFormatPath: string;
138
+ /** Worktree onde a review roda (ausente = clone principal). */
139
+ worktree?: string;
138
140
  docs: readonly string[];
139
141
  context?: WatchdogRenderContext | null;
140
142
  candidates?: readonly WatchdogRenderCandidate[];
@@ -24,6 +24,12 @@ export type HuntOptions = {
24
24
  concurrency?: number;
25
25
  provider?: AiProviderKind;
26
26
  model?: string;
27
+ /** Número da PR (`--pr`) — o comando resolve/cria o worktree. */
28
+ pr?: number;
29
+ /** Worktree informado via flag (relativo ao cwd ou absoluto). */
30
+ worktree?: string;
31
+ /** Worktree resolvido (absoluto) para persistir na review. */
32
+ worktreePath?: string;
27
33
  };
28
34
 
29
35
  /** Defaults do hunt. */
@@ -54,6 +60,17 @@ export function parseHuntArgs(args: string[]): HuntOptions {
54
60
  const model = parser.value("--model");
55
61
  const maxCalls = parser.value("--max-calls");
56
62
  const concurrency = parser.value("--concurrency");
63
+ const pr = parser.value("--pr");
64
+ const worktree = parser.value("--worktree");
65
+ if (pr) {
66
+ const number = Number.parseInt(pr, 10);
67
+ if (Number.isFinite(number) && number > 0) options.pr = number;
68
+ else throw new Error(`--pr inválido: '${pr}'.`);
69
+ }
70
+ if (worktree) options.worktree = worktree;
71
+ if (options.pr !== undefined && options.worktree !== undefined) {
72
+ throw new Error("Use --pr ou --worktree, não os dois.");
73
+ }
57
74
  if (base) options.base = base;
58
75
  if (head) options.head = head;
59
76
  if (branch) options.branch = branch;