@payconductor/houndsman 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -13
- package/package.json +1 -1
- package/src/cli/commands/doctor-command.ts +22 -0
- package/src/cli/commands/hunt-command.ts +440 -29
- package/src/cli/commands/review-command.ts +20 -2
- package/src/cli/help.ts +16 -8
- package/src/cli/index.ts +15 -2
- package/src/cli/progress.ts +45 -0
- package/src/core/ai/ai-client.ts +3 -7
- package/src/core/ai/ai-sdk-model-provider.ts +43 -24
- package/src/core/ai/features/brief-enricher.ts +0 -1
- package/src/core/ai/features/classifier.ts +0 -1
- package/src/core/ai/features/context-summarizer.ts +0 -1
- package/src/core/ai/features/pre-analyzer.ts +0 -1
- package/src/core/ai/features/technical-summarizer.ts +0 -1
- package/src/core/ai/features/verifier.ts +0 -1
- package/src/core/ai/index.ts +6 -4
- package/src/core/ai/prompts/pre-analysis.ts +3 -2
- package/src/core/ai/schemas.ts +1 -23
- package/src/core/ai/types.ts +4 -15
- package/src/core/ai/usage-budget.ts +1 -1
- package/src/core/ast/diff.ts +37 -2
- package/src/core/ast/service.ts +1 -1
- package/src/core/brief.ts +54 -5
- package/src/core/changed-scope.ts +207 -0
- package/src/core/commands/scaffold.ts +12 -18
- package/src/core/config.ts +30 -15
- package/src/core/defaults.ts +14 -5
- package/src/core/engine/review-scope.ts +131 -17
- package/src/core/engine/review-session.ts +7 -3
- package/src/core/engine/review-workspace.ts +14 -0
- package/src/core/env.ts +14 -0
- package/src/core/hunt/hunt-runner.ts +328 -0
- package/src/core/hunt/index.ts +59 -0
- package/src/core/hunt/maki-install.ts +331 -0
- package/src/core/hunt/maki-model.ts +101 -0
- package/src/core/hunt/maki-prompt.ts +71 -0
- package/src/core/hunt/maki-release.ts +104 -0
- package/src/core/hunt/maki-runner.ts +276 -0
- package/src/core/hunt/pool.ts +30 -0
- package/src/core/jev/jev-detector.ts +7 -2
- package/src/core/logging/index.ts +3 -1
- package/src/core/logging/logger.ts +22 -3
- package/src/core/logging/progress.ts +282 -0
- package/src/core/orchestrator.ts +173 -59
- package/src/core/project-config.ts +34 -24
- package/src/core/registry-check.ts +2 -12
- package/src/core/rule-text.ts +3 -55
- package/src/core/services/git-repository.ts +8 -0
- package/src/core/storage/types.ts +1 -1
- package/src/core/types.ts +3 -14
- package/src/index.ts +74 -3
- package/src/server/client/utils/constants.ts +3 -6
- package/src/server/client/utils/types.ts +0 -1
- package/src/skill/OUTPUT-FORMAT.md +5 -0
- package/src/skill/README.md +14 -1
- package/src/skill/SETUP.md +22 -2
- package/src/skill/SKILL.md +4 -0
- package/src/skill/fix.md +1 -1
- package/src/skill/hunt.md +38 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +69 -32
- package/src/tests/ai-core.test.ts +38 -1
- package/src/tests/changed-scope.test.ts +197 -0
- package/src/tests/cli.test.ts +1 -0
- package/src/tests/fallback.test.ts +1 -96
- package/src/tests/fixtures/fake-model-provider.ts +0 -1
- package/src/tests/hunt-cli.test.ts +185 -0
- package/src/tests/hunt-install.test.ts +153 -0
- package/src/tests/hunt-maki.test.ts +467 -0
- package/src/tests/progress.test.ts +117 -0
- package/src/tests/review-scope.test.ts +95 -0
- package/src/tests/worktree.test.ts +1 -3
- package/src/harness/README.md +0 -15
- package/src/harness/agent/agent-context.ts +0 -33
- package/src/harness/agent/agent-errors.ts +0 -30
- package/src/harness/agent/agent-factory.ts +0 -46
- package/src/harness/agent/agent-reporter.ts +0 -34
- package/src/harness/agent/agent-result.ts +0 -16
- package/src/harness/agent/agent-runner.ts +0 -32
- package/src/harness/agent/agent-scheduler.ts +0 -37
- package/src/harness/agent/review-agent.ts +0 -401
- package/src/harness/config/harness-config.ts +0 -89
- package/src/harness/hunt/hunt-harness.ts +0 -83
- package/src/harness/hunt/hunt-options.ts +0 -92
- package/src/harness/hunt/hunt-result.ts +0 -17
- package/src/harness/hunt/hunt-session.ts +0 -359
- package/src/harness/hunt/review-planner.ts +0 -98
- package/src/harness/index.ts +0 -36
- package/src/harness/investigation/agent-state.ts +0 -134
- package/src/harness/investigation/evidence-verifier.ts +0 -129
- package/src/harness/investigation/evidence.ts +0 -110
- package/src/harness/investigation/finding-critic.ts +0 -96
- package/src/harness/investigation/investigation-planner.ts +0 -118
- package/src/harness/investigation/investigation-task.ts +0 -10
- package/src/harness/investigation/review-journal.ts +0 -87
- package/src/harness/investigation/rule-analyzer.ts +0 -178
- package/src/harness/investigation/rule-signals.ts +0 -110
- package/src/harness/prompts/agent-schemas.ts +0 -57
- package/src/harness/prompts/reviewer-prompt-builder.ts +0 -101
- package/src/harness/prompts/states/conclude-prompt.ts +0 -16
- package/src/harness/prompts/states/investigate-prompt.ts +0 -37
- package/src/harness/prompts/states/orient-prompt.ts +0 -15
- package/src/harness/prompts/states/verify-prompt.ts +0 -22
- package/src/harness/tools/agent-tool.ts +0 -102
- package/src/harness/tools/builtin/changed-symbols-tool.ts +0 -48
- package/src/harness/tools/builtin/doc-context-tool.ts +0 -74
- package/src/harness/tools/builtin/file-outline-tool.ts +0 -66
- package/src/harness/tools/builtin/find-references-tool.ts +0 -66
- package/src/harness/tools/builtin/find-symbol-tool.ts +0 -91
- package/src/harness/tools/builtin/helpers.ts +0 -115
- package/src/harness/tools/builtin/impact-tool.ts +0 -48
- package/src/harness/tools/builtin/imports-exports-tool.ts +0 -53
- package/src/harness/tools/builtin/index.ts +0 -59
- package/src/harness/tools/builtin/list-files-tool.ts +0 -85
- package/src/harness/tools/builtin/pr-context-tool.ts +0 -41
- package/src/harness/tools/builtin/read-file-range-tool.ts +0 -73
- package/src/harness/tools/builtin/rule-signals-tool.ts +0 -36
- package/src/harness/tools/builtin/search-code-tool.ts +0 -65
- package/src/harness/tools/builtin/symbol-context-tool.ts +0 -106
- package/src/harness/tools/builtin/tests-for-symbol-tool.ts +0 -61
- package/src/harness/tools/path-guard.ts +0 -90
- package/src/harness/tools/result-limiter.ts +0 -49
- package/src/harness/tools/secret-redactor.ts +0 -54
- package/src/harness/tools/tool-cache.ts +0 -50
- package/src/harness/tools/tool-call-budget.ts +0 -60
- package/src/harness/tools/tool-context.ts +0 -5
- package/src/harness/tools/tool-middleware.ts +0 -105
- package/src/harness/tools/tool-policy.ts +0 -51
- package/src/harness/tools/tool-registry.ts +0 -112
- package/src/harness/tools/tool-result.ts +0 -15
- package/src/harness/tools/toolbox.ts +0 -36
- package/src/harness/validation/finding-validator.ts +0 -119
- package/src/tests/fixtures/fake-agent-tools.ts +0 -69
- package/src/tests/fixtures/fake-investigation.ts +0 -64
- package/src/tests/harness-agent.test.ts +0 -141
- package/src/tests/harness-investigation.test.ts +0 -206
- package/src/tests/harness-tools.test.ts +0 -148
- package/src/tests/hunt-command.test.ts +0 -212
- package/src/tests/rule-analyzer.test.ts +0 -61
- package/src/tests/tool-contracts.test.ts +0 -82
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execução headless do maki (harness local).
|
|
3
|
+
*
|
|
4
|
+
* Um processo por watchdog, sem TUI: o prompt vai por stdin (`maki -p`) e o
|
|
5
|
+
* houndsman lê o JSON de `--output-format json`. O watchdog não recebe
|
|
6
|
+
* permissão de escrita: `write`/`edit`/`bash`/rede/`task` são negados (deny
|
|
7
|
+
* vence qualquer yolo). Nenhuma config global do maki é alterada.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Ferramentas negadas ao watchdog (deny vence `--yolo`). */
|
|
11
|
+
export const MAKI_DISALLOWED_TOOLS =
|
|
12
|
+
"bash,webfetch,websearch,write,edit,multiedit,edit_lines,insert_lines,task";
|
|
13
|
+
|
|
14
|
+
/** Pedido de execução de um watchdog. */
|
|
15
|
+
export type MakiRunRequest = {
|
|
16
|
+
/** Prompt completo (brief + contrato). */
|
|
17
|
+
prompt: string;
|
|
18
|
+
/** Raiz de leitura (worktree ou clone principal). */
|
|
19
|
+
cwd: string;
|
|
20
|
+
/** Spec `provider/model-id`. */
|
|
21
|
+
model: string;
|
|
22
|
+
/** Variável nativa que o maki lê (`DEEPSEEK_API_KEY` etc.). */
|
|
23
|
+
apiKeyEnv: string;
|
|
24
|
+
/** Valor da chave (injetado só no processo filho). */
|
|
25
|
+
apiKey: string;
|
|
26
|
+
/** Timeout do processo (ms). */
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Uso de tokens normalizado. */
|
|
31
|
+
export type MakiUsage = {
|
|
32
|
+
inputTokens?: number;
|
|
33
|
+
outputTokens?: number;
|
|
34
|
+
totalTokens?: number;
|
|
35
|
+
cacheReadInputTokens?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Métricas de uma execução. */
|
|
39
|
+
export type MakiRunMetrics = {
|
|
40
|
+
sessionId?: string;
|
|
41
|
+
durationMs: number;
|
|
42
|
+
numTurns?: number;
|
|
43
|
+
costUsd?: number;
|
|
44
|
+
usage?: MakiUsage;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Resultado de uma execução. */
|
|
48
|
+
export type MakiRunResult =
|
|
49
|
+
| {
|
|
50
|
+
ok: true;
|
|
51
|
+
/** Texto final do agente (deve conter o JSON dos findings). */
|
|
52
|
+
text: string;
|
|
53
|
+
metrics: MakiRunMetrics;
|
|
54
|
+
stderr: string;
|
|
55
|
+
exitCode: number;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
ok: false;
|
|
59
|
+
error: string;
|
|
60
|
+
metrics: MakiRunMetrics;
|
|
61
|
+
stderr: string;
|
|
62
|
+
exitCode?: number;
|
|
63
|
+
timedOut?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Runner injetável (testes usam um fake sem processo). */
|
|
67
|
+
export interface MakiProcessRunner {
|
|
68
|
+
run(request: MakiRunRequest): Promise<MakiRunResult>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Saída JSON do `maki --print --output-format json`. */
|
|
72
|
+
export type MakiJsonOutput = {
|
|
73
|
+
type?: string;
|
|
74
|
+
subtype?: string;
|
|
75
|
+
is_error?: boolean;
|
|
76
|
+
result?: string;
|
|
77
|
+
session_id?: string;
|
|
78
|
+
duration_ms?: number;
|
|
79
|
+
num_turns?: number;
|
|
80
|
+
total_cost_usd?: number;
|
|
81
|
+
usage?: {
|
|
82
|
+
input_tokens?: number;
|
|
83
|
+
output_tokens?: number;
|
|
84
|
+
total_tokens?: number;
|
|
85
|
+
cache_read_input_tokens?: number;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Argumentos do processo (o prompt vai por stdin). */
|
|
90
|
+
export function makiArgs(model: string): string[] {
|
|
91
|
+
return [
|
|
92
|
+
"-p",
|
|
93
|
+
"--output-format",
|
|
94
|
+
"json",
|
|
95
|
+
"--model",
|
|
96
|
+
model,
|
|
97
|
+
"--disallowed-tools",
|
|
98
|
+
MAKI_DISALLOWED_TOOLS,
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Runner real, via `Bun.spawn`. */
|
|
103
|
+
export class SpawnMakiRunner implements MakiProcessRunner {
|
|
104
|
+
private readonly binary: string;
|
|
105
|
+
|
|
106
|
+
constructor(binary: string) {
|
|
107
|
+
this.binary = binary;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async run(request: MakiRunRequest): Promise<MakiRunResult> {
|
|
111
|
+
const started = Date.now();
|
|
112
|
+
let timedOut = false;
|
|
113
|
+
try {
|
|
114
|
+
const proc = Bun.spawn([this.binary, ...makiArgs(request.model)], {
|
|
115
|
+
cwd: request.cwd,
|
|
116
|
+
env: { ...process.env, [request.apiKeyEnv]: request.apiKey },
|
|
117
|
+
stdin: "pipe",
|
|
118
|
+
stdout: "pipe",
|
|
119
|
+
stderr: "pipe",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
proc.stdin.write(request.prompt);
|
|
124
|
+
void Promise.resolve(proc.stdin.end()).catch(() => {});
|
|
125
|
+
} catch {
|
|
126
|
+
// O processo pode ter encerrado antes de ler o stdin; o exit code
|
|
127
|
+
// e o stderr abaixo explicam o motivo.
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Consome os pipes desde já para não travar em saídas grandes.
|
|
131
|
+
const stdoutPromise = new Response(proc.stdout).text();
|
|
132
|
+
const stderrPromise = new Response(proc.stderr).text();
|
|
133
|
+
const timer = setTimeout(
|
|
134
|
+
() => {
|
|
135
|
+
timedOut = true;
|
|
136
|
+
proc.kill();
|
|
137
|
+
},
|
|
138
|
+
Math.max(1_000, request.timeoutMs),
|
|
139
|
+
);
|
|
140
|
+
const exitCode = await proc.exited;
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
143
|
+
const durationMs = Date.now() - started;
|
|
144
|
+
const parsed = parseMakiOutput(stdout);
|
|
145
|
+
const metrics = metricsFrom(parsed, durationMs);
|
|
146
|
+
|
|
147
|
+
if (timedOut) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
error: `Timeout após ${Math.round(durationMs / 1000)}s no maki.`,
|
|
151
|
+
metrics,
|
|
152
|
+
stderr,
|
|
153
|
+
exitCode,
|
|
154
|
+
timedOut: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (exitCode !== 0 || parsed?.is_error === true) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error:
|
|
161
|
+
parsed?.result?.trim() ||
|
|
162
|
+
stderr.trim() ||
|
|
163
|
+
`maki saiu com código ${exitCode}.`,
|
|
164
|
+
metrics,
|
|
165
|
+
stderr,
|
|
166
|
+
exitCode,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (typeof parsed?.result !== "string" || parsed.result.trim().length === 0) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
error: "maki não retornou `result` (esperado com --output-format json).",
|
|
173
|
+
metrics,
|
|
174
|
+
stderr,
|
|
175
|
+
exitCode,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
text: parsed.result,
|
|
181
|
+
metrics,
|
|
182
|
+
stderr,
|
|
183
|
+
exitCode,
|
|
184
|
+
};
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
error:
|
|
189
|
+
error instanceof Error
|
|
190
|
+
? `Falha ao executar o maki: ${error.message}`
|
|
191
|
+
: `Falha ao executar o maki: ${String(error)}`,
|
|
192
|
+
metrics: { durationMs: Date.now() - started },
|
|
193
|
+
stderr: "",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Extrai o objeto JSON do stdout do maki (linha única ou objeto completo). */
|
|
200
|
+
export function parseMakiOutput(stdout: string): MakiJsonOutput | null {
|
|
201
|
+
const whole = tryParseJson(stdout.trim());
|
|
202
|
+
if (whole.ok && isObject(whole.value)) return whole.value as MakiJsonOutput;
|
|
203
|
+
|
|
204
|
+
const lines = stdout.split(/\r?\n/);
|
|
205
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
206
|
+
const line = lines[index].trim();
|
|
207
|
+
if (!line.startsWith("{")) continue;
|
|
208
|
+
const parsed = tryParseJson(line);
|
|
209
|
+
if (parsed.ok && isObject(parsed.value)) return parsed.value as MakiJsonOutput;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Extrai o JSON dos findings da resposta final do agente.
|
|
216
|
+
* Aceita JSON puro, bloco markdown ou objeto cercado por texto.
|
|
217
|
+
*/
|
|
218
|
+
export function extractJsonObject(text: string): unknown | null {
|
|
219
|
+
const trimmed = text.trim();
|
|
220
|
+
if (trimmed.length === 0) return null;
|
|
221
|
+
|
|
222
|
+
const direct = tryParseJson(trimmed);
|
|
223
|
+
if (direct.ok) return direct.value;
|
|
224
|
+
|
|
225
|
+
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
226
|
+
if (fenced) {
|
|
227
|
+
const parsed = tryParseJson(fenced[1].trim());
|
|
228
|
+
if (parsed.ok) return parsed.value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const start = trimmed.indexOf("{");
|
|
232
|
+
const end = trimmed.lastIndexOf("}");
|
|
233
|
+
if (start >= 0 && end > start) {
|
|
234
|
+
const parsed = tryParseJson(trimmed.slice(start, end + 1));
|
|
235
|
+
if (parsed.ok) return parsed.value;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Normaliza as métricas do JSON do maki. */
|
|
241
|
+
export function metricsFrom(
|
|
242
|
+
output: MakiJsonOutput | null,
|
|
243
|
+
durationMs: number,
|
|
244
|
+
): MakiRunMetrics {
|
|
245
|
+
const usage = output?.usage;
|
|
246
|
+
return {
|
|
247
|
+
sessionId: typeof output?.session_id === "string" ? output.session_id : undefined,
|
|
248
|
+
durationMs: numberOr(output?.duration_ms) ?? durationMs,
|
|
249
|
+
numTurns: numberOr(output?.num_turns),
|
|
250
|
+
costUsd: numberOr(output?.total_cost_usd),
|
|
251
|
+
usage: usage
|
|
252
|
+
? {
|
|
253
|
+
inputTokens: numberOr(usage.input_tokens),
|
|
254
|
+
outputTokens: numberOr(usage.output_tokens),
|
|
255
|
+
totalTokens: numberOr(usage.total_tokens),
|
|
256
|
+
cacheReadInputTokens: numberOr(usage.cache_read_input_tokens),
|
|
257
|
+
}
|
|
258
|
+
: undefined,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function numberOr(value: unknown): number | undefined {
|
|
263
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
267
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function tryParseJson(text: string): { ok: true; value: unknown } | { ok: false } {
|
|
271
|
+
try {
|
|
272
|
+
return { ok: true, value: JSON.parse(text) };
|
|
273
|
+
} catch {
|
|
274
|
+
return { ok: false };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool de concorrência mínimo (harness local).
|
|
3
|
+
*
|
|
4
|
+
* Mantém no máximo `limit` promessas ativas e preserva a ordem dos resultados.
|
|
5
|
+
* Não trata erros: o chamador decide se cada item falha isoladamente.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Executa `mapper` para cada item com concorrência limitada. */
|
|
9
|
+
export async function mapPool<T, R>(
|
|
10
|
+
items: readonly T[],
|
|
11
|
+
limit: number,
|
|
12
|
+
mapper: (item: T, index: number) => Promise<R>,
|
|
13
|
+
): Promise<R[]> {
|
|
14
|
+
const results = new Array<R>(items.length);
|
|
15
|
+
if (items.length === 0) return results;
|
|
16
|
+
|
|
17
|
+
let next = 0;
|
|
18
|
+
const workers = Array.from(
|
|
19
|
+
{ length: Math.max(1, Math.min(limit, items.length)) },
|
|
20
|
+
async () => {
|
|
21
|
+
while (true) {
|
|
22
|
+
const index = next++;
|
|
23
|
+
if (index >= items.length) return;
|
|
24
|
+
results[index] = await mapper(items[index], index);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
await Promise.all(workers);
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
@@ -156,10 +156,15 @@ export class JevDetector {
|
|
|
156
156
|
const hunk =
|
|
157
157
|
diff.byFile.get(filePath)?.slice(0, JevDetector.MAX_DIFF_CHARS) ?? "";
|
|
158
158
|
const parts = [`Arquivo: ${filePath} (${status})`];
|
|
159
|
+
parts.push(
|
|
160
|
+
"Escopo: apenas o diff abaixo refere-se ao que mudou nesta revisão.",
|
|
161
|
+
"O topo do arquivo é contexto para entender imports/declarações — não reporte",
|
|
162
|
+
"problemas apenas nele.",
|
|
163
|
+
);
|
|
159
164
|
if (header) {
|
|
160
|
-
parts.push("--- topo do arquivo (
|
|
165
|
+
parts.push("--- topo do arquivo (contexto) ---", header);
|
|
161
166
|
}
|
|
162
|
-
if (hunk) parts.push("--- diff ---", hunk);
|
|
167
|
+
if (hunk) parts.push("--- diff (escopo alterado) ---", hunk);
|
|
163
168
|
return parts.join("\n").slice(0, JevDetector.MAX_STATE_CHARS);
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Instância pronta do logger (`import { logger } from "../core/logging"`).
|
|
3
3
|
*
|
|
4
|
-
* Infraestrutura compartilhada por `cli
|
|
4
|
+
* Infraestrutura compartilhada por `cli` e `server`. Módulos de
|
|
5
5
|
* negócio do core não escrevem saída.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -15,3 +15,5 @@ export type {
|
|
|
15
15
|
SpinnerLike,
|
|
16
16
|
} from "./logger";
|
|
17
17
|
export { Logger, SilentLogger } from "./logger";
|
|
18
|
+
export type { ProgressBoardOptions } from "./progress";
|
|
19
|
+
export { formatDuration, PhaseReporter, ProgressBoard } from "./progress";
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Logger único do CLI
|
|
2
|
+
* Logger único do CLI (infraestrutura).
|
|
3
3
|
*
|
|
4
4
|
* Usa `chalk` para cor e `ora` para spinner, mas cai para texto puro quando a
|
|
5
5
|
* saída não é TTY, `NO_COLOR` está definido, `TERM=dumb` ou `--quiet`.
|
|
6
6
|
*
|
|
7
|
-
* Não é usado por módulos de negócio do `core/`: só `cli
|
|
8
|
-
*
|
|
7
|
+
* Não é usado por módulos de negócio do `core/`: só `cli/` e `server/`
|
|
8
|
+
* escrevem saída.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { Chalk, type ChalkInstance } from "chalk";
|
|
@@ -38,6 +38,10 @@ export interface LoggerLike {
|
|
|
38
38
|
text: string,
|
|
39
39
|
run: (spinner: SpinnerLike | null) => Promise<T>,
|
|
40
40
|
): Promise<T>;
|
|
41
|
+
/** `true` quando a saída é um TTY e o modo quiet está desligado. */
|
|
42
|
+
readonly interactive: boolean;
|
|
43
|
+
/** Stream de saída (usado pelos blocos de progresso). */
|
|
44
|
+
readonly stream: NodeJS.WritableStream;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/** Opções do logger (injetáveis nos testes). */
|
|
@@ -82,6 +86,16 @@ export class Logger implements LoggerLike {
|
|
|
82
86
|
this.tty = options.isTTY ?? Boolean(process.stdout.isTTY);
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
/** Loader/blocos só fazem sentido em terminal interativo. */
|
|
90
|
+
get interactive(): boolean {
|
|
91
|
+
return this.tty && !this.quiet;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Stream dos blocos de progresso. */
|
|
95
|
+
get stream(): NodeJS.WritableStream {
|
|
96
|
+
return this.out;
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
/** Só aparece em `--verbose`. */
|
|
86
100
|
debug(message: string): void {
|
|
87
101
|
if (!this.verbose || this.quiet) return;
|
|
@@ -164,6 +178,11 @@ export class Logger implements LoggerLike {
|
|
|
164
178
|
|
|
165
179
|
/** Logger silencioso para testes/serviços. */
|
|
166
180
|
export class SilentLogger implements LoggerLike {
|
|
181
|
+
readonly interactive = false;
|
|
182
|
+
readonly stream: NodeJS.WritableStream = {
|
|
183
|
+
write: () => true,
|
|
184
|
+
} as unknown as NodeJS.WritableStream;
|
|
185
|
+
|
|
167
186
|
debug(_message?: string): void {}
|
|
168
187
|
info(_message?: string): void {}
|
|
169
188
|
success(_message?: string): void {}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progresso visual do CLI (infraestrutura).
|
|
3
|
+
*
|
|
4
|
+
* `PhaseReporter` acompanha fases sequenciais (spinner em TTY; `→`/`✔` em
|
|
5
|
+
* pipe). `ProgressBoard` mantém tarefas concorrentes com loader e tempo
|
|
6
|
+
* decorrido — é o que o harness local usa por watchdog.
|
|
7
|
+
*
|
|
8
|
+
* Progresso é diagnóstico: nenhum método lança.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LoggerLike, SpinnerLike } from "./logger";
|
|
12
|
+
|
|
13
|
+
/** Formata uma duração em ms (`820ms`, `12.3s`, `2m05s`). */
|
|
14
|
+
export function formatDuration(ms: number): string {
|
|
15
|
+
const safe = Math.max(0, Math.round(ms));
|
|
16
|
+
if (safe < 1_000) return `${safe}ms`;
|
|
17
|
+
const seconds = safe / 1_000;
|
|
18
|
+
if (seconds < 10) return `${seconds.toFixed(1)}s`;
|
|
19
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
20
|
+
const minutes = Math.floor(seconds / 60);
|
|
21
|
+
const rest = Math.round(seconds % 60);
|
|
22
|
+
return `${minutes}m${String(rest).padStart(2, "0")}s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Relógio curto no formato `m:ss` (usado no loader). */
|
|
26
|
+
function clock(ms: number): string {
|
|
27
|
+
const total = Math.max(0, Math.round(ms / 1_000));
|
|
28
|
+
const minutes = Math.floor(total / 60);
|
|
29
|
+
return `${minutes}:${String(total % 60).padStart(2, "0")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Relógio de uma fase: spinner no TTY, linhas `→`/`✔` em pipe. */
|
|
33
|
+
export class PhaseReporter {
|
|
34
|
+
private readonly logger: LoggerLike;
|
|
35
|
+
private active: { stage: string; spinner: SpinnerLike } | null = null;
|
|
36
|
+
|
|
37
|
+
constructor(logger: LoggerLike) {
|
|
38
|
+
this.logger = logger;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Abre a fase (substituindo qualquer loader anterior). */
|
|
42
|
+
begin(stage: string, message: string): void {
|
|
43
|
+
try {
|
|
44
|
+
this.stop();
|
|
45
|
+
const spinner = this.logger.spinner(message);
|
|
46
|
+
if (spinner) {
|
|
47
|
+
spinner.start();
|
|
48
|
+
this.active = { stage, spinner };
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.logger.step(message);
|
|
52
|
+
} catch {
|
|
53
|
+
// Progresso nunca derruba a revisão.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fecha a fase com a duração medida. */
|
|
58
|
+
end(stage: string, message: string, durationMs: number): void {
|
|
59
|
+
try {
|
|
60
|
+
const text = `${message} (${formatDuration(durationMs)})`;
|
|
61
|
+
if (this.active?.stage === stage) {
|
|
62
|
+
const { spinner } = this.active;
|
|
63
|
+
this.active = null;
|
|
64
|
+
spinner.succeed(text);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.logger.success(text);
|
|
68
|
+
} catch {
|
|
69
|
+
// Progresso nunca derruba a revisão.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Encerra um loader pendente (sem mensagem de sucesso). */
|
|
74
|
+
stop(): void {
|
|
75
|
+
if (!this.active) return;
|
|
76
|
+
const { spinner } = this.active;
|
|
77
|
+
this.active = null;
|
|
78
|
+
try {
|
|
79
|
+
spinner.stop();
|
|
80
|
+
} catch {
|
|
81
|
+
// Progresso nunca derruba a revisão.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Tarefa concorrente exibida pelo board. */
|
|
87
|
+
type BoardTask = {
|
|
88
|
+
id: string;
|
|
89
|
+
detail: string;
|
|
90
|
+
startedMs: number;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type ProgressBoardOptions = {
|
|
94
|
+
logger: LoggerLike;
|
|
95
|
+
/** Intervalo de repintura do loader em TTY (ms). */
|
|
96
|
+
frameMs?: number;
|
|
97
|
+
/** Intervalo do heartbeat textual fora de TTY (ms). */
|
|
98
|
+
heartbeatMs?: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Board de tarefas concorrentes (watchdogs do `hunt`).
|
|
105
|
+
*
|
|
106
|
+
* Em TTY repinta um bloco com loader + tempo decorrido por tarefa. Fora de
|
|
107
|
+
* TTY mantém as linhas de início/fim e emite um heartbeat periódico com quem
|
|
108
|
+
* ainda está rodando — o log do CI continua mostrando progresso.
|
|
109
|
+
*/
|
|
110
|
+
export class ProgressBoard {
|
|
111
|
+
private readonly logger: LoggerLike;
|
|
112
|
+
private readonly frameMs: number;
|
|
113
|
+
private readonly heartbeatMs: number;
|
|
114
|
+
private readonly tasks = new Map<string, BoardTask>();
|
|
115
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
116
|
+
private rendered = 0;
|
|
117
|
+
private frame = 0;
|
|
118
|
+
private done = 0;
|
|
119
|
+
private failed = 0;
|
|
120
|
+
private total = 0;
|
|
121
|
+
private lastHeartbeat = Date.now();
|
|
122
|
+
|
|
123
|
+
constructor(options: ProgressBoardOptions) {
|
|
124
|
+
this.logger = options.logger;
|
|
125
|
+
this.frameMs = options.frameMs ?? 200;
|
|
126
|
+
this.heartbeatMs = options.heartbeatMs ?? 30_000;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Define o total de tarefas do lote (rodapé do bloco). */
|
|
130
|
+
start(total: number): void {
|
|
131
|
+
this.total = Math.max(0, total);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Registra uma tarefa em execução. */
|
|
135
|
+
add(id: string, detail = "em execução"): void {
|
|
136
|
+
try {
|
|
137
|
+
this.tasks.set(id, { id, detail, startedMs: Date.now() });
|
|
138
|
+
if (!this.logger.interactive) this.logger.bullet(`${id}: ${detail}…`);
|
|
139
|
+
this.ensureTimer();
|
|
140
|
+
this.paint();
|
|
141
|
+
} catch {
|
|
142
|
+
// Progresso nunca derruba a revisão.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Atualiza o detalhe de uma tarefa (ex.: tentativa atual). */
|
|
147
|
+
update(id: string, detail: string): void {
|
|
148
|
+
const task = this.tasks.get(id);
|
|
149
|
+
if (!task) return;
|
|
150
|
+
task.detail = detail;
|
|
151
|
+
this.paint();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Atualiza o detalhe e avisa em pipe (tentativas/retries). */
|
|
155
|
+
retry(id: string, detail: string, message: string): void {
|
|
156
|
+
try {
|
|
157
|
+
this.update(id, detail);
|
|
158
|
+
if (!this.logger.interactive) this.logger.bullet(message);
|
|
159
|
+
} catch {
|
|
160
|
+
// Progresso nunca derruba a revisão.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Conclui uma tarefa com a mensagem final. */
|
|
165
|
+
succeed(id: string, message: string): void {
|
|
166
|
+
try {
|
|
167
|
+
if (this.tasks.delete(id)) this.done++;
|
|
168
|
+
if (!this.logger.interactive) {
|
|
169
|
+
this.logger.success(message);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.clear();
|
|
173
|
+
this.logger.success(message);
|
|
174
|
+
this.paint();
|
|
175
|
+
} catch {
|
|
176
|
+
// Progresso nunca derruba a revisão.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Falha uma tarefa com a mensagem final. */
|
|
181
|
+
fail(id: string, message: string): void {
|
|
182
|
+
try {
|
|
183
|
+
if (this.tasks.delete(id)) this.failed++;
|
|
184
|
+
if (!this.logger.interactive) {
|
|
185
|
+
this.logger.warn(message);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.clear();
|
|
189
|
+
this.logger.warn(message);
|
|
190
|
+
this.paint();
|
|
191
|
+
} catch {
|
|
192
|
+
// Progresso nunca derruba a revisão.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Encerra o board (timer + bloco renderizado). */
|
|
197
|
+
stop(): void {
|
|
198
|
+
this.stopTimer();
|
|
199
|
+
this.clear();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private ensureTimer(): void {
|
|
203
|
+
if (this.timer) return;
|
|
204
|
+
const period = this.logger.interactive ? this.frameMs : this.heartbeatMs;
|
|
205
|
+
this.timer = setInterval(() => this.tick(), period);
|
|
206
|
+
// Nunca segura o processo vivo por causa de um loader.
|
|
207
|
+
(this.timer as unknown as { unref?: () => void }).unref?.();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private stopTimer(): void {
|
|
211
|
+
if (!this.timer) return;
|
|
212
|
+
clearInterval(this.timer);
|
|
213
|
+
this.timer = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private tick(): void {
|
|
217
|
+
if (this.tasks.size === 0) {
|
|
218
|
+
this.stopTimer();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (this.logger.interactive) {
|
|
222
|
+
this.frame++;
|
|
223
|
+
this.paint();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (Date.now() - this.lastHeartbeat < this.heartbeatMs) return;
|
|
227
|
+
this.lastHeartbeat = Date.now();
|
|
228
|
+
try {
|
|
229
|
+
const running = [...this.tasks.values()]
|
|
230
|
+
.map((task) => `${task.id} (${clock(Date.now() - task.startedMs)})`)
|
|
231
|
+
.join(", ");
|
|
232
|
+
this.logger.bullet(`${this.tasks.size} tarefa(s) em execução: ${running}`);
|
|
233
|
+
} catch {
|
|
234
|
+
// Progresso nunca derruba a revisão.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Linhas do bloco (ativas + rodapé). */
|
|
239
|
+
private lines(): string[] {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const active = [...this.tasks.values()];
|
|
242
|
+
const width = Math.max(...active.map((task) => task.id.length), 0);
|
|
243
|
+
const spinner = FRAMES[this.frame % FRAMES.length];
|
|
244
|
+
const rows = active.map(
|
|
245
|
+
(task) =>
|
|
246
|
+
` ${spinner} ${task.id.padEnd(width)} ${task.detail} · ${clock(now - task.startedMs)}`,
|
|
247
|
+
);
|
|
248
|
+
const finished = this.done + this.failed;
|
|
249
|
+
const failures = this.failed > 0 ? ` · ${this.failed} falha(s)` : "";
|
|
250
|
+
rows.push(
|
|
251
|
+
` ${finished}/${this.total} concluído(s)${failures} · ${active.length} em execução`,
|
|
252
|
+
);
|
|
253
|
+
return rows;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Repinta o bloco no lugar (movendo o cursor para cima). */
|
|
257
|
+
private paint(): void {
|
|
258
|
+
if (!this.logger.interactive) return;
|
|
259
|
+
const lines = this.lines();
|
|
260
|
+
const stream = this.logger.stream;
|
|
261
|
+
const total = Math.max(this.rendered, lines.length);
|
|
262
|
+
if (this.rendered > 0) stream.write(`\u001b[${this.rendered}A`);
|
|
263
|
+
for (let index = 0; index < total; index++) {
|
|
264
|
+
stream.write(`\u001b[2K${lines[index] ?? ""}\n`);
|
|
265
|
+
}
|
|
266
|
+
const extra = total - lines.length;
|
|
267
|
+
if (extra > 0) stream.write(`\u001b[${extra}A`);
|
|
268
|
+
this.rendered = lines.length;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Apaga o bloco renderizado (deixa o cursor no início dele). */
|
|
272
|
+
private clear(): void {
|
|
273
|
+
if (!this.logger.interactive || this.rendered === 0) return;
|
|
274
|
+
const stream = this.logger.stream;
|
|
275
|
+
stream.write(`\u001b[${this.rendered}A`);
|
|
276
|
+
for (let index = 0; index < this.rendered; index++) {
|
|
277
|
+
stream.write("\u001b[2K\n");
|
|
278
|
+
}
|
|
279
|
+
stream.write(`\u001b[${this.rendered}A`);
|
|
280
|
+
this.rendered = 0;
|
|
281
|
+
}
|
|
282
|
+
}
|