@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.
- 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/worktree-command.ts +118 -0
- package/src/cli/help.ts +12 -2
- 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 +11 -5
- 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 +7 -0
- 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/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/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 +49 -11
- package/src/server/client/DeleteRunModal.tsx +163 -0
- package/src/server/client/ResumePanel.tsx +8 -8
- package/src/server/client/Sidebar.tsx +56 -1
- package/src/server/client/icons.tsx +10 -0
- package/src/server/client/utils/types.ts +26 -0
- package/src/server/index.ts +97 -4
- package/src/server/pr-inbox.ts +15 -4
- package/src/server/run-repository.ts +103 -2
- 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/cli.test.ts +2 -1
- package/src/tests/server.test.ts +38 -0
- 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;
|
package/src/server/index.ts
CHANGED
|
@@ -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.
|
|
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(
|
|
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; }
|
package/src/server/pr-inbox.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/skill/README.md
CHANGED
|
@@ -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 --
|
|
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` ·
|
|
67
|
+
`--branch <nome>` · `--pr <n>` · `--worktree <path>` · `--clean` · `--full` · `--no-jev` ·
|
|
68
|
+
`--fail-on-blocking` (no `--finalize`).
|
|
61
69
|
|
|
62
70
|
## Artefatos
|
|
63
71
|
|