@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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger único do CLI/harness (infraestrutura).
|
|
3
|
+
*
|
|
4
|
+
* Usa `chalk` para cor e `ora` para spinner, mas cai para texto puro quando a
|
|
5
|
+
* saída não é TTY, `NO_COLOR` está definido, `TERM=dumb` ou `--quiet`.
|
|
6
|
+
*
|
|
7
|
+
* Não é usado por módulos de negócio do `core/`: só `cli/`, `harness/` e
|
|
8
|
+
* `server/` escrevem saída.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Chalk, type ChalkInstance } from "chalk";
|
|
12
|
+
import ora, { type Ora } from "ora";
|
|
13
|
+
import { env } from "../env";
|
|
14
|
+
|
|
15
|
+
/** Spinner mínimo (compatível com `ora` e com o modo silencioso). */
|
|
16
|
+
export type SpinnerLike = {
|
|
17
|
+
start(): SpinnerLike;
|
|
18
|
+
stop(): void;
|
|
19
|
+
succeed(text?: string): void;
|
|
20
|
+
fail(text?: string): void;
|
|
21
|
+
warn(text?: string): void;
|
|
22
|
+
readonly text: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Superfície de log usada por comandos e reporter. */
|
|
26
|
+
export interface LoggerLike {
|
|
27
|
+
debug(message: string): void;
|
|
28
|
+
info(message: string): void;
|
|
29
|
+
success(message: string): void;
|
|
30
|
+
warn(message: string): void;
|
|
31
|
+
error(message: string): void;
|
|
32
|
+
step(message: string): void;
|
|
33
|
+
bullet(message: string): void;
|
|
34
|
+
table(rows: readonly (readonly string[])[]): void;
|
|
35
|
+
progress(message: string): void;
|
|
36
|
+
spinner(text: string): SpinnerLike | null;
|
|
37
|
+
withSpinner<T>(
|
|
38
|
+
text: string,
|
|
39
|
+
run: (spinner: SpinnerLike | null) => Promise<T>,
|
|
40
|
+
): Promise<T>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Opções do logger (injetáveis nos testes). */
|
|
44
|
+
export type LoggerOptions = {
|
|
45
|
+
/** `auto` (default), `true` (força cor) ou `false` (plain). */
|
|
46
|
+
color?: boolean | "auto";
|
|
47
|
+
quiet?: boolean;
|
|
48
|
+
verbose?: boolean;
|
|
49
|
+
stdout?: NodeJS.WritableStream;
|
|
50
|
+
stderr?: NodeJS.WritableStream;
|
|
51
|
+
/** Sobrescreve a detecção de TTY (testes). */
|
|
52
|
+
isTTY?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** `true` quando cores devem ser desabilitadas pelo ambiente. */
|
|
56
|
+
function colorsDisabled(): boolean {
|
|
57
|
+
return Boolean(env.noColor) || env.term === "dumb";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Cria a instância de chalk respeitando as opções. */
|
|
61
|
+
function createChalk(color: LoggerOptions["color"]): ChalkInstance {
|
|
62
|
+
if (color === false || colorsDisabled()) return new Chalk({ level: 0 });
|
|
63
|
+
if (color === true) return new Chalk({ level: 1 });
|
|
64
|
+
return new Chalk();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Logger com saída injetável. */
|
|
68
|
+
export class Logger implements LoggerLike {
|
|
69
|
+
private readonly out: NodeJS.WritableStream;
|
|
70
|
+
private readonly err: NodeJS.WritableStream;
|
|
71
|
+
private readonly chalk: ChalkInstance;
|
|
72
|
+
private readonly quiet: boolean;
|
|
73
|
+
private readonly verbose: boolean;
|
|
74
|
+
private readonly tty: boolean;
|
|
75
|
+
|
|
76
|
+
constructor(options: LoggerOptions = {}) {
|
|
77
|
+
this.out = options.stdout ?? process.stdout;
|
|
78
|
+
this.err = options.stderr ?? process.stderr;
|
|
79
|
+
this.chalk = createChalk(options.color ?? "auto");
|
|
80
|
+
this.quiet = options.quiet === true;
|
|
81
|
+
this.verbose = options.verbose === true;
|
|
82
|
+
this.tty = options.isTTY ?? Boolean(process.stdout.isTTY);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Só aparece em `--verbose`. */
|
|
86
|
+
debug(message: string): void {
|
|
87
|
+
if (!this.verbose || this.quiet) return;
|
|
88
|
+
this.out.write(`${this.chalk.dim(`[debug] ${message}`)}\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
info(message: string): void {
|
|
92
|
+
if (this.quiet) return;
|
|
93
|
+
this.out.write(`${message}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
success(message: string): void {
|
|
97
|
+
if (this.quiet) return;
|
|
98
|
+
this.out.write(`${this.chalk.green("✔")} ${message}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
warn(message: string): void {
|
|
102
|
+
if (this.quiet) return;
|
|
103
|
+
this.err.write(`${this.chalk.yellow("⚠")} ${message}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
error(message: string): void {
|
|
107
|
+
this.err.write(`${this.chalk.red("✖")} ${message}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
step(message: string): void {
|
|
111
|
+
if (this.quiet) return;
|
|
112
|
+
this.out.write(`${this.chalk.cyan("→")} ${message}\n`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
bullet(message: string): void {
|
|
116
|
+
if (this.quiet) return;
|
|
117
|
+
this.out.write(` ${this.chalk.dim("•")} ${message}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Tabela simples com colunas alinhadas (sem ANSI nos valores). */
|
|
121
|
+
table(rows: readonly (readonly string[])[]): void {
|
|
122
|
+
if (this.quiet || rows.length === 0) return;
|
|
123
|
+
const width = Math.max(...rows.map((row) => row.length));
|
|
124
|
+
const sizes = Array.from({ length: width }, (_, column) =>
|
|
125
|
+
Math.max(...rows.map((row) => (row[column] ?? "").length)),
|
|
126
|
+
);
|
|
127
|
+
rows.forEach((row, index) => {
|
|
128
|
+
const line = row
|
|
129
|
+
.map((cell, column) => (cell ?? "").padEnd(sizes[column] ?? 0))
|
|
130
|
+
.join(" ")
|
|
131
|
+
.trimEnd();
|
|
132
|
+
this.out.write(`${index === 0 ? this.chalk.bold(line) : line}\n`);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Progresso textual (usado pelo `AgentReporter`). */
|
|
137
|
+
progress(message: string): void {
|
|
138
|
+
this.info(message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Spinner habilitado apenas em TTY e fora do modo quiet. */
|
|
142
|
+
spinner(text: string): SpinnerLike | null {
|
|
143
|
+
if (!this.tty || this.quiet) return null;
|
|
144
|
+
return ora({ text, stream: this.out as NodeJS.WriteStream, isEnabled: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Executa `run` com um spinner opcional; sempre para o spinner. */
|
|
148
|
+
async withSpinner<T>(
|
|
149
|
+
text: string,
|
|
150
|
+
run: (spinner: SpinnerLike | null) => Promise<T>,
|
|
151
|
+
): Promise<T> {
|
|
152
|
+
const spinner = this.spinner(text);
|
|
153
|
+
spinner?.start();
|
|
154
|
+
try {
|
|
155
|
+
const result = await run(spinner);
|
|
156
|
+
spinner?.succeed();
|
|
157
|
+
return result;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
spinner?.fail();
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Logger silencioso para testes/serviços. */
|
|
166
|
+
export class SilentLogger implements LoggerLike {
|
|
167
|
+
debug(_message?: string): void {}
|
|
168
|
+
info(_message?: string): void {}
|
|
169
|
+
success(_message?: string): void {}
|
|
170
|
+
warn(_message?: string): void {}
|
|
171
|
+
error(_message?: string): void {}
|
|
172
|
+
step(_message?: string): void {}
|
|
173
|
+
bullet(_message?: string): void {}
|
|
174
|
+
table(_rows?: readonly (readonly string[])[]): void {}
|
|
175
|
+
progress(_message?: string): void {}
|
|
176
|
+
spinner(_text?: string): SpinnerLike | null {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
async withSpinner<T>(
|
|
180
|
+
_text: string,
|
|
181
|
+
run: (spinner: SpinnerLike | null) => Promise<T>,
|
|
182
|
+
): Promise<T> {
|
|
183
|
+
return run(null);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type { Ora };
|
package/src/core/orchestrator.ts
CHANGED
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
* review.<watchdog>.md, context e context-summary.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import type { AiClient } from "./ai/ai-client";
|
|
21
|
+
import { AiBriefEnricher } from "./ai/features/brief-enricher";
|
|
22
|
+
import { AiClassifier } from "./ai/features/classifier";
|
|
23
|
+
import { AiContextSummarizer } from "./ai/features/context-summarizer";
|
|
24
|
+
import { AiPreAnalyzer } from "./ai/features/pre-analyzer";
|
|
25
|
+
import type { BriefPlan } from "./ai/schemas";
|
|
26
|
+
import type { AstService } from "./ast/service";
|
|
27
|
+
import type { AstAnalysis, AstFileBrief, AstImpact } from "./ast/types";
|
|
19
28
|
import {
|
|
20
29
|
type HoundsmanConfig,
|
|
21
30
|
resolveJevConcurrency,
|
|
@@ -27,6 +36,7 @@ import { Classification } from "./domain/classification";
|
|
|
27
36
|
import { Watchdog } from "./domain/watchdog";
|
|
28
37
|
import { mergeCarryover } from "./engine/carryover";
|
|
29
38
|
import { ReviewScopeResolver, type RunClassification } from "./engine/review-scope";
|
|
39
|
+
import { ENV_NAMES } from "./env";
|
|
30
40
|
import type { BudgetSelection, ClassificationResult } from "./file-classifier";
|
|
31
41
|
import { buildStateFromFindings, fileReader } from "./findings-state";
|
|
32
42
|
import { resolveSha } from "./git-revision";
|
|
@@ -49,7 +59,11 @@ import {
|
|
|
49
59
|
import { currentBranch } from "./pr-context";
|
|
50
60
|
import { ProjectConfig } from "./project-config";
|
|
51
61
|
import { BriefRenderer } from "./renderers/brief-renderer";
|
|
52
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
type ManifestAi,
|
|
64
|
+
type ManifestAst,
|
|
65
|
+
ManifestWriter,
|
|
66
|
+
} from "./renderers/manifest-writer";
|
|
53
67
|
import { ReportRenderer } from "./renderers/report-renderer";
|
|
54
68
|
import { renderReviewResume } from "./renderers/resume-renderer";
|
|
55
69
|
import {
|
|
@@ -75,8 +89,12 @@ export type ReviewOptions = {
|
|
|
75
89
|
failOnBlocking: boolean;
|
|
76
90
|
clean: boolean;
|
|
77
91
|
noJev: boolean;
|
|
92
|
+
/** Desativa a análise estrutural (AST) nesta run. */
|
|
93
|
+
noAst: boolean;
|
|
78
94
|
/** Força review completo (ignora o modo incremental). */
|
|
79
95
|
full: boolean;
|
|
96
|
+
/** Desativa as features de IA do fluxo normal (hunt não usa esta flag). */
|
|
97
|
+
noAi: boolean;
|
|
80
98
|
};
|
|
81
99
|
|
|
82
100
|
/** Valores default das opções (o CLI sobrepõe com as flags). */
|
|
@@ -88,7 +106,9 @@ export function defaultOptions(): ReviewOptions {
|
|
|
88
106
|
failOnBlocking: false,
|
|
89
107
|
clean: false,
|
|
90
108
|
noJev: false,
|
|
109
|
+
noAst: false,
|
|
91
110
|
full: false,
|
|
111
|
+
noAi: false,
|
|
92
112
|
};
|
|
93
113
|
}
|
|
94
114
|
|
|
@@ -149,6 +169,8 @@ function writeManifest(
|
|
|
149
169
|
activeIds: string[],
|
|
150
170
|
pending: string[],
|
|
151
171
|
jev: JevCandidatesFile,
|
|
172
|
+
ast?: ManifestAst,
|
|
173
|
+
ai?: ManifestAi,
|
|
152
174
|
): void {
|
|
153
175
|
new ManifestWriter(
|
|
154
176
|
ctx.storage,
|
|
@@ -156,7 +178,7 @@ function writeManifest(
|
|
|
156
178
|
ctx.work,
|
|
157
179
|
new ProjectConfig(config),
|
|
158
180
|
opts,
|
|
159
|
-
).write(classification, activeIds, pending, jev);
|
|
181
|
+
).write(classification, activeIds, pending, jev, ast, ai);
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
// ---------------------------------------------------------------------------
|
|
@@ -171,6 +193,9 @@ function writeBriefs(
|
|
|
171
193
|
context: ContextSummary | null,
|
|
172
194
|
candidates: JevCandidate[],
|
|
173
195
|
config: HoundsmanConfig,
|
|
196
|
+
astAnalyses?: ReadonlyMap<string, AstAnalysis>,
|
|
197
|
+
astImpact?: AstImpact,
|
|
198
|
+
aiPlans?: ReadonlyMap<string, BriefPlan>,
|
|
174
199
|
): void {
|
|
175
200
|
const work = ctx.work;
|
|
176
201
|
const docs = discoverDocs(Object.keys(classification.files), config);
|
|
@@ -180,6 +205,17 @@ function writeBriefs(
|
|
|
180
205
|
const def = findWatchdog(config, id);
|
|
181
206
|
if (!def) continue;
|
|
182
207
|
const { selected, overflow } = budgetFor(classification, id, maxFilesPerBrief);
|
|
208
|
+
const astFiles: AstFileBrief[] = selected.flatMap((file) => {
|
|
209
|
+
const analysis = astAnalyses?.get(file);
|
|
210
|
+
if (!analysis?.delta) return [];
|
|
211
|
+
return [
|
|
212
|
+
{
|
|
213
|
+
file,
|
|
214
|
+
delta: analysis.delta,
|
|
215
|
+
importers: astImpact?.importers[file],
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
});
|
|
183
219
|
const brief = renderer.render(new Watchdog(def), {
|
|
184
220
|
watchdogId: id,
|
|
185
221
|
files: selected,
|
|
@@ -193,6 +229,8 @@ function writeBriefs(
|
|
|
193
229
|
docs,
|
|
194
230
|
context,
|
|
195
231
|
candidates: candidates.filter((c) => c.watchdogId === id),
|
|
232
|
+
ast: astFiles,
|
|
233
|
+
aiPlan: aiPlans?.get(id),
|
|
196
234
|
});
|
|
197
235
|
const content = `${brief}\n`;
|
|
198
236
|
work.writeBrief(id, content);
|
|
@@ -219,6 +257,7 @@ async function resolveCandidates(
|
|
|
219
257
|
ctx: ReviewContext,
|
|
220
258
|
config: HoundsmanConfig,
|
|
221
259
|
diff: DiffSource,
|
|
260
|
+
ai?: AiClient,
|
|
222
261
|
): Promise<JevCandidatesFile> {
|
|
223
262
|
const save = (data: JevCandidatesFile): JevCandidatesFile => {
|
|
224
263
|
ctx.storage.writeArtifact(ctx.review.id, {
|
|
@@ -229,13 +268,46 @@ async function resolveCandidates(
|
|
|
229
268
|
return data;
|
|
230
269
|
};
|
|
231
270
|
|
|
271
|
+
// Pré-análise por IA (opcional) tem precedência sobre o Jev.
|
|
272
|
+
if (ai && !opts.noAi) {
|
|
273
|
+
const analyzer = new AiPreAnalyzer(ai);
|
|
274
|
+
if (analyzer.enabled) {
|
|
275
|
+
const candidates: JevCandidate[] = [];
|
|
276
|
+
for (const [id, files] of Object.entries(classification.watchdogs)) {
|
|
277
|
+
const def = findWatchdog(config, id);
|
|
278
|
+
if (!def) continue;
|
|
279
|
+
const inputs = files.slice(0, 5).flatMap((file) => {
|
|
280
|
+
const snippet = readSnippet(file);
|
|
281
|
+
return snippet ? [{ file, snippet }] : [];
|
|
282
|
+
});
|
|
283
|
+
candidates.push(
|
|
284
|
+
...(await analyzer.analyze({
|
|
285
|
+
watchdogId: id,
|
|
286
|
+
name: def.name,
|
|
287
|
+
rules: def.rules,
|
|
288
|
+
files: inputs,
|
|
289
|
+
})),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return save({
|
|
293
|
+
available: true,
|
|
294
|
+
model: ai.modelId,
|
|
295
|
+
threshold: JevCandidatesStore.THRESHOLD,
|
|
296
|
+
analyzedFiles: Object.values(classification.watchdogs).flat().length,
|
|
297
|
+
candidates,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
232
302
|
if (opts.noJev) {
|
|
233
303
|
return save(JevCandidatesStore.unavailable("desativado (--no-jev)"));
|
|
234
304
|
}
|
|
235
305
|
|
|
236
306
|
const client = new JevClient();
|
|
237
307
|
if (!client.isConfigured()) {
|
|
238
|
-
return save(
|
|
308
|
+
return save(
|
|
309
|
+
JevCandidatesStore.unavailable(`${ENV_NAMES.typeSafeApiKey} não configurada`),
|
|
310
|
+
);
|
|
239
311
|
}
|
|
240
312
|
|
|
241
313
|
try {
|
|
@@ -304,15 +376,130 @@ function budgetMessage(
|
|
|
304
376
|
];
|
|
305
377
|
}
|
|
306
378
|
|
|
379
|
+
/** Primeiras 80 linhas de um arquivo (pré-análise por IA; fail-open). */
|
|
380
|
+
function readSnippet(file: string): string | null {
|
|
381
|
+
try {
|
|
382
|
+
return readFileSync(file, "utf8").split("\n").slice(0, 80).join("\n");
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
307
388
|
/** Resultado do prepare — mensagens para o CLI imprimir. */
|
|
308
389
|
export type PrepareResult = { messages: string[] };
|
|
309
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Classificação fallback por IA (opcional): atribui arquivos não classificados
|
|
393
|
+
* a watchdogs conhecidos. Fail-open — falha mantém `unclassified`.
|
|
394
|
+
*/
|
|
395
|
+
async function applyAiClassification(
|
|
396
|
+
classification: RunClassification,
|
|
397
|
+
config: HoundsmanConfig,
|
|
398
|
+
ai: AiClient | undefined,
|
|
399
|
+
opts: ReviewOptions,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
if (!ai || opts.noAi || classification.unclassified.length === 0) return;
|
|
402
|
+
const classifier = new AiClassifier(ai);
|
|
403
|
+
if (!classifier.enabled) return;
|
|
404
|
+
const watchdogs = config.watchdogIds.flatMap((id) => {
|
|
405
|
+
const def = config.watchdogs[id];
|
|
406
|
+
return def ? [{ id, name: def.name, scope: def.scope }] : [];
|
|
407
|
+
});
|
|
408
|
+
const assignments = await classifier.classify(classification.unclassified, watchdogs);
|
|
409
|
+
if (assignments.size === 0) return;
|
|
410
|
+
for (const [file, id] of assignments) {
|
|
411
|
+
const list = classification.watchdogs[id] ?? [];
|
|
412
|
+
if (!list.includes(file)) list.push(file);
|
|
413
|
+
classification.watchdogs[id] = list;
|
|
414
|
+
classification.stats[id] = list.length;
|
|
415
|
+
}
|
|
416
|
+
classification.unclassified = classification.unclassified.filter(
|
|
417
|
+
(file) => !assignments.has(file),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Resumo do contexto da PR: usa o do subagente ou, na ausência, a IA
|
|
423
|
+
* (`AiContextSummarizer`). Grava o artefato e o staging.
|
|
424
|
+
*/
|
|
425
|
+
async function resolveContextSummary(
|
|
426
|
+
ctx: ReviewContext,
|
|
427
|
+
ai: AiClient | undefined,
|
|
428
|
+
opts: ReviewOptions,
|
|
429
|
+
): Promise<ContextSummary | null> {
|
|
430
|
+
const existing = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
|
|
431
|
+
if (existing) return existing;
|
|
432
|
+
if (!ai || opts.noAi) return null;
|
|
433
|
+
const raw = ctx.work.readContextRaw();
|
|
434
|
+
if (!raw) return null;
|
|
435
|
+
const summary = await new AiContextSummarizer(ai).summarize(raw);
|
|
436
|
+
if (!summary) return null;
|
|
437
|
+
ctx.work.writeContextSummary(summary);
|
|
438
|
+
ctx.storage.writeArtifact(ctx.review.id, {
|
|
439
|
+
name: "context-summary.json",
|
|
440
|
+
kind: "context-summary",
|
|
441
|
+
content: `${JSON.stringify(summary, null, 2)}\n`,
|
|
442
|
+
});
|
|
443
|
+
return summary;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Planos de revisão por watchdog (uma chamada por brief, concorrência
|
|
448
|
+
* limitada). Sem IA/chave/feature, devolve mapa vazio e o brief fica igual.
|
|
449
|
+
*/
|
|
450
|
+
async function buildAiPlans(
|
|
451
|
+
activeIds: string[],
|
|
452
|
+
classification: ClassificationResult,
|
|
453
|
+
config: HoundsmanConfig,
|
|
454
|
+
context: ContextSummary | null,
|
|
455
|
+
astAnalyses: ReadonlyMap<string, AstAnalysis> | undefined,
|
|
456
|
+
astImpact: AstImpact | undefined,
|
|
457
|
+
ai: AiClient | undefined,
|
|
458
|
+
opts: ReviewOptions,
|
|
459
|
+
): Promise<Map<string, BriefPlan>> {
|
|
460
|
+
const plans = new Map<string, BriefPlan>();
|
|
461
|
+
if (!ai || opts.noAi) return plans;
|
|
462
|
+
const enricher = new AiBriefEnricher(ai);
|
|
463
|
+
if (!enricher.enabled) return plans;
|
|
464
|
+
|
|
465
|
+
const docs = discoverDocs(Object.keys(classification.files), config);
|
|
466
|
+
const maxFilesPerBrief = resolveMaxFilesPerBrief(config);
|
|
467
|
+
const inputs = [];
|
|
468
|
+
for (const id of activeIds) {
|
|
469
|
+
const def = findWatchdog(config, id);
|
|
470
|
+
if (!def) continue;
|
|
471
|
+
const { selected } = budgetFor(classification, id, maxFilesPerBrief);
|
|
472
|
+
const astFiles: AstFileBrief[] = selected.flatMap((file) => {
|
|
473
|
+
const analysis = astAnalyses?.get(file);
|
|
474
|
+
if (!analysis?.delta) return [];
|
|
475
|
+
return [
|
|
476
|
+
{ file, delta: analysis.delta, importers: astImpact?.importers[file] },
|
|
477
|
+
];
|
|
478
|
+
});
|
|
479
|
+
inputs.push({
|
|
480
|
+
watchdogId: id,
|
|
481
|
+
name: def.name,
|
|
482
|
+
description: def.description,
|
|
483
|
+
scope: def.scope,
|
|
484
|
+
rules: def.rules,
|
|
485
|
+
files: selected,
|
|
486
|
+
churn: classification.churn,
|
|
487
|
+
context,
|
|
488
|
+
ast: astFiles,
|
|
489
|
+
docs,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return enricher.enrichAll(inputs);
|
|
493
|
+
}
|
|
494
|
+
|
|
310
495
|
/** Passo 1 — descobre os arquivos e grava briefs + manifesto. */
|
|
311
496
|
export async function prepare(
|
|
312
497
|
opts: ReviewOptions,
|
|
313
498
|
branch: string,
|
|
314
499
|
config: HoundsmanConfig,
|
|
315
500
|
ctx: ReviewContext,
|
|
501
|
+
ast?: AstService,
|
|
502
|
+
ai?: AiClient,
|
|
316
503
|
): Promise<PrepareResult> {
|
|
317
504
|
const messages: string[] = [];
|
|
318
505
|
ctx.work.ensure();
|
|
@@ -322,7 +509,11 @@ export async function prepare(
|
|
|
322
509
|
// Marca o commit revisado e decide entre completo, incremental ou no-op.
|
|
323
510
|
const headSha = resolveSha(opts.head);
|
|
324
511
|
const previous = ctx.storage.readFindings(ctx.review.id);
|
|
325
|
-
const scope = new ReviewScopeResolver(
|
|
512
|
+
const scope = await new ReviewScopeResolver(
|
|
513
|
+
opts,
|
|
514
|
+
config,
|
|
515
|
+
opts.noAst ? undefined : ast,
|
|
516
|
+
).resolve(previous, headSha);
|
|
326
517
|
|
|
327
518
|
// Mesmo commit já revisado: preserva artefatos e triagem, sem re-revisar.
|
|
328
519
|
if (scope.kind === "noop") {
|
|
@@ -349,13 +540,44 @@ export async function prepare(
|
|
|
349
540
|
});
|
|
350
541
|
|
|
351
542
|
const { classification, diff } = scope;
|
|
543
|
+
await applyAiClassification(classification, config, ai, opts);
|
|
352
544
|
ctx.storage.writeArtifact(ctx.review.id, {
|
|
353
545
|
name: "classification.json",
|
|
354
546
|
kind: "classification",
|
|
355
547
|
content: `${JSON.stringify(classification, null, 2)}\n`,
|
|
356
548
|
});
|
|
357
549
|
|
|
358
|
-
const
|
|
550
|
+
const astAnalyses = scope.ast;
|
|
551
|
+
const astImpact = astAnalyses && ast ? await ast.impact(astAnalyses) : undefined;
|
|
552
|
+
if (astAnalyses && ast?.enabled) {
|
|
553
|
+
const summary = {
|
|
554
|
+
enabled: true,
|
|
555
|
+
files: Object.fromEntries(
|
|
556
|
+
[...astAnalyses].map(([path, analysis]) => [
|
|
557
|
+
path,
|
|
558
|
+
{
|
|
559
|
+
facts: analysis.facts,
|
|
560
|
+
delta: analysis.delta,
|
|
561
|
+
deleted: analysis.deleted,
|
|
562
|
+
},
|
|
563
|
+
]),
|
|
564
|
+
),
|
|
565
|
+
};
|
|
566
|
+
ctx.storage.writeArtifact(ctx.review.id, {
|
|
567
|
+
name: "ast-summary.json",
|
|
568
|
+
kind: "ast",
|
|
569
|
+
content: `${JSON.stringify(summary, null, 2)}\n`,
|
|
570
|
+
});
|
|
571
|
+
if (astImpact) {
|
|
572
|
+
ctx.storage.writeArtifact(ctx.review.id, {
|
|
573
|
+
name: "ast-impact.json",
|
|
574
|
+
kind: "ast-impact",
|
|
575
|
+
content: `${JSON.stringify(astImpact, null, 2)}\n`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const jev = await resolveCandidates(opts, classification, ctx, config, diff, ai);
|
|
359
581
|
|
|
360
582
|
const activeIds = Object.keys(classification.watchdogs);
|
|
361
583
|
if (Object.keys(classification.files).length === 0) {
|
|
@@ -380,17 +602,103 @@ export async function prepare(
|
|
|
380
602
|
);
|
|
381
603
|
}
|
|
382
604
|
|
|
383
|
-
const context = ctx.work.readContextSummary() ?? readPersistedContextSummary(ctx);
|
|
384
605
|
persistContext(ctx);
|
|
606
|
+
const context = await resolveContextSummary(ctx, ai, opts);
|
|
607
|
+
|
|
608
|
+
const manifestAst: ManifestAst | undefined = astAnalyses
|
|
609
|
+
? {
|
|
610
|
+
enabled: ast?.enabled ?? false,
|
|
611
|
+
languages: [
|
|
612
|
+
...new Set(
|
|
613
|
+
[...astAnalyses.values()].map(
|
|
614
|
+
(analysis) => analysis.facts.language,
|
|
615
|
+
),
|
|
616
|
+
),
|
|
617
|
+
].sort(),
|
|
618
|
+
files: astAnalyses.size,
|
|
619
|
+
impact: {
|
|
620
|
+
affectedFiles: astImpact?.affectedFiles.length ?? 0,
|
|
621
|
+
importers: Object.keys(astImpact?.importers ?? {}).length,
|
|
622
|
+
exportedChanges: astImpact?.exportedChanges.length ?? 0,
|
|
623
|
+
},
|
|
624
|
+
}
|
|
625
|
+
: undefined;
|
|
385
626
|
|
|
386
|
-
|
|
387
|
-
|
|
627
|
+
const aiPlans = await buildAiPlans(
|
|
628
|
+
activeIds,
|
|
629
|
+
classification,
|
|
630
|
+
config,
|
|
631
|
+
context,
|
|
632
|
+
astAnalyses,
|
|
633
|
+
astImpact,
|
|
634
|
+
ai,
|
|
635
|
+
opts,
|
|
636
|
+
);
|
|
637
|
+
if (aiPlans.size > 0) {
|
|
638
|
+
ctx.storage.writeArtifact(ctx.review.id, {
|
|
639
|
+
name: "ai-briefs.json",
|
|
640
|
+
kind: "ai-brief",
|
|
641
|
+
content: `${JSON.stringify(
|
|
642
|
+
{ plans: Object.fromEntries(aiPlans) },
|
|
643
|
+
null,
|
|
644
|
+
2,
|
|
645
|
+
)}\n`,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const aiUsage = ai?.recorder.total;
|
|
649
|
+
const manifestAi: ManifestAi | undefined =
|
|
650
|
+
ai && !opts.noAi && (aiPlans.size > 0 || (aiUsage?.calls ?? 0) > 0)
|
|
651
|
+
? {
|
|
652
|
+
enabled: true,
|
|
653
|
+
provider: ai.projectConfig.aiProvider,
|
|
654
|
+
model: ai.modelId,
|
|
655
|
+
briefPlans: aiPlans.size,
|
|
656
|
+
usage: aiUsage ?? {
|
|
657
|
+
inputTokens: 0,
|
|
658
|
+
outputTokens: 0,
|
|
659
|
+
totalTokens: 0,
|
|
660
|
+
calls: 0,
|
|
661
|
+
},
|
|
662
|
+
}
|
|
663
|
+
: undefined;
|
|
664
|
+
|
|
665
|
+
writeBriefs(
|
|
666
|
+
activeIds,
|
|
667
|
+
classification,
|
|
668
|
+
ctx,
|
|
669
|
+
branch,
|
|
670
|
+
context,
|
|
671
|
+
jev.candidates,
|
|
672
|
+
config,
|
|
673
|
+
astAnalyses,
|
|
674
|
+
astImpact,
|
|
675
|
+
aiPlans,
|
|
676
|
+
);
|
|
677
|
+
writeManifest(
|
|
678
|
+
ctx,
|
|
679
|
+
config,
|
|
680
|
+
opts,
|
|
681
|
+
classification,
|
|
682
|
+
activeIds,
|
|
683
|
+
activeIds,
|
|
684
|
+
jev,
|
|
685
|
+
manifestAst,
|
|
686
|
+
manifestAi,
|
|
687
|
+
);
|
|
388
688
|
|
|
389
689
|
messages.push(
|
|
390
690
|
...discoveryMessages(activeIds, classification, ctx.work.briefsDir),
|
|
391
691
|
...budgetMessage(classification, config),
|
|
392
692
|
...jevSummary(jev),
|
|
393
693
|
);
|
|
694
|
+
if (astAnalyses) {
|
|
695
|
+
messages.push(
|
|
696
|
+
`AST: ${astAnalyses.size} arquivo(s) analisado(s), ` +
|
|
697
|
+
`${astImpact?.affectedFiles.length ?? 0} impactado(s).`,
|
|
698
|
+
);
|
|
699
|
+
} else {
|
|
700
|
+
messages.push("AST: desabilitado ou sem grammar compatível.");
|
|
701
|
+
}
|
|
394
702
|
|
|
395
703
|
if (context) {
|
|
396
704
|
messages.push("Contexto da PR resumido e injetado nos briefs.");
|
|
@@ -399,6 +707,17 @@ export async function prepare(
|
|
|
399
707
|
"Sem contexto da PR (rode `bun run houndsman context` antes, se houver PR).",
|
|
400
708
|
);
|
|
401
709
|
}
|
|
710
|
+
if (aiPlans.size > 0) {
|
|
711
|
+
messages.push(
|
|
712
|
+
`IA: plano de revisão em ${aiPlans.size} brief(s) (sugestão, não substitui regras).`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (aiUsage && aiUsage.calls > 0) {
|
|
716
|
+
messages.push(
|
|
717
|
+
`IA: ${aiUsage.calls} chamada(s), ${aiUsage.totalTokens} token(s).`,
|
|
718
|
+
);
|
|
719
|
+
ai?.recorder.write(ctx.storage, ctx.review.id);
|
|
720
|
+
}
|
|
402
721
|
|
|
403
722
|
messages.push(
|
|
404
723
|
`\nPróximo passo: despachar os sub-agentes (um por brief em ${displayPath(ctx.work.briefsDir)}/).`,
|
|
@@ -439,6 +758,24 @@ function persistContext(ctx: ReviewContext): void {
|
|
|
439
758
|
// Passo 2 — finalizar
|
|
440
759
|
// ---------------------------------------------------------------------------
|
|
441
760
|
|
|
761
|
+
/** Reaproveita `ast`/`ai` do manifesto anterior (o finalize não os recalcula). */
|
|
762
|
+
function readManifestExtras(ctx: ReviewContext): {
|
|
763
|
+
ast?: ManifestAst;
|
|
764
|
+
ai?: ManifestAi;
|
|
765
|
+
} {
|
|
766
|
+
const artifact = ctx.storage.readArtifact(ctx.review.id, "manifest.json");
|
|
767
|
+
if (!artifact) return {};
|
|
768
|
+
try {
|
|
769
|
+
const parsed = JSON.parse(artifact.content) as {
|
|
770
|
+
ast?: ManifestAst;
|
|
771
|
+
ai?: ManifestAi;
|
|
772
|
+
};
|
|
773
|
+
return { ast: parsed.ast, ai: parsed.ai };
|
|
774
|
+
} catch {
|
|
775
|
+
return {};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
442
779
|
/** Lê o `classification.json` persistido pelo prepare (lança se ausente). */
|
|
443
780
|
function readClassification(ctx: ReviewContext): RunClassification {
|
|
444
781
|
const artifact = ctx.storage.readArtifact(ctx.review.id, "classification.json");
|
|
@@ -562,7 +899,18 @@ export function finalize(
|
|
|
562
899
|
const { reports, pending, warnings } = ctx.work.readFindings(activeIds);
|
|
563
900
|
messages.push(...warnings);
|
|
564
901
|
const jev = readCandidates(ctx, config);
|
|
565
|
-
|
|
902
|
+
const extras = readManifestExtras(ctx);
|
|
903
|
+
writeManifest(
|
|
904
|
+
ctx,
|
|
905
|
+
config,
|
|
906
|
+
opts,
|
|
907
|
+
classification,
|
|
908
|
+
activeIds,
|
|
909
|
+
pending,
|
|
910
|
+
jev,
|
|
911
|
+
extras.ast,
|
|
912
|
+
extras.ai,
|
|
913
|
+
);
|
|
566
914
|
|
|
567
915
|
if (pending.length > 0) {
|
|
568
916
|
throw new Error(
|