@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.
Files changed (54) 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/ast/service.ts +7 -2
  11. package/src/core/brief.ts +1 -0
  12. package/src/core/commands/context.ts +9 -1
  13. package/src/core/commands/findings.ts +11 -5
  14. package/src/core/commands/review.ts +13 -0
  15. package/src/core/commands/scaffold.ts +4 -2
  16. package/src/core/config.ts +13 -0
  17. package/src/core/defaults.ts +7 -0
  18. package/src/core/engine/houndsman-engine.ts +26 -3
  19. package/src/core/engine/review-scope.ts +17 -22
  20. package/src/core/engine/review-session.ts +25 -2
  21. package/src/core/engine/review-workspace.ts +204 -0
  22. package/src/core/jev/jev-detector.ts +10 -2
  23. package/src/core/orchestrator.ts +62 -13
  24. package/src/core/project-config.ts +12 -0
  25. package/src/core/services/git-repository.ts +46 -0
  26. package/src/core/services/pr-resolver.ts +147 -0
  27. package/src/core/services/worktree-manager.ts +269 -0
  28. package/src/core/storage/file/file-storage.ts +14 -1
  29. package/src/core/storage/paths.ts +30 -1
  30. package/src/core/storage/sqlite/mappers.ts +4 -0
  31. package/src/core/storage/sqlite/schema.ts +13 -3
  32. package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
  33. package/src/core/storage/types.ts +12 -0
  34. package/src/core/storage/work-area.ts +20 -1
  35. package/src/core/types.ts +2 -0
  36. package/src/harness/hunt/hunt-options.ts +17 -0
  37. package/src/harness/hunt/hunt-session.ts +7 -20
  38. package/src/index.ts +31 -0
  39. package/src/server/client/App.tsx +49 -11
  40. package/src/server/client/DeleteRunModal.tsx +163 -0
  41. package/src/server/client/ResumePanel.tsx +8 -8
  42. package/src/server/client/Sidebar.tsx +56 -1
  43. package/src/server/client/icons.tsx +10 -0
  44. package/src/server/client/utils/types.ts +26 -0
  45. package/src/server/index.ts +97 -4
  46. package/src/server/pr-inbox.ts +15 -4
  47. package/src/server/run-repository.ts +103 -2
  48. package/src/skill/README.md +11 -3
  49. package/src/skill/SKILL.md +44 -10
  50. package/src/skill/hunt.md +33 -7
  51. package/src/tests/__snapshots__/baseline.test.ts.snap +94 -24
  52. package/src/tests/cli.test.ts +2 -1
  53. package/src/tests/server.test.ts +38 -0
  54. package/src/tests/worktree.test.ts +369 -0
@@ -6,9 +6,11 @@
6
6
  * de instanciar/importar tudo diretamente.
7
7
  */
8
8
 
9
+ import { resolve } from "node:path";
9
10
  import { AiClient } from "../ai/ai-client";
10
11
  import { AstService } from "../ast/service";
11
12
  import type { HoundsmanConfig } from "../config";
13
+ import { createGitRunner } from "../git-runner";
12
14
  import type { ReviewOptions } from "../orchestrator";
13
15
  import { ProjectConfig } from "../project-config";
14
16
  import { FileClassifier } from "../services/file-classifier";
@@ -19,9 +21,14 @@ import { createStorage } from "../storage/factory";
19
21
  import { resolveRepoIdentity } from "../storage/repo-identity";
20
22
  import type { RepoIdentity, StorageProvider } from "../storage/types";
21
23
  import { ReviewSession } from "./review-session";
24
+ import { mainRootOf } from "./review-workspace";
22
25
 
23
26
  /** Colaboradores injetáveis (testes/CLI). */
24
27
  export type EngineDeps = {
28
+ /** Raiz operacional (worktree ou clone principal). */
29
+ root?: string;
30
+ /** Raiz do clone principal (identidade/config). */
31
+ mainRoot?: string;
25
32
  git?: GitRepository;
26
33
  classifier?: FileClassifier;
27
34
  verifier?: Verifier;
@@ -35,6 +42,10 @@ export type EngineDeps = {
35
42
  /** Composition root do motor. */
36
43
  export class HoundsmanEngine {
37
44
  readonly project: ProjectConfig;
45
+ /** Onde os arquivos da revisão são lidos (worktree ou clone principal). */
46
+ readonly root: string;
47
+ /** Clone principal (identidade estável + camada/config). */
48
+ readonly mainRoot: string;
38
49
  readonly git: GitRepository;
39
50
  readonly classifier: FileClassifier;
40
51
  readonly verifier: Verifier;
@@ -46,16 +57,27 @@ export class HoundsmanEngine {
46
57
 
47
58
  constructor(raw: HoundsmanConfig, deps: EngineDeps = {}) {
48
59
  this.project = new ProjectConfig(raw);
49
- this.git = deps.git ?? new GitRepository();
60
+ this.root = resolve(deps.root ?? process.cwd());
61
+ this.git = deps.git ?? new GitRepository(createGitRunner(this.root));
62
+ this.mainRoot = resolve(
63
+ deps.mainRoot ?? mainRootOf(this.git, this.repoRootFallback()),
64
+ );
50
65
  this.classifier = deps.classifier ?? new FileClassifier(raw);
51
66
  this.verifier = deps.verifier ?? new Verifier();
52
67
  this.fixBatch = deps.fixBatch ?? new FixBatchBuilder();
53
68
  this.storage = deps.storage ?? createStorage(raw);
54
- this.repo = deps.repo ?? resolveRepoIdentity(this.git);
55
- this.ast = deps.ast ?? new AstService(raw, this.git);
69
+ this.repo =
70
+ deps.repo ??
71
+ resolveRepoIdentity(new GitRepository(createGitRunner(this.mainRoot)));
72
+ this.ast = deps.ast ?? new AstService(raw, this.git, this.root);
56
73
  this.ai = deps.ai ?? new AiClient({ project: this.project });
57
74
  }
58
75
 
76
+ /** Raiz do clone principal a partir do git atual (fallback: `root`). */
77
+ private repoRootFallback(): string {
78
+ return this.git.rootPath() ?? this.root;
79
+ }
80
+
59
81
  /** Configuração crua (contrato do consumidor). */
60
82
  get config(): HoundsmanConfig {
61
83
  return this.project.raw;
@@ -70,6 +92,7 @@ export class HoundsmanEngine {
70
92
  this.repo,
71
93
  this.ast,
72
94
  this.ai,
95
+ { root: this.root, mainRoot: this.mainRoot, git: this.git },
73
96
  );
74
97
  }
75
98
 
@@ -8,13 +8,12 @@
8
8
  import type { AstService } from "../ast/service";
9
9
  import type { AstAnalysis, AstFacts } from "../ast/types";
10
10
  import type { HoundsmanConfig } from "../config";
11
- import { churnToRecord, collectDiff, type DiffSource } from "../diff-source";
11
+ import { churnToRecord, type DiffSource } from "../diff-source";
12
12
  import { type ClassificationResult, parseEntries } from "../file-classifier";
13
13
  import type { FindingsStateFile } from "../findings-state";
14
- import { collectDiffSince, diffEntries, isAncestor } from "../git-revision";
15
- import { runGit, tryGit } from "../git-runner";
16
14
  import type { MergedFinding } from "../report-builder";
17
15
  import { FileClassifier } from "../services/file-classifier";
16
+ import { GitRepository } from "../services/git-repository";
18
17
  import { buildCarryover } from "./carryover";
19
18
 
20
19
  /** Opções que afetam o escopo (subset do `ReviewOptions`). */
@@ -60,11 +59,18 @@ export class ReviewScopeResolver {
60
59
  private readonly options: ScopeOptions;
61
60
  private readonly classifier: FileClassifier;
62
61
  private readonly ast?: AstService;
62
+ private readonly git: GitRepository;
63
63
 
64
- constructor(options: ScopeOptions, config: HoundsmanConfig, ast?: AstService) {
64
+ constructor(
65
+ options: ScopeOptions,
66
+ config: HoundsmanConfig,
67
+ ast?: AstService,
68
+ git: GitRepository = new GitRepository(),
69
+ ) {
65
70
  this.options = options;
66
71
  this.classifier = new FileClassifier(config);
67
72
  this.ast = ast;
73
+ this.git = git;
68
74
  }
69
75
 
70
76
  /**
@@ -89,18 +95,15 @@ export class ReviewScopeResolver {
89
95
  this.options.full ||
90
96
  this.options.clean ||
91
97
  !prevSha ||
92
- !isAncestor(prevSha, headSha)
98
+ !this.git.isAncestor(prevSha, headSha)
93
99
  ) {
94
- const diff = collectDiff(this.options.base, this.options.head);
100
+ const diff = this.git.collectDiff(this.options.base, this.options.head);
95
101
  const entries = parseEntries(
96
- runGit([
97
- "diff",
98
- `${this.options.base}...${this.options.head}`,
99
- "--name-status",
100
- ]),
102
+ this.git.diffNameStatus(this.options.base, this.options.head),
101
103
  );
102
104
  const baseRef =
103
- mergeBase(this.options.base, this.options.head) ?? this.options.base;
105
+ this.git.mergeBase(this.options.base, this.options.head) ??
106
+ this.options.base;
104
107
  const ast = this.ast
105
108
  ? await this.ast.analyze(entries, diff, baseRef)
106
109
  : undefined;
@@ -119,8 +122,8 @@ export class ReviewScopeResolver {
119
122
  }
120
123
 
121
124
  const range = `${prevSha}..${headSha}`;
122
- const diff = collectDiffSince(prevSha, headSha);
123
- const entries = diffEntries(range);
125
+ const diff = this.git.collectDiffSince(prevSha, headSha);
126
+ const entries = parseEntries(this.git.run(["diff", range, "--name-status"]));
124
127
  const changedPaths = new Set(entries.map((entry) => entry.path));
125
128
  const ast = this.ast ? await this.ast.analyze(entries, diff, prevSha) : undefined;
126
129
  const classification: RunClassification = {
@@ -149,11 +152,3 @@ function factsOf(
149
152
  }
150
153
  return facts;
151
154
  }
152
-
153
- /** SHA do merge-base entre base e head (best effort). */
154
- function mergeBase(base: string, head: string): string | null {
155
- const result = tryGit(["merge-base", base, head]);
156
- if (!result.ok) return null;
157
- const sha = result.stdout.trim();
158
- return sha.length > 0 ? sha : null;
159
- }
@@ -7,6 +7,7 @@
7
7
  import type { AiClient } from "../ai/ai-client";
8
8
  import type { AstService } from "../ast/service";
9
9
  import type { HoundsmanConfig } from "../config";
10
+ import { createGitRunner } from "../git-runner";
10
11
  import {
11
12
  type FinalizeResult,
12
13
  finalize,
@@ -14,10 +15,12 @@ import {
14
15
  prepare,
15
16
  type ReviewContext,
16
17
  type ReviewOptions,
18
+ type ReviewWorkspaceContext,
17
19
  resolveBranch,
18
20
  reviewContext,
19
21
  } from "../orchestrator";
20
22
  import { sanitizeBranch } from "../report-builder";
23
+ import { GitRepository } from "../services/git-repository";
21
24
  import type { RepoIdentity, StorageProvider } from "../storage/types";
22
25
 
23
26
  /** Uma revisão em andamento (prepare ou finalize). */
@@ -37,6 +40,7 @@ export class ReviewSession {
37
40
  repo: RepoIdentity,
38
41
  ast?: AstService,
39
42
  ai?: AiClient,
43
+ workspace?: ReviewWorkspaceContext,
40
44
  ) {
41
45
  this.config = config;
42
46
  this.options = options;
@@ -51,13 +55,30 @@ export class ReviewSession {
51
55
  slug: sanitizeBranch(branch),
52
56
  base: options.base,
53
57
  head: options.head,
58
+ worktreePath: options.worktreePath,
59
+ prNumber: options.pr,
54
60
  });
55
61
  if (!review) {
56
62
  throw new Error(
57
63
  `Nenhuma revisão para '${branch}'. Rode \`houndsman\` (preparar) primeiro.`,
58
64
  );
59
65
  }
60
- this.context = reviewContext(storage, review);
66
+ // Um prepare no clone principal invalida um worktree usado antes; o finalize
67
+ // não mexe (pode ser rodado de outro lugar depois do prepare no worktree).
68
+ const current =
69
+ !options.finalize && !options.worktreePath && review.worktreePath
70
+ ? (storage.updateReview(review.id, { resetWorkspace: true }) ?? review)
71
+ : review;
72
+ // Finalize sem flag explícita reusa o worktree gravado no prepare.
73
+ const effective =
74
+ options.finalize && !options.worktreePath && current.worktreePath
75
+ ? {
76
+ root: current.worktreePath,
77
+ mainRoot: workspace?.mainRoot,
78
+ git: new GitRepository(createGitRunner(current.worktreePath)),
79
+ }
80
+ : workspace;
81
+ this.context = reviewContext(storage, current, effective);
61
82
  }
62
83
 
63
84
  /** Resolve a branch (opção explícita ou a atual do git). */
@@ -68,15 +89,17 @@ export class ReviewSession {
68
89
  repo: RepoIdentity,
69
90
  ast?: AstService,
70
91
  ai?: AiClient,
92
+ workspace?: ReviewWorkspaceContext,
71
93
  ): ReviewSession {
72
94
  return new ReviewSession(
73
95
  config,
74
96
  options,
75
- resolveBranch(options),
97
+ resolveBranch(options, workspace?.git),
76
98
  storage,
77
99
  repo,
78
100
  ast,
79
101
  ai,
102
+ workspace,
80
103
  );
81
104
  }
82
105
 
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Resolução do workspace de uma revisão (engine).
3
+ *
4
+ * Decide onde a revisão roda: clone principal, worktree explícito (`--worktree`)
5
+ * ou worktree criado/reusado para uma PR (`--pr`). Devolve a raiz operacional,
6
+ * a raiz do clone principal (identidade/config) e as refs do diff.
7
+ */
8
+
9
+ import { basename, dirname, resolve } from "node:path";
10
+ import { createGitRunner } from "../git-runner";
11
+ import { createGhRunner, type GhRunner } from "../services/gh-client";
12
+ import { GitRepository } from "../services/git-repository";
13
+ import { PrResolver } from "../services/pr-resolver";
14
+ import { WorktreeManager } from "../services/worktree-manager";
15
+ import { resolveWorktreesRoot } from "../storage/paths";
16
+ import { resolveRepoIdentity } from "../storage/repo-identity";
17
+ import type { RepoIdentity } from "../storage/types";
18
+
19
+ /** Pedido de resolução (flags do CLI). */
20
+ export type WorkspaceRequest = {
21
+ cwd: string;
22
+ base: string;
23
+ head: string;
24
+ branch?: string;
25
+ /** Número da PR (`--pr`). */
26
+ pr?: number;
27
+ /** Worktree já existente (`--worktree`). */
28
+ worktree?: string;
29
+ };
30
+
31
+ /** Dependências injetáveis (testes). */
32
+ export type WorkspaceDeps = {
33
+ ghRunner?: GhRunner;
34
+ /** `config.worktrees.location` (default: `workspace`). */
35
+ worktreesLocation?: string;
36
+ /** Override explícito da raiz de worktrees (testes). */
37
+ worktreesRoot?: string;
38
+ };
39
+
40
+ /** Workspace resolvido para a revisão. */
41
+ export type ResolvedWorkspace = {
42
+ /** Onde os arquivos e o git da revisão são lidos. */
43
+ root: string;
44
+ /** Clone principal (identidade estável + camada/config). */
45
+ mainRoot: string;
46
+ repo: RepoIdentity;
47
+ git: GitRepository;
48
+ branch: string;
49
+ base: string;
50
+ head: string;
51
+ prNumber?: number;
52
+ worktreePath?: string;
53
+ /** Avisos para o CLI (fetch parcial, reuso etc.). */
54
+ messages: string[];
55
+ };
56
+
57
+ /**
58
+ * Raiz do clone principal a partir de um worktree linkado.
59
+ * `--git-common-dir` aponta para `<main>/.git`; qualquer outro formato usa `fallback`.
60
+ */
61
+ export function mainRootOf(git: GitRepository, fallback: string): string {
62
+ const common = git.gitCommonDir();
63
+ if (!common) return fallback;
64
+ const normalized = common.replace(/[\\/]+$/, "");
65
+ return basename(normalized).toLowerCase() === ".git" ? dirname(normalized) : fallback;
66
+ }
67
+
68
+ /** Cria um repositório git enraizado em `root`. */
69
+ function repoAt(root: string): GitRepository {
70
+ return new GitRepository(createGitRunner(root));
71
+ }
72
+
73
+ /** Compara paths levando em conta o filesystem (Windows é case-insensitive). */
74
+ function samePath(a: string, b: string): boolean {
75
+ const norm = (path: string): string => {
76
+ const absolute = resolve(path).replace(/[\\/]+$/, "");
77
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
78
+ };
79
+ return norm(a) === norm(b);
80
+ }
81
+
82
+ /** Resolve o workspace efetivo da revisão. */
83
+ export function resolveReviewWorkspace(
84
+ request: WorkspaceRequest,
85
+ deps: WorkspaceDeps = {},
86
+ ): ResolvedWorkspace {
87
+ const messages: string[] = [];
88
+ const cwdGit = repoAt(request.cwd);
89
+ const cwdRoot = resolve(cwdGit.rootPath() ?? request.cwd);
90
+ const mainRoot = resolve(mainRootOf(cwdGit, cwdRoot));
91
+ const mainGit = repoAt(mainRoot);
92
+
93
+ // Worktree explícito: a revisão roda onde o usuário mandou.
94
+ if (request.worktree) {
95
+ const path = resolve(request.cwd, request.worktree);
96
+ const git = repoAt(path);
97
+ const root = git.rootPath();
98
+ if (!root) {
99
+ throw new Error(`'${path}' não é um worktree/repositório git válido.`);
100
+ }
101
+ const repo = resolveRepoIdentity(git, root);
102
+ const branch = request.branch ?? git.currentBranch();
103
+ if (!branch) {
104
+ throw new Error(
105
+ `Worktree '${root}' está em detached HEAD. Informe --branch <nome>.`,
106
+ );
107
+ }
108
+ return {
109
+ root,
110
+ mainRoot: mainRootOf(git, repo.root),
111
+ repo,
112
+ git,
113
+ branch,
114
+ base: request.base,
115
+ head: request.head,
116
+ worktreePath: root,
117
+ messages,
118
+ };
119
+ }
120
+
121
+ // PR: cria/reusa o worktree detached no commit da PR.
122
+ if (request.pr !== undefined) {
123
+ const repo = resolveRepoIdentity(mainGit, mainRoot);
124
+ const resolver = new PrResolver(
125
+ deps.ghRunner ?? createGhRunner(mainRoot),
126
+ mainGit,
127
+ );
128
+ const resolved = resolver.resolve(request.pr);
129
+ if (!resolved.ok) {
130
+ throw new Error(
131
+ `Não foi possível resolver a PR #${request.pr}: ${resolved.detail}`,
132
+ );
133
+ }
134
+ const { pr } = resolved;
135
+ const refs = resolver.prepareRefs(pr);
136
+ if (refs.warning) messages.push(`GitHub: ${refs.warning}`);
137
+
138
+ const manager = new WorktreeManager(mainGit, {
139
+ repoKey: repo.key,
140
+ repoRoot: mainRoot,
141
+ baseDir:
142
+ deps.worktreesRoot ??
143
+ resolveWorktreesRoot(deps.worktreesLocation, mainRoot),
144
+ });
145
+ const worktree = manager.ensure({ branch: pr.headRefName, ref: refs.head });
146
+ const worktreePath = resolve(worktree.path);
147
+ messages.push(
148
+ `Worktree ${worktree.created ? "criado" : "reutilizado"}: ${worktreePath}`,
149
+ `PR #${pr.number} — ${pr.headRefName} → ${pr.baseRefName} @ ${refs.head.slice(0, 7)}.`,
150
+ );
151
+
152
+ return {
153
+ root: worktreePath,
154
+ mainRoot,
155
+ repo,
156
+ git: repoAt(worktreePath),
157
+ branch: pr.headRefName,
158
+ base: refs.base,
159
+ head: refs.head,
160
+ prNumber: pr.number,
161
+ worktreePath,
162
+ messages,
163
+ };
164
+ }
165
+
166
+ // Já estamos dentro de um worktree: ele é a raiz operacional.
167
+ const repo = resolveRepoIdentity(mainGit, mainRoot);
168
+ if (!samePath(cwdRoot, mainRoot)) {
169
+ const branch = request.branch ?? cwdGit.currentBranch();
170
+ if (!branch) {
171
+ throw new Error(
172
+ `Worktree '${cwdRoot}' está em detached HEAD. Informe --branch <nome>.`,
173
+ );
174
+ }
175
+ messages.push(`Usando o worktree atual: ${cwdRoot}`);
176
+ return {
177
+ root: cwdRoot,
178
+ mainRoot,
179
+ repo,
180
+ git: cwdGit,
181
+ branch,
182
+ base: request.base,
183
+ head: request.head,
184
+ worktreePath: cwdRoot,
185
+ messages,
186
+ };
187
+ }
188
+
189
+ // Clone principal.
190
+ const branch = request.branch ?? mainGit.currentBranch();
191
+ if (!branch) {
192
+ throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
193
+ }
194
+ return {
195
+ root: mainRoot,
196
+ mainRoot,
197
+ repo,
198
+ git: mainGit,
199
+ branch,
200
+ base: request.base,
201
+ head: request.head,
202
+ messages,
203
+ };
204
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
12
13
  import { collectDiff, type DiffSource } from "../diff-source";
13
14
  import type { ClassificationResult } from "../file-classifier";
14
15
  import type { WatchdogMeta } from "../types";
@@ -47,11 +48,18 @@ export class JevDetector {
47
48
 
48
49
  private readonly client: JevClient;
49
50
  private readonly questions: JevQuestionFactory;
51
+ /** Raiz dos arquivos (worktree ou clone principal). */
52
+ private readonly root: string;
50
53
 
51
54
  /** Construtor que recebe o cliente e o registry de watchdogs (injeção). */
52
- constructor(client: JevClient, registry: Record<string, WatchdogMeta>) {
55
+ constructor(
56
+ client: JevClient,
57
+ registry: Record<string, WatchdogMeta>,
58
+ root: string = process.cwd(),
59
+ ) {
53
60
  this.client = client;
54
61
  this.questions = new JevQuestionFactory(registry);
62
+ this.root = root;
55
63
  }
56
64
 
57
65
  /** Roda a pré-análise e devolve os candidatos (ordenados por confiança desc). */
@@ -157,7 +165,7 @@ export class JevDetector {
157
165
 
158
166
  private readHeader(filePath: string): string {
159
167
  try {
160
- return readFileSync(filePath, "utf8")
168
+ return readFileSync(join(this.root, filePath), "utf8")
161
169
  .split("\n")
162
170
  .slice(0, JevDetector.HEADER_LINES)
163
171
  .join("\n");
@@ -16,13 +16,15 @@
16
16
  * review.<watchdog>.md, context e context-summary.
17
17
  */
18
18
 
19
- import { readFileSync } from "node:fs";
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
20
21
  import type { AiClient } from "./ai/ai-client";
21
22
  import { AiBriefEnricher } from "./ai/features/brief-enricher";
22
23
  import { AiClassifier } from "./ai/features/classifier";
23
24
  import { AiContextSummarizer } from "./ai/features/context-summarizer";
24
25
  import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
25
26
  import type { BriefPlan } from "./ai/schemas";
27
+ import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
26
28
  import type { AstService } from "./ast/service";
27
29
  import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
28
30
  import {
@@ -39,7 +41,6 @@ import { ReviewScopeResolver, type RunClassification } from "./engine/review-sco
39
41
  import { ENV_NAMES } from "./env";
40
42
  import type { BudgetSelection, ClassificationResult } from "./file-classifier";
41
43
  import { buildStateFromFindings, fileReader } from "./findings-state";
42
- import { resolveSha } from "./git-revision";
43
44
  import { syncResolvedThreads } from "./github-review";
44
45
  import {
45
46
  type JevCandidate,
@@ -56,7 +57,6 @@ import {
56
57
  REVIEW_FILE,
57
58
  REVIEW_RESUME_FILE,
58
59
  } from "./paths";
59
- import { currentBranch } from "./pr-context";
60
60
  import { ProjectConfig } from "./project-config";
61
61
  import { BriefRenderer } from "./renderers/brief-renderer";
62
62
  import {
@@ -76,6 +76,7 @@ import {
76
76
  } from "./report-builder";
77
77
  import { writeRunStatus } from "./run-status";
78
78
  import { systemClock } from "./services/clock";
79
+ import { GitRepository } from "./services/git-repository";
79
80
  import type { ReviewRecord, StorageProvider } from "./storage/types";
80
81
  import { ReviewWorkArea } from "./storage/work-area";
81
82
  import type { WatchdogMeta } from "./types";
@@ -95,6 +96,12 @@ export type ReviewOptions = {
95
96
  full: boolean;
96
97
  /** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
97
98
  noAi: boolean;
99
+ /** PR de origem (resolvida via `--pr`). */
100
+ pr?: number;
101
+ /** Worktree informado via flag (`--worktree`). */
102
+ worktree?: string;
103
+ /** Worktree resolvido (absoluto; ausente = clone principal). */
104
+ worktreePath?: string;
98
105
  };
99
106
 
100
107
  /** Valores default das opções (o CLI sobrepõe com as flags). */
@@ -112,19 +119,49 @@ export function defaultOptions(): ReviewOptions {
112
119
  };
113
120
  }
114
121
 
115
- /** Contexto de uma run: storage + review + staging. */
122
+ /** Workspace onde os arquivos da review são lidos. */
123
+ export type ReviewWorkspaceContext = {
124
+ /** Raiz operacional (worktree ou clone principal). */
125
+ root?: string;
126
+ /** Raiz do clone principal (identidade/config). */
127
+ mainRoot?: string;
128
+ git?: GitRepository;
129
+ };
130
+
131
+ /** Contexto de uma run: storage + review + staging + raiz operacional. */
116
132
  export type ReviewContext = {
117
133
  storage: StorageProvider;
118
134
  review: ReviewRecord;
119
135
  work: ReviewWorkArea;
136
+ root: string;
137
+ mainRoot: string;
138
+ git: GitRepository;
120
139
  };
121
140
 
122
141
  /** Cria o contexto de uma review existente. */
123
142
  export function reviewContext(
124
143
  storage: StorageProvider,
125
144
  review: ReviewRecord,
145
+ workspace: ReviewWorkspaceContext = {},
126
146
  ): ReviewContext {
127
- return { storage, review, work: new ReviewWorkArea(review) };
147
+ const root = workspace.root ?? process.cwd();
148
+ return {
149
+ storage,
150
+ review,
151
+ work: new ReviewWorkArea(review),
152
+ root,
153
+ mainRoot: workspace.mainRoot ?? root,
154
+ git: workspace.git ?? new GitRepository(),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Schema do output: relativo à raiz da review quando a camada existe; no
160
+ * worktree sem camada, cai para o asset embarcado (caminho absoluto legível).
161
+ */
162
+ function outputFormatPathFor(ctx: ReviewContext): string {
163
+ if (existsSync(join(ctx.root, OUTPUT_FORMAT_PATH))) return OUTPUT_FORMAT_PATH;
164
+ return ctx.review.worktreePath ? assetPath(OUTPUT_FORMAT_FILE) : OUTPUT_FORMAT_PATH;
128
165
  }
129
166
 
130
167
  // ---------------------------------------------------------------------------
@@ -225,7 +262,8 @@ function writeBriefs(
225
262
  deleted: classification.deleted,
226
263
  branch,
227
264
  outputPath: work.findingFile(id),
228
- outputFormatPath: OUTPUT_FORMAT_PATH,
265
+ outputFormatPath: outputFormatPathFor(ctx),
266
+ worktree: ctx.review.worktreePath,
229
267
  docs,
230
268
  context,
231
269
  candidates: candidates.filter((c) => c.watchdogId === id),
@@ -277,7 +315,7 @@ async function resolveCandidates(
277
315
  const def = findWatchdog(config, id);
278
316
  if (!def) continue;
279
317
  const inputs = files.slice(0, 5).flatMap((file) => {
280
- const snippet = readSnippet(file);
318
+ const snippet = readSnippet(ctx.root, file);
281
319
  return snippet ? [{ file, snippet }] : [];
282
320
  });
283
321
  candidates.push(
@@ -314,6 +352,7 @@ async function resolveCandidates(
314
352
  const detector = new JevDetector(
315
353
  client,
316
354
  config.watchdogs as Record<string, WatchdogMeta>,
355
+ ctx.root,
317
356
  );
318
357
  const result = await detector.detect(classification, opts.base, opts.head, {
319
358
  diff,
@@ -377,9 +416,10 @@ function budgetMessage(
377
416
  }
378
417
 
379
418
  /** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
380
- function readSnippet(file: string): string | null {
419
+ function readSnippet(root: string, file: string): string | null {
381
420
  try {
382
- return readFileSync(file, "utf8").split("\n").slice(0, 80).join("\n");
421
+ const path = join(root, file);
422
+ return readFileSync(path, "utf8").split("\n").slice(0, 80).join("\n");
383
423
  } catch {
384
424
  return null;
385
425
  }
@@ -507,12 +547,13 @@ export async function prepare(
507
547
  ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
508
548
 
509
549
  // Marca o commit revisado e decide entre completo, incremental ou no-op.
510
- const headSha = resolveSha(opts.head);
550
+ const headSha = ctx.git.resolveSha(opts.head);
511
551
  const previous = ctx.storage.readFindings(ctx.review.id);
512
552
  const scope = await new ReviewScopeResolver(
513
553
  opts,
514
554
  config,
515
555
  opts.noAst ? undefined : ast,
556
+ ctx.git,
516
557
  ).resolve(previous, headSha);
517
558
 
518
559
  // Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
@@ -937,7 +978,7 @@ export function finalize(
937
978
  // (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
938
979
  const state = buildStateFromFindings(merged, {
939
980
  branch,
940
- readFile: fileReader(process.cwd()),
981
+ readFile: fileReader(ctx.root),
941
982
  previous: ctx.storage.readFindings(ctx.review.id),
942
983
  reviewedPaths: new Set([
943
984
  ...reports.flatMap((report) => report.reviewedFiles),
@@ -1042,6 +1083,14 @@ export function finalize(
1042
1083
  }
1043
1084
 
1044
1085
  /** Resolve a branch (opção explícita ou a atual do git). */
1045
- export function resolveBranch(opts: ReviewOptions): string {
1046
- return opts.branch ?? currentBranch();
1086
+ export function resolveBranch(
1087
+ opts: ReviewOptions,
1088
+ git: GitRepository = new GitRepository(),
1089
+ ): string {
1090
+ if (opts.branch) return opts.branch;
1091
+ const branch = git.currentBranch();
1092
+ if (!branch) {
1093
+ throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
1094
+ }
1095
+ return branch;
1047
1096
  }
@@ -13,6 +13,7 @@ import type {
13
13
  HoundsmanAst,
14
14
  HoundsmanConfig,
15
15
  HoundsmanStorage,
16
+ HoundsmanWorktrees,
16
17
  } from "./config";
17
18
  import {
18
19
  DEFAULT_AI_CONCURRENCY,
@@ -27,6 +28,7 @@ import {
27
28
  DEFAULT_AST_MAX_IMPACT_FILES,
28
29
  DEFAULT_JEV_CONCURRENCY,
29
30
  DEFAULT_MAX_FILES_PER_BRIEF,
31
+ DEFAULT_WORKTREES_LOCATION,
30
32
  } from "./defaults";
31
33
  import { WatchdogRegistry } from "./watchdog-registry";
32
34
 
@@ -48,6 +50,16 @@ export class ProjectConfig<TId extends string = string> {
48
50
  return this.raw.storage ?? {};
49
51
  }
50
52
 
53
+ /** Config de worktrees (objeto vazio quando ausente). */
54
+ get worktrees(): HoundsmanWorktrees {
55
+ return this.raw.worktrees ?? {};
56
+ }
57
+
58
+ /** Localização dos worktrees resolvida (default: workspace). */
59
+ get worktreesLocation(): string {
60
+ return this.worktrees.location?.trim() || DEFAULT_WORKTREES_LOCATION;
61
+ }
62
+
51
63
  /** Config do AST (objeto vazio quando ausente). */
52
64
  get ast(): HoundsmanAst {
53
65
  return this.raw.ast ?? {};