@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,369 @@
1
+ /**
2
+ * Worktrees: criação isolada por PR e revisões paralelas.
3
+ *
4
+ * Exercita o caminho real (git + gh fake): resolver a PR, criar o worktree
5
+ * detached, preparar duas revisões em paralelo e garantir que cada uma lê a
6
+ * sua própria árvore.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { parseReviewArgs } from "../core/commands/review";
21
+ import { HoundsmanEngine } from "../core/engine/houndsman-engine";
22
+ import { resolveReviewWorkspace } from "../core/engine/review-workspace";
23
+ import { createGitRunner } from "../core/git-runner";
24
+ import { defaultOptions } from "../core/orchestrator";
25
+ import type { GhRunner } from "../core/services/gh-client";
26
+ import { GitRepository } from "../core/services/git-repository";
27
+ import { parseWorktreeList, WorktreeManager } from "../core/services/worktree-manager";
28
+ import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
29
+ import { parseHuntArgs } from "../harness/hunt/hunt-options";
30
+ import { fakeHoundsmanConfig } from "./fixtures/fake-project";
31
+
32
+ function git(cwd: string, args: string[]): string {
33
+ const result = Bun.spawnSync(["git", ...args], {
34
+ cwd,
35
+ stdout: "pipe",
36
+ stderr: "pipe",
37
+ });
38
+ if (result.exitCode !== 0) {
39
+ throw new Error(`git ${args.join(" ")}: ${result.stderr.toString()}`);
40
+ }
41
+ return result.stdout.toString();
42
+ }
43
+
44
+ type Sandbox = {
45
+ root: string;
46
+ origin: string;
47
+ repo: string;
48
+ home: string;
49
+ mainSha: string;
50
+ featureSha: string;
51
+ cleanup: () => void;
52
+ };
53
+
54
+ /** Remote bare + clone com `main` e uma branch `feature`. */
55
+ function makeSandbox(): Sandbox {
56
+ const root = mkdtempSync(join(tmpdir(), "houndsman-worktree-"));
57
+ const origin = join(root, "origin.git");
58
+ const repo = join(root, "repo");
59
+ const home = join(root, "home");
60
+
61
+ git(root, ["init", "--bare", "-b", "main", origin]);
62
+ git(root, ["init", "-b", "main", repo]);
63
+ git(repo, ["config", "user.email", "wt@example.test"]);
64
+ git(repo, ["config", "user.name", "Worktree"]);
65
+ git(repo, ["config", "commit.gpgsign", "false"]);
66
+ git(repo, ["config", "tag.gpgsign", "false"]);
67
+ git(repo, ["remote", "add", "origin", origin]);
68
+
69
+ mkdirSync(join(repo, "src", "alpha"), { recursive: true });
70
+ writeFileSync(join(repo, "README.md"), "# Fake\n");
71
+ writeFileSync(join(repo, "src", "alpha", "a.ts"), "export const a = 1;\n");
72
+ git(repo, ["add", "."]);
73
+ git(repo, ["commit", "-m", "initial"]);
74
+ const mainSha = git(repo, ["rev-parse", "HEAD"]).trim();
75
+ git(repo, ["push", "-u", "origin", "main"]);
76
+
77
+ git(repo, ["checkout", "-b", "feature"]);
78
+ writeFileSync(join(repo, "src", "alpha", "a.ts"), "export const a = 2; // pr\n");
79
+ git(repo, ["add", "-A"]);
80
+ git(repo, ["commit", "-m", "feature"]);
81
+ const featureSha = git(repo, ["rev-parse", "HEAD"]).trim();
82
+ git(repo, ["push", "-u", "origin", "feature"]);
83
+ git(repo, ["checkout", "main"]);
84
+
85
+ return {
86
+ root,
87
+ origin,
88
+ repo,
89
+ home,
90
+ mainSha,
91
+ featureSha,
92
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
93
+ };
94
+ }
95
+
96
+ /** `gh` fake devolvendo a PR 7 apontando para a branch `feature`. */
97
+ function fakeGh(
98
+ sandbox: Sandbox,
99
+ headRef = "feature",
100
+ headSha = sandbox.featureSha,
101
+ ): GhRunner {
102
+ return (args) => {
103
+ if (args[0] === "pr" && args[1] === "view") {
104
+ return {
105
+ ok: true,
106
+ code: 0,
107
+ stdout: JSON.stringify({
108
+ number: 7,
109
+ title: "Feature",
110
+ headRefName: headRef,
111
+ baseRefName: "main",
112
+ headRefOid: headSha,
113
+ isCrossRepository: false,
114
+ isDraft: false,
115
+ url: "https://example.test/pr/7",
116
+ }),
117
+ stderr: "",
118
+ };
119
+ }
120
+ return { ok: false, code: 1, stdout: "", stderr: "unexpected gh call" };
121
+ };
122
+ }
123
+
124
+ describe("flags de worktree", () => {
125
+ test("parse de review/hunt e exclusão mútua", () => {
126
+ expect(parseReviewArgs(["--pr", "7"]).pr).toBe(7);
127
+ expect(parseReviewArgs(["--worktree", "/tmp/wt"]).worktree).toBe("/tmp/wt");
128
+ expect(() => parseReviewArgs(["--pr", "7", "--worktree", "/tmp/wt"])).toThrow();
129
+ expect(parseHuntArgs(["--pr", "8"]).pr).toBe(8);
130
+ });
131
+ });
132
+
133
+ describe("parseWorktreeList", () => {
134
+ test("lê main e worktree detached", () => {
135
+ const entries = parseWorktreeList(
136
+ [
137
+ "worktree /repo",
138
+ "HEAD 1111111111111111111111111111111111111111",
139
+ "branch refs/heads/main",
140
+ "",
141
+ "worktree /home/worktrees/repo/feature",
142
+ "HEAD 2222222222222222222222222222222222222222",
143
+ "detached",
144
+ "",
145
+ ].join("\n"),
146
+ );
147
+ expect(entries).toHaveLength(2);
148
+ expect(entries[0].branch).toBe("refs/heads/main");
149
+ expect(entries[1].detached).toBe(true);
150
+ expect(entries[1].head?.startsWith("2222")).toBe(true);
151
+ });
152
+ });
153
+
154
+ describe("WorktreeManager", () => {
155
+ test("cria detached, reusa o mesmo commit e remove", () => {
156
+ const sandbox = makeSandbox();
157
+ try {
158
+ const gitRepo = new GitRepository(createGitRunner(sandbox.repo));
159
+ const manager = new WorktreeManager(gitRepo, {
160
+ repoKey: "path:fake",
161
+ repoRoot: sandbox.repo,
162
+ baseDir: sandbox.home,
163
+ });
164
+
165
+ const first = manager.ensure({ branch: "feature", ref: sandbox.featureSha });
166
+ expect(first.created).toBe(true);
167
+ expect(existsSync(first.path)).toBe(true);
168
+ expect(
169
+ readFileSync(join(first.path, "src", "alpha", "a.ts"), "utf8"),
170
+ ).toContain("// pr");
171
+
172
+ const second = manager.ensure({ branch: "feature", ref: sandbox.featureSha });
173
+ expect(second.created).toBe(false);
174
+ expect(second.reused).toBe(true);
175
+ expect(second.path).toBe(first.path);
176
+
177
+ const removed = manager.remove("feature");
178
+ expect(removed.removed).toBe(true);
179
+ expect(existsSync(first.path)).toBe(false);
180
+ } finally {
181
+ sandbox.cleanup();
182
+ }
183
+ });
184
+ });
185
+
186
+ describe("revisões paralelas em worktrees", () => {
187
+ test("resolve a PR, prepara no worktree e persiste o caminho", async () => {
188
+ const sandbox = makeSandbox();
189
+ const originalHome = process.env.HOUNDSMAN_HOME;
190
+ try {
191
+ const workspace = resolveReviewWorkspace(
192
+ { cwd: sandbox.repo, base: "main", head: "HEAD", pr: 7 },
193
+ { ghRunner: fakeGh(sandbox) },
194
+ );
195
+
196
+ // Default: worktree dentro do workspace (evita prompts de permissão).
197
+ expect(
198
+ workspace.root.startsWith(join(sandbox.repo, ".houndsman", "worktrees")),
199
+ ).toBe(true);
200
+ expect(workspace.branch).toBe("feature");
201
+ expect(workspace.prNumber).toBe(7);
202
+ expect(workspace.base).toBe("origin/main");
203
+ expect(workspace.worktreePath).toBe(workspace.root);
204
+ expect(existsSync(join(workspace.root, "src", "alpha", "a.ts"))).toBe(true);
205
+
206
+ // Rodar de dentro do worktree não volta para o clone principal.
207
+ const inside = resolveReviewWorkspace({
208
+ cwd: workspace.root,
209
+ base: "main",
210
+ head: "HEAD",
211
+ branch: "feature",
212
+ });
213
+ expect(inside.root).toBe(workspace.root);
214
+ expect(inside.mainRoot).toBe(workspace.mainRoot);
215
+
216
+ process.env.HOUNDSMAN_HOME = sandbox.home;
217
+ const storage = new SQLiteStorageProvider(join(sandbox.home, "test.db"));
218
+ const engine = new HoundsmanEngine(fakeHoundsmanConfig, {
219
+ root: workspace.root,
220
+ mainRoot: workspace.mainRoot,
221
+ repo: workspace.repo,
222
+ storage,
223
+ });
224
+
225
+ const session = engine.session({
226
+ ...defaultOptions(),
227
+ branch: workspace.branch,
228
+ base: workspace.base,
229
+ head: workspace.head,
230
+ pr: workspace.prNumber,
231
+ worktreePath: workspace.worktreePath,
232
+ noJev: true,
233
+ noAi: true,
234
+ });
235
+ await session.prepare();
236
+
237
+ const classification = storage.readArtifact(
238
+ session.context.review.id,
239
+ "classification.json",
240
+ );
241
+ expect(classification?.content).toContain("src/alpha/a.ts");
242
+ expect(session.context.review.worktreePath).toBe(workspace.root);
243
+ expect(session.context.review.prNumber).toBe(7);
244
+
245
+ const brief = storage.readArtifact(
246
+ session.context.review.id,
247
+ "briefs/alpha.md",
248
+ );
249
+ expect(brief?.content).toContain(`**Worktree:** \`${workspace.root}\``);
250
+
251
+ // Staging também fica dentro do worktree (briefs/findings/contexto).
252
+ expect(
253
+ session.context.work.root.startsWith(
254
+ join(workspace.root, ".houndsman", "work"),
255
+ ),
256
+ ).toBe(true);
257
+
258
+ // O clone principal não foi tocado (continua em main).
259
+ expect(git(sandbox.repo, ["branch", "--show-current"]).trim()).toBe("main");
260
+ storage.close();
261
+ } finally {
262
+ if (originalHome === undefined) delete process.env.HOUNDSMAN_HOME;
263
+ else process.env.HOUNDSMAN_HOME = originalHome;
264
+ sandbox.cleanup();
265
+ }
266
+ });
267
+
268
+ test("duas PRs preparam em paralelo sem se contaminar", async () => {
269
+ const sandbox = makeSandbox();
270
+ const originalHome = process.env.HOUNDSMAN_HOME;
271
+ try {
272
+ // Segunda branch com arquivo próprio.
273
+ git(sandbox.repo, ["checkout", "-b", "feature-b", "main"]);
274
+ writeFileSync(
275
+ join(sandbox.repo, "src", "alpha", "b.ts"),
276
+ "export const b = 1;\n",
277
+ );
278
+ git(sandbox.repo, ["add", "-A"]);
279
+ git(sandbox.repo, ["commit", "-m", "feature b"]);
280
+ const featureBSha = git(sandbox.repo, ["rev-parse", "HEAD"]).trim();
281
+ git(sandbox.repo, ["push", "-u", "origin", "feature-b"]);
282
+ git(sandbox.repo, ["checkout", "main"]);
283
+
284
+ process.env.HOUNDSMAN_HOME = sandbox.home;
285
+ const storage = new SQLiteStorageProvider(join(sandbox.home, "parallel.db"));
286
+
287
+ const ghA = fakeGh(sandbox);
288
+ const ghB: GhRunner = (args) => {
289
+ if (args[0] === "pr" && args[1] === "view" && args[2] === "8") {
290
+ return {
291
+ ok: true,
292
+ code: 0,
293
+ stdout: JSON.stringify({
294
+ number: 8,
295
+ title: "Feature B",
296
+ headRefName: "feature-b",
297
+ baseRefName: "main",
298
+ headRefOid: featureBSha,
299
+ isCrossRepository: false,
300
+ isDraft: false,
301
+ }),
302
+ stderr: "",
303
+ };
304
+ }
305
+ return { ok: false, code: 1, stdout: "", stderr: "unexpected gh call" };
306
+ };
307
+
308
+ const workspaceA = resolveReviewWorkspace(
309
+ { cwd: sandbox.repo, base: "main", head: "HEAD", pr: 7 },
310
+ { ghRunner: ghA },
311
+ );
312
+ const workspaceB = resolveReviewWorkspace(
313
+ { cwd: sandbox.repo, base: "main", head: "HEAD", pr: 8 },
314
+ { ghRunner: ghB },
315
+ );
316
+ expect(workspaceA.root).not.toBe(workspaceB.root);
317
+
318
+ const options = (workspace: typeof workspaceA) => ({
319
+ ...defaultOptions(),
320
+ branch: workspace.branch,
321
+ base: workspace.base,
322
+ head: workspace.head,
323
+ pr: workspace.prNumber,
324
+ worktreePath: workspace.worktreePath,
325
+ noJev: true,
326
+ noAi: true,
327
+ });
328
+
329
+ const engineA = new HoundsmanEngine(fakeHoundsmanConfig, {
330
+ root: workspaceA.root,
331
+ mainRoot: workspaceA.mainRoot,
332
+ repo: workspaceA.repo,
333
+ storage,
334
+ });
335
+ const engineB = new HoundsmanEngine(fakeHoundsmanConfig, {
336
+ root: workspaceB.root,
337
+ mainRoot: workspaceB.mainRoot,
338
+ repo: workspaceB.repo,
339
+ storage,
340
+ });
341
+
342
+ const [sessionA, sessionB] = [
343
+ engineA.session(options(workspaceA)),
344
+ engineB.session(options(workspaceB)),
345
+ ];
346
+ await Promise.all([sessionA.prepare(), sessionB.prepare()]);
347
+
348
+ const classA = storage.readArtifact(
349
+ sessionA.context.review.id,
350
+ "classification.json",
351
+ );
352
+ const classB = storage.readArtifact(
353
+ sessionB.context.review.id,
354
+ "classification.json",
355
+ );
356
+
357
+ expect(classA?.content).toContain("src/alpha/a.ts");
358
+ expect(classA?.content).not.toContain("src/alpha/b.ts");
359
+ expect(classB?.content).toContain("src/alpha/b.ts");
360
+ expect(classB?.content).not.toContain("src/alpha/a.ts");
361
+ expect(storage.listReviews(workspaceA.repo.key)).toHaveLength(2);
362
+ storage.close();
363
+ } finally {
364
+ if (originalHome === undefined) delete process.env.HOUNDSMAN_HOME;
365
+ else process.env.HOUNDSMAN_HOME = originalHome;
366
+ sandbox.cleanup();
367
+ }
368
+ });
369
+ });
@@ -1,75 +0,0 @@
1
- /**
2
- * Sidebar: logo do houndsman + lista de execuções (mais recentes primeiro),
3
- * com data/hora, relação de findings presentes e badge de correção pronta.
4
- */
5
-
6
- import { BranchIcon, Clock, Sparkles } from "./icons";
7
- import { describeRunStatus } from "./utils/constants";
8
- import { formatRunTime } from "./utils/format";
9
- import type { RunSummary } from "./utils/types";
10
-
11
- export function RunList({
12
- runs,
13
- slug,
14
- onSelect,
15
- }: {
16
- runs: RunSummary[];
17
- slug: string | null;
18
- onSelect: (slug: string) => void;
19
- }) {
20
- return (
21
- <aside class="sidebar">
22
- <div class="brand">
23
- <img class="logo" src="/assets/logo.png" alt="" />
24
- <div>
25
- <h1>Houndsman</h1>
26
- <p class="sub">Revisões e triagem</p>
27
- </div>
28
- </div>
29
- <p class="section-label">Execuções</p>
30
- <nav class="runs">
31
- {runs.length === 0 && <p class="empty">Nenhuma run encontrada.</p>}
32
- {runs.map((run) => {
33
- const status = describeRunStatus(run);
34
- const fixReady = status?.kind === "fix_done";
35
- return (
36
- <button
37
- type="button"
38
- title={`Abrir a run da branch ${run.branch}`}
39
- class={`run${run.slug === slug ? " active" : ""}`}
40
- onClick={() => onSelect(run.slug)}
41
- >
42
- <span class="run-top">
43
- <BranchIcon size={14} class="run-icon" />
44
- <span class="run-branch">{run.branch}</span>
45
- </span>
46
- <span class="run-meta">
47
- <span class="run-when">
48
- <Clock size={12} />
49
- {formatRunTime(run.updatedAt) || "sem data"}
50
- </span>
51
- <span
52
- class="run-count"
53
- title={`${run.summary.present} de ${run.summary.total} findings presentes`}
54
- >
55
- {run.summary.present}/{run.summary.total}
56
- </span>
57
- </span>
58
- {fixReady ? (
59
- <span class="run-badge">
60
- <Sparkles size={12} /> Correção pronta
61
- </span>
62
- ) : (
63
- status && (
64
- <span class={`run-status status-${status.kind}`}>
65
- {status.label}
66
- </span>
67
- )
68
- )}
69
- </button>
70
- );
71
- })}
72
- </nav>
73
- </aside>
74
- );
75
- }