@payconductor/houndsman 0.7.0 → 0.10.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 (88) hide show
  1. package/README.md +20 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/command.ts +10 -1
  4. package/src/cli/commands/context-command.ts +40 -2
  5. package/src/cli/commands/hunt-command.ts +28 -2
  6. package/src/cli/commands/review-command.ts +30 -2
  7. package/src/cli/commands/worktree-command.ts +118 -0
  8. package/src/cli/help.ts +12 -2
  9. package/src/cli/index.ts +14 -4
  10. package/src/core/ai/ai-client.ts +21 -0
  11. package/src/core/ai/ai-sdk-model-provider.ts +43 -18
  12. package/src/core/ai/features/technical-summarizer.ts +43 -0
  13. package/src/core/ai/index.ts +4 -1
  14. package/src/core/ai/prompts/context.ts +3 -3
  15. package/src/core/ai/prompts/technical.ts +30 -0
  16. package/src/core/ai/provider-capabilities.ts +45 -0
  17. package/src/core/ai/schemas.ts +19 -3
  18. package/src/core/ai/types.ts +3 -1
  19. package/src/core/ast/service.ts +7 -2
  20. package/src/core/brief.ts +26 -4
  21. package/src/core/commands/context.ts +9 -1
  22. package/src/core/commands/findings.ts +11 -5
  23. package/src/core/commands/review.ts +13 -0
  24. package/src/core/commands/scaffold.ts +9 -2
  25. package/src/core/config.ts +30 -1
  26. package/src/core/defaults.ts +7 -0
  27. package/src/core/engine/houndsman-engine.ts +26 -3
  28. package/src/core/engine/review-scope.ts +17 -22
  29. package/src/core/engine/review-session.ts +25 -2
  30. package/src/core/engine/review-workspace.ts +204 -0
  31. package/src/core/git-runner.ts +8 -1
  32. package/src/core/jev/jev-detector.ts +10 -2
  33. package/src/core/orchestrator.ts +176 -15
  34. package/src/core/paths.ts +3 -0
  35. package/src/core/pr-context.ts +11 -6
  36. package/src/core/project-config.ts +21 -1
  37. package/src/core/renderers/resume-renderer.ts +17 -34
  38. package/src/core/report-builder.ts +6 -6
  39. package/src/core/services/git-repository.ts +46 -0
  40. package/src/core/services/pr-resolver.ts +147 -0
  41. package/src/core/services/worktree-manager.ts +293 -0
  42. package/src/core/storage/file/file-storage.ts +14 -1
  43. package/src/core/storage/paths.ts +30 -1
  44. package/src/core/storage/sqlite/mappers.ts +4 -0
  45. package/src/core/storage/sqlite/schema.ts +13 -3
  46. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  47. package/src/core/storage/types.ts +13 -0
  48. package/src/core/storage/work-area.ts +20 -1
  49. package/src/core/technical-summary.ts +398 -0
  50. package/src/core/types.ts +2 -0
  51. package/src/harness/agent/agent-factory.ts +1 -0
  52. package/src/harness/agent/review-agent.ts +11 -1
  53. package/src/harness/config/harness-config.ts +6 -1
  54. package/src/harness/hunt/hunt-options.ts +17 -0
  55. package/src/harness/hunt/hunt-session.ts +7 -20
  56. package/src/index.ts +33 -0
  57. package/src/server/client/App.tsx +51 -12
  58. package/src/server/client/DeleteRunModal.tsx +163 -0
  59. package/src/server/client/PrsSection.tsx +296 -0
  60. package/src/server/client/ResumePanel.tsx +17 -20
  61. package/src/server/client/RunsSection.tsx +73 -0
  62. package/src/server/client/Sidebar.tsx +171 -358
  63. package/src/server/client/icons.tsx +10 -0
  64. package/src/server/client/sidebar-sections.tsx +73 -0
  65. package/src/server/client/utils/constants.ts +7 -2
  66. package/src/server/client/utils/sidebar-prefs.ts +64 -0
  67. package/src/server/client/utils/types.ts +27 -0
  68. package/src/server/index.ts +113 -406
  69. package/src/server/pr-inbox.ts +15 -4
  70. package/src/server/run-repository.ts +103 -2
  71. package/src/server/styles.css +454 -0
  72. package/src/skill/README.md +11 -3
  73. package/src/skill/SKILL.md +44 -10
  74. package/src/skill/hunt.md +33 -7
  75. package/src/tests/__snapshots__/baseline.test.ts.snap +99 -24
  76. package/src/tests/ai-core.test.ts +71 -0
  77. package/src/tests/ai-features.test.ts +36 -0
  78. package/src/tests/baseline.test.ts +4 -1
  79. package/src/tests/cli.test.ts +2 -1
  80. package/src/tests/harness-agent.test.ts +19 -0
  81. package/src/tests/review-resume.test.ts +36 -8
  82. package/src/tests/review-scope.test.ts +67 -0
  83. package/src/tests/review-scripts.test.ts +2 -2
  84. package/src/tests/server.test.ts +54 -1
  85. package/src/tests/sidebar-sections.test.ts +94 -0
  86. package/src/tests/technical-summary.test.ts +176 -0
  87. package/src/tests/worktree.test.ts +404 -0
  88. package/assets/logo-legacy.png +0 -0
@@ -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
 
@@ -90,6 +102,7 @@ export type ArtifactKind =
90
102
  | "finding-report"
91
103
  | "review"
92
104
  | "resume"
105
+ | "technical"
93
106
  | "domain-review"
94
107
  | "verify"
95
108
  | "ast"
@@ -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
  }