@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,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
|
+
}
|
package/src/core/git-runner.ts
CHANGED
|
@@ -24,8 +24,15 @@ export type GitRunner = (args: string[]) => GitResult;
|
|
|
24
24
|
* certo. Sem `cwd`, herda o diretório do processo (default).
|
|
25
25
|
*/
|
|
26
26
|
export function createGitRunner(cwd?: string): GitRunner {
|
|
27
|
+
// `core.longpaths` evita "Filename too long" no checkout de worktrees
|
|
28
|
+
// dentro de caminhos fundos (MAX_PATH do Windows). O `-c` é propagado aos
|
|
29
|
+
// subprocessos do git via GIT_CONFIG_PARAMETERS.
|
|
30
|
+
const config =
|
|
31
|
+
process.platform === "win32"
|
|
32
|
+
? ["-c", "core.quotepath=false", "-c", "core.longpaths=true"]
|
|
33
|
+
: ["-c", "core.quotepath=false"];
|
|
27
34
|
return (args) => {
|
|
28
|
-
const result = Bun.spawnSync(["git",
|
|
35
|
+
const result = Bun.spawnSync(["git", ...config, ...args], {
|
|
29
36
|
cwd,
|
|
30
37
|
stdout: "pipe",
|
|
31
38
|
stderr: "pipe",
|
|
@@ -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,16 @@
|
|
|
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";
|
|
26
|
+
import { AiTechnicalSummarizer } from "./ai/features/technical-summarizer";
|
|
25
27
|
import type { BriefPlan } from "./ai/schemas";
|
|
28
|
+
import { assetPath, OUTPUT_FORMAT_FILE } from "./assets";
|
|
26
29
|
import type { AstService } from "./ast/service";
|
|
27
30
|
import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
|
|
28
31
|
import {
|
|
@@ -39,7 +42,6 @@ import { ReviewScopeResolver, type RunClassification } from "./engine/review-sco
|
|
|
39
42
|
import { ENV_NAMES } from "./env";
|
|
40
43
|
import type { BudgetSelection, ClassificationResult } from "./file-classifier";
|
|
41
44
|
import { buildStateFromFindings, fileReader } from "./findings-state";
|
|
42
|
-
import { resolveSha } from "./git-revision";
|
|
43
45
|
import { syncResolvedThreads } from "./github-review";
|
|
44
46
|
import {
|
|
45
47
|
type JevCandidate,
|
|
@@ -55,8 +57,8 @@ import {
|
|
|
55
57
|
OUTPUT_FORMAT_PATH,
|
|
56
58
|
REVIEW_FILE,
|
|
57
59
|
REVIEW_RESUME_FILE,
|
|
60
|
+
TECHNICAL_SUMMARY_FILE,
|
|
58
61
|
} from "./paths";
|
|
59
|
-
import { currentBranch } from "./pr-context";
|
|
60
62
|
import { ProjectConfig } from "./project-config";
|
|
61
63
|
import { BriefRenderer } from "./renderers/brief-renderer";
|
|
62
64
|
import {
|
|
@@ -76,8 +78,14 @@ import {
|
|
|
76
78
|
} from "./report-builder";
|
|
77
79
|
import { writeRunStatus } from "./run-status";
|
|
78
80
|
import { systemClock } from "./services/clock";
|
|
81
|
+
import { GitRepository } from "./services/git-repository";
|
|
79
82
|
import type { ReviewRecord, StorageProvider } from "./storage/types";
|
|
80
83
|
import { ReviewWorkArea } from "./storage/work-area";
|
|
84
|
+
import {
|
|
85
|
+
buildTechnicalSummary,
|
|
86
|
+
parseTechnicalSummary,
|
|
87
|
+
type TechnicalSummary,
|
|
88
|
+
} from "./technical-summary";
|
|
81
89
|
import type { WatchdogMeta } from "./types";
|
|
82
90
|
import { findWatchdog } from "./watchdog-registry";
|
|
83
91
|
|
|
@@ -95,6 +103,12 @@ export type ReviewOptions = {
|
|
|
95
103
|
full: boolean;
|
|
96
104
|
/** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
|
|
97
105
|
noAi: boolean;
|
|
106
|
+
/** PR de origem (resolvida via `--pr`). */
|
|
107
|
+
pr?: number;
|
|
108
|
+
/** Worktree informado via flag (`--worktree`). */
|
|
109
|
+
worktree?: string;
|
|
110
|
+
/** Worktree resolvido (absoluto; ausente = clone principal). */
|
|
111
|
+
worktreePath?: string;
|
|
98
112
|
};
|
|
99
113
|
|
|
100
114
|
/** Valores default das opções (o CLI sobrepõe com as flags). */
|
|
@@ -112,19 +126,49 @@ export function defaultOptions(): ReviewOptions {
|
|
|
112
126
|
};
|
|
113
127
|
}
|
|
114
128
|
|
|
115
|
-
/**
|
|
129
|
+
/** Workspace onde os arquivos da review são lidos. */
|
|
130
|
+
export type ReviewWorkspaceContext = {
|
|
131
|
+
/** Raiz operacional (worktree ou clone principal). */
|
|
132
|
+
root?: string;
|
|
133
|
+
/** Raiz do clone principal (identidade/config). */
|
|
134
|
+
mainRoot?: string;
|
|
135
|
+
git?: GitRepository;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Contexto de uma run: storage + review + staging + raiz operacional. */
|
|
116
139
|
export type ReviewContext = {
|
|
117
140
|
storage: StorageProvider;
|
|
118
141
|
review: ReviewRecord;
|
|
119
142
|
work: ReviewWorkArea;
|
|
143
|
+
root: string;
|
|
144
|
+
mainRoot: string;
|
|
145
|
+
git: GitRepository;
|
|
120
146
|
};
|
|
121
147
|
|
|
122
148
|
/** Cria o contexto de uma review existente. */
|
|
123
149
|
export function reviewContext(
|
|
124
150
|
storage: StorageProvider,
|
|
125
151
|
review: ReviewRecord,
|
|
152
|
+
workspace: ReviewWorkspaceContext = {},
|
|
126
153
|
): ReviewContext {
|
|
127
|
-
|
|
154
|
+
const root = workspace.root ?? process.cwd();
|
|
155
|
+
return {
|
|
156
|
+
storage,
|
|
157
|
+
review,
|
|
158
|
+
work: new ReviewWorkArea(review),
|
|
159
|
+
root,
|
|
160
|
+
mainRoot: workspace.mainRoot ?? root,
|
|
161
|
+
git: workspace.git ?? new GitRepository(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Schema do output: relativo à raiz da review quando a camada existe; no
|
|
167
|
+
* worktree sem camada, cai para o asset embarcado (caminho absoluto legível).
|
|
168
|
+
*/
|
|
169
|
+
function outputFormatPathFor(ctx: ReviewContext): string {
|
|
170
|
+
if (existsSync(join(ctx.root, OUTPUT_FORMAT_PATH))) return OUTPUT_FORMAT_PATH;
|
|
171
|
+
return ctx.review.worktreePath ? assetPath(OUTPUT_FORMAT_FILE) : OUTPUT_FORMAT_PATH;
|
|
128
172
|
}
|
|
129
173
|
|
|
130
174
|
// ---------------------------------------------------------------------------
|
|
@@ -225,7 +269,8 @@ function writeBriefs(
|
|
|
225
269
|
deleted: classification.deleted,
|
|
226
270
|
branch,
|
|
227
271
|
outputPath: work.findingFile(id),
|
|
228
|
-
outputFormatPath:
|
|
272
|
+
outputFormatPath: outputFormatPathFor(ctx),
|
|
273
|
+
worktree: ctx.review.worktreePath,
|
|
229
274
|
docs,
|
|
230
275
|
context,
|
|
231
276
|
candidates: candidates.filter((c) => c.watchdogId === id),
|
|
@@ -277,7 +322,7 @@ async function resolveCandidates(
|
|
|
277
322
|
const def = findWatchdog(config, id);
|
|
278
323
|
if (!def) continue;
|
|
279
324
|
const inputs = files.slice(0, 5).flatMap((file) => {
|
|
280
|
-
const snippet = readSnippet(file);
|
|
325
|
+
const snippet = readSnippet(ctx.root, file);
|
|
281
326
|
return snippet ? [{ file, snippet }] : [];
|
|
282
327
|
});
|
|
283
328
|
candidates.push(
|
|
@@ -314,6 +359,7 @@ async function resolveCandidates(
|
|
|
314
359
|
const detector = new JevDetector(
|
|
315
360
|
client,
|
|
316
361
|
config.watchdogs as Record<string, WatchdogMeta>,
|
|
362
|
+
ctx.root,
|
|
317
363
|
);
|
|
318
364
|
const result = await detector.detect(classification, opts.base, opts.head, {
|
|
319
365
|
diff,
|
|
@@ -377,9 +423,10 @@ function budgetMessage(
|
|
|
377
423
|
}
|
|
378
424
|
|
|
379
425
|
/** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
|
|
380
|
-
function readSnippet(file: string): string | null {
|
|
426
|
+
function readSnippet(root: string, file: string): string | null {
|
|
381
427
|
try {
|
|
382
|
-
|
|
428
|
+
const path = join(root, file);
|
|
429
|
+
return readFileSync(path, "utf8").split("\n").slice(0, 80).join("\n");
|
|
383
430
|
} catch {
|
|
384
431
|
return null;
|
|
385
432
|
}
|
|
@@ -443,6 +490,50 @@ async function resolveContextSummary(
|
|
|
443
490
|
return summary;
|
|
444
491
|
}
|
|
445
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Pontos técnicos estruturais: IA quando disponível e, sempre, um fallback
|
|
495
|
+
* determinístico a partir da classificação/AST. Persistido para auditoria —
|
|
496
|
+
* o `finalize` apenas o consome.
|
|
497
|
+
*/
|
|
498
|
+
async function resolveTechnicalSummary(
|
|
499
|
+
ctx: ReviewContext,
|
|
500
|
+
ai: AiClient | undefined,
|
|
501
|
+
opts: ReviewOptions,
|
|
502
|
+
input: {
|
|
503
|
+
branch: string;
|
|
504
|
+
classification: RunClassification;
|
|
505
|
+
context: ContextSummary | null;
|
|
506
|
+
ast?: ReadonlyMap<string, AstAnalysis>;
|
|
507
|
+
impact?: AstImpact;
|
|
508
|
+
domains: readonly string[];
|
|
509
|
+
},
|
|
510
|
+
): Promise<TechnicalSummary> {
|
|
511
|
+
const fallback = buildTechnicalSummary({
|
|
512
|
+
classification: input.classification,
|
|
513
|
+
ast: input.ast,
|
|
514
|
+
impact: input.impact,
|
|
515
|
+
headSha: input.classification.headSha,
|
|
516
|
+
});
|
|
517
|
+
const summary =
|
|
518
|
+
ai && !opts.noAi
|
|
519
|
+
? ((await new AiTechnicalSummarizer(ai).summarize({
|
|
520
|
+
branch: input.branch,
|
|
521
|
+
context: input.context,
|
|
522
|
+
classification: input.classification,
|
|
523
|
+
ast: input.ast,
|
|
524
|
+
impact: input.impact,
|
|
525
|
+
headSha: input.classification.headSha,
|
|
526
|
+
domains: input.domains,
|
|
527
|
+
})) ?? fallback)
|
|
528
|
+
: fallback;
|
|
529
|
+
ctx.storage.writeArtifact(ctx.review.id, {
|
|
530
|
+
name: TECHNICAL_SUMMARY_FILE,
|
|
531
|
+
kind: "technical",
|
|
532
|
+
content: `${JSON.stringify(summary, null, 2)}\n`,
|
|
533
|
+
});
|
|
534
|
+
return summary;
|
|
535
|
+
}
|
|
536
|
+
|
|
446
537
|
/**
|
|
447
538
|
* Planos de revisão por watchdog (uma chamada por brief, concorrência
|
|
448
539
|
* limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
|
|
@@ -507,12 +598,13 @@ export async function prepare(
|
|
|
507
598
|
ctx.storage.updateReview(ctx.review.id, { base: opts.base, head: opts.head });
|
|
508
599
|
|
|
509
600
|
// Marca o commit revisado e decide entre completo, incremental ou no-op.
|
|
510
|
-
const headSha = resolveSha(opts.head);
|
|
601
|
+
const headSha = ctx.git.resolveSha(opts.head);
|
|
511
602
|
const previous = ctx.storage.readFindings(ctx.review.id);
|
|
512
603
|
const scope = await new ReviewScopeResolver(
|
|
513
604
|
opts,
|
|
514
605
|
config,
|
|
515
606
|
opts.noAst ? undefined : ast,
|
|
607
|
+
ctx.git,
|
|
516
608
|
).resolve(previous, headSha);
|
|
517
609
|
|
|
518
610
|
// Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
|
|
@@ -533,13 +625,30 @@ export async function prepare(
|
|
|
533
625
|
return { messages };
|
|
534
626
|
}
|
|
535
627
|
|
|
628
|
+
const { classification, diff } = scope;
|
|
629
|
+
|
|
630
|
+
// Run recém-criada e sem nenhum arquivo alterado: não persiste uma "run
|
|
631
|
+
// vazia" na lista. Acontece quando o comando roda no clone principal sem
|
|
632
|
+
// `--pr`/`--worktree` (ex.: `houndsman` na `main` logo após revisar uma PR).
|
|
633
|
+
// Run com artefatos anteriores (contexto, revisão) continua existindo.
|
|
634
|
+
if (Object.keys(classification.files).length === 0) {
|
|
635
|
+
const fresh = ctx.storage.listArtifacts(ctx.review.id).length === 0;
|
|
636
|
+
if (fresh) {
|
|
637
|
+
ctx.work.clean();
|
|
638
|
+
ctx.storage.deleteReview(ctx.review.id);
|
|
639
|
+
messages.push(
|
|
640
|
+
`Nenhuma alteração entre ${opts.base}...${opts.head} — nenhuma run criada.`,
|
|
641
|
+
);
|
|
642
|
+
return { messages };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
536
646
|
// Marca o run como "revisando" (reinicia o ciclo) — a UI mostra o status.
|
|
537
647
|
writeRunStatus(ctx.storage, ctx.review.id, {
|
|
538
648
|
status: "reviewing",
|
|
539
649
|
resetStartedAt: true,
|
|
540
650
|
});
|
|
541
651
|
|
|
542
|
-
const { classification, diff } = scope;
|
|
543
652
|
await applyAiClassification(classification, config, ai, opts);
|
|
544
653
|
ctx.storage.writeArtifact(ctx.review.id, {
|
|
545
654
|
name: "classification.json",
|
|
@@ -604,6 +713,14 @@ export async function prepare(
|
|
|
604
713
|
|
|
605
714
|
persistContext(ctx);
|
|
606
715
|
const context = await resolveContextSummary(ctx, ai, opts);
|
|
716
|
+
await resolveTechnicalSummary(ctx, ai, opts, {
|
|
717
|
+
branch,
|
|
718
|
+
classification,
|
|
719
|
+
context,
|
|
720
|
+
ast: astAnalyses,
|
|
721
|
+
impact: astImpact,
|
|
722
|
+
domains: activeIds.map((id) => findWatchdog(config, id)?.name ?? id),
|
|
723
|
+
});
|
|
607
724
|
|
|
608
725
|
const manifestAst: ManifestAst | undefined = astAnalyses
|
|
609
726
|
? {
|
|
@@ -812,6 +929,40 @@ function readPersistedContextSummary(ctx: ReviewContext): ContextSummary | null
|
|
|
812
929
|
}
|
|
813
930
|
}
|
|
814
931
|
|
|
932
|
+
/** JSON persistido, ou `null` quando ausente/inválido. */
|
|
933
|
+
function readArtifactJson<T>(ctx: ReviewContext, name: string): T | null {
|
|
934
|
+
const artifact = ctx.storage.readArtifact(ctx.review.id, name);
|
|
935
|
+
if (!artifact) return null;
|
|
936
|
+
try {
|
|
937
|
+
return JSON.parse(artifact.content) as T;
|
|
938
|
+
} catch {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Pontos técnicos persistidos pelo `prepare`; `null` quando ausentes. */
|
|
944
|
+
function readPersistedTechnicalSummary(ctx: ReviewContext): TechnicalSummary | null {
|
|
945
|
+
const raw = readArtifactJson<unknown>(ctx, TECHNICAL_SUMMARY_FILE);
|
|
946
|
+
return raw ? parseTechnicalSummary(raw) : null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Fallback para runs antigas: reconstrói os destaques dos artefatos de AST. */
|
|
950
|
+
function buildTechnicalFallback(
|
|
951
|
+
ctx: ReviewContext,
|
|
952
|
+
classification: RunClassification,
|
|
953
|
+
): TechnicalSummary {
|
|
954
|
+
const ast = readArtifactJson<{ files?: Record<string, AstAnalysis> }>(
|
|
955
|
+
ctx,
|
|
956
|
+
"ast-summary.json",
|
|
957
|
+
);
|
|
958
|
+
return buildTechnicalSummary({
|
|
959
|
+
classification,
|
|
960
|
+
ast: ast?.files ? new Map(Object.entries(ast.files)) : undefined,
|
|
961
|
+
impact: readArtifactJson<AstImpact>(ctx, "ast-impact.json") ?? undefined,
|
|
962
|
+
headSha: classification.headSha,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
815
966
|
/**
|
|
816
967
|
* Grava um `review.<watchdog>.md` por watchdog com achados e devolve o índice.
|
|
817
968
|
* Mantém o `review.md` consolidado navegável em PRs grandes (centenas de achados).
|
|
@@ -937,7 +1088,7 @@ export function finalize(
|
|
|
937
1088
|
// (`reviewed.headSha`) habilita o review incremental e o diff focado do verify.
|
|
938
1089
|
const state = buildStateFromFindings(merged, {
|
|
939
1090
|
branch,
|
|
940
|
-
readFile: fileReader(
|
|
1091
|
+
readFile: fileReader(ctx.root),
|
|
941
1092
|
previous: ctx.storage.readFindings(ctx.review.id),
|
|
942
1093
|
reviewedPaths: new Set([
|
|
943
1094
|
...reports.flatMap((report) => report.reviewedFiles),
|
|
@@ -987,6 +1138,8 @@ export function finalize(
|
|
|
987
1138
|
|
|
988
1139
|
persistContext(ctx);
|
|
989
1140
|
const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
|
|
1141
|
+
const technical =
|
|
1142
|
+
readPersistedTechnicalSummary(ctx) ?? buildTechnicalFallback(ctx, classification);
|
|
990
1143
|
const nameOf = (id: string): string => findWatchdog(config, id)?.name ?? id;
|
|
991
1144
|
const domains = writeDomainReports(
|
|
992
1145
|
ctx,
|
|
@@ -1020,7 +1173,7 @@ export function finalize(
|
|
|
1020
1173
|
context,
|
|
1021
1174
|
classification,
|
|
1022
1175
|
findings: merged,
|
|
1023
|
-
|
|
1176
|
+
technical,
|
|
1024
1177
|
}),
|
|
1025
1178
|
});
|
|
1026
1179
|
|
|
@@ -1042,6 +1195,14 @@ export function finalize(
|
|
|
1042
1195
|
}
|
|
1043
1196
|
|
|
1044
1197
|
/** Resolve a branch (opção explícita ou a atual do git). */
|
|
1045
|
-
export function resolveBranch(
|
|
1046
|
-
|
|
1198
|
+
export function resolveBranch(
|
|
1199
|
+
opts: ReviewOptions,
|
|
1200
|
+
git: GitRepository = new GitRepository(),
|
|
1201
|
+
): string {
|
|
1202
|
+
if (opts.branch) return opts.branch;
|
|
1203
|
+
const branch = git.currentBranch();
|
|
1204
|
+
if (!branch) {
|
|
1205
|
+
throw new Error("Não foi possível detectar a branch. Use --branch <nome>.");
|
|
1206
|
+
}
|
|
1207
|
+
return branch;
|
|
1047
1208
|
}
|
package/src/core/paths.ts
CHANGED
|
@@ -55,3 +55,6 @@ export const REVIEW_FILE = "review.md";
|
|
|
55
55
|
|
|
56
56
|
/** Resumo final da review (artefato `review-resume.md`) — leitura humana apenas. */
|
|
57
57
|
export const REVIEW_RESUME_FILE = "review-resume.md";
|
|
58
|
+
|
|
59
|
+
/** Pontos técnicos estruturais usados no resumo final (`technical-summary.json`). */
|
|
60
|
+
export const TECHNICAL_SUMMARY_FILE = "technical-summary.json";
|
package/src/core/pr-context.ts
CHANGED
|
@@ -229,13 +229,17 @@ export function renderContextBrief(
|
|
|
229
229
|
|
|
230
230
|
lines.push("## Regras", "");
|
|
231
231
|
lines.push(
|
|
232
|
-
"-
|
|
232
|
+
"- O resumo abre o relatório e o resumo final; nos briefs ele é recortado.",
|
|
233
233
|
);
|
|
234
234
|
lines.push("- Baseie-se **somente** no `context.json` — não invente intenção.");
|
|
235
|
-
lines.push("- `objective`: 1 frase (≤ 200 caracteres) sobre o que a PR entrega.");
|
|
236
|
-
lines.push("- `motivation`: 1 frase (≤ 240 caracteres) sobre o porquê.");
|
|
237
235
|
lines.push(
|
|
238
|
-
"- `
|
|
236
|
+
"- `objective`: até 2 frases (≤ 400 caracteres) sobre o que a PR entrega.",
|
|
237
|
+
);
|
|
238
|
+
lines.push(
|
|
239
|
+
"- `motivation`: até 3 frases (≤ 480 caracteres) sobre o porquê e o impacto.",
|
|
240
|
+
);
|
|
241
|
+
lines.push(
|
|
242
|
+
"- `changeSummary`: no máximo 6 bullets (≤ 240 caracteres cada), focados em comportamento — não liste arquivos.",
|
|
239
243
|
);
|
|
240
244
|
lines.push(
|
|
241
245
|
"- NÃO copie a descrição da PR: sintetize. Copie `source` do contexto bruto.",
|
|
@@ -253,7 +257,8 @@ const CONTEXT_SUMMARY_TEMPLATE = {
|
|
|
253
257
|
base: "main",
|
|
254
258
|
head: "feature/x",
|
|
255
259
|
},
|
|
256
|
-
objective: "O que a PR entrega, em 1
|
|
257
|
-
motivation:
|
|
260
|
+
objective: "O que a PR entrega, em 1-2 frases.",
|
|
261
|
+
motivation:
|
|
262
|
+
"Por que a mudança é necessária, com o problema e o impacto (até 3 frases).",
|
|
258
263
|
changeSummary: ["Bullet de comportamento 1", "Bullet de comportamento 2"],
|
|
259
264
|
};
|