@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.
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/cli/commands/command.ts +10 -1
- package/src/cli/commands/context-command.ts +40 -2
- package/src/cli/commands/hunt-command.ts +28 -2
- package/src/cli/commands/review-command.ts +30 -2
- package/src/cli/commands/worktree-command.ts +118 -0
- package/src/cli/help.ts +12 -2
- package/src/cli/index.ts +14 -4
- package/src/core/ai/ai-client.ts +21 -0
- package/src/core/ai/ai-sdk-model-provider.ts +43 -18
- package/src/core/ai/features/technical-summarizer.ts +43 -0
- package/src/core/ai/index.ts +4 -1
- package/src/core/ai/prompts/context.ts +3 -3
- package/src/core/ai/prompts/technical.ts +30 -0
- package/src/core/ai/provider-capabilities.ts +45 -0
- package/src/core/ai/schemas.ts +19 -3
- package/src/core/ai/types.ts +3 -1
- package/src/core/ast/service.ts +7 -2
- package/src/core/brief.ts +26 -4
- package/src/core/commands/context.ts +9 -1
- package/src/core/commands/findings.ts +11 -5
- package/src/core/commands/review.ts +13 -0
- package/src/core/commands/scaffold.ts +9 -2
- package/src/core/config.ts +30 -1
- package/src/core/defaults.ts +7 -0
- package/src/core/engine/houndsman-engine.ts +26 -3
- package/src/core/engine/review-scope.ts +17 -22
- package/src/core/engine/review-session.ts +25 -2
- package/src/core/engine/review-workspace.ts +204 -0
- package/src/core/git-runner.ts +8 -1
- package/src/core/jev/jev-detector.ts +10 -2
- package/src/core/orchestrator.ts +176 -15
- package/src/core/paths.ts +3 -0
- package/src/core/pr-context.ts +11 -6
- package/src/core/project-config.ts +21 -1
- package/src/core/renderers/resume-renderer.ts +17 -34
- package/src/core/report-builder.ts +6 -6
- package/src/core/services/git-repository.ts +46 -0
- package/src/core/services/pr-resolver.ts +147 -0
- package/src/core/services/worktree-manager.ts +293 -0
- package/src/core/storage/file/file-storage.ts +14 -1
- package/src/core/storage/paths.ts +30 -1
- package/src/core/storage/sqlite/mappers.ts +4 -0
- package/src/core/storage/sqlite/schema.ts +13 -3
- package/src/core/storage/sqlite/sqlite-storage.ts +28 -8
- package/src/core/storage/types.ts +13 -0
- package/src/core/storage/work-area.ts +20 -1
- package/src/core/technical-summary.ts +398 -0
- package/src/core/types.ts +2 -0
- package/src/harness/agent/agent-factory.ts +1 -0
- package/src/harness/agent/review-agent.ts +11 -1
- package/src/harness/config/harness-config.ts +6 -1
- package/src/harness/hunt/hunt-options.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +7 -20
- package/src/index.ts +33 -0
- package/src/server/client/App.tsx +51 -12
- package/src/server/client/DeleteRunModal.tsx +163 -0
- package/src/server/client/PrsSection.tsx +296 -0
- package/src/server/client/ResumePanel.tsx +17 -20
- package/src/server/client/RunsSection.tsx +73 -0
- package/src/server/client/Sidebar.tsx +171 -358
- package/src/server/client/icons.tsx +10 -0
- package/src/server/client/sidebar-sections.tsx +73 -0
- package/src/server/client/utils/constants.ts +7 -2
- package/src/server/client/utils/sidebar-prefs.ts +64 -0
- package/src/server/client/utils/types.ts +27 -0
- package/src/server/index.ts +113 -406
- package/src/server/pr-inbox.ts +15 -4
- package/src/server/run-repository.ts +103 -2
- package/src/server/styles.css +454 -0
- package/src/skill/README.md +11 -3
- package/src/skill/SKILL.md +44 -10
- package/src/skill/hunt.md +33 -7
- package/src/tests/__snapshots__/baseline.test.ts.snap +99 -24
- package/src/tests/ai-core.test.ts +71 -0
- package/src/tests/ai-features.test.ts +36 -0
- package/src/tests/baseline.test.ts +4 -1
- package/src/tests/cli.test.ts +2 -1
- package/src/tests/harness-agent.test.ts +19 -0
- package/src/tests/review-resume.test.ts +36 -8
- package/src/tests/review-scope.test.ts +67 -0
- package/src/tests/review-scripts.test.ts +2 -2
- package/src/tests/server.test.ts +54 -1
- package/src/tests/sidebar-sections.test.ts +94 -0
- package/src/tests/technical-summary.test.ts +176 -0
- package/src/tests/worktree.test.ts +404 -0
- 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
|
+
}
|
package/src/core/ai/schemas.ts
CHANGED
|
@@ -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(
|
|
26
|
-
motivation: z.string().max(
|
|
27
|
-
changeSummary: z.array(z.string().max(
|
|
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([]),
|
package/src/core/ai/types.ts
CHANGED
|
@@ -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
|
|
package/src/core/ast/service.ts
CHANGED
|
@@ -27,10 +27,15 @@ export class AstService {
|
|
|
27
27
|
private readonly git: GitRepository;
|
|
28
28
|
private readonly parser: AstParser;
|
|
29
29
|
|
|
30
|
-
constructor(
|
|
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
|
-
|
|
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)
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
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: {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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).
|
package/src/core/defaults.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
55
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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) ??
|
|
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 =
|
|
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
|
-
|
|
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
|
|