@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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolução do modelo/credencial do maki (harness local).
|
|
3
|
+
*
|
|
4
|
+
* O houndsman reaproveita a config de IA (`ai.provider`/`ai.model`) e a env já
|
|
5
|
+
* preenchida (`HOUNDSMAN_DEEP_SEEK_API_KEY` etc.). O maki lê o nome nativo do
|
|
6
|
+
* provider (`DEEPSEEK_API_KEY`); a chave é injetada só no processo filho.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AiProviderKind } from "../config";
|
|
10
|
+
import { ENV_NAMES, env } from "../env";
|
|
11
|
+
import type { ProjectConfig } from "../project-config";
|
|
12
|
+
|
|
13
|
+
/** Modelo e credencial no formato esperado pelo maki. */
|
|
14
|
+
export type MakiModel = {
|
|
15
|
+
/** Spec `provider/model-id` passada em `--model`. */
|
|
16
|
+
spec: string;
|
|
17
|
+
provider: AiProviderKind;
|
|
18
|
+
/** Variável nativa que o maki lê (ex.: `DEEPSEEK_API_KEY`). */
|
|
19
|
+
apiKeyEnv: string;
|
|
20
|
+
/** Chave do houndsman (pode faltar; `requireMakiModel` exige). */
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Env nativa do maki por provider. */
|
|
25
|
+
export const MAKI_API_KEY_ENV: Record<AiProviderKind, string> = {
|
|
26
|
+
deepseek: "DEEPSEEK_API_KEY",
|
|
27
|
+
openai: "OPENAI_API_KEY",
|
|
28
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Chave do houndsman por provider (para mensagens acionáveis). */
|
|
32
|
+
const HOUNDSMAN_KEY_ENV: Record<AiProviderKind, string> = {
|
|
33
|
+
deepseek: ENV_NAMES.deepSeekApiKey,
|
|
34
|
+
openai: ENV_NAMES.openAiApiKey,
|
|
35
|
+
anthropic: ENV_NAMES.anthropicApiKey,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Nome da env do houndsman que guarda a chave do provider. */
|
|
39
|
+
export function houndsmanKeyEnvFor(provider: AiProviderKind): string {
|
|
40
|
+
return HOUNDSMAN_KEY_ENV[provider];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `true` para um slug de provider suportado. */
|
|
44
|
+
function isAiProvider(value: string): value is AiProviderKind {
|
|
45
|
+
return value === "deepseek" || value === "openai" || value === "anthropic";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Chave do houndsman para o provider. */
|
|
49
|
+
function providerKey(provider: AiProviderKind): string | undefined {
|
|
50
|
+
switch (provider) {
|
|
51
|
+
case "deepseek":
|
|
52
|
+
return env.deepSeekApiKey;
|
|
53
|
+
case "openai":
|
|
54
|
+
return env.openAiApiKey;
|
|
55
|
+
case "anthropic":
|
|
56
|
+
return env.anthropicApiKey;
|
|
57
|
+
default:
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve o modelo: override → `hunt.model` → `ai.model`.
|
|
64
|
+
*
|
|
65
|
+
* Um prefixo reconhecido (`openai/...`, `anthropic/...`) troca o provider do
|
|
66
|
+
* harness e, junto, a variável de credencial usada.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveMakiModel(project: ProjectConfig, override?: string): MakiModel {
|
|
69
|
+
const configured = override?.trim() || project.huntModel;
|
|
70
|
+
let provider = project.aiProvider;
|
|
71
|
+
let modelId = configured;
|
|
72
|
+
|
|
73
|
+
const [prefix, ...tail] = configured.split("/");
|
|
74
|
+
if (tail.length > 0 && isAiProvider(prefix)) {
|
|
75
|
+
provider = prefix;
|
|
76
|
+
modelId = tail.join("/");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const spec = `${provider}/${modelId}`;
|
|
80
|
+
return {
|
|
81
|
+
spec,
|
|
82
|
+
provider,
|
|
83
|
+
apiKeyEnv: MAKI_API_KEY_ENV[provider],
|
|
84
|
+
apiKey: providerKey(provider),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Como `resolveMakiModel`, mas lança quando a credencial falta. */
|
|
89
|
+
export function requireMakiModel(
|
|
90
|
+
project: ProjectConfig,
|
|
91
|
+
override?: string,
|
|
92
|
+
): MakiModel & { apiKey: string } {
|
|
93
|
+
const model = resolveMakiModel(project, override);
|
|
94
|
+
if (!model.apiKey) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Harness local sem credencial: defina ${HOUNDSMAN_KEY_ENV[model.provider]} ` +
|
|
97
|
+
`(o maki usa ${model.apiKeyEnv}).`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return { ...model, apiKey: model.apiKey };
|
|
101
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt do watchdog no harness local.
|
|
3
|
+
*
|
|
4
|
+
* O brief original manda gravar o JSON em `findings/<id>.json`. No maki o
|
|
5
|
+
* watchdog **não** recebe permissão de escrita: ele devolve o JSON na resposta
|
|
6
|
+
* final e o houndsman valida e grava. Este módulo só acrescenta o contrato;
|
|
7
|
+
* não altera o brief usado pela skill `/houndsman hunt`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Dados do prompt de um watchdog. */
|
|
11
|
+
export type HuntPromptInput = {
|
|
12
|
+
projectName: string;
|
|
13
|
+
watchdogId: string;
|
|
14
|
+
/** Conteúdo do brief renderizado pelo prepare. */
|
|
15
|
+
brief: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Schema compacto repetido no prompt (evita depender de arquivo externo). */
|
|
19
|
+
function outputSchema(watchdogId: string): string {
|
|
20
|
+
return `{
|
|
21
|
+
"watchdogId": "${watchdogId}",
|
|
22
|
+
"reviewedFiles": ["caminho/relativo.ts"],
|
|
23
|
+
"skipped": ["arquivo não lido + motivo"],
|
|
24
|
+
"findings": [
|
|
25
|
+
{
|
|
26
|
+
"rule": "regra do brief",
|
|
27
|
+
"severity": "blocking | attention | suggestion | nit",
|
|
28
|
+
"file": "caminho/relativo.ts",
|
|
29
|
+
"line": 123,
|
|
30
|
+
"message": "o que está errado",
|
|
31
|
+
"action": "o que fazer",
|
|
32
|
+
"evidence": "trecho que comprova"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Monta o prompt enviado ao maki para um brief. */
|
|
39
|
+
export function renderHuntPrompt(input: HuntPromptInput): string {
|
|
40
|
+
return [
|
|
41
|
+
`Você é o watchdog \`${input.watchdogId}\` do projeto ${input.projectName}.`,
|
|
42
|
+
"Execute o brief abaixo e devolva os achados como JSON.",
|
|
43
|
+
"",
|
|
44
|
+
"## Contrato do harness local",
|
|
45
|
+
"",
|
|
46
|
+
"- Use apenas leitura e busca; **não escreva arquivos** e não rode comandos.",
|
|
47
|
+
"- Ignore a instrução do brief sobre gravar `outputPath`: o houndsman grava por você.",
|
|
48
|
+
"- Responda **somente** com um objeto JSON válido (sem markdown, sem texto antes/depois).",
|
|
49
|
+
"- `watchdogId` deve ser exatamente o id acima; use `findings: []` quando não houver achado.",
|
|
50
|
+
"- `line` deve estar dentro do escopo do diff descrito no brief.",
|
|
51
|
+
"",
|
|
52
|
+
"### Schema",
|
|
53
|
+
"",
|
|
54
|
+
outputSchema(input.watchdogId),
|
|
55
|
+
"",
|
|
56
|
+
"## Brief",
|
|
57
|
+
"",
|
|
58
|
+
input.brief.trim(),
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Sufixo corretivo das retentativas (saída inválida). */
|
|
63
|
+
export function renderRetrySuffix(error: string): string {
|
|
64
|
+
return [
|
|
65
|
+
"",
|
|
66
|
+
"## Correção obrigatória",
|
|
67
|
+
"",
|
|
68
|
+
`A resposta anterior foi rejeitada: ${error}`,
|
|
69
|
+
"Responda APENAS com o objeto JSON completo, no schema acima, sem markdown.",
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Releases do maki (harness local) — resolução pura de versão, asset e checksum.
|
|
3
|
+
*
|
|
4
|
+
* O houndsman fixa uma versão testada (`DEFAULT_MAKI_VERSION`) e baixa o binário
|
|
5
|
+
* do GitHub Releases. Nada aqui acessa rede: são funções puras para teste.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_MAKI_VERSION } from "../defaults";
|
|
9
|
+
import { env } from "../env";
|
|
10
|
+
|
|
11
|
+
/** Repositório oficial do maki (binários por plataforma). */
|
|
12
|
+
export const MAKI_REPO = "tontinton/maki";
|
|
13
|
+
|
|
14
|
+
/** Base dos downloads de release. */
|
|
15
|
+
export const MAKI_RELEASE_BASE = `https://github.com/${MAKI_REPO}/releases/download`;
|
|
16
|
+
|
|
17
|
+
/** Asset de release para uma combinação plataforma/arquitetura. */
|
|
18
|
+
export type MakiAsset = {
|
|
19
|
+
/** Nome do arquivo no release. */
|
|
20
|
+
name: string;
|
|
21
|
+
/** Formato de compressão (só informativo; a extração usa `tar`). */
|
|
22
|
+
format: "tar.gz" | "zip";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Assets publicados por plataforma/arquitetura. */
|
|
26
|
+
const MAKI_ASSETS: Record<string, MakiAsset> = {
|
|
27
|
+
"win32-x64": { name: "maki-v{version}-x86_64-pc-windows-msvc.zip", format: "zip" },
|
|
28
|
+
"darwin-x64": {
|
|
29
|
+
name: "maki-v{version}-x86_64-apple-darwin.tar.gz",
|
|
30
|
+
format: "tar.gz",
|
|
31
|
+
},
|
|
32
|
+
"darwin-arm64": {
|
|
33
|
+
name: "maki-v{version}-aarch64-apple-darwin.tar.gz",
|
|
34
|
+
format: "tar.gz",
|
|
35
|
+
},
|
|
36
|
+
"linux-x64": {
|
|
37
|
+
name: "maki-v{version}-x86_64-unknown-linux-musl.tar.gz",
|
|
38
|
+
format: "tar.gz",
|
|
39
|
+
},
|
|
40
|
+
"linux-arm64": {
|
|
41
|
+
name: "maki-v{version}-aarch64-unknown-linux-musl.tar.gz",
|
|
42
|
+
format: "tar.gz",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Nome do executável do maki na plataforma atual. */
|
|
47
|
+
export function makiBinaryName(platform: string = process.platform): string {
|
|
48
|
+
return platform === "win32" ? "maki.exe" : "maki";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Normaliza a versão (`v0.6.5` → `0.6.5`) e valida os caracteres. */
|
|
52
|
+
export function normalizeMakiVersion(value: string): string {
|
|
53
|
+
const normalized = value.trim().replace(/^v/i, "");
|
|
54
|
+
if (!/^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(normalized)) {
|
|
55
|
+
throw new Error(`Versão do maki inválida: '${value}'.`);
|
|
56
|
+
}
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Versão efetiva: flag/env (`HOUNDSMAN_MAKI_VERSION`) ou a fixada no pacote. */
|
|
61
|
+
export function effectiveMakiVersion(override?: string): string {
|
|
62
|
+
const value = override?.trim() || env.makiVersion || DEFAULT_MAKI_VERSION;
|
|
63
|
+
return normalizeMakiVersion(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Asset da plataforma/arquitetura informada, ou `null` se não houver release. */
|
|
67
|
+
export function makiAssetFor(
|
|
68
|
+
platform: string,
|
|
69
|
+
arch: string,
|
|
70
|
+
version: string,
|
|
71
|
+
): MakiAsset | null {
|
|
72
|
+
const asset = MAKI_ASSETS[`${platform}-${arch}`];
|
|
73
|
+
if (!asset) return null;
|
|
74
|
+
return {
|
|
75
|
+
name: asset.name.replace("{version}", normalizeMakiVersion(version)),
|
|
76
|
+
format: asset.format,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** URL do asset no GitHub Releases. */
|
|
81
|
+
export function makiDownloadUrl(version: string, asset: string): string {
|
|
82
|
+
return `${MAKI_RELEASE_BASE}/v${normalizeMakiVersion(version)}/${asset}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** URL do arquivo de checksums do release. */
|
|
86
|
+
export function makiChecksumsUrl(version: string): string {
|
|
87
|
+
return `${MAKI_RELEASE_BASE}/v${normalizeMakiVersion(version)}/sha256sums.txt`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Converte o `sha256sums.txt` (`<hash> <arquivo>`) em mapa. */
|
|
91
|
+
export function parseChecksums(text: string): Map<string, string> {
|
|
92
|
+
const checksums = new Map<string, string>();
|
|
93
|
+
for (const line of text.split(/\r?\n/)) {
|
|
94
|
+
const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
|
|
95
|
+
if (!match) continue;
|
|
96
|
+
checksums.set(match[2].trim(), match[1].toLowerCase());
|
|
97
|
+
}
|
|
98
|
+
return checksums;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Checksum esperado de um asset (ou `undefined` se o release não listar). */
|
|
102
|
+
export function checksumFor(checksumsText: string, asset: string): string | undefined {
|
|
103
|
+
return parseChecksums(checksumsText).get(asset);
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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 {}
|