@payconductor/houndsman 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payconductor/houndsman",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Orquestrador de revisão de Pull Requests: classifica o diff, despacha watchdogs especializados por domínio e consolida o relatório.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/index.ts CHANGED
@@ -31,6 +31,7 @@ import type { HoundsmanConfig } from "../core/config";
31
31
  import { loadConfig } from "../core/config-loader";
32
32
  import { HoundsmanEngine } from "../core/engine/houndsman-engine";
33
33
  import { logger } from "../core/logging";
34
+ import { syncProjectLayerForRoot } from "../core/services/worktree-manager";
34
35
  import { ArgParser } from "./arg-parser";
35
36
  import type { CliContext } from "./commands/command";
36
37
  import { ContextCommand } from "./commands/context-command";
@@ -90,7 +91,14 @@ export async function main(): Promise<void> {
90
91
 
91
92
  let loaded: Awaited<ReturnType<typeof loadConfig>> | null = null;
92
93
  const config = async (): Promise<HoundsmanConfig> => {
93
- loaded ??= await loadConfig({ configPath: parser.value("--config") });
94
+ loaded ??= await (async () => {
95
+ // Rodando dentro de um worktree, a camada gerada (`.houndsman/core`)
96
+ // não existe (é ignorada pelo git). Repõe a partir do clone principal
97
+ // antes de importar a config — sem isso os watchdogs não resolvem
98
+ // `../core/types`. No clone principal é no-op.
99
+ syncProjectLayerForRoot(process.cwd());
100
+ return loadConfig({ configPath: parser.value("--config") });
101
+ })();
94
102
  return loaded.config;
95
103
  };
96
104
 
@@ -14,6 +14,13 @@ export const UNTRUSTED_DATA_RULES: readonly string[] = [
14
14
  "Responda apenas no formato estruturado pedido.",
15
15
  ];
16
16
 
17
+ /**
18
+ * Idioma da saída textual. Nomes próprios, paths, símbolos e comandos ficam
19
+ * como estão — só a prosa é traduzida.
20
+ */
21
+ export const OUTPUT_LANGUAGE_RULE =
22
+ "Escreva todo o texto em português do Brasil (pt-BR), mesmo que o contexto, o diff ou os nomes de arquivos estejam em inglês.";
23
+
17
24
  /** Monta um system prompt estável (papel + regras). */
18
25
  export function buildSystem(role: string, extra: readonly string[] = []): string {
19
26
  return [
@@ -2,13 +2,14 @@
2
2
  * Prompt do resumo de contexto da PR (`AiContextSummarizer`).
3
3
  */
4
4
 
5
- import { buildSystem, clip } from "./base";
5
+ import { buildSystem, clip, OUTPUT_LANGUAGE_RULE } from "./base";
6
6
 
7
7
  /** Instruções de sistema do resumo. */
8
8
  export function contextInstructions(): string {
9
9
  return buildSystem(
10
10
  "Você resume o contexto de uma Pull Request para revisores técnicos.",
11
11
  [
12
+ OUTPUT_LANGUAGE_RULE,
12
13
  "Objetivo em até 2 frases (máx. 400 caracteres).",
13
14
  "Motivação em até 3 frases (máx. 480 caracteres), com o problema e o impacto esperado.",
14
15
  "Resumo das alterações em até 6 bullets (máx. 240 caracteres cada), focado em comportamento, fluxos e impacto.",
@@ -2,13 +2,14 @@
2
2
  * Prompt dos pontos técnicos estruturais (`AiTechnicalSummarizer`).
3
3
  */
4
4
 
5
- import { buildSystem, clip } from "./base";
5
+ import { buildSystem, clip, OUTPUT_LANGUAGE_RULE } from "./base";
6
6
 
7
7
  /** Instruções de sistema dos pontos técnicos. */
8
8
  export function technicalInstructions(): string {
9
9
  return buildSystem(
10
10
  "Você descreve a estrutura técnica de uma Pull Request para revisores.",
11
11
  [
12
+ OUTPUT_LANGUAGE_RULE,
12
13
  "Foque em arquitetura e contratos: novos módulos/services, interfaces, providers, repositories, camadas, exports, dependências e impacto.",
13
14
  "NÃO avalie qualidade, NÃO liste achados, bugs ou riscos de revisão.",
14
15
  "NÃO repita a motivação de negócio: isso pertence ao resumo de negócio.",
@@ -31,6 +31,12 @@ export type LoadedConfig<TId extends string = string> = {
31
31
  path: string;
32
32
  };
33
33
 
34
+ /** Forma do módulo de config (default ou export nomeado). */
35
+ type ConfigModule<TId extends string> = {
36
+ default?: HoundsmanConfig<TId>;
37
+ houndsmanConfig?: HoundsmanConfig<TId>;
38
+ };
39
+
34
40
  /** Primeiro arquivo de config existente no diretório (ou `null`). */
35
41
  export function resolveConfigPath(cwd: string = process.cwd()): string | null {
36
42
  for (const name of CONFIG_FILE_CANDIDATES) {
@@ -61,10 +67,18 @@ export async function loadConfig<TId extends string = string>(
61
67
  throw new Error(MISSING_CONFIG_HINT);
62
68
  }
63
69
 
64
- const module = (await import(pathToFileURL(path).href)) as {
65
- default?: HoundsmanConfig<TId>;
66
- houndsmanConfig?: HoundsmanConfig<TId>;
67
- };
70
+ let module: ConfigModule<TId>;
71
+ try {
72
+ module = (await import(pathToFileURL(path).href)) as ConfigModule<TId>;
73
+ } catch (error) {
74
+ const detail = error instanceof Error ? error.message : String(error);
75
+ // Causa comum: camada gerada ausente (`.houndsman/core`), importada pelos
76
+ // watchdogs como `../core/types` — típico de worktree ainda não sincronizado.
77
+ throw new Error(
78
+ `Falha ao carregar ${path}: ${detail}\n` +
79
+ "A camada gerada (`.houndsman/core`) existe? Rode `houndsman update` na raiz do projeto.",
80
+ );
81
+ }
68
82
  const config = module.default ?? module.houndsmanConfig;
69
83
 
70
84
  if (!config) {
@@ -11,7 +11,7 @@ import { createGitRunner } from "../git-runner";
11
11
  import { createGhRunner, type GhRunner } from "../services/gh-client";
12
12
  import { GitRepository } from "../services/git-repository";
13
13
  import { PrResolver } from "../services/pr-resolver";
14
- import { WorktreeManager } from "../services/worktree-manager";
14
+ import { syncProjectLayer, WorktreeManager } from "../services/worktree-manager";
15
15
  import { resolveWorktreesRoot } from "../storage/paths";
16
16
  import { resolveRepoIdentity } from "../storage/repo-identity";
17
17
  import type { RepoIdentity } from "../storage/types";
@@ -105,9 +105,13 @@ export function resolveReviewWorkspace(
105
105
  `Worktree '${root}' está em detached HEAD. Informe --branch <nome>.`,
106
106
  );
107
107
  }
108
+ // A camada gerada não vem no checkout; sem ela os comandos no worktree
109
+ // não carregam a config (`../core/types`).
110
+ const explicitMainRoot = mainRootOf(git, repo.root);
111
+ syncProjectLayer(explicitMainRoot, root);
108
112
  return {
109
113
  root,
110
- mainRoot: mainRootOf(git, repo.root),
114
+ mainRoot: explicitMainRoot,
111
115
  repo,
112
116
  git,
113
117
  branch,
@@ -10,12 +10,17 @@
10
10
  * truncado porque o checkout de uma PR herda a profundidade dos arquivos do
11
11
  * projeto, e o Windows (MAX_PATH = 260) estoura "Filename too long" com
12
12
  * repo+branch longos. O hash mantém o nome determinístico e único.
13
+ *
14
+ * Cada worktree recebe o espelho da camada gerada (`.houndsman/core`) do clone
15
+ * principal — o checkout não traz arquivos ignorados e, sem ela, a config do
16
+ * projeto não importa (`../core/types`).
13
17
  */
14
18
 
15
19
  import { createHash } from "node:crypto";
16
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
21
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
18
22
  import { createGitRunner } from "../git-runner";
23
+ import { CORE_DIR } from "../paths";
19
24
  import { sanitizeBranch } from "../report-builder";
20
25
  import { worktreesRoot } from "../storage/paths";
21
26
  import { GitRepository } from "./git-repository";
@@ -59,7 +64,7 @@ export type RemoveWorktreeResult = {
59
64
  /** Opções do gerenciador. */
60
65
  export type WorktreeManagerOptions = {
61
66
  repoKey: string;
62
- /** Raiz do clone principal. */
67
+ /** Raiz do clone principal (fonte da camada gerada espelhada). */
63
68
  repoRoot: string;
64
69
  /**
65
70
  * Raiz onde os diretórios por repositório são criados.
@@ -112,6 +117,54 @@ function isInside(root: string, path: string): boolean {
112
117
  return rel === "" || (!rel.startsWith("..") && !rel.split(sep).includes(".."));
113
118
  }
114
119
 
120
+ /** Raiz do clone principal a partir do diretório git comum (ou `null`). */
121
+ function mainRootOf(git: GitRepository): string | null {
122
+ const common = git.gitCommonDir();
123
+ if (!common) return null;
124
+ const gitDir = resolve(common);
125
+ return basename(gitDir).toLowerCase() === ".git" ? dirname(gitDir) : null;
126
+ }
127
+
128
+ /**
129
+ * Espelha a camada gerada (`.houndsman/core`) do clone principal no worktree.
130
+ *
131
+ * O checkout de uma PR só traz arquivos versionados e `core/` é gerado (e
132
+ * ignorado pelo git). Sem ele, qualquer comando dentro do worktree falha ao
133
+ * importar `../core/types` a partir dos watchdogs. Fail-open: nunca bloqueia
134
+ * a revisão.
135
+ */
136
+ export function syncProjectLayer(mainRoot: string, worktreeRoot: string): boolean {
137
+ const source = join(mainRoot, CORE_DIR);
138
+ const dest = join(worktreeRoot, CORE_DIR);
139
+ if (normalizePath(source) === normalizePath(dest)) return false;
140
+ if (!existsSync(source)) return false;
141
+ try {
142
+ mkdirSync(dirname(dest), { recursive: true });
143
+ cpSync(source, dest, { recursive: true, force: true });
144
+ return true;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Auto-reparo para comandos executados **dentro** de um worktree: repõe a
152
+ * camada gerada a partir do clone principal quando ela não existe ali. No
153
+ * clone principal (camada presente) é no-op. Fail-open.
154
+ */
155
+ export function syncProjectLayerForRoot(root: string): boolean {
156
+ try {
157
+ const git = new GitRepository(createGitRunner(root));
158
+ const gitRoot = git.rootPath();
159
+ if (!gitRoot || existsSync(join(gitRoot, CORE_DIR))) return false;
160
+ const mainRoot = mainRootOf(git);
161
+ if (!mainRoot) return false;
162
+ return syncProjectLayer(mainRoot, gitRoot);
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
115
168
  /** Fatia a saída de `git worktree list --porcelain` em entradas. */
116
169
  export function parseWorktreeList(raw: string): WorktreeEntry[] {
117
170
  const entries: WorktreeEntry[] = [];
@@ -150,11 +203,13 @@ export function parseWorktreeList(raw: string): WorktreeEntry[] {
150
203
  export class WorktreeManager {
151
204
  private readonly git: GitRepository;
152
205
  private readonly repoKey: string;
206
+ private readonly repoRoot: string;
153
207
  private readonly rootDir: string;
154
208
 
155
209
  constructor(git: GitRepository, options: WorktreeManagerOptions) {
156
210
  this.git = git;
157
211
  this.repoKey = options.repoKey;
212
+ this.repoRoot = options.repoRoot;
158
213
  this.rootDir = join(
159
214
  options.baseDir ?? worktreesRoot(),
160
215
  repoSlug(options.repoKey),
@@ -214,6 +269,7 @@ export class WorktreeManager {
214
269
 
215
270
  if (existing) {
216
271
  if (existing.head === target) {
272
+ syncProjectLayer(this.repoRoot, path);
217
273
  return { path, head: target, created: false, reused: true };
218
274
  }
219
275
  const checkout = new GitRepository(createGitRunner(path)).tryRun([
@@ -222,6 +278,7 @@ export class WorktreeManager {
222
278
  target,
223
279
  ]);
224
280
  if (checkout.ok) {
281
+ syncProjectLayer(this.repoRoot, path);
225
282
  return { path, head: target, created: false, reused: true };
226
283
  }
227
284
  // Worktree quebrado/inacessível: remove e recria do zero.
@@ -251,6 +308,8 @@ export class WorktreeManager {
251
308
  );
252
309
  }
253
310
  }
311
+ // A camada gerada é ignorada pelo git e não vem no checkout da PR.
312
+ syncProjectLayer(this.repoRoot, path);
254
313
  return { path, head: target, created: true, reused: false };
255
314
  }
256
315
 
@@ -11,6 +11,8 @@ import { AiPreAnalyzer } from "../core/ai/features/pre-analyzer";
11
11
  import { AiTechnicalSummarizer } from "../core/ai/features/technical-summarizer";
12
12
  import { AiVerifier } from "../core/ai/features/verifier";
13
13
  import { briefInstructions, briefPrompt } from "../core/ai/prompts/brief";
14
+ import { contextInstructions } from "../core/ai/prompts/context";
15
+ import { technicalInstructions } from "../core/ai/prompts/technical";
14
16
  import { renderBrief } from "../core/brief";
15
17
  import { ProjectConfig } from "../core/project-config";
16
18
  import { FakeModelProvider } from "./fixtures/fake-model-provider";
@@ -217,3 +219,12 @@ describe("AiPreAnalyzer", () => {
217
219
  ).toEqual([]);
218
220
  });
219
221
  });
222
+
223
+ describe("idioma dos resumos", () => {
224
+ test("prompts de contexto e pontos técnicos exigem pt-BR", () => {
225
+ for (const instructions of [contextInstructions(), technicalInstructions()]) {
226
+ expect(instructions).toContain("português do Brasil");
227
+ expect(instructions).toContain("pt-BR");
228
+ }
229
+ });
230
+ });
@@ -24,7 +24,12 @@ import { createGitRunner } from "../core/git-runner";
24
24
  import { defaultOptions } from "../core/orchestrator";
25
25
  import type { GhRunner } from "../core/services/gh-client";
26
26
  import { GitRepository } from "../core/services/git-repository";
27
- import { parseWorktreeList, WorktreeManager } from "../core/services/worktree-manager";
27
+ import {
28
+ parseWorktreeList,
29
+ syncProjectLayer,
30
+ syncProjectLayerForRoot,
31
+ WorktreeManager,
32
+ } from "../core/services/worktree-manager";
28
33
  import { SQLiteStorageProvider } from "../core/storage/sqlite/sqlite-storage";
29
34
  import { parseHuntArgs } from "../harness/hunt/hunt-options";
30
35
  import { fakeHoundsmanConfig } from "./fixtures/fake-project";
@@ -186,6 +191,71 @@ describe("nomes de diretório do worktree", () => {
186
191
  });
187
192
  });
188
193
 
194
+ describe("camada gerada no worktree", () => {
195
+ test("espelha `.houndsman/core` do clone principal e ignora origem ausente", () => {
196
+ const sandbox = makeSandbox();
197
+ try {
198
+ const worktree = join(sandbox.root, "wt-layer");
199
+ mkdirSync(worktree, { recursive: true });
200
+ mkdirSync(join(sandbox.repo, ".houndsman", "core"), { recursive: true });
201
+ writeFileSync(
202
+ join(sandbox.repo, ".houndsman", "core", "types.ts"),
203
+ "export {};\n",
204
+ );
205
+
206
+ expect(syncProjectLayer(sandbox.repo, worktree)).toBe(true);
207
+ expect(existsSync(join(worktree, ".houndsman", "core", "types.ts"))).toBe(
208
+ true,
209
+ );
210
+
211
+ // Sem camada na origem: no-op (fail-open).
212
+ const empty = join(sandbox.root, "empty");
213
+ mkdirSync(empty, { recursive: true });
214
+ expect(syncProjectLayer(empty, worktree)).toBe(false);
215
+ } finally {
216
+ sandbox.cleanup();
217
+ }
218
+ });
219
+
220
+ test("ensure copia a camada e o auto-reparo repõe num worktree existente", () => {
221
+ const sandbox = makeSandbox();
222
+ try {
223
+ mkdirSync(join(sandbox.repo, ".houndsman", "core"), { recursive: true });
224
+ writeFileSync(
225
+ join(sandbox.repo, ".houndsman", "core", "types.ts"),
226
+ "export {};\n",
227
+ );
228
+ const manager = new WorktreeManager(
229
+ new GitRepository(createGitRunner(sandbox.repo)),
230
+ {
231
+ repoKey: "path:fake",
232
+ repoRoot: sandbox.repo,
233
+ baseDir: sandbox.home,
234
+ },
235
+ );
236
+
237
+ const ensured = manager.ensure({
238
+ branch: "feature",
239
+ ref: sandbox.featureSha,
240
+ });
241
+ const layer = join(ensured.path, ".houndsman", "core", "types.ts");
242
+ expect(existsSync(layer)).toBe(true);
243
+
244
+ // Comando rodando no worktree (sem `--pr`) também se auto-repara.
245
+ rmSync(join(ensured.path, ".houndsman", "core"), {
246
+ recursive: true,
247
+ force: true,
248
+ });
249
+ expect(syncProjectLayerForRoot(ensured.path)).toBe(true);
250
+ expect(existsSync(layer)).toBe(true);
251
+
252
+ manager.remove("feature");
253
+ } finally {
254
+ sandbox.cleanup();
255
+ }
256
+ }, 20_000);
257
+ });
258
+
189
259
  describe("WorktreeManager", () => {
190
260
  test("cria detached, reusa o mesmo commit e remove", () => {
191
261
  const sandbox = makeSandbox();