@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.
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/cli/commands/command.ts +10 -1
- package/src/cli/commands/context-command.ts +40 -2
- package/src/cli/commands/hunt-command.ts +28 -2
- package/src/cli/commands/review-command.ts +30 -2
- package/src/cli/commands/server-command.ts +7 -1
- package/src/cli/commands/worktree-command.ts +118 -0
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +14 -4
- package/src/core/ast/service.ts +7 -2
- package/src/core/brief.ts +1 -0
- package/src/core/commands/context.ts +9 -1
- package/src/core/commands/findings.ts +17 -6
- package/src/core/commands/review.ts +13 -0
- package/src/core/commands/scaffold.ts +4 -2
- package/src/core/config.ts +13 -0
- package/src/core/defaults.ts +8 -1
- package/src/core/engine/houndsman-engine.ts +26 -3
- package/src/core/engine/review-scope.ts +17 -22
- package/src/core/engine/review-session.ts +25 -2
- package/src/core/engine/review-workspace.ts +204 -0
- package/src/core/git-runner.ts +25 -13
- package/src/core/github-review.ts +10 -7
- package/src/core/jev/jev-detector.ts +10 -2
- package/src/core/orchestrator.ts +62 -13
- package/src/core/project-config.ts +12 -0
- package/src/core/services/gh-client.ts +38 -22
- package/src/core/services/git-repository.ts +46 -0
- package/src/core/services/pr-resolver.ts +147 -0
- package/src/core/services/worktree-manager.ts +269 -0
- package/src/core/storage/file/file-storage.ts +14 -1
- package/src/core/storage/paths.ts +30 -1
- package/src/core/storage/sqlite/mappers.ts +4 -0
- package/src/core/storage/sqlite/schema.ts +13 -3
- package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
- package/src/core/storage/types.ts +12 -0
- package/src/core/storage/work-area.ts +20 -1
- package/src/core/types.ts +2 -0
- package/src/harness/hunt/hunt-options.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +7 -20
- package/src/index.ts +31 -0
- package/src/server/client/App.tsx +86 -22
- package/src/server/client/CommentsPanel.tsx +255 -0
- package/src/server/client/DeleteRunModal.tsx +163 -0
- package/src/server/client/FindingCard.tsx +12 -4
- package/src/server/client/FindingsList.tsx +4 -1
- package/src/server/client/ResumePanel.tsx +8 -8
- package/src/server/client/Sidebar.tsx +452 -0
- package/src/server/client/SubmitModal.tsx +61 -5
- package/src/server/client/icons.tsx +29 -0
- package/src/server/client/index.tsx +1 -1
- package/src/server/client/utils/constants.ts +2 -0
- package/src/server/client/utils/format.ts +47 -2
- package/src/server/client/utils/types.ts +108 -0
- package/src/server/editor.ts +129 -0
- package/src/server/index.ts +205 -3
- package/src/server/pr-comments.ts +256 -0
- package/src/server/pr-inbox.ts +213 -0
- package/src/server/run-repository.ts +136 -3
- package/src/skill/README.md +11 -3
- package/src/skill/SKILL.md +44 -10
- package/src/skill/hunt.md +33 -7
- package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
- package/src/tests/ai-core.test.ts +2 -2
- package/src/tests/cli.test.ts +35 -1
- package/src/tests/github-review.test.ts +9 -0
- package/src/tests/server-editor.test.ts +100 -0
- package/src/tests/server.test.ts +333 -2
- package/src/tests/worktree.test.ts +369 -0
- 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 =
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 =
|
|
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;
|