@payconductor/houndsman 0.12.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 +17 -1
- package/package.json +1 -1
- package/src/cli/commands/doctor-command.ts +22 -0
- package/src/cli/commands/hunt-command.ts +462 -0
- package/src/cli/commands/review-command.ts +20 -2
- package/src/cli/help.ts +17 -2
- package/src/cli/index.ts +17 -2
- package/src/cli/progress.ts +45 -0
- package/src/core/ai/ai-sdk-model-provider.ts +39 -0
- package/src/core/ai/index.ts +6 -1
- package/src/core/ai/types.ts +2 -0
- package/src/core/commands/scaffold.ts +11 -0
- package/src/core/config.ts +28 -0
- package/src/core/defaults.ts +15 -0
- package/src/core/engine/review-scope.ts +92 -25
- 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/logging/index.ts +2 -0
- package/src/core/logging/logger.ts +19 -0
- package/src/core/logging/progress.ts +282 -0
- package/src/core/orchestrator.ts +158 -49
- package/src/core/project-config.ts +38 -0
- package/src/core/storage/types.ts +1 -0
- package/src/index.ts +49 -0
- package/src/skill/README.md +12 -0
- package/src/skill/SETUP.md +21 -1
- package/src/skill/SKILL.md +4 -0
- package/src/skill/hunt.md +36 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +22 -3
- package/src/tests/ai-core.test.ts +38 -1
- package/src/tests/cli.test.ts +1 -0
- 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 +62 -0
package/src/cli/index.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* --finalize Passo 3 — consolidar os findings em review.md
|
|
16
16
|
* context Passo 0 — coletar o contexto da PR via `gh`
|
|
17
17
|
* findings <sub> Triagem/correção dos findings (list/set/done/sync/batch/verify/submit/...)
|
|
18
|
+
* hunt [flags] Harness local (maki): prepare → watchdogs via maki → finalize
|
|
18
19
|
* status [ping] Mostra o status da run; `ping` renova o heartbeat da execução
|
|
19
20
|
* server Sobe a UI local de triagem (Bun.serve)
|
|
20
21
|
* setup Cria a configuração inicial do projeto (config + skills)
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
import type { HoundsmanConfig } from "../core/config";
|
|
31
32
|
import { loadConfig } from "../core/config-loader";
|
|
32
33
|
import { HoundsmanEngine } from "../core/engine/houndsman-engine";
|
|
33
|
-
import { logger } from "../core/logging";
|
|
34
|
+
import { formatDuration, logger } from "../core/logging";
|
|
34
35
|
import { syncProjectLayerForRoot } from "../core/services/worktree-manager";
|
|
35
36
|
import { ArgParser } from "./arg-parser";
|
|
36
37
|
import type { CliContext } from "./commands/command";
|
|
@@ -38,6 +39,7 @@ import { ContextCommand } from "./commands/context-command";
|
|
|
38
39
|
import { DoctorCommand } from "./commands/doctor-command";
|
|
39
40
|
import { FindingsCommand } from "./commands/findings-command";
|
|
40
41
|
import { HelpCommand } from "./commands/help-command";
|
|
42
|
+
import { HuntCommand } from "./commands/hunt-command";
|
|
41
43
|
import { CommandRegistry } from "./commands/registry";
|
|
42
44
|
import { ReviewCommand } from "./commands/review-command";
|
|
43
45
|
import { ServerCommand } from "./commands/server-command";
|
|
@@ -57,6 +59,7 @@ function buildRegistry(): CommandRegistry {
|
|
|
57
59
|
return new CommandRegistry()
|
|
58
60
|
.register(new ReviewCommand(), { fallback: true })
|
|
59
61
|
.register(new HelpCommand())
|
|
62
|
+
.register(new HuntCommand())
|
|
60
63
|
.register(new ContextCommand())
|
|
61
64
|
.register(new FindingsCommand())
|
|
62
65
|
.register(new StatusCommand())
|
|
@@ -88,14 +91,26 @@ export async function main(): Promise<void> {
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
let loaded: Awaited<ReturnType<typeof loadConfig>> | null = null;
|
|
94
|
+
// `--json` é contrato de máquina: nada de progresso no stdout.
|
|
95
|
+
const silentProgress = parser.has("--json");
|
|
91
96
|
const config = async (): Promise<HoundsmanConfig> => {
|
|
92
97
|
loaded ??= await (async () => {
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
if (!silentProgress) logger.step("Carregando a configuração do projeto…");
|
|
93
100
|
// Rodando dentro de um worktree, a camada gerada (`.houndsman/core`)
|
|
94
101
|
// não existe (é ignorada pelo git). Repõe a partir do clone principal
|
|
95
102
|
// antes de importar a config — sem isso os watchdogs não resolvem
|
|
96
103
|
// `../core/types`. No clone principal é no-op.
|
|
97
104
|
syncProjectLayerForRoot(process.cwd());
|
|
98
|
-
|
|
105
|
+
const result = await loadConfig({ configPath: parser.value("--config") });
|
|
106
|
+
if (!silentProgress) {
|
|
107
|
+
logger.success(
|
|
108
|
+
`Configuração: ${result.config.projectName} · ` +
|
|
109
|
+
`${result.config.watchdogIds.length} watchdog(s) ` +
|
|
110
|
+
`(${formatDuration(Date.now() - started)})`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
99
114
|
})();
|
|
100
115
|
return loaded.config;
|
|
101
116
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progresso do CLI por etapa do prepare (apresentação).
|
|
3
|
+
*
|
|
4
|
+
* O core emite eventos (`PrepareProgressEvent`); aqui eles viram spinner/linha
|
|
5
|
+
* com a duração de cada fase — o `hunt` começa a dar sinal de vida antes do
|
|
6
|
+
* primeiro `Discovery:`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatDuration, type LoggerLike, PhaseReporter } from "../core/logging";
|
|
10
|
+
import type { PrepareProgress, PrepareStage } from "../core/orchestrator";
|
|
11
|
+
|
|
12
|
+
/** Texto de cada etapa (início no gerúndio, fim no particípio). */
|
|
13
|
+
export const PREPARE_STAGE_TEXT: Record<PrepareStage, { start: string; done: string }> = {
|
|
14
|
+
diff: { start: "Lendo o diff da revisão", done: "Diff lido" },
|
|
15
|
+
ast: { start: "Analisando a AST dos arquivos alterados", done: "AST analisada" },
|
|
16
|
+
classify: {
|
|
17
|
+
start: "Classificando os arquivos por watchdog",
|
|
18
|
+
done: "Classificação concluída",
|
|
19
|
+
},
|
|
20
|
+
impact: { start: "Medindo o impacto nos importadores", done: "Impacto calculado" },
|
|
21
|
+
jev: { start: "Pré-analisando o diff (Jev/IA)", done: "Pré-análise concluída" },
|
|
22
|
+
context: { start: "Resumindo o contexto da PR", done: "Contexto resumido" },
|
|
23
|
+
technical: {
|
|
24
|
+
start: "Consolidando os pontos técnicos",
|
|
25
|
+
done: "Pontos técnicos prontos",
|
|
26
|
+
},
|
|
27
|
+
"ai-plans": { start: "Planejando os briefs com IA", done: "Planos de brief prontos" },
|
|
28
|
+
briefs: { start: "Gerando os briefs", done: "Briefs gerados" },
|
|
29
|
+
manifest: { start: "Gravando o manifesto da revisão", done: "Manifesto gravado" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Cria o callback de progresso do prepare ligado ao logger do CLI. */
|
|
33
|
+
export function prepareProgress(logger: LoggerLike): PrepareProgress {
|
|
34
|
+
const reporter = new PhaseReporter(logger);
|
|
35
|
+
return (event) => {
|
|
36
|
+
const text = PREPARE_STAGE_TEXT[event.stage];
|
|
37
|
+
if (event.type === "start") reporter.begin(event.stage, text.start);
|
|
38
|
+
else reporter.end(event.stage, text.done, event.durationMs);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Log de um passo concluído com a duração (usado fora do prepare). */
|
|
43
|
+
export function stepDone(logger: LoggerLike, message: string, startedMs: number): void {
|
|
44
|
+
logger.success(`${message} (${formatDuration(Date.now() - startedMs)})`);
|
|
45
|
+
}
|
|
@@ -31,6 +31,43 @@ function numberOrZero(value: unknown): number {
|
|
|
31
31
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Silencia o emissor default de avisos do AI SDK.
|
|
36
|
+
*
|
|
37
|
+
* O default usa `process.emitWarning` — com stack trace — a cada chamada, o que
|
|
38
|
+
* polui o stderr do CLI. Os avisos continuam disponíveis no resultado da
|
|
39
|
+
* chamada (`ModelResponse.warnings`). Um chamador que queira o comportamento
|
|
40
|
+
* original basta definir `AI_SDK_LOG_WARNINGS` antes de usar o SDK.
|
|
41
|
+
*/
|
|
42
|
+
function installWarningSink(): void {
|
|
43
|
+
if (globalThis.AI_SDK_LOG_WARNINGS === undefined) {
|
|
44
|
+
globalThis.AI_SDK_LOG_WARNINGS = () => {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
installWarningSink();
|
|
49
|
+
|
|
50
|
+
/** Normaliza um aviso do provider em texto legível (puro). */
|
|
51
|
+
export function formatSdkWarning(raw: unknown): string {
|
|
52
|
+
const warning = (raw ?? {}) as Record<string, unknown>;
|
|
53
|
+
const label = [warning.type, warning.feature, warning.setting]
|
|
54
|
+
.filter((value): value is string => typeof value === "string" && value !== "")
|
|
55
|
+
.join(": ");
|
|
56
|
+
const detail =
|
|
57
|
+
typeof warning.details === "string"
|
|
58
|
+
? warning.details
|
|
59
|
+
: typeof warning.message === "string"
|
|
60
|
+
? warning.message
|
|
61
|
+
: "";
|
|
62
|
+
const head = label || "aviso";
|
|
63
|
+
return detail ? `${head} — ${detail}` : head;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Lista de avisos do provider em texto (puro). */
|
|
67
|
+
export function formatSdkWarnings(raw: unknown): string[] {
|
|
68
|
+
return Array.isArray(raw) ? raw.map(formatSdkWarning) : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
/** Argumentos aceitos por `generateText` (inclui `providerOptions` do SDK). */
|
|
35
72
|
type GenerateTextArgs = Parameters<typeof generateText>[0];
|
|
36
73
|
|
|
@@ -86,10 +123,12 @@ export class AiSdkModelProvider implements ModelProvider {
|
|
|
86
123
|
const output = request.outputSchema
|
|
87
124
|
? (result.output as T)
|
|
88
125
|
: ((result.text ?? "") as unknown as T);
|
|
126
|
+
const warnings = formatSdkWarnings(result.warnings);
|
|
89
127
|
return {
|
|
90
128
|
output,
|
|
91
129
|
usage: mapUsage(result.usage),
|
|
92
130
|
finishReason: result.finishReason ?? "unknown",
|
|
131
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
93
132
|
};
|
|
94
133
|
} catch (error) {
|
|
95
134
|
if (error instanceof Error && error.name === "AbortError") {
|
package/src/core/ai/index.ts
CHANGED
|
@@ -10,7 +10,12 @@ export type {
|
|
|
10
10
|
TextRequest,
|
|
11
11
|
} from "./ai-client";
|
|
12
12
|
export { AiClient } from "./ai-client";
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
AiSdkModelProvider,
|
|
15
|
+
formatSdkWarning,
|
|
16
|
+
formatSdkWarnings,
|
|
17
|
+
mapUsage,
|
|
18
|
+
} from "./ai-sdk-model-provider";
|
|
14
19
|
export {
|
|
15
20
|
AiBudgetError,
|
|
16
21
|
AiConfigurationError,
|
package/src/core/ai/types.ts
CHANGED
|
@@ -50,6 +50,8 @@ export type ModelResponse<T> = {
|
|
|
50
50
|
output: T;
|
|
51
51
|
usage: AiUsage;
|
|
52
52
|
finishReason: string;
|
|
53
|
+
/** Avisos do provider já normalizados em texto (ex.: modo de compatibilidade). */
|
|
54
|
+
warnings?: string[];
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
/** Provider de modelo (adaptador do AI SDK ou fake nos testes). */
|
|
@@ -242,6 +242,16 @@ export type HoundsmanAi = {
|
|
|
242
242
|
watchdogs?: Record<string, HoundsmanAiWatchdog>;
|
|
243
243
|
};
|
|
244
244
|
|
|
245
|
+
/** Harness local da capability \`hunt\` (\`houndsman hunt\`) — opcional. */
|
|
246
|
+
export type HoundsmanHunt = {
|
|
247
|
+
enabled?: boolean;
|
|
248
|
+
model?: string;
|
|
249
|
+
concurrency?: number;
|
|
250
|
+
timeoutMs?: number;
|
|
251
|
+
retries?: number;
|
|
252
|
+
binaryPath?: string;
|
|
253
|
+
};
|
|
254
|
+
|
|
245
255
|
/** Contrato de configuração do motor. */
|
|
246
256
|
export type HoundsmanConfig<TId extends string = string> = {
|
|
247
257
|
projectName: string;
|
|
@@ -255,6 +265,7 @@ export type HoundsmanConfig<TId extends string = string> = {
|
|
|
255
265
|
concurrency?: HoundsmanConcurrency;
|
|
256
266
|
ast?: HoundsmanAst;
|
|
257
267
|
ai?: HoundsmanAi;
|
|
268
|
+
hunt?: HoundsmanHunt;
|
|
258
269
|
};
|
|
259
270
|
|
|
260
271
|
/** Helper de autoria (identidade) — apenas tipagem. */
|
package/src/core/config.ts
CHANGED
|
@@ -19,7 +19,11 @@ export {
|
|
|
19
19
|
DEFAULT_ALWAYS_INCLUDE_DOCS,
|
|
20
20
|
DEFAULT_AST_MAX_FILE_BYTES,
|
|
21
21
|
DEFAULT_AST_MAX_IMPACT_FILES,
|
|
22
|
+
DEFAULT_HUNT_CONCURRENCY,
|
|
23
|
+
DEFAULT_HUNT_RETRIES,
|
|
24
|
+
DEFAULT_HUNT_TIMEOUT_MS,
|
|
22
25
|
DEFAULT_JEV_CONCURRENCY,
|
|
26
|
+
DEFAULT_MAKI_VERSION,
|
|
23
27
|
DEFAULT_MAX_FILES_PER_BRIEF,
|
|
24
28
|
} from "./defaults";
|
|
25
29
|
|
|
@@ -122,6 +126,28 @@ export type HoundsmanWorktrees = {
|
|
|
122
126
|
location?: "home" | "workspace" | string;
|
|
123
127
|
};
|
|
124
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Harness local da capability `hunt` (`houndsman hunt`) — opcional.
|
|
131
|
+
*
|
|
132
|
+
* Sem esta config, o comando usa defaults seguros: modelo derivado de `ai`,
|
|
133
|
+
* 4 processos simultâneos e timeout de 15 min por watchdog. A skill
|
|
134
|
+
* `/houndsman hunt` (harness externo) não depende deste bloco.
|
|
135
|
+
*/
|
|
136
|
+
export type HoundsmanHunt = {
|
|
137
|
+
/** Desliga o harness local sem desinstalar o binário (default: ligado). */
|
|
138
|
+
enabled?: boolean;
|
|
139
|
+
/** Modelo no formato do maki (`provider/model-id`). Default: derivado de `ai`. */
|
|
140
|
+
model?: string;
|
|
141
|
+
/** Processos maki simultâneos (default: 4). */
|
|
142
|
+
concurrency?: number;
|
|
143
|
+
/** Timeout por watchdog em ms (default: 900_000). */
|
|
144
|
+
timeoutMs?: number;
|
|
145
|
+
/** Tentativas extras quando a saída do watchdog é inválida (default: 1). */
|
|
146
|
+
retries?: number;
|
|
147
|
+
/** Caminho explícito do binário (`HOUNDSMAN_MAKI_PATH` tem precedência). */
|
|
148
|
+
binaryPath?: string;
|
|
149
|
+
};
|
|
150
|
+
|
|
125
151
|
/** Backend de persistência (reviews, findings e artefatos). */
|
|
126
152
|
export type HoundsmanStorage = {
|
|
127
153
|
/**
|
|
@@ -169,6 +195,8 @@ export type HoundsmanConfig<TId extends string = string> = {
|
|
|
169
195
|
ast?: HoundsmanAst;
|
|
170
196
|
/** Camada de IA (briefs, contexto, classificação, pré-análise e verify). */
|
|
171
197
|
ai?: HoundsmanAi;
|
|
198
|
+
/** Harness local da capability `hunt` (maki) — opcional. */
|
|
199
|
+
hunt?: HoundsmanHunt;
|
|
172
200
|
};
|
|
173
201
|
|
|
174
202
|
/** Entrada de `defineHoundsmanConfig` — `watchdogIds` é derivado, não informado. */
|
package/src/core/defaults.ts
CHANGED
|
@@ -46,3 +46,18 @@ export const DEFAULT_AI_TIMEOUT_MS = 120_000;
|
|
|
46
46
|
|
|
47
47
|
/** Teto default de chamadas de IA por review. */
|
|
48
48
|
export const DEFAULT_AI_MAX_CALLS_PER_REVIEW = 40;
|
|
49
|
+
|
|
50
|
+
/** Processos maki simultâneos no harness local (`houndsman hunt`). */
|
|
51
|
+
export const DEFAULT_HUNT_CONCURRENCY = 4;
|
|
52
|
+
|
|
53
|
+
/** Timeout default de um watchdog no harness local (15 min). */
|
|
54
|
+
export const DEFAULT_HUNT_TIMEOUT_MS = 900_000;
|
|
55
|
+
|
|
56
|
+
/** Retentativas default quando a saída do watchdog é inválida. */
|
|
57
|
+
export const DEFAULT_HUNT_RETRIES = 1;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Versão do maki fixada pelo houndsman. O binário é baixado por
|
|
61
|
+
* `houndsman hunt install`; `HOUNDSMAN_MAKI_VERSION` sobrescreve.
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_MAKI_VERSION = "0.6.5";
|
|
@@ -57,23 +57,37 @@ export type ReviewScope =
|
|
|
57
57
|
ast?: Map<string, AstAnalysis>;
|
|
58
58
|
};
|
|
59
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
|
+
|
|
60
71
|
/** Decide entre review completo e incremental. */
|
|
61
72
|
export class ReviewScopeResolver {
|
|
62
73
|
private readonly options: ScopeOptions;
|
|
63
74
|
private readonly classifier: FileClassifier;
|
|
64
75
|
private readonly ast?: AstService;
|
|
65
76
|
private readonly git: GitRepository;
|
|
77
|
+
private readonly onProgress?: ScopeProgress;
|
|
66
78
|
|
|
67
79
|
constructor(
|
|
68
80
|
options: ScopeOptions,
|
|
69
81
|
config: HoundsmanConfig,
|
|
70
82
|
ast?: AstService,
|
|
71
83
|
git: GitRepository = new GitRepository(),
|
|
84
|
+
onProgress?: ScopeProgress,
|
|
72
85
|
) {
|
|
73
86
|
this.options = options;
|
|
74
87
|
this.classifier = new FileClassifier(config);
|
|
75
88
|
this.ast = ast;
|
|
76
89
|
this.git = git;
|
|
90
|
+
this.onProgress = onProgress;
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
/**
|
|
@@ -100,24 +114,33 @@ export class ReviewScopeResolver {
|
|
|
100
114
|
!prevSha ||
|
|
101
115
|
!this.git.isAncestor(prevSha, headSha)
|
|
102
116
|
) {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
)
|
|
117
138
|
: undefined;
|
|
118
|
-
const result = this.
|
|
119
|
-
.
|
|
120
|
-
|
|
139
|
+
const result = this.step("classify", () =>
|
|
140
|
+
this.classifier
|
|
141
|
+
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
142
|
+
.toJSON(),
|
|
143
|
+
);
|
|
121
144
|
return {
|
|
122
145
|
kind: "review",
|
|
123
146
|
diff,
|
|
@@ -136,16 +159,25 @@ export class ReviewScopeResolver {
|
|
|
136
159
|
}
|
|
137
160
|
|
|
138
161
|
const range = `${prevSha}..${headSha}`;
|
|
139
|
-
const
|
|
140
|
-
|
|
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;
|
|
141
169
|
const changedPaths = new Set(entries.map((entry) => entry.path));
|
|
142
|
-
const
|
|
143
|
-
const ast =
|
|
144
|
-
? await this.ast
|
|
170
|
+
const astService = this.ast;
|
|
171
|
+
const ast = astService
|
|
172
|
+
? await this.stepAsync("ast", () =>
|
|
173
|
+
astService.analyze(entries, scopeDiff, prevSha),
|
|
174
|
+
)
|
|
145
175
|
: undefined;
|
|
146
|
-
const result = this.
|
|
147
|
-
.
|
|
148
|
-
|
|
176
|
+
const result = this.step("classify", () =>
|
|
177
|
+
this.classifier
|
|
178
|
+
.classify(entries, churnToRecord(diff.churn), factsOf(ast))
|
|
179
|
+
.toJSON(),
|
|
180
|
+
);
|
|
149
181
|
const classification: RunClassification = {
|
|
150
182
|
...result,
|
|
151
183
|
base: prevSha,
|
|
@@ -163,6 +195,41 @@ export class ReviewScopeResolver {
|
|
|
163
195
|
return { kind: "review", classification, diff, reviewedFrom: prevSha, ast };
|
|
164
196
|
}
|
|
165
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
|
+
|
|
166
233
|
/** Patch `-U0` do intervalo; falha aberta cai no diff já coletado. */
|
|
167
234
|
private zeroContext(range: string, fallback: DiffSource): DiffSource {
|
|
168
235
|
try {
|
|
@@ -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
|
},
|