@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 +1 -1
- package/src/cli/index.ts +9 -1
- package/src/core/ai/prompts/base.ts +7 -0
- package/src/core/ai/prompts/context.ts +2 -1
- package/src/core/ai/prompts/technical.ts +2 -1
- package/src/core/config-loader.ts +18 -4
- package/src/core/engine/review-workspace.ts +6 -2
- package/src/core/services/worktree-manager.ts +61 -2
- package/src/tests/ai-features.test.ts +11 -0
- package/src/tests/worktree.test.ts +71 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payconductor/houndsman",
|
|
3
|
-
"version": "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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
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 {
|
|
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();
|