@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
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Capacidades do provider de IA (puro).
3
+ *
4
+ * O AI SDK descarta `temperature`/`topP` e emite warning a cada chamada quando
5
+ * o DeepSeek opera em thinking mode — que é o default nos modelos V4. Enviar o
6
+ * parâmetro nesse caso não tem efeito: o adapter o omite.
7
+ */
8
+
9
+ import type { AiProviderKind, AiProviderOptions } from "../config";
10
+
11
+ /** `true` quando o modelo DeepSeek usa thinking sem `providerOptions`. */
12
+ function isDeepSeekV4Model(modelId: string): boolean {
13
+ return !/(^|[/.])deepseek-(?:chat|reasoner|v[1-3](?:\.\d+)?)(?=[-/:.]|$)/.test(
14
+ modelId,
15
+ );
16
+ }
17
+
18
+ /**
19
+ * `true` quando o DeepSeek vai operar em thinking mode.
20
+ *
21
+ * Espelha a resolução do `@ai-sdk/deepseek`: `thinking.type` explícito manda;
22
+ * sem ele, V4 (e o alias `deepseek-reasoner`) ligam thinking.
23
+ */
24
+ export function deepSeekThinkingEnabled(
25
+ modelId: string,
26
+ providerOptions: AiProviderOptions | undefined,
27
+ ): boolean {
28
+ const thinking = providerOptions?.deepseek?.thinking as
29
+ | Record<string, unknown>
30
+ | undefined;
31
+ const type = thinking?.type;
32
+ if (type === "disabled") return false;
33
+ if (type === "enabled" || type === "adaptive") return true;
34
+ return modelId === "deepseek-reasoner" || isDeepSeekV4Model(modelId);
35
+ }
36
+
37
+ /** `true` quando `temperature` chega ao provider (não é descartada). */
38
+ export function temperatureApplies(
39
+ provider: AiProviderKind,
40
+ modelId: string,
41
+ providerOptions?: AiProviderOptions,
42
+ ): boolean {
43
+ if (provider !== "deepseek") return true;
44
+ return !deepSeekThinkingEnabled(modelId, providerOptions);
45
+ }
@@ -22,12 +22,28 @@ export const ContextSummarySchema = z.object({
22
22
  head: z.string().optional(),
23
23
  })
24
24
  .optional(),
25
- objective: z.string().max(200).optional(),
26
- motivation: z.string().max(240).optional(),
27
- changeSummary: z.array(z.string().max(160)).max(4).optional(),
25
+ objective: z.string().max(400).optional(),
26
+ motivation: z.string().max(480).optional(),
27
+ changeSummary: z.array(z.string().max(240)).max(6).optional(),
28
28
  });
29
29
  export type ContextSummaryPlan = z.infer<typeof ContextSummarySchema>;
30
30
 
31
+ /** Pontos técnicos estruturais do resumo final (`technical-summary.json`). */
32
+ export const TechnicalSummarySchema = z.object({
33
+ /** Parágrafo curto de abertura (opcional). */
34
+ overview: z.string().max(600).optional(),
35
+ highlights: z
36
+ .array(
37
+ z.object({
38
+ area: z.string().min(1).max(80),
39
+ detail: z.string().min(1).max(400),
40
+ }),
41
+ )
42
+ .max(8)
43
+ .default([]),
44
+ });
45
+ export type TechnicalSummaryPlan = z.infer<typeof TechnicalSummarySchema>;
46
+
31
47
  /** Plano de revisão sugerido por IA para um brief. */
32
48
  export const BriefPlanSchema = z.object({
33
49
  focus: z.array(z.string()).max(5).default([]),
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ZodType, ZodTypeAny } from "zod";
9
- import type { AiProviderKind } from "../config";
9
+ import type { AiProviderKind, AiProviderOptions } from "../config";
10
10
 
11
11
  /** Uso de tokens de uma chamada (ou acumulado de uma review). */
12
12
  export type AiUsage = {
@@ -52,6 +52,8 @@ export type ModelRequest<T> = {
52
52
  maxSteps?: number;
53
53
  maxOutputTokens?: number;
54
54
  temperature?: number;
55
+ /** Opções do AI SDK por provider (repassadas ao SDK sem interpretação). */
56
+ providerOptions?: AiProviderOptions;
55
57
  signal?: AbortSignal;
56
58
  };
57
59
 
@@ -27,10 +27,15 @@ export class AstService {
27
27
  private readonly git: GitRepository;
28
28
  private readonly parser: AstParser;
29
29
 
30
- constructor(config: HoundsmanConfig, git: GitRepository = new GitRepository()) {
30
+ constructor(
31
+ config: HoundsmanConfig,
32
+ git: GitRepository = new GitRepository(),
33
+ root: string = process.cwd(),
34
+ ) {
31
35
  this.project = new ProjectConfig(config);
32
36
  this.git = git;
33
- const layerGrammars = join(process.cwd(), CORE_DIR, "grammars");
37
+ // Camada do projeto (quando presente); sem ela o parser usa o wasm embarcado.
38
+ const layerGrammars = join(root, CORE_DIR, "grammars");
34
39
  this.parser = new AstParser({
35
40
  maxFileBytes: this.project.astMaxFileBytes,
36
41
  languages: this.project.ast.languages,
package/src/core/brief.ts CHANGED
@@ -27,15 +27,36 @@ export const PROCEDURE: readonly string[] = [
27
27
  '5. **CONCLUDE** — grave o JSON no caminho de output, com `reviewedFiles` preenchido. Sem achados → `"findings": []` (arquivo obrigatório). Não escreva markdown livre.',
28
28
  ];
29
29
 
30
+ /** Limites do contexto injetado em cada brief (o resumo final é maior). */
31
+ const BRIEF_CONTEXT_LIMITS = {
32
+ objective: 200,
33
+ motivation: 240,
34
+ changeSummaryItem: 160,
35
+ changeSummaryItems: 4,
36
+ } as const;
37
+
30
38
  /** Bloco de contexto da PR, injetado no brief de cada watchdog. */
31
39
  export function renderBriefContext(context: WatchdogRenderContext): string[] {
32
40
  const lines = ["## Contexto da PR", ""];
33
- if (context.objective) lines.push(`**Objetivo:** ${context.objective}`);
34
- if (context.motivation) lines.push(`**Motivação:** ${context.motivation}`);
35
- const changeSummary = context.changeSummary ?? [];
41
+ if (context.objective) {
42
+ lines.push(
43
+ `**Objetivo:** ${shorten(context.objective, BRIEF_CONTEXT_LIMITS.objective)}`,
44
+ );
45
+ }
46
+ if (context.motivation) {
47
+ lines.push(
48
+ `**Motivação:** ${shorten(context.motivation, BRIEF_CONTEXT_LIMITS.motivation)}`,
49
+ );
50
+ }
51
+ const changeSummary = (context.changeSummary ?? []).slice(
52
+ 0,
53
+ BRIEF_CONTEXT_LIMITS.changeSummaryItems,
54
+ );
36
55
  if (changeSummary.length > 0) {
37
56
  lines.push("", "**Resumo das alterações:**", "");
38
- for (const item of changeSummary) lines.push(`- ${item}`);
57
+ for (const item of changeSummary) {
58
+ lines.push(`- ${shorten(item, BRIEF_CONTEXT_LIMITS.changeSummaryItem)}`);
59
+ }
39
60
  }
40
61
  lines.push("");
41
62
  return lines;
@@ -265,6 +286,7 @@ export function defaultRenderBrief(
265
286
  `# Brief — ${def.name} (\`${def.id}\`)`,
266
287
  "",
267
288
  `**Branch:** ${args.branch}`,
289
+ ...(args.worktree ? [`**Worktree:** \`${args.worktree}\``] : []),
268
290
  `**Output obrigatório:** \`${args.outputPath}\``,
269
291
  `**Schema do output:** \`${args.outputFormatPath}\``,
270
292
  "",
@@ -26,13 +26,21 @@ export function runContextCommand(
26
26
  _config: HoundsmanConfig,
27
27
  storage: StorageProvider,
28
28
  repo: RepoIdentity,
29
- opts: { branch?: string } = {},
29
+ opts: {
30
+ branch?: string;
31
+ /** Worktree onde a review roda (persistido na review). */
32
+ worktreePath?: string;
33
+ /** PR de origem (persistida na review). */
34
+ prNumber?: number;
35
+ } = {},
30
36
  ): ContextRunResult {
31
37
  const branch = opts.branch ?? currentBranch();
32
38
  const review = storage.getOrCreateReview({
33
39
  repo,
34
40
  branch,
35
41
  slug: sanitizeBranch(branch),
42
+ worktreePath: opts.worktreePath,
43
+ prNumber: opts.prNumber,
36
44
  });
37
45
  const work = new ReviewWorkArea(review);
38
46
  const paths = work.contextPaths;
@@ -10,6 +10,7 @@
10
10
  * com o UUID interno no banco.
11
11
  */
12
12
 
13
+ import { existsSync } from "node:fs";
13
14
  import { AiClient } from "../ai/ai-client";
14
15
  import { AiVerifier } from "../ai/features/verifier";
15
16
  import { type HoundsmanConfig, resolveJevConcurrency } from "../config";
@@ -107,11 +108,13 @@ function parseArgs(args: string[]): ParsedArgs {
107
108
  return parsed;
108
109
  }
109
110
 
110
- /** Contexto de uma review (storage + registro + staging). */
111
+ /** Contexto de uma review (storage + registro + staging + raiz). */
111
112
  type CommandContext = {
112
113
  storage: StorageProvider;
113
114
  review: ReviewRecord;
114
115
  work: ReviewWorkArea;
116
+ /** Worktree da review quando existir; senão, a raiz do repositório. */
117
+ root: string;
115
118
  };
116
119
 
117
120
  /** Resolve a review da branch atual (explícita ou via git). */
@@ -127,7 +130,10 @@ function openContext(
127
130
  `Nenhuma revisão para '${branch}'. Rode \`houndsman --finalize\` primeiro.`,
128
131
  );
129
132
  }
130
- return { storage, review, work: new ReviewWorkArea(review) };
133
+ const worktree = review.worktreePath;
134
+ // Sem worktree, mantém a raiz onde o comando foi invocado (comportamento histórico).
135
+ const root = worktree && existsSync(worktree) ? worktree : process.cwd();
136
+ return { storage, review, work: new ReviewWorkArea(review), root };
131
137
  }
132
138
 
133
139
  /** Lê o estado de triagem; lança se a review ainda não tem findings. */
@@ -380,7 +386,7 @@ function execute(
380
386
  }
381
387
  case "sync": {
382
388
  const state = loadState(ctx);
383
- const result = syncState(state, fileReader(process.cwd()));
389
+ const result = syncState(state, fileReader(ctx.root));
384
390
  ctx.storage.writeFindings(ctx.review.id, state);
385
391
  pingStatus(ctx, state);
386
392
  const output = parsed.bools.has("--json")
@@ -517,7 +523,7 @@ async function executeVerify(
517
523
  const { JevCandidatesStore, JevClient, JevVerifier } = await import("../jev");
518
524
 
519
525
  const findings = verifier.select(state, filterFrom(parsed));
520
- const entries = verifier.verifyLocal(findings, fileReader(process.cwd()));
526
+ const entries = verifier.verifyLocal(findings, fileReader(ctx.root));
521
527
 
522
528
  // Tier 1 — Jev só nos ambíguos (fail-open: sem chave/erro mantém o provisório).
523
529
  let jevChecked = 0;
@@ -565,7 +571,7 @@ async function executeVerify(
565
571
  } else if (!aiVerifier.enabled) {
566
572
  aiReason = "feature ai.features.verify desligada";
567
573
  } else {
568
- const readFile = fileReader(process.cwd());
574
+ const readFile = fileReader(ctx.root);
569
575
  const byId = new Map(findings.map((finding) => [finding.id, finding]));
570
576
  const confidence = await aiVerifier.verify({
571
577
  findings: stillAmbiguous.map((entry) => ({
@@ -20,9 +20,22 @@ export function parseReviewArgs(args: string[]): ReviewOptions {
20
20
  const base = parser.value("--base");
21
21
  const head = parser.value("--head");
22
22
  const branch = parser.value("--branch");
23
+ const pr = parser.value("--pr");
24
+ const worktree = parser.value("--worktree");
23
25
  if (base) opts.base = base;
24
26
  if (head) opts.head = head;
25
27
  if (branch) opts.branch = branch;
28
+ if (worktree) opts.worktree = worktree;
29
+ if (pr) {
30
+ const number = Number.parseInt(pr, 10);
31
+ if (!Number.isFinite(number) || number <= 0) {
32
+ throw new Error(`--pr inválido: '${pr}'.`);
33
+ }
34
+ opts.pr = number;
35
+ }
36
+ if (opts.pr !== undefined && opts.worktree !== undefined) {
37
+ throw new Error("Use --pr ou --worktree, não os dois.");
38
+ }
26
39
 
27
40
  if (parser.has("--finalize") || parser.has("--report-only")) opts.finalize = true;
28
41
  if (parser.has("--fail-on-blocking")) opts.failOnBlocking = true;
@@ -219,10 +219,14 @@ export type HoundsmanAst = {
219
219
  /** Providers suportados pela camada de IA. */
220
220
  export type AiProviderKind = "deepseek" | "openai" | "anthropic";
221
221
 
222
+ /** Opções do AI SDK por provider (repassadas sem interpretação). */
223
+ export type AiProviderOptions = Record<string, Record<string, unknown>>;
224
+
222
225
  /** Features de IA do fluxo normal (todas fail-open). */
223
226
  export type HoundsmanAiFeatures = {
224
227
  context?: boolean;
225
228
  brief?: boolean;
229
+ technical?: boolean;
226
230
  classification?: boolean;
227
231
  preAnalysis?: boolean;
228
232
  verify?: boolean;
@@ -245,6 +249,7 @@ export type HoundsmanAi = {
245
249
  maxSteps?: number;
246
250
  maxOutputTokens?: number;
247
251
  temperature?: number;
252
+ providerOptions?: AiProviderOptions;
248
253
  timeoutMs?: number;
249
254
  maxCallsPerReview?: number;
250
255
  maxTokensPerReview?: number;
@@ -398,10 +403,12 @@ const DOC_MAPPINGS_TEMPLATE = `/**
398
403
  export const DOC_MAPPINGS: readonly (readonly [RegExp, readonly string[]])[] = [];
399
404
  `;
400
405
 
401
- const PROJECT_GITIGNORE = `# Diretórios gerados pelo houndsman — \`core/\` é regenerado por \`houndsman update\`
402
- # e \`.runs/\` guarda os artefatos de revisão.
406
+ const PROJECT_GITIGNORE = `# Diretórios gerados pelo houndsman — \`core/\` é regenerado por \`houndsman update\`.
407
+ # \`work/\` e \`worktrees/\` são runtime de revisão (staging e worktrees).
403
408
  core/
404
409
  .runs/
410
+ work/
411
+ worktrees/
405
412
  `;
406
413
 
407
414
  // ---------------------------------------------------------------------------
@@ -57,12 +57,23 @@ export type HoundsmanConcurrency = {
57
57
  /** Providers suportados pela camada de IA. */
58
58
  export type AiProviderKind = "deepseek" | "openai" | "anthropic";
59
59
 
60
+ /**
61
+ * Opções do AI SDK indexadas pelo id do provider
62
+ * (`{ deepseek: { thinking: { type: "disabled" } } }`).
63
+ *
64
+ * O motor não interpreta o conteúdo: só repassa. Use para controlar
65
+ * thinking/reasoning sem acoplar a config a um provider específico.
66
+ */
67
+ export type AiProviderOptions = Record<string, Record<string, unknown>>;
68
+
60
69
  /** Features de IA do fluxo determinístico (todas fail-open). */
61
70
  export type HoundsmanAiFeatures = {
62
71
  /** Resume o contexto da PR (`context-summary`) quando o subagente não gerou. */
63
72
  context?: boolean;
64
73
  /** Enriquece os briefs com um plano de revisão sugerido. */
65
74
  brief?: boolean;
75
+ /** Gera os pontos técnicos estruturais do resumo final. */
76
+ technical?: boolean;
66
77
  /** Classifica arquivos que nenhum watchdog casou. */
67
78
  classification?: boolean;
68
79
  /** Gera candidatos de pré-análise (substitui o Jev quando disponível). */
@@ -94,8 +105,13 @@ export type HoundsmanAi = {
94
105
  maxSteps?: number;
95
106
  /** Teto de tokens de saída por chamada. */
96
107
  maxOutputTokens?: number;
97
- /** Temperatura (default: 0). */
108
+ /**
109
+ * Temperatura (default: 0). Ignorada quando o provider usa thinking
110
+ * (DeepSeek V4 liga thinking por default) — nesse caso o motor não a envia.
111
+ */
98
112
  temperature?: number;
113
+ /** Opções do AI SDK por provider (repassadas sem interpretação). */
114
+ providerOptions?: AiProviderOptions;
99
115
  /** Timeout por chamada em ms (default: 120_000). */
100
116
  timeoutMs?: number;
101
117
  /** Teto de chamadas por review (default: 40). */
@@ -108,6 +124,17 @@ export type HoundsmanAi = {
108
124
  watchdogs?: Record<string, HoundsmanAiWatchdog>;
109
125
  };
110
126
 
127
+ /** Onde os worktrees de revisão são criados. */
128
+ export type HoundsmanWorktrees = {
129
+ /**
130
+ * `workspace` (default): `<repo>/.houndsman/worktrees` — dentro da pasta
131
+ * aberta no editor, então agentes com permissão escopada ao workspace
132
+ * conseguem ler/executar sem prompts. `home`: `<HOUNDSMAN_HOME>/worktrees`.
133
+ * Qualquer outro valor é um caminho (absoluto ou relativo à raiz do repo).
134
+ */
135
+ location?: "home" | "workspace" | string;
136
+ };
137
+
111
138
  /** Backend de persistência (reviews, findings e artefatos). */
112
139
  export type HoundsmanStorage = {
113
140
  /**
@@ -132,6 +159,8 @@ export type HoundsmanConfig<TId extends string = string> = {
132
159
  projectName: string;
133
160
  /** Persistência das runs e artefatos (default: SQLite global). */
134
161
  storage?: HoundsmanStorage;
162
+ /** Localização dos worktrees de revisão (default: dentro do workspace). */
163
+ worktrees?: HoundsmanWorktrees;
135
164
  /**
136
165
  * Ids dos watchdogs — **derivados** das chaves de `watchdogs` por
137
166
  * `defineHoundsmanConfig` (não informar manualmente).
@@ -19,6 +19,13 @@ export const DEFAULT_MAX_FILES_PER_BRIEF = 40;
19
19
  /** Concorrência default das chamadas simultâneas ao Jev. */
20
20
  export const DEFAULT_JEV_CONCURRENCY = 5;
21
21
 
22
+ /**
23
+ * Localização default dos worktrees: dentro do workspace aberto no editor.
24
+ * Mantém leitura/execução dentro da pasta do projeto — agentes com permissão
25
+ * escopada ao workspace não pedem autorização a cada acesso.
26
+ */
27
+ export const DEFAULT_WORKTREES_LOCATION = "workspace";
28
+
22
29
  /** Teto default de bytes por arquivo no AST (evita bundles/minificados). */
23
30
  export const DEFAULT_AST_MAX_FILE_BYTES = 512_000;
24
31
 
@@ -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