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