@payconductor/houndsman 0.5.0 → 0.6.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 +24 -4
- package/assets/grammars/tree-sitter-javascript.wasm +0 -0
- package/assets/grammars/tree-sitter-tsx.wasm +0 -0
- package/assets/grammars/tree-sitter-typescript.wasm +0 -0
- package/assets/grammars/web-tree-sitter.wasm +0 -0
- package/package.json +11 -3
- package/src/cli/commands/command.ts +5 -0
- package/src/cli/commands/doctor-command.ts +116 -24
- package/src/cli/commands/hunt-command.ts +25 -0
- package/src/cli/help.ts +17 -1
- package/src/cli/index.ts +10 -6
- package/src/core/ai/ai-client.ts +221 -0
- package/src/core/ai/ai-sdk-model-provider.ts +101 -0
- package/src/core/ai/errors.ts +36 -0
- package/src/core/ai/features/brief-enricher.ts +62 -0
- package/src/core/ai/features/classifier.ts +50 -0
- package/src/core/ai/features/context-summarizer.ts +31 -0
- package/src/core/ai/features/pre-analyzer.ts +51 -0
- package/src/core/ai/features/verifier.ts +45 -0
- package/src/core/ai/index.ts +56 -0
- package/src/core/ai/model-factory.ts +137 -0
- package/src/core/ai/prompts/base.ts +36 -0
- package/src/core/ai/prompts/brief.ts +87 -0
- package/src/core/ai/prompts/classification.ts +36 -0
- package/src/core/ai/prompts/context.ts +29 -0
- package/src/core/ai/prompts/pre-analysis.ts +49 -0
- package/src/core/ai/prompts/verify.ts +40 -0
- package/src/core/ai/schemas.ts +110 -0
- package/src/core/ai/types.ts +76 -0
- package/src/core/ai/usage-budget.ts +75 -0
- package/src/core/ai/usage-recorder.ts +79 -0
- package/src/core/assets.ts +16 -0
- package/src/core/ast/diff.ts +220 -0
- package/src/core/ast/impact.ts +127 -0
- package/src/core/ast/parser.ts +167 -0
- package/src/core/ast/service.ts +233 -0
- package/src/core/ast/symbols.ts +294 -0
- package/src/core/ast/types.ts +125 -0
- package/src/core/brief.ts +138 -0
- package/src/core/commands/findings.ts +59 -1
- package/src/core/commands/review.ts +2 -0
- package/src/core/commands/scaffold.ts +159 -10
- package/src/core/commands/update.ts +5 -3
- package/src/core/config.ts +85 -0
- package/src/core/defaults.ts +27 -0
- package/src/core/domain/watchdog.ts +43 -5
- package/src/core/engine/houndsman-engine.ts +16 -1
- package/src/core/engine/review-scope.ts +52 -6
- package/src/core/engine/review-session.ts +27 -2
- package/src/core/env.ts +101 -0
- package/src/core/file-classifier.ts +25 -4
- package/src/core/jev/jev-client.ts +8 -7
- package/src/core/jev/jev-verify.ts +2 -1
- package/src/core/logging/index.ts +17 -0
- package/src/core/logging/logger.ts +187 -0
- package/src/core/orchestrator.ts +357 -9
- package/src/core/project-config.ts +123 -1
- package/src/core/registry-check.ts +148 -6
- package/src/core/renderers/manifest-writer.ts +33 -0
- package/src/core/rule-text.ts +97 -0
- package/src/core/services/file-classifier.ts +10 -3
- package/src/core/services/verifier.ts +14 -3
- package/src/core/storage/paths.ts +6 -5
- package/src/core/storage/types.ts +6 -0
- package/src/core/storage/work-area.ts +12 -0
- package/src/core/types.ts +37 -1
- package/src/core/verify.ts +17 -5
- package/src/harness/README.md +15 -0
- package/src/harness/agent/agent-context.ts +33 -0
- package/src/harness/agent/agent-errors.ts +30 -0
- package/src/harness/agent/agent-factory.ts +45 -0
- package/src/harness/agent/agent-reporter.ts +34 -0
- package/src/harness/agent/agent-result.ts +16 -0
- package/src/harness/agent/agent-runner.ts +32 -0
- package/src/harness/agent/agent-scheduler.ts +37 -0
- package/src/harness/agent/review-agent.ts +391 -0
- package/src/harness/config/harness-config.ts +84 -0
- package/src/harness/hunt/hunt-harness.ts +83 -0
- package/src/harness/hunt/hunt-options.ts +75 -0
- package/src/harness/hunt/hunt-result.ts +17 -0
- package/src/harness/hunt/hunt-session.ts +372 -0
- package/src/harness/hunt/review-planner.ts +98 -0
- package/src/harness/index.ts +36 -0
- package/src/harness/investigation/agent-state.ts +134 -0
- package/src/harness/investigation/evidence-verifier.ts +129 -0
- package/src/harness/investigation/evidence.ts +110 -0
- package/src/harness/investigation/finding-critic.ts +96 -0
- package/src/harness/investigation/investigation-planner.ts +118 -0
- package/src/harness/investigation/investigation-task.ts +10 -0
- package/src/harness/investigation/review-journal.ts +87 -0
- package/src/harness/investigation/rule-analyzer.ts +178 -0
- package/src/harness/investigation/rule-signals.ts +110 -0
- package/src/harness/prompts/agent-schemas.ts +57 -0
- package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
- package/src/harness/prompts/states/conclude-prompt.ts +16 -0
- package/src/harness/prompts/states/investigate-prompt.ts +37 -0
- package/src/harness/prompts/states/orient-prompt.ts +15 -0
- package/src/harness/prompts/states/verify-prompt.ts +22 -0
- package/src/harness/tools/agent-tool.ts +102 -0
- package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
- package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
- package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
- package/src/harness/tools/builtin/find-references-tool.ts +66 -0
- package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
- package/src/harness/tools/builtin/helpers.ts +115 -0
- package/src/harness/tools/builtin/impact-tool.ts +48 -0
- package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
- package/src/harness/tools/builtin/index.ts +59 -0
- package/src/harness/tools/builtin/list-files-tool.ts +85 -0
- package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
- package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
- package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
- package/src/harness/tools/builtin/search-code-tool.ts +65 -0
- package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
- package/src/harness/tools/path-guard.ts +90 -0
- package/src/harness/tools/result-limiter.ts +49 -0
- package/src/harness/tools/secret-redactor.ts +54 -0
- package/src/harness/tools/tool-cache.ts +50 -0
- package/src/harness/tools/tool-call-budget.ts +60 -0
- package/src/harness/tools/tool-context.ts +5 -0
- package/src/harness/tools/tool-middleware.ts +105 -0
- package/src/harness/tools/tool-policy.ts +51 -0
- package/src/harness/tools/tool-registry.ts +112 -0
- package/src/harness/tools/tool-result.ts +15 -0
- package/src/harness/tools/toolbox.ts +36 -0
- package/src/harness/validation/finding-validator.ts +119 -0
- package/src/index.ts +46 -0
- package/src/server/client/utils/constants.ts +27 -5
- package/src/server/client/utils/types.ts +6 -0
- package/src/server/index.ts +3 -2
- package/src/skill/OUTPUT-FORMAT.md +12 -0
- package/src/skill/SETUP.md +6 -2
- package/src/skill/hunt.md +14 -2
- package/src/skill/verify.md +1 -1
- package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
- package/src/tests/ai-core.test.ts +120 -0
- package/src/tests/ai-features.test.ts +183 -0
- package/src/tests/ast.test.ts +335 -0
- package/src/tests/env.test.ts +69 -0
- package/src/tests/fallback.test.ts +172 -0
- package/src/tests/fixtures/fake-agent-tools.ts +69 -0
- package/src/tests/fixtures/fake-investigation.ts +64 -0
- package/src/tests/fixtures/fake-model-provider.ts +48 -0
- package/src/tests/harness-agent.test.ts +122 -0
- package/src/tests/harness-investigation.test.ts +206 -0
- package/src/tests/harness-tools.test.ts +148 -0
- package/src/tests/hunt-command.test.ts +212 -0
- package/src/tests/logger.test.ts +83 -0
- package/src/tests/review-scope.test.ts +11 -11
- package/src/tests/rule-analyzer.test.ts +61 -0
- package/src/tests/tool-contracts.test.ts +82 -0
- package/src/tests/verify.test.ts +1 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Máquina de estados do revisor (`ReviewAgent`).
|
|
3
|
+
*
|
|
4
|
+
* ORIENT → PLAN → INVESTIGATE → VERIFY → CONCLUDE. O modelo escolhe *como*
|
|
5
|
+
* investigar dentro de INVESTIGATE; ele **não** pode pular VERIFY nem emitir
|
|
6
|
+
* findings sem evidência.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { type AiUsage, addUsage, emptyUsage, type ModelProvider } from "../../core/ai";
|
|
12
|
+
import type { AstService } from "../../core/ast/service";
|
|
13
|
+
import type { WatchdogReport } from "../../core/report-builder";
|
|
14
|
+
import type { Severity } from "../../core/types";
|
|
15
|
+
import { AgentState } from "../investigation/agent-state";
|
|
16
|
+
import type { FindingDraft } from "../investigation/evidence";
|
|
17
|
+
import { EvidenceVerifier } from "../investigation/evidence-verifier";
|
|
18
|
+
import { downgrade, FindingCritic } from "../investigation/finding-critic";
|
|
19
|
+
import { InvestigationPlanner } from "../investigation/investigation-planner";
|
|
20
|
+
import {
|
|
21
|
+
InvestigateOutputSchema,
|
|
22
|
+
OrientOutputSchema,
|
|
23
|
+
PlanOutputSchema,
|
|
24
|
+
} from "../prompts/agent-schemas";
|
|
25
|
+
import {
|
|
26
|
+
buildReviewerSystem,
|
|
27
|
+
buildReviewerUser,
|
|
28
|
+
} from "../prompts/reviewer-prompt-builder";
|
|
29
|
+
import { investigatePrompt } from "../prompts/states/investigate-prompt";
|
|
30
|
+
import { orientPrompt } from "../prompts/states/orient-prompt";
|
|
31
|
+
import { ToolCache } from "../tools/tool-cache";
|
|
32
|
+
import { ToolCallBudget } from "../tools/tool-call-budget";
|
|
33
|
+
import type { ToolContext } from "../tools/tool-context";
|
|
34
|
+
import { ToolMiddleware } from "../tools/tool-middleware";
|
|
35
|
+
import { type AgentStateName, ToolPolicy } from "../tools/tool-policy";
|
|
36
|
+
import type { ToolRegistry } from "../tools/tool-registry";
|
|
37
|
+
import { Toolbox } from "../tools/toolbox";
|
|
38
|
+
import { FindingValidator } from "../validation/finding-validator";
|
|
39
|
+
import type { AgentContext } from "./agent-context";
|
|
40
|
+
import type { AgentReporter } from "./agent-reporter";
|
|
41
|
+
import type { AgentResult } from "./agent-result";
|
|
42
|
+
|
|
43
|
+
/** Dependências do agente. */
|
|
44
|
+
export type ReviewAgentDeps = {
|
|
45
|
+
context: AgentContext;
|
|
46
|
+
provider: ModelProvider;
|
|
47
|
+
registry: ToolRegistry;
|
|
48
|
+
policy?: ToolPolicy;
|
|
49
|
+
reporter: AgentReporter;
|
|
50
|
+
maxOutputTokens: number;
|
|
51
|
+
temperature: number;
|
|
52
|
+
timeoutMs: number;
|
|
53
|
+
ast: AstService;
|
|
54
|
+
readFile?: (path: string) => string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Resultado final de um agente com o estado (para transcript). */
|
|
58
|
+
export type ReviewAgentOutcome = AgentResult & { state: AgentState };
|
|
59
|
+
|
|
60
|
+
/** Agente de um watchdog. */
|
|
61
|
+
export class ReviewAgent {
|
|
62
|
+
private readonly deps: ReviewAgentDeps;
|
|
63
|
+
private readonly state = new AgentState();
|
|
64
|
+
private usage: AiUsage = emptyUsage();
|
|
65
|
+
private readonly toolbox: Toolbox;
|
|
66
|
+
private readonly toolContext: ToolContext;
|
|
67
|
+
|
|
68
|
+
constructor(deps: ReviewAgentDeps) {
|
|
69
|
+
this.deps = deps;
|
|
70
|
+
const toolBudget = new ToolCallBudget(
|
|
71
|
+
deps.context.maxCalls,
|
|
72
|
+
Math.max(4, Math.ceil(deps.context.maxCalls / 2)),
|
|
73
|
+
);
|
|
74
|
+
const middleware = new ToolMiddleware();
|
|
75
|
+
const policy = deps.policy ?? new ToolPolicy();
|
|
76
|
+
this.toolbox = new Toolbox(deps.registry, policy, middleware);
|
|
77
|
+
this.toolContext = {
|
|
78
|
+
repoRoot: deps.context.repoRoot,
|
|
79
|
+
watchdog: deps.context.watchdog,
|
|
80
|
+
diff: deps.context.diff,
|
|
81
|
+
ast: deps.ast,
|
|
82
|
+
state: this.state,
|
|
83
|
+
budget: toolBudget,
|
|
84
|
+
cache: new ToolCache(),
|
|
85
|
+
logger: deps.reporter,
|
|
86
|
+
agent: deps.context,
|
|
87
|
+
rules: new Map(
|
|
88
|
+
deps.context.rules.map((analysis) => [analysis.rule, analysis]),
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Executa a investigação completa. */
|
|
94
|
+
async run(): Promise<ReviewAgentOutcome> {
|
|
95
|
+
const { context, reporter } = this.deps;
|
|
96
|
+
const plan = new InvestigationPlanner().plan(
|
|
97
|
+
{
|
|
98
|
+
watchdogId: context.watchdog.id,
|
|
99
|
+
files: context.files,
|
|
100
|
+
symbols: symbolsOf(context),
|
|
101
|
+
impacted: context.impact?.affectedFiles,
|
|
102
|
+
},
|
|
103
|
+
context.rules,
|
|
104
|
+
);
|
|
105
|
+
this.state.setTasks(plan.tasks);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await this.orient();
|
|
109
|
+
this.transition("ORIENT", "PLAN");
|
|
110
|
+
await this.plan();
|
|
111
|
+
this.transition("PLAN", "INVESTIGATE");
|
|
112
|
+
await this.investigate();
|
|
113
|
+
this.transition("INVESTIGATE", "VERIFY");
|
|
114
|
+
const kept = this.verify();
|
|
115
|
+
this.transition("VERIFY", "CONCLUDE");
|
|
116
|
+
const report = this.conclude(kept);
|
|
117
|
+
reporter.progress(
|
|
118
|
+
`[${context.watchdog.id}] concluído (${report.findings.length} achado(s)).`,
|
|
119
|
+
);
|
|
120
|
+
return {
|
|
121
|
+
watchdogId: context.watchdog.id,
|
|
122
|
+
status: "done",
|
|
123
|
+
report,
|
|
124
|
+
usage: this.usage,
|
|
125
|
+
state: this.state,
|
|
126
|
+
};
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
129
|
+
reporter.journal.note(`falha: ${message}`);
|
|
130
|
+
return {
|
|
131
|
+
watchdogId: context.watchdog.id,
|
|
132
|
+
status: "failed",
|
|
133
|
+
error: message,
|
|
134
|
+
usage: this.usage,
|
|
135
|
+
state: this.state,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** ORIENT — hipóteses iniciais (falha não derruba a investigação). */
|
|
141
|
+
private async orient(): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
const output = await this.callModel(
|
|
144
|
+
"harness-orient",
|
|
145
|
+
OrientOutputSchema,
|
|
146
|
+
orientPrompt(this.deps.context),
|
|
147
|
+
1,
|
|
148
|
+
);
|
|
149
|
+
for (const hypothesis of output.hypotheses)
|
|
150
|
+
this.state.addHypothesis(hypothesis);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.deps.reporter.journal.note(
|
|
153
|
+
`orient falhou (segue): ${error instanceof Error ? error.message : String(error)}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** PLAN — seleciona tarefas do plano determinístico. */
|
|
159
|
+
private async plan(): Promise<void> {
|
|
160
|
+
const pending = this.state.tasksByStatus("pending");
|
|
161
|
+
if (pending.length === 0) return;
|
|
162
|
+
try {
|
|
163
|
+
const output = await this.callModel(
|
|
164
|
+
"harness-plan",
|
|
165
|
+
PlanOutputSchema,
|
|
166
|
+
[
|
|
167
|
+
"Escolha as tarefas que serão investigadas (ids).",
|
|
168
|
+
...pending.map((task) => `- ${task.id}: ${task.rule}`),
|
|
169
|
+
].join("\n"),
|
|
170
|
+
1,
|
|
171
|
+
);
|
|
172
|
+
const selected = new Set(output.taskIds);
|
|
173
|
+
if (selected.size === 0) return;
|
|
174
|
+
for (const task of pending) {
|
|
175
|
+
if (!selected.has(task.id)) this.state.skipTask(task.id, "fora do plano");
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
this.deps.reporter.journal.note(
|
|
179
|
+
`plan falhou (segue com o plano determinístico): ${
|
|
180
|
+
error instanceof Error ? error.message : String(error)
|
|
181
|
+
}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** INVESTIGATE — tools + evidências + drafts. */
|
|
187
|
+
private async investigate(): Promise<void> {
|
|
188
|
+
const { context } = this.deps;
|
|
189
|
+
const tools = this.toolbox.toModelTools("INVESTIGATE", this.toolContext);
|
|
190
|
+
const system = buildReviewerSystem(context, this.toolbox.describe("INVESTIGATE"));
|
|
191
|
+
const prompt = [
|
|
192
|
+
buildReviewerUser(context),
|
|
193
|
+
"",
|
|
194
|
+
this.state.hypotheses.length > 0
|
|
195
|
+
? `## Hipóteses\n${this.state.hypotheses.map((item) => `- ${item}`).join("\n")}`
|
|
196
|
+
: "",
|
|
197
|
+
"",
|
|
198
|
+
investigatePrompt(context, this.state.tasksByStatus("pending")),
|
|
199
|
+
].join("\n");
|
|
200
|
+
|
|
201
|
+
const output = await this.callModel(
|
|
202
|
+
"harness-investigate",
|
|
203
|
+
InvestigateOutputSchema,
|
|
204
|
+
prompt,
|
|
205
|
+
context.maxSteps,
|
|
206
|
+
system,
|
|
207
|
+
tools,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
for (const draft of output.findingDrafts) this.state.addDraft(draft);
|
|
211
|
+
for (const coverage of output.coverage) {
|
|
212
|
+
this.state.setCoverage(coverage.rule, coverage.status, coverage.reason);
|
|
213
|
+
}
|
|
214
|
+
for (const task of this.state.tasksByStatus("pending")) {
|
|
215
|
+
const covered = this.state.isCovered(task.rule);
|
|
216
|
+
this.state.completeTask(
|
|
217
|
+
task.id,
|
|
218
|
+
covered ? "coberto" : "investigado sem cobertura",
|
|
219
|
+
);
|
|
220
|
+
if (!covered)
|
|
221
|
+
this.state.setCoverage(task.rule, "skipped", "sem cobertura do modelo");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** VERIFY — evidência obrigatória + crítica. */
|
|
226
|
+
private verify(): FindingDraft[] {
|
|
227
|
+
const { context } = this.deps;
|
|
228
|
+
const readFile = this.deps.readFile ?? this.defaultReader();
|
|
229
|
+
const allowed = new Set([
|
|
230
|
+
...context.files,
|
|
231
|
+
...(context.impact?.affectedFiles ?? []),
|
|
232
|
+
]);
|
|
233
|
+
const verifier = new EvidenceVerifier({ allowedFiles: allowed, readFile });
|
|
234
|
+
const critic = new FindingCritic({
|
|
235
|
+
knownRules: new Set(context.watchdog.rules.map((rule) => rule.text.trim())),
|
|
236
|
+
verifier,
|
|
237
|
+
allowEmergentFindings:
|
|
238
|
+
context.watchdog.definition.allowEmergentFindings === true,
|
|
239
|
+
});
|
|
240
|
+
const decisions = critic.review(this.state.drafts, this.state);
|
|
241
|
+
const kept: FindingDraft[] = [];
|
|
242
|
+
for (const decision of decisions) {
|
|
243
|
+
this.deps.reporter.journal.note(
|
|
244
|
+
`crítico: ${decision.verdict} ${decision.draft.file}:${decision.draft.line ?? "?"} — ${decision.reason}`,
|
|
245
|
+
);
|
|
246
|
+
if (decision.verdict === "drop") {
|
|
247
|
+
this.state.setCoverage(
|
|
248
|
+
decision.draft.rule,
|
|
249
|
+
"checked",
|
|
250
|
+
"refutado pelo crítico",
|
|
251
|
+
);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
kept.push(
|
|
255
|
+
decision.verdict === "downgrade"
|
|
256
|
+
? {
|
|
257
|
+
...decision.draft,
|
|
258
|
+
severity: downgrade(decision.draft.severity),
|
|
259
|
+
confidence: decision.draft.confidence,
|
|
260
|
+
}
|
|
261
|
+
: decision.draft,
|
|
262
|
+
);
|
|
263
|
+
this.state.setCoverage(decision.draft.rule, "checked", "confirmado");
|
|
264
|
+
}
|
|
265
|
+
return kept;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** CONCLUDE — relatório determinístico + cobertura honesta. */
|
|
269
|
+
private conclude(kept: readonly FindingDraft[]): WatchdogReport {
|
|
270
|
+
const { context } = this.deps;
|
|
271
|
+
for (const rule of context.watchdog.rules) {
|
|
272
|
+
if (!this.state.isCovered(rule.text)) {
|
|
273
|
+
this.state.setCoverage(rule.text, "skipped", "não investigada");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const evidenceFiles = new Set(
|
|
277
|
+
[...this.state.evidence.values()].map((item) => item.file),
|
|
278
|
+
);
|
|
279
|
+
const skipped = new Set(context.overflow.map((file) => `${file} (budget)`));
|
|
280
|
+
for (const coverage of this.state.coverage.values()) {
|
|
281
|
+
if (coverage.status === "skipped") {
|
|
282
|
+
skipped.add(`${coverage.rule} — ${coverage.reason ?? "skipped"}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const report: WatchdogReport = {
|
|
287
|
+
watchdogId: context.watchdog.id,
|
|
288
|
+
reviewedFiles: [...evidenceFiles].filter((file) =>
|
|
289
|
+
context.files.includes(file),
|
|
290
|
+
),
|
|
291
|
+
skipped: [...skipped],
|
|
292
|
+
findings: kept.map((draft) => ({
|
|
293
|
+
rule: draft.rule,
|
|
294
|
+
severity: draft.severity as Severity,
|
|
295
|
+
file: draft.file,
|
|
296
|
+
...(draft.line ? { line: draft.line } : {}),
|
|
297
|
+
message: draft.message,
|
|
298
|
+
action: draft.action,
|
|
299
|
+
evidence: this.evidenceLabel(draft),
|
|
300
|
+
})),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const validator = new FindingValidator({
|
|
304
|
+
watchdogId: context.watchdog.id,
|
|
305
|
+
knownRules: new Set(context.watchdog.rules.map((rule) => rule.text.trim())),
|
|
306
|
+
allowedFiles: new Set([
|
|
307
|
+
...context.files,
|
|
308
|
+
...(context.impact?.affectedFiles ?? []),
|
|
309
|
+
]),
|
|
310
|
+
overflow: new Set(context.overflow),
|
|
311
|
+
readFile: this.deps.readFile ?? this.defaultReader(),
|
|
312
|
+
allowEmergentFindings:
|
|
313
|
+
context.watchdog.definition.allowEmergentFindings === true,
|
|
314
|
+
});
|
|
315
|
+
const result = validator.validate(report);
|
|
316
|
+
for (const dropped of result.dropped) {
|
|
317
|
+
this.deps.reporter.journal.note(
|
|
318
|
+
`validator: descartado ${dropped.finding} — ${dropped.reason}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return result.report;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Chamada de modelo com uso e timeout. */
|
|
325
|
+
private async callModel<T>(
|
|
326
|
+
feature: string,
|
|
327
|
+
schema: import("zod").ZodType<T>,
|
|
328
|
+
prompt: string,
|
|
329
|
+
maxSteps: number,
|
|
330
|
+
instructions?: string,
|
|
331
|
+
tools?: Record<string, import("../../core/ai").AiTool>,
|
|
332
|
+
): Promise<T> {
|
|
333
|
+
const response = await this.deps.provider.generate<T>({
|
|
334
|
+
instructions,
|
|
335
|
+
prompt,
|
|
336
|
+
outputSchema: schema,
|
|
337
|
+
...(tools ? { tools } : {}),
|
|
338
|
+
maxSteps,
|
|
339
|
+
maxOutputTokens: this.deps.maxOutputTokens,
|
|
340
|
+
temperature: this.deps.temperature,
|
|
341
|
+
signal: AbortSignal.timeout(this.deps.timeoutMs),
|
|
342
|
+
});
|
|
343
|
+
this.usage = addUsage(this.usage, response.usage);
|
|
344
|
+
this.deps.reporter.journal.note(
|
|
345
|
+
`${feature}: ${response.usage.calls} chamada(s), ${response.usage.totalTokens} token(s)`,
|
|
346
|
+
);
|
|
347
|
+
return response.output;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Transição de estado registrada no journal. */
|
|
351
|
+
private transition(from: AgentStateName, to: AgentStateName): void {
|
|
352
|
+
this.deps.reporter.journal.transition(from, to);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Rótulo de evidência (`arquivo:linha`). */
|
|
356
|
+
private evidenceLabel(draft: FindingDraft): string {
|
|
357
|
+
const first = draft.evidenceIds
|
|
358
|
+
.map((id) => this.state.getEvidence(id))
|
|
359
|
+
.find((item) => item !== undefined);
|
|
360
|
+
if (!first) return draft.file;
|
|
361
|
+
return `${first.file}:${first.startLine}-${first.endLine}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Leitor padrão: relativo à raiz do repo. */
|
|
365
|
+
private defaultReader(): (path: string) => string | null {
|
|
366
|
+
const root = this.deps.context.repoRoot;
|
|
367
|
+
return (path: string) => {
|
|
368
|
+
try {
|
|
369
|
+
return readFileSync(join(root, path), "utf8");
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Símbolos citados nas regras (para o planner). */
|
|
378
|
+
function symbolsOf(context: AgentContext): string[] {
|
|
379
|
+
const symbols = new Set<string>();
|
|
380
|
+
for (const analysis of context.rules) {
|
|
381
|
+
for (const signal of analysis.signals) {
|
|
382
|
+
if (signal.kind === "symbol") symbols.add(signal.value);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
for (const analysis of context.ast.values()) {
|
|
386
|
+
for (const symbol of analysis.delta?.addedSymbols ?? []) symbols.add(symbol.name);
|
|
387
|
+
for (const symbol of analysis.delta?.modifiedSymbols ?? [])
|
|
388
|
+
symbols.add(symbol.name);
|
|
389
|
+
}
|
|
390
|
+
return [...symbols].slice(0, 30);
|
|
391
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuração resolvida do harness nativo.
|
|
3
|
+
*
|
|
4
|
+
* O harness **não** reimplementa defaults: consome o `ProjectConfig` do motor e
|
|
5
|
+
* aplica apenas overrides de execução (`hunt --model`, `--max-calls`, etc.).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AiProviderKind } from "../../core/config";
|
|
9
|
+
import { ProjectConfig } from "../../core/project-config";
|
|
10
|
+
|
|
11
|
+
/** Overrides de execução do harness. */
|
|
12
|
+
export type HarnessOverrides = {
|
|
13
|
+
provider?: AiProviderKind;
|
|
14
|
+
model?: string;
|
|
15
|
+
concurrency?: number;
|
|
16
|
+
maxSteps?: number;
|
|
17
|
+
maxCalls?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Configuração resolvida (imutável para o agente). */
|
|
21
|
+
export class HarnessConfig {
|
|
22
|
+
readonly project: ProjectConfig;
|
|
23
|
+
private readonly overrides: HarnessOverrides;
|
|
24
|
+
|
|
25
|
+
constructor(project: ProjectConfig, overrides: HarnessOverrides = {}) {
|
|
26
|
+
this.project = project;
|
|
27
|
+
this.overrides = overrides;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `ProjectConfig` com os overrides aplicados (usado pelo `AiClient`). */
|
|
31
|
+
withOverrides(): ProjectConfig {
|
|
32
|
+
const provider = this.overrides.provider ?? this.project.aiProvider;
|
|
33
|
+
const model = this.overrides.model?.trim() || this.project.aiModel;
|
|
34
|
+
return new ProjectConfig({
|
|
35
|
+
...this.project.raw,
|
|
36
|
+
ai: { ...this.project.raw.ai, provider, model },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get provider(): AiProviderKind {
|
|
41
|
+
return this.overrides.provider ?? this.project.aiProvider;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get model(): string {
|
|
45
|
+
return this.overrides.model?.trim() || this.project.aiModel;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
modelFor(watchdogId: string): string {
|
|
49
|
+
return this.overrides.model?.trim() || this.project.aiModelFor(watchdogId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
maxStepsFor(watchdogId: string): number {
|
|
53
|
+
return positive(this.overrides.maxSteps, this.project.aiMaxStepsFor(watchdogId));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
maxOutputTokensFor(watchdogId: string): number {
|
|
57
|
+
return this.project.aiMaxOutputTokensFor(watchdogId);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get concurrency(): number {
|
|
61
|
+
return positive(this.overrides.concurrency, this.project.aiConcurrency);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get maxCalls(): number {
|
|
65
|
+
return positive(this.overrides.maxCalls, this.project.aiMaxCallsPerReview);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get maxTokens(): number | undefined {
|
|
69
|
+
return this.project.aiMaxTokensPerReview;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get timeoutMs(): number {
|
|
73
|
+
return this.project.aiTimeoutMs;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get temperature(): number {
|
|
77
|
+
return this.project.aiTemperature;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Inteiro positivo ou o default. */
|
|
82
|
+
function positive(value: number | undefined, fallback: number): number {
|
|
83
|
+
return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
84
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition root do harness nativo (`houndsman hunt`).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { AiUsageRecorder, ModelFactory, type ModelProvider } from "../../core/ai";
|
|
6
|
+
import { AiConfigurationError } from "../../core/ai/errors";
|
|
7
|
+
import type { HoundsmanEngine } from "../../core/engine/houndsman-engine";
|
|
8
|
+
import { logger as defaultLogger, type LoggerLike } from "../../core/logging";
|
|
9
|
+
import { type AgentReporter, LoggerReporter } from "../agent/agent-reporter";
|
|
10
|
+
import { HarnessConfig } from "../config/harness-config";
|
|
11
|
+
import type { HuntOptions } from "./hunt-options";
|
|
12
|
+
import type { HuntResult } from "./hunt-result";
|
|
13
|
+
import { HuntSession } from "./hunt-session";
|
|
14
|
+
|
|
15
|
+
/** Orquestrador do hunt nativo. */
|
|
16
|
+
export class HuntHarness {
|
|
17
|
+
private constructor(
|
|
18
|
+
private readonly engine: HoundsmanEngine,
|
|
19
|
+
private readonly options: HuntOptions,
|
|
20
|
+
private readonly reporter: AgentReporter,
|
|
21
|
+
private readonly logger: LoggerLike,
|
|
22
|
+
private readonly config: HarnessConfig,
|
|
23
|
+
private readonly provider?: ModelProvider,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Monta o harness: resolve provider/model (overrides das flags) e cria o
|
|
28
|
+
* provider quando não é dry-run. `--provider`/`--model` sobrescrevem a config.
|
|
29
|
+
*/
|
|
30
|
+
static async from(
|
|
31
|
+
engine: HoundsmanEngine,
|
|
32
|
+
options: HuntOptions,
|
|
33
|
+
logger: LoggerLike = defaultLogger,
|
|
34
|
+
providerOverride?: ModelProvider,
|
|
35
|
+
): Promise<HuntHarness> {
|
|
36
|
+
const config = new HarnessConfig(engine.project, {
|
|
37
|
+
provider: options.provider,
|
|
38
|
+
model: options.model,
|
|
39
|
+
concurrency: options.concurrency,
|
|
40
|
+
maxCalls: options.maxCalls,
|
|
41
|
+
});
|
|
42
|
+
const reporter = new LoggerReporter(logger);
|
|
43
|
+
if (options.dryRun) {
|
|
44
|
+
return new HuntHarness(engine, options, reporter, logger, config);
|
|
45
|
+
}
|
|
46
|
+
if (providerOverride) {
|
|
47
|
+
return new HuntHarness(
|
|
48
|
+
engine,
|
|
49
|
+
options,
|
|
50
|
+
reporter,
|
|
51
|
+
logger,
|
|
52
|
+
config,
|
|
53
|
+
providerOverride,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const factory = new ModelFactory(config.withOverrides());
|
|
57
|
+
try {
|
|
58
|
+
const provider = await factory.create();
|
|
59
|
+
return new HuntHarness(engine, options, reporter, logger, config, provider);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof AiConfigurationError) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`hunt exige IA configurada: ${error.message} Use --dry-run para inspecionar o plano.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Executa o hunt. */
|
|
71
|
+
async run(): Promise<HuntResult> {
|
|
72
|
+
const recorder = new AiUsageRecorder(this.config.provider, this.config.model);
|
|
73
|
+
return new HuntSession({
|
|
74
|
+
engine: this.engine,
|
|
75
|
+
options: this.options,
|
|
76
|
+
config: this.config,
|
|
77
|
+
reporter: this.reporter,
|
|
78
|
+
logger: this.logger,
|
|
79
|
+
provider: this.provider,
|
|
80
|
+
recorder,
|
|
81
|
+
}).run();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opções do `houndsman hunt` (harness nativo).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ArgParser } from "../../core/arg-parser";
|
|
6
|
+
import type { AiProviderKind } from "../../core/config";
|
|
7
|
+
|
|
8
|
+
/** Opções resolvidas do hunt. */
|
|
9
|
+
export type HuntOptions = {
|
|
10
|
+
base: string;
|
|
11
|
+
head: string;
|
|
12
|
+
branch?: string;
|
|
13
|
+
watchdog?: string;
|
|
14
|
+
full: boolean;
|
|
15
|
+
clean: boolean;
|
|
16
|
+
noAst: boolean;
|
|
17
|
+
/** Hunt usa IA própria; Jev fica desligado por padrão (evita custo duplo). */
|
|
18
|
+
noJev: boolean;
|
|
19
|
+
resume: boolean;
|
|
20
|
+
allowPartial: boolean;
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
failOnBlocking: boolean;
|
|
23
|
+
maxCalls?: number;
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
provider?: AiProviderKind;
|
|
26
|
+
model?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Defaults do hunt. */
|
|
30
|
+
export function defaultHuntOptions(): HuntOptions {
|
|
31
|
+
return {
|
|
32
|
+
base: "main",
|
|
33
|
+
head: "HEAD",
|
|
34
|
+
full: false,
|
|
35
|
+
clean: false,
|
|
36
|
+
noAst: false,
|
|
37
|
+
noJev: true,
|
|
38
|
+
resume: false,
|
|
39
|
+
allowPartial: false,
|
|
40
|
+
dryRun: false,
|
|
41
|
+
failOnBlocking: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Converte argv nas opções do hunt. */
|
|
46
|
+
export function parseHuntArgs(args: string[]): HuntOptions {
|
|
47
|
+
const parser = ArgParser.from(args);
|
|
48
|
+
const options = defaultHuntOptions();
|
|
49
|
+
const base = parser.value("--base");
|
|
50
|
+
const head = parser.value("--head");
|
|
51
|
+
const branch = parser.value("--branch");
|
|
52
|
+
const watchdog = parser.value("--watchdog");
|
|
53
|
+
const provider = parser.value("--provider");
|
|
54
|
+
const model = parser.value("--model");
|
|
55
|
+
const maxCalls = parser.value("--max-calls");
|
|
56
|
+
const concurrency = parser.value("--concurrency");
|
|
57
|
+
if (base) options.base = base;
|
|
58
|
+
if (head) options.head = head;
|
|
59
|
+
if (branch) options.branch = branch;
|
|
60
|
+
if (watchdog) options.watchdog = watchdog;
|
|
61
|
+
if (provider) options.provider = provider as AiProviderKind;
|
|
62
|
+
if (model) options.model = model;
|
|
63
|
+
if (maxCalls) options.maxCalls = Number.parseInt(maxCalls, 10);
|
|
64
|
+
if (concurrency) options.concurrency = Number.parseInt(concurrency, 10);
|
|
65
|
+
|
|
66
|
+
if (parser.has("--full")) options.full = true;
|
|
67
|
+
if (parser.has("--clean")) options.clean = true;
|
|
68
|
+
if (parser.has("--no-ast")) options.noAst = true;
|
|
69
|
+
if (parser.has("--with-jev")) options.noJev = false;
|
|
70
|
+
if (parser.has("--resume")) options.resume = true;
|
|
71
|
+
if (parser.has("--allow-partial")) options.allowPartial = true;
|
|
72
|
+
if (parser.has("--dry-run")) options.dryRun = true;
|
|
73
|
+
if (parser.has("--fail-on-blocking")) options.failOnBlocking = true;
|
|
74
|
+
return options;
|
|
75
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resultado do `hunt`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AiUsage } from "../../core/ai";
|
|
6
|
+
import type { AgentResult } from "../agent/agent-result";
|
|
7
|
+
|
|
8
|
+
/** Status final da execução. */
|
|
9
|
+
export type HuntResult = {
|
|
10
|
+
status: "completed" | "partial" | "dry-run" | "failed";
|
|
11
|
+
reviewId: string;
|
|
12
|
+
blockers: number;
|
|
13
|
+
attention: number;
|
|
14
|
+
watchdogs: AgentResult[];
|
|
15
|
+
usage: AiUsage;
|
|
16
|
+
messages: string[];
|
|
17
|
+
};
|