@payconductor/houndsman 0.11.0 → 0.13.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 +14 -13
- package/package.json +1 -1
- package/src/cli/commands/doctor-command.ts +22 -0
- package/src/cli/commands/hunt-command.ts +440 -29
- package/src/cli/commands/review-command.ts +20 -2
- package/src/cli/help.ts +16 -8
- package/src/cli/index.ts +15 -2
- package/src/cli/progress.ts +45 -0
- package/src/core/ai/ai-client.ts +3 -7
- package/src/core/ai/ai-sdk-model-provider.ts +43 -24
- package/src/core/ai/features/brief-enricher.ts +0 -1
- package/src/core/ai/features/classifier.ts +0 -1
- package/src/core/ai/features/context-summarizer.ts +0 -1
- package/src/core/ai/features/pre-analyzer.ts +0 -1
- package/src/core/ai/features/technical-summarizer.ts +0 -1
- package/src/core/ai/features/verifier.ts +0 -1
- package/src/core/ai/index.ts +6 -4
- package/src/core/ai/prompts/pre-analysis.ts +3 -2
- package/src/core/ai/schemas.ts +1 -23
- package/src/core/ai/types.ts +4 -15
- package/src/core/ai/usage-budget.ts +1 -1
- package/src/core/ast/diff.ts +37 -2
- package/src/core/ast/service.ts +1 -1
- package/src/core/brief.ts +54 -5
- package/src/core/changed-scope.ts +207 -0
- package/src/core/commands/scaffold.ts +12 -18
- package/src/core/config.ts +30 -15
- package/src/core/defaults.ts +14 -5
- package/src/core/engine/review-scope.ts +131 -17
- package/src/core/engine/review-session.ts +7 -3
- package/src/core/engine/review-workspace.ts +14 -0
- package/src/core/env.ts +14 -0
- package/src/core/hunt/hunt-runner.ts +328 -0
- package/src/core/hunt/index.ts +59 -0
- package/src/core/hunt/maki-install.ts +331 -0
- package/src/core/hunt/maki-model.ts +101 -0
- package/src/core/hunt/maki-prompt.ts +71 -0
- package/src/core/hunt/maki-release.ts +104 -0
- package/src/core/hunt/maki-runner.ts +276 -0
- package/src/core/hunt/pool.ts +30 -0
- package/src/core/jev/jev-detector.ts +7 -2
- package/src/core/logging/index.ts +3 -1
- package/src/core/logging/logger.ts +22 -3
- package/src/core/logging/progress.ts +282 -0
- package/src/core/orchestrator.ts +173 -59
- package/src/core/project-config.ts +34 -24
- package/src/core/registry-check.ts +2 -12
- package/src/core/rule-text.ts +3 -55
- package/src/core/services/git-repository.ts +8 -0
- package/src/core/storage/types.ts +1 -1
- package/src/core/types.ts +3 -14
- package/src/index.ts +74 -3
- package/src/server/client/utils/constants.ts +3 -6
- package/src/server/client/utils/types.ts +0 -1
- package/src/skill/OUTPUT-FORMAT.md +5 -0
- package/src/skill/README.md +14 -1
- package/src/skill/SETUP.md +22 -2
- package/src/skill/SKILL.md +4 -0
- package/src/skill/fix.md +1 -1
- package/src/skill/hunt.md +38 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +69 -32
- package/src/tests/ai-core.test.ts +38 -1
- package/src/tests/changed-scope.test.ts +197 -0
- package/src/tests/cli.test.ts +1 -0
- package/src/tests/fallback.test.ts +1 -96
- package/src/tests/fixtures/fake-model-provider.ts +0 -1
- package/src/tests/hunt-cli.test.ts +185 -0
- package/src/tests/hunt-install.test.ts +153 -0
- package/src/tests/hunt-maki.test.ts +467 -0
- package/src/tests/progress.test.ts +117 -0
- package/src/tests/review-scope.test.ts +95 -0
- package/src/tests/worktree.test.ts +1 -3
- package/src/harness/README.md +0 -15
- package/src/harness/agent/agent-context.ts +0 -33
- package/src/harness/agent/agent-errors.ts +0 -30
- package/src/harness/agent/agent-factory.ts +0 -46
- package/src/harness/agent/agent-reporter.ts +0 -34
- package/src/harness/agent/agent-result.ts +0 -16
- package/src/harness/agent/agent-runner.ts +0 -32
- package/src/harness/agent/agent-scheduler.ts +0 -37
- package/src/harness/agent/review-agent.ts +0 -401
- package/src/harness/config/harness-config.ts +0 -89
- package/src/harness/hunt/hunt-harness.ts +0 -83
- package/src/harness/hunt/hunt-options.ts +0 -92
- package/src/harness/hunt/hunt-result.ts +0 -17
- package/src/harness/hunt/hunt-session.ts +0 -359
- package/src/harness/hunt/review-planner.ts +0 -98
- package/src/harness/index.ts +0 -36
- package/src/harness/investigation/agent-state.ts +0 -134
- package/src/harness/investigation/evidence-verifier.ts +0 -129
- package/src/harness/investigation/evidence.ts +0 -110
- package/src/harness/investigation/finding-critic.ts +0 -96
- package/src/harness/investigation/investigation-planner.ts +0 -118
- package/src/harness/investigation/investigation-task.ts +0 -10
- package/src/harness/investigation/review-journal.ts +0 -87
- package/src/harness/investigation/rule-analyzer.ts +0 -178
- package/src/harness/investigation/rule-signals.ts +0 -110
- package/src/harness/prompts/agent-schemas.ts +0 -57
- package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
- package/src/harness/prompts/states/conclude-prompt.ts +0 -16
- package/src/harness/prompts/states/investigate-prompt.ts +0 -37
- package/src/harness/prompts/states/orient-prompt.ts +0 -15
- package/src/harness/prompts/states/verify-prompt.ts +0 -22
- package/src/harness/tools/agent-tool.ts +0 -102
- package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
- package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
- package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
- package/src/harness/tools/builtin/find-references-tool.ts +0 -66
- package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
- package/src/harness/tools/builtin/helpers.ts +0 -115
- package/src/harness/tools/builtin/impact-tool.ts +0 -48
- package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
- package/src/harness/tools/builtin/index.ts +0 -59
- package/src/harness/tools/builtin/list-files-tool.ts +0 -85
- package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
- package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
- package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
- package/src/harness/tools/builtin/search-code-tool.ts +0 -65
- package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
- package/src/harness/tools/path-guard.ts +0 -90
- package/src/harness/tools/result-limiter.ts +0 -49
- package/src/harness/tools/secret-redactor.ts +0 -54
- package/src/harness/tools/tool-cache.ts +0 -50
- package/src/harness/tools/tool-call-budget.ts +0 -60
- package/src/harness/tools/tool-context.ts +0 -5
- package/src/harness/tools/tool-middleware.ts +0 -105
- package/src/harness/tools/tool-policy.ts +0 -51
- package/src/harness/tools/tool-registry.ts +0 -112
- package/src/harness/tools/tool-result.ts +0 -15
- package/src/harness/tools/toolbox.ts +0 -36
- package/src/harness/validation/finding-validator.ts +0 -119
- package/src/tests/fixtures/fake-agent-tools.ts +0 -69
- package/src/tests/fixtures/fake-investigation.ts +0 -64
- package/src/tests/harness-agent.test.ts +0 -141
- package/src/tests/harness-investigation.test.ts +0 -206
- package/src/tests/harness-tools.test.ts +0 -148
- package/src/tests/hunt-command.test.ts +0 -212
- package/src/tests/rule-analyzer.test.ts +0 -61
- package/src/tests/tool-contracts.test.ts +0 -82
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AstService } from "../ast/service";
|
|
9
9
|
import type { AstAnalysis, AstFacts } from "../ast/types";
|
|
10
|
+
import { buildChangedScope, type ChangedScope, type ScopeEntry } from "../changed-scope";
|
|
10
11
|
import type { HoundsmanConfig } from "../config";
|
|
11
12
|
import { churnToRecord, type DiffSource } from "../diff-source";
|
|
12
13
|
import { type ClassificationResult, parseEntries } from "../file-classifier";
|
|
@@ -34,6 +35,8 @@ export type RunClassification = ClassificationResult & {
|
|
|
34
35
|
reviewedFrom?: string;
|
|
35
36
|
/** Findings preservados do review anterior (arquivos inalterados). */
|
|
36
37
|
carryover?: MergedFinding[];
|
|
38
|
+
/** Faixas revisáveis por arquivo (símbolos alterados; hunks como fallback). */
|
|
39
|
+
changedScope?: ChangedScope;
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
/** Resultado da resolução de escopo: revisar ou nada a fazer. */
|
|
@@ -54,23 +57,37 @@ export type ReviewScope =
|
|
|
54
57
|
ast?: Map<string, AstAnalysis>;
|
|
55
58
|
};
|
|
56
59
|
|
|
60
|
+
/** Etapas da resolução de escopo reportadas ao CLI (sem log no core). */
|
|
61
|
+
export type ScopeStage = "diff" | "ast" | "classify";
|
|
62
|
+
|
|
63
|
+
/** Evento de progresso de uma etapa do escopo. */
|
|
64
|
+
export type ScopeProgressEvent =
|
|
65
|
+
| { type: "start"; stage: ScopeStage }
|
|
66
|
+
| { type: "done"; stage: ScopeStage; durationMs: number };
|
|
67
|
+
|
|
68
|
+
/** Callback de progresso (falhas do callback são ignoradas). */
|
|
69
|
+
export type ScopeProgress = (event: ScopeProgressEvent) => void;
|
|
70
|
+
|
|
57
71
|
/** Decide entre review completo e incremental. */
|
|
58
72
|
export class ReviewScopeResolver {
|
|
59
73
|
private readonly options: ScopeOptions;
|
|
60
74
|
private readonly classifier: FileClassifier;
|
|
61
75
|
private readonly ast?: AstService;
|
|
62
76
|
private readonly git: GitRepository;
|
|
77
|
+
private readonly onProgress?: ScopeProgress;
|
|
63
78
|
|
|
64
79
|
constructor(
|
|
65
80
|
options: ScopeOptions,
|
|
66
81
|
config: HoundsmanConfig,
|
|
67
82
|
ast?: AstService,
|
|
68
83
|
git: GitRepository = new GitRepository(),
|
|
84
|
+
onProgress?: ScopeProgress,
|
|
69
85
|
) {
|
|
70
86
|
this.options = options;
|
|
71
87
|
this.classifier = new FileClassifier(config);
|
|
72
88
|
this.ast = ast;
|
|
73
89
|
this.git = git;
|
|
90
|
+
this.onProgress = onProgress;
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
/**
|
|
@@ -97,48 +114,131 @@ export class ReviewScopeResolver {
|
|
|
97
114
|
!prevSha ||
|
|
98
115
|
!this.git.isAncestor(prevSha, headSha)
|
|
99
116
|
) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
117
|
+
const diffData = this.step("diff", () => {
|
|
118
|
+
const diff = this.git.collectDiff(this.options.base, this.options.head);
|
|
119
|
+
const entries = parseEntries(
|
|
120
|
+
this.git.diffNameStatus(this.options.base, this.options.head),
|
|
121
|
+
);
|
|
122
|
+
const baseRef =
|
|
123
|
+
this.git.mergeBase(this.options.base, this.options.head) ??
|
|
124
|
+
this.options.base;
|
|
125
|
+
// Patch `-U0` sem truncar: mais preciso para delta AST e escopo.
|
|
126
|
+
const scopeDiff = this.zeroContext(
|
|
127
|
+
`${this.options.base}...${this.options.head}`,
|
|
128
|
+
diff,
|
|
129
|
+
);
|
|
130
|
+
return { diff, entries, baseRef, scopeDiff };
|
|
131
|
+
});
|
|
132
|
+
const { diff, entries, baseRef, scopeDiff } = diffData;
|
|
133
|
+
const astService = this.ast;
|
|
134
|
+
const ast = astService
|
|
135
|
+
? await this.stepAsync("ast", () =>
|
|
136
|
+
astService.analyze(entries, scopeDiff, baseRef),
|
|
137
|
+
)
|
|
109
138
|
: undefined;
|
|
139
|
+
const result = this.step("classify", () =>
|
|
140
|
+
this.classifier
|
|
141
|
+
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
142
|
+
.toJSON(),
|
|
143
|
+
);
|
|
110
144
|
return {
|
|
111
145
|
kind: "review",
|
|
112
146
|
diff,
|
|
113
147
|
ast,
|
|
114
148
|
classification: {
|
|
115
|
-
...
|
|
116
|
-
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
117
|
-
.toJSON(),
|
|
149
|
+
...result,
|
|
118
150
|
headSha,
|
|
119
151
|
mode: "full",
|
|
152
|
+
changedScope: buildChangedScope({
|
|
153
|
+
entries: scopeEntries(entries, result),
|
|
154
|
+
diff: scopeDiff,
|
|
155
|
+
ast,
|
|
156
|
+
}),
|
|
120
157
|
},
|
|
121
158
|
};
|
|
122
159
|
}
|
|
123
160
|
|
|
124
161
|
const range = `${prevSha}..${headSha}`;
|
|
125
|
-
const
|
|
126
|
-
|
|
162
|
+
const diffData = this.step("diff", () => {
|
|
163
|
+
const diff = this.git.collectDiffSince(prevSha, headSha);
|
|
164
|
+
const entries = parseEntries(this.git.run(["diff", range, "--name-status"]));
|
|
165
|
+
const scopeDiff = this.zeroContext(range, diff);
|
|
166
|
+
return { diff, entries, scopeDiff };
|
|
167
|
+
});
|
|
168
|
+
const { diff, entries, scopeDiff } = diffData;
|
|
127
169
|
const changedPaths = new Set(entries.map((entry) => entry.path));
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
170
|
+
const astService = this.ast;
|
|
171
|
+
const ast = astService
|
|
172
|
+
? await this.stepAsync("ast", () =>
|
|
173
|
+
astService.analyze(entries, scopeDiff, prevSha),
|
|
174
|
+
)
|
|
175
|
+
: undefined;
|
|
176
|
+
const result = this.step("classify", () =>
|
|
177
|
+
this.classifier
|
|
131
178
|
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
132
179
|
.toJSON(),
|
|
180
|
+
);
|
|
181
|
+
const classification: RunClassification = {
|
|
182
|
+
...result,
|
|
133
183
|
base: prevSha,
|
|
134
184
|
head: this.options.head,
|
|
135
185
|
headSha,
|
|
136
186
|
mode: "incremental",
|
|
137
187
|
reviewedFrom: prevSha,
|
|
138
188
|
carryover: buildCarryover(previous, changedPaths),
|
|
189
|
+
changedScope: buildChangedScope({
|
|
190
|
+
entries: scopeEntries(entries, result),
|
|
191
|
+
diff: scopeDiff,
|
|
192
|
+
ast,
|
|
193
|
+
}),
|
|
139
194
|
};
|
|
140
195
|
return { kind: "review", classification, diff, reviewedFrom: prevSha, ast };
|
|
141
196
|
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Executa uma etapa síncrona reportando início/fim. O callback de progresso
|
|
200
|
+
* é diagnóstico: nunca derruba a resolução.
|
|
201
|
+
*/
|
|
202
|
+
private step<T>(stage: ScopeStage, run: () => T): T {
|
|
203
|
+
if (!this.onProgress) return run();
|
|
204
|
+
const started = Date.now();
|
|
205
|
+
this.report({ type: "start", stage });
|
|
206
|
+
try {
|
|
207
|
+
return run();
|
|
208
|
+
} finally {
|
|
209
|
+
this.report({ type: "done", stage, durationMs: Date.now() - started });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Versão assíncrona de `step`. */
|
|
214
|
+
private async stepAsync<T>(stage: ScopeStage, run: () => Promise<T> | T): Promise<T> {
|
|
215
|
+
if (!this.onProgress) return run();
|
|
216
|
+
const started = Date.now();
|
|
217
|
+
this.report({ type: "start", stage });
|
|
218
|
+
try {
|
|
219
|
+
return await run();
|
|
220
|
+
} finally {
|
|
221
|
+
this.report({ type: "done", stage, durationMs: Date.now() - started });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private report(event: ScopeProgressEvent): void {
|
|
226
|
+
try {
|
|
227
|
+
this.onProgress?.(event);
|
|
228
|
+
} catch {
|
|
229
|
+
// Progresso nunca derruba a revisão.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Patch `-U0` do intervalo; falha aberta cai no diff já coletado. */
|
|
234
|
+
private zeroContext(range: string, fallback: DiffSource): DiffSource {
|
|
235
|
+
try {
|
|
236
|
+
const byFile = this.git.patchZeroContext(range);
|
|
237
|
+
return byFile.size > 0 ? { byFile, churn: fallback.churn } : fallback;
|
|
238
|
+
} catch {
|
|
239
|
+
return fallback;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
142
242
|
}
|
|
143
243
|
|
|
144
244
|
/** Fatos apenas dos arquivos presentes (deletados não classificam). */
|
|
@@ -152,3 +252,17 @@ function factsOf(
|
|
|
152
252
|
}
|
|
153
253
|
return facts;
|
|
154
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Entradas revisáveis do `--name-status`: exclui arquivos do `exclusions` e
|
|
258
|
+
* deletados (não há código atual para ancorar finding).
|
|
259
|
+
*/
|
|
260
|
+
function scopeEntries(
|
|
261
|
+
entries: ReturnType<typeof parseEntries>,
|
|
262
|
+
result: ClassificationResult,
|
|
263
|
+
): ScopeEntry[] {
|
|
264
|
+
const excluded = new Set(result.excluded);
|
|
265
|
+
return entries
|
|
266
|
+
.filter((entry) => entry.status !== "deleted" && !excluded.has(entry.path))
|
|
267
|
+
.map((entry) => ({ path: entry.path, status: entry.status }));
|
|
268
|
+
}
|
|
@@ -11,6 +11,7 @@ import { createGitRunner } from "../git-runner";
|
|
|
11
11
|
import {
|
|
12
12
|
type FinalizeResult,
|
|
13
13
|
finalize,
|
|
14
|
+
type PrepareProgress,
|
|
14
15
|
type PrepareResult,
|
|
15
16
|
prepare,
|
|
16
17
|
type ReviewContext,
|
|
@@ -104,7 +105,7 @@ export class ReviewSession {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/** Passo 1 — preparar. */
|
|
107
|
-
prepare(): Promise<PrepareResult> {
|
|
108
|
+
prepare(onProgress?: PrepareProgress): Promise<PrepareResult> {
|
|
108
109
|
return prepare(
|
|
109
110
|
this.options,
|
|
110
111
|
this.branch,
|
|
@@ -112,6 +113,7 @@ export class ReviewSession {
|
|
|
112
113
|
this.context,
|
|
113
114
|
this.ast,
|
|
114
115
|
this.ai,
|
|
116
|
+
onProgress,
|
|
115
117
|
);
|
|
116
118
|
}
|
|
117
119
|
|
|
@@ -121,7 +123,9 @@ export class ReviewSession {
|
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
/** Executa o passo escolhido em `--finalize`. */
|
|
124
|
-
run(): Promise<PrepareResult | FinalizeResult> {
|
|
125
|
-
return this.options.finalize
|
|
126
|
+
run(onProgress?: PrepareProgress): Promise<PrepareResult | FinalizeResult> {
|
|
127
|
+
return this.options.finalize
|
|
128
|
+
? Promise.resolve(this.finalize())
|
|
129
|
+
: this.prepare(onProgress);
|
|
126
130
|
}
|
|
127
131
|
}
|
|
@@ -35,6 +35,8 @@ export type WorkspaceDeps = {
|
|
|
35
35
|
worktreesLocation?: string;
|
|
36
36
|
/** Override explícito da raiz de worktrees (testes). */
|
|
37
37
|
worktreesRoot?: string;
|
|
38
|
+
/** Passos longos (gh/worktree) reportados ao CLI; nunca lança. */
|
|
39
|
+
onProgress?: (message: string) => void;
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
/** Workspace resolvido para a revisão. */
|
|
@@ -85,6 +87,14 @@ export function resolveReviewWorkspace(
|
|
|
85
87
|
deps: WorkspaceDeps = {},
|
|
86
88
|
): ResolvedWorkspace {
|
|
87
89
|
const messages: string[] = [];
|
|
90
|
+
/** Progresso é diagnóstico: nunca derruba a resolução. */
|
|
91
|
+
const report = (message: string): void => {
|
|
92
|
+
try {
|
|
93
|
+
deps.onProgress?.(message);
|
|
94
|
+
} catch {
|
|
95
|
+
// Progresso nunca derruba a revisão.
|
|
96
|
+
}
|
|
97
|
+
};
|
|
88
98
|
const cwdGit = repoAt(request.cwd);
|
|
89
99
|
const cwdRoot = resolve(cwdGit.rootPath() ?? request.cwd);
|
|
90
100
|
const mainRoot = resolve(mainRootOf(cwdGit, cwdRoot));
|
|
@@ -108,6 +118,7 @@ export function resolveReviewWorkspace(
|
|
|
108
118
|
// A camada gerada não vem no checkout; sem ela os comandos no worktree
|
|
109
119
|
// não carregam a config (`../core/types`).
|
|
110
120
|
const explicitMainRoot = mainRootOf(git, repo.root);
|
|
121
|
+
report("Sincronizando a camada gerada no worktree…");
|
|
111
122
|
syncProjectLayer(explicitMainRoot, root);
|
|
112
123
|
return {
|
|
113
124
|
root,
|
|
@@ -129,6 +140,7 @@ export function resolveReviewWorkspace(
|
|
|
129
140
|
deps.ghRunner ?? createGhRunner(mainRoot),
|
|
130
141
|
mainGit,
|
|
131
142
|
);
|
|
143
|
+
report(`Consultando a PR #${request.pr} via gh…`);
|
|
132
144
|
const resolved = resolver.resolve(request.pr);
|
|
133
145
|
if (!resolved.ok) {
|
|
134
146
|
throw new Error(
|
|
@@ -136,6 +148,7 @@ export function resolveReviewWorkspace(
|
|
|
136
148
|
);
|
|
137
149
|
}
|
|
138
150
|
const { pr } = resolved;
|
|
151
|
+
report("Buscando as refs da PR…");
|
|
139
152
|
const refs = resolver.prepareRefs(pr);
|
|
140
153
|
if (refs.warning) messages.push(`GitHub: ${refs.warning}`);
|
|
141
154
|
|
|
@@ -146,6 +159,7 @@ export function resolveReviewWorkspace(
|
|
|
146
159
|
deps.worktreesRoot ??
|
|
147
160
|
resolveWorktreesRoot(deps.worktreesLocation, mainRoot),
|
|
148
161
|
});
|
|
162
|
+
report("Criando/reutilizando o worktree da PR…");
|
|
149
163
|
const worktree = manager.ensure({ branch: pr.headRefName, ref: refs.head });
|
|
150
164
|
const worktreePath = resolve(worktree.path);
|
|
151
165
|
messages.push(
|
package/src/core/env.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const ENV_NAMES = {
|
|
|
19
19
|
deepSeekApiKey: "HOUNDSMAN_DEEP_SEEK_API_KEY",
|
|
20
20
|
openAiApiKey: "HOUNDSMAN_OPENAI_API_KEY",
|
|
21
21
|
anthropicApiKey: "HOUNDSMAN_ANTHROPIC_API_KEY",
|
|
22
|
+
makiPath: "HOUNDSMAN_MAKI_PATH",
|
|
23
|
+
makiVersion: "HOUNDSMAN_MAKI_VERSION",
|
|
22
24
|
home: "HOUNDSMAN_HOME",
|
|
23
25
|
localAppData: "LOCALAPPDATA",
|
|
24
26
|
xdgDataHome: "XDG_DATA_HOME",
|
|
@@ -33,6 +35,10 @@ export type HoundsmanEnv = {
|
|
|
33
35
|
deepSeekApiKey?: string;
|
|
34
36
|
openAiApiKey?: string;
|
|
35
37
|
anthropicApiKey?: string;
|
|
38
|
+
/** Caminho explícito do binário do maki (harness local `hunt`). */
|
|
39
|
+
makiPath?: string;
|
|
40
|
+
/** Versão do maki a instalar (default: a fixada pelo pacote). */
|
|
41
|
+
makiVersion?: string;
|
|
36
42
|
home?: string;
|
|
37
43
|
localAppData?: string;
|
|
38
44
|
xdgDataHome?: string;
|
|
@@ -54,6 +60,8 @@ export function readEnv(): HoundsmanEnv {
|
|
|
54
60
|
deepSeekApiKey: read(ENV_NAMES.deepSeekApiKey),
|
|
55
61
|
openAiApiKey: read(ENV_NAMES.openAiApiKey),
|
|
56
62
|
anthropicApiKey: read(ENV_NAMES.anthropicApiKey),
|
|
63
|
+
makiPath: read(ENV_NAMES.makiPath),
|
|
64
|
+
makiVersion: read(ENV_NAMES.makiVersion),
|
|
57
65
|
home: read(ENV_NAMES.home),
|
|
58
66
|
localAppData: read(ENV_NAMES.localAppData),
|
|
59
67
|
xdgDataHome: read(ENV_NAMES.xdgDataHome),
|
|
@@ -80,6 +88,12 @@ export const env = {
|
|
|
80
88
|
get anthropicApiKey(): string | undefined {
|
|
81
89
|
return read(ENV_NAMES.anthropicApiKey);
|
|
82
90
|
},
|
|
91
|
+
get makiPath(): string | undefined {
|
|
92
|
+
return read(ENV_NAMES.makiPath);
|
|
93
|
+
},
|
|
94
|
+
get makiVersion(): string | undefined {
|
|
95
|
+
return read(ENV_NAMES.makiVersion);
|
|
96
|
+
},
|
|
83
97
|
get home(): string | undefined {
|
|
84
98
|
return read(ENV_NAMES.home);
|
|
85
99
|
},
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orquestração do harness local (`houndsman hunt`).
|
|
3
|
+
*
|
|
4
|
+
* Recebe os briefs já preparados e despacha um processo maki por watchdog. O
|
|
5
|
+
* watchdog devolve o JSON na resposta final; validamos com o mesmo parser do
|
|
6
|
+
* `--finalize` e gravamos em `findings/<id>.json`. Falhas são reportadas por
|
|
7
|
+
* watchdog — a política de finalizar ou não fica com o chamador (CLI).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { parseWatchdogReport } from "../report-builder";
|
|
12
|
+
import { systemClock } from "../services/clock";
|
|
13
|
+
import type { ReviewWorkArea } from "../storage/work-area";
|
|
14
|
+
import { renderHuntPrompt, renderRetrySuffix } from "./maki-prompt";
|
|
15
|
+
import {
|
|
16
|
+
extractJsonObject,
|
|
17
|
+
type MakiProcessRunner,
|
|
18
|
+
type MakiRunMetrics,
|
|
19
|
+
type MakiUsage,
|
|
20
|
+
} from "./maki-runner";
|
|
21
|
+
import { mapPool } from "./pool";
|
|
22
|
+
|
|
23
|
+
/** Evento de progresso (CLI loga; core não escreve saída). */
|
|
24
|
+
export type HuntEvent =
|
|
25
|
+
| { type: "start"; watchdogId: string }
|
|
26
|
+
| {
|
|
27
|
+
type: "retry";
|
|
28
|
+
watchdogId: string;
|
|
29
|
+
attempt: number;
|
|
30
|
+
maxAttempts: number;
|
|
31
|
+
error: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
type: "done";
|
|
35
|
+
watchdogId: string;
|
|
36
|
+
findings: number;
|
|
37
|
+
reviewedFiles: number;
|
|
38
|
+
attempts: number;
|
|
39
|
+
metrics: MakiRunMetrics;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
type: "failed";
|
|
43
|
+
watchdogId: string;
|
|
44
|
+
error: string;
|
|
45
|
+
attempts: number;
|
|
46
|
+
metrics: MakiRunMetrics;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Resultado de um watchdog. */
|
|
50
|
+
export type HuntWatchdogResult = {
|
|
51
|
+
watchdogId: string;
|
|
52
|
+
status: "done" | "failed";
|
|
53
|
+
findings: number;
|
|
54
|
+
reviewedFiles: number;
|
|
55
|
+
attempts: number;
|
|
56
|
+
durationMs: number;
|
|
57
|
+
sessionId?: string;
|
|
58
|
+
numTurns?: number;
|
|
59
|
+
costUsd?: number;
|
|
60
|
+
usage?: MakiUsage;
|
|
61
|
+
error?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Agregados do relatório. */
|
|
65
|
+
export type HuntTotals = {
|
|
66
|
+
watchdogs: number;
|
|
67
|
+
done: number;
|
|
68
|
+
failed: number;
|
|
69
|
+
findings: number;
|
|
70
|
+
costUsd: number;
|
|
71
|
+
inputTokens: number;
|
|
72
|
+
outputTokens: number;
|
|
73
|
+
totalTokens: number;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Relatório persistido como `hunt-report.json`. */
|
|
77
|
+
export type HuntReport = {
|
|
78
|
+
version: 1;
|
|
79
|
+
engine: "maki";
|
|
80
|
+
model: string;
|
|
81
|
+
startedAt: string;
|
|
82
|
+
finishedAt: string;
|
|
83
|
+
durationMs: number;
|
|
84
|
+
watchdogs: HuntWatchdogResult[];
|
|
85
|
+
totals: HuntTotals;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Dependências do runner. */
|
|
89
|
+
export type HuntRunnerOptions = {
|
|
90
|
+
/** Staging da review (briefs/findings). */
|
|
91
|
+
work: ReviewWorkArea;
|
|
92
|
+
/** Raiz de leitura dos arquivos da review. */
|
|
93
|
+
root: string;
|
|
94
|
+
projectName: string;
|
|
95
|
+
/** Spec `provider/model-id`. */
|
|
96
|
+
model: string;
|
|
97
|
+
apiKeyEnv: string;
|
|
98
|
+
apiKey: string;
|
|
99
|
+
concurrency: number;
|
|
100
|
+
timeoutMs: number;
|
|
101
|
+
retries: number;
|
|
102
|
+
runner: MakiProcessRunner;
|
|
103
|
+
onEvent?: (event: HuntEvent) => void;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Despacha os briefs via maki e devolve o relatório consolidado. */
|
|
107
|
+
export class HuntRunner {
|
|
108
|
+
private readonly options: HuntRunnerOptions;
|
|
109
|
+
|
|
110
|
+
constructor(options: HuntRunnerOptions) {
|
|
111
|
+
this.options = options;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async run(activeIds: readonly string[]): Promise<HuntReport> {
|
|
115
|
+
const startedAt = systemClock();
|
|
116
|
+
const startedMs = Date.now();
|
|
117
|
+
const watchdogs = await mapPool(activeIds, this.options.concurrency, (id) =>
|
|
118
|
+
this.runOne(id),
|
|
119
|
+
);
|
|
120
|
+
const finishedAt = systemClock();
|
|
121
|
+
return {
|
|
122
|
+
version: 1,
|
|
123
|
+
engine: "maki",
|
|
124
|
+
model: this.options.model,
|
|
125
|
+
startedAt,
|
|
126
|
+
finishedAt,
|
|
127
|
+
durationMs: Date.now() - startedMs,
|
|
128
|
+
watchdogs,
|
|
129
|
+
totals: totalsOf(watchdogs),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async runOne(watchdogId: string): Promise<HuntWatchdogResult> {
|
|
134
|
+
const briefPath = this.options.work.briefFile(watchdogId);
|
|
135
|
+
let brief: string;
|
|
136
|
+
try {
|
|
137
|
+
brief = readFileSync(briefPath, "utf8");
|
|
138
|
+
} catch {
|
|
139
|
+
const error = `Brief ausente: ${briefPath}`;
|
|
140
|
+
this.emit({
|
|
141
|
+
type: "failed",
|
|
142
|
+
watchdogId,
|
|
143
|
+
error,
|
|
144
|
+
attempts: 0,
|
|
145
|
+
metrics: { durationMs: 0 },
|
|
146
|
+
});
|
|
147
|
+
return failedResult(watchdogId, error, 0, { durationMs: 0 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const basePrompt = renderHuntPrompt({
|
|
151
|
+
projectName: this.options.projectName,
|
|
152
|
+
watchdogId,
|
|
153
|
+
brief,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.emit({ type: "start", watchdogId });
|
|
157
|
+
let attempts = 0;
|
|
158
|
+
let lastError = "";
|
|
159
|
+
const accumulated: MakiRunMetrics = { durationMs: 0 };
|
|
160
|
+
|
|
161
|
+
while (attempts <= this.options.retries) {
|
|
162
|
+
attempts++;
|
|
163
|
+
const prompt =
|
|
164
|
+
attempts === 1
|
|
165
|
+
? basePrompt
|
|
166
|
+
: `${basePrompt}\n${renderRetrySuffix(lastError)}`;
|
|
167
|
+
const result = await this.options.runner.run({
|
|
168
|
+
prompt,
|
|
169
|
+
cwd: this.options.root,
|
|
170
|
+
model: this.options.model,
|
|
171
|
+
apiKeyEnv: this.options.apiKeyEnv,
|
|
172
|
+
apiKey: this.options.apiKey,
|
|
173
|
+
timeoutMs: this.options.timeoutMs,
|
|
174
|
+
});
|
|
175
|
+
mergeMetrics(accumulated, result.metrics);
|
|
176
|
+
|
|
177
|
+
if (!result.ok) {
|
|
178
|
+
lastError = result.error;
|
|
179
|
+
this.emitRetry(watchdogId, attempts, lastError);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const raw = extractJsonObject(result.text);
|
|
184
|
+
if (raw === null) {
|
|
185
|
+
lastError = "a resposta final não contém um objeto JSON.";
|
|
186
|
+
this.emitRetry(watchdogId, attempts, lastError);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const warnings: string[] = [];
|
|
191
|
+
const report = parseWatchdogReport(raw, `maki:${watchdogId}`, (message) =>
|
|
192
|
+
warnings.push(message),
|
|
193
|
+
);
|
|
194
|
+
if (!report) {
|
|
195
|
+
lastError = warnings[0] ?? "JSON fora do schema do watchdog.";
|
|
196
|
+
this.emitRetry(watchdogId, attempts, lastError);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (report.watchdogId !== watchdogId) {
|
|
200
|
+
lastError = `watchdogId '${report.watchdogId}' não corresponde a '${watchdogId}'.`;
|
|
201
|
+
this.emitRetry(watchdogId, attempts, lastError);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
this.options.work.writeFinding(
|
|
206
|
+
watchdogId,
|
|
207
|
+
`${JSON.stringify(report, null, 2)}\n`,
|
|
208
|
+
);
|
|
209
|
+
const done: HuntWatchdogResult = {
|
|
210
|
+
watchdogId,
|
|
211
|
+
status: "done",
|
|
212
|
+
findings: report.findings.length,
|
|
213
|
+
reviewedFiles: report.reviewedFiles.length,
|
|
214
|
+
attempts,
|
|
215
|
+
durationMs: accumulated.durationMs,
|
|
216
|
+
sessionId: accumulated.sessionId,
|
|
217
|
+
numTurns: accumulated.numTurns,
|
|
218
|
+
costUsd: accumulated.costUsd,
|
|
219
|
+
usage: accumulated.usage,
|
|
220
|
+
};
|
|
221
|
+
this.emit({
|
|
222
|
+
type: "done",
|
|
223
|
+
watchdogId,
|
|
224
|
+
findings: done.findings,
|
|
225
|
+
reviewedFiles: done.reviewedFiles,
|
|
226
|
+
attempts,
|
|
227
|
+
metrics: accumulated,
|
|
228
|
+
});
|
|
229
|
+
return done;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const error = lastError || "falha desconhecida.";
|
|
233
|
+
this.emit({ type: "failed", watchdogId, error, attempts, metrics: accumulated });
|
|
234
|
+
return failedResult(watchdogId, error, attempts, accumulated);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private emit(event: HuntEvent): void {
|
|
238
|
+
try {
|
|
239
|
+
this.options.onEvent?.(event);
|
|
240
|
+
} catch {
|
|
241
|
+
// Progresso nunca derruba a revisão.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Avisa a próxima tentativa (só quando ainda há retry). */
|
|
246
|
+
private emitRetry(watchdogId: string, attempt: number, error: string): void {
|
|
247
|
+
if (attempt > this.options.retries) return;
|
|
248
|
+
this.emit({
|
|
249
|
+
type: "retry",
|
|
250
|
+
watchdogId,
|
|
251
|
+
attempt,
|
|
252
|
+
maxAttempts: this.options.retries + 1,
|
|
253
|
+
error,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Soma métricas de várias tentativas no acumulado. */
|
|
259
|
+
function mergeMetrics(target: MakiRunMetrics, source: MakiRunMetrics): void {
|
|
260
|
+
target.durationMs += source.durationMs;
|
|
261
|
+
target.sessionId = source.sessionId ?? target.sessionId;
|
|
262
|
+
if (source.numTurns !== undefined) {
|
|
263
|
+
target.numTurns = (target.numTurns ?? 0) + source.numTurns;
|
|
264
|
+
}
|
|
265
|
+
if (source.costUsd !== undefined) {
|
|
266
|
+
target.costUsd = (target.costUsd ?? 0) + source.costUsd;
|
|
267
|
+
}
|
|
268
|
+
if (source.usage) {
|
|
269
|
+
target.usage = {
|
|
270
|
+
inputTokens:
|
|
271
|
+
(target.usage?.inputTokens ?? 0) + (source.usage.inputTokens ?? 0),
|
|
272
|
+
outputTokens:
|
|
273
|
+
(target.usage?.outputTokens ?? 0) + (source.usage.outputTokens ?? 0),
|
|
274
|
+
totalTokens:
|
|
275
|
+
(target.usage?.totalTokens ?? 0) + (source.usage.totalTokens ?? 0),
|
|
276
|
+
cacheReadInputTokens:
|
|
277
|
+
(target.usage?.cacheReadInputTokens ?? 0) +
|
|
278
|
+
(source.usage.cacheReadInputTokens ?? 0),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Resultado de falha de um watchdog. */
|
|
284
|
+
function failedResult(
|
|
285
|
+
watchdogId: string,
|
|
286
|
+
error: string,
|
|
287
|
+
attempts: number,
|
|
288
|
+
metrics: MakiRunMetrics,
|
|
289
|
+
): HuntWatchdogResult {
|
|
290
|
+
return {
|
|
291
|
+
watchdogId,
|
|
292
|
+
status: "failed",
|
|
293
|
+
findings: 0,
|
|
294
|
+
reviewedFiles: 0,
|
|
295
|
+
attempts,
|
|
296
|
+
durationMs: metrics.durationMs,
|
|
297
|
+
sessionId: metrics.sessionId,
|
|
298
|
+
numTurns: metrics.numTurns,
|
|
299
|
+
costUsd: metrics.costUsd,
|
|
300
|
+
usage: metrics.usage,
|
|
301
|
+
error,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Agrega os resultados por watchdog. */
|
|
306
|
+
function totalsOf(watchdogs: readonly HuntWatchdogResult[]): HuntTotals {
|
|
307
|
+
const totals: HuntTotals = {
|
|
308
|
+
watchdogs: watchdogs.length,
|
|
309
|
+
done: 0,
|
|
310
|
+
failed: 0,
|
|
311
|
+
findings: 0,
|
|
312
|
+
costUsd: 0,
|
|
313
|
+
inputTokens: 0,
|
|
314
|
+
outputTokens: 0,
|
|
315
|
+
totalTokens: 0,
|
|
316
|
+
};
|
|
317
|
+
for (const watchdog of watchdogs) {
|
|
318
|
+
if (watchdog.status === "done") totals.done++;
|
|
319
|
+
else totals.failed++;
|
|
320
|
+
totals.findings += watchdog.findings;
|
|
321
|
+
totals.costUsd += watchdog.costUsd ?? 0;
|
|
322
|
+
totals.inputTokens += watchdog.usage?.inputTokens ?? 0;
|
|
323
|
+
totals.outputTokens += watchdog.usage?.outputTokens ?? 0;
|
|
324
|
+
totals.totalTokens += watchdog.usage?.totalTokens ?? 0;
|
|
325
|
+
}
|
|
326
|
+
totals.costUsd = Number(totals.costUsd.toFixed(6));
|
|
327
|
+
return totals;
|
|
328
|
+
}
|