@payconductor/houndsman 0.12.0 → 0.14.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 +23 -2
- package/package.json +1 -1
- package/src/cli/commands/compare-command.ts +112 -0
- package/src/cli/commands/context-command.ts +1 -0
- package/src/cli/commands/doctor-command.ts +22 -0
- package/src/cli/commands/findings-command.ts +11 -1
- package/src/cli/commands/hunt-command.ts +479 -0
- package/src/cli/commands/review-command.ts +58 -2
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +20 -3
- package/src/cli/index.ts +20 -3
- package/src/cli/progress.ts +50 -0
- package/src/core/ai/ai-client.ts +38 -2
- package/src/core/ai/ai-sdk-model-provider.ts +39 -0
- package/src/core/ai/features/brief-enricher.ts +1 -1
- package/src/core/ai/features/business-analyzer.ts +61 -0
- package/src/core/ai/features/business-summarizer.ts +44 -0
- package/src/core/ai/index.ts +14 -1
- package/src/core/ai/prompts/brief.ts +33 -7
- package/src/core/ai/prompts/business.ts +76 -0
- package/src/core/ai/provider-capabilities.ts +12 -0
- package/src/core/ai/schemas.ts +70 -2
- package/src/core/ai/types.ts +2 -0
- package/src/core/ai/usage-recorder.ts +53 -1
- package/src/core/brief.ts +246 -12
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +233 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +38 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +243 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +57 -1
- package/src/core/defaults.ts +63 -2
- package/src/core/domain/budget.ts +62 -4
- package/src/core/domain/classification.ts +12 -2
- package/src/core/engine/carryover.ts +4 -0
- package/src/core/engine/finalize-pipeline.ts +347 -0
- package/src/core/engine/prepare-pipeline.ts +1294 -0
- package/src/core/engine/review-context.ts +51 -0
- package/src/core/engine/review-options.ts +47 -0
- package/src/core/engine/review-scope.ts +106 -26
- package/src/core/engine/review-session.ts +18 -16
- package/src/core/engine/review-workspace.ts +14 -0
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/env.ts +14 -0
- package/src/core/findings-state.ts +13 -1
- package/src/core/github-review.ts +5 -2
- package/src/core/hunt/hunt-runner.ts +418 -0
- package/src/core/hunt/index.ts +61 -0
- package/src/core/hunt/maki-install.ts +331 -0
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-model.ts +101 -0
- package/src/core/hunt/maki-prompt.ts +83 -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 +8 -0
- package/src/core/logging/index.ts +7 -0
- package/src/core/logging/logger.ts +19 -0
- package/src/core/logging/progress.ts +366 -0
- package/src/core/orchestrator.ts +33 -1180
- package/src/core/paths.ts +6 -3
- package/src/core/pr-context.ts +2 -4
- package/src/core/prepare-report.ts +61 -0
- package/src/core/project-config.ts +46 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +60 -21
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +108 -278
- package/src/core/storage/file/file-storage.ts +7 -3
- package/src/core/storage/sqlite/mappers.ts +10 -0
- package/src/core/storage/sqlite/schema.ts +104 -23
- package/src/core/storage/sqlite/sqlite-storage.ts +22 -9
- package/src/core/storage/types.ts +17 -4
- package/src/core/storage/work-area.ts +59 -1
- package/src/core/types.ts +31 -0
- package/src/core/watchdog-registry.ts +21 -3
- package/src/index.ts +78 -4
- package/src/server/client/ArtifactsPanel.tsx +4 -2
- package/src/server/client/FindingCard.tsx +21 -1
- package/src/server/client/RunsSection.tsx +5 -0
- package/src/server/client/utils/constants.ts +21 -11
- package/src/server/client/utils/markdown.ts +1 -1
- package/src/server/client/utils/types.ts +14 -2
- package/src/server/run-repository.ts +35 -14
- package/src/server/styles.css +2 -0
- package/src/skill/OUTPUT-FORMAT.md +36 -10
- package/src/skill/README.md +30 -14
- package/src/skill/SETUP.md +22 -1
- package/src/skill/SKILL.md +17 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +181 -58
- package/src/tests/__snapshots__/baseline.test.ts.snap +206 -178
- package/src/tests/ai-core.test.ts +84 -1
- package/src/tests/ai-features.test.ts +95 -4
- package/src/tests/baseline.test.ts +29 -121
- package/src/tests/brief-scope.test.ts +250 -0
- package/src/tests/business-summary.test.ts +258 -0
- package/src/tests/cli.test.ts +1 -0
- package/src/tests/compare.test.ts +233 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -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 +525 -0
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +145 -0
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +90 -19
- package/src/tests/review-scripts.test.ts +106 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/storage.test.ts +194 -9
- package/src/core/renderers/report-renderer.ts +0 -66
- package/src/skill/example-review.md +0 -66
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instalação local do maki (sem rede).
|
|
3
|
+
*
|
|
4
|
+
* Valida a resolução de asset/versão/checksum e a instalação a partir de um
|
|
5
|
+
* arquivo local (`--from`), além do ponteiro gerenciado e do uninstall.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
installMaki,
|
|
21
|
+
makiCurrentFile,
|
|
22
|
+
makiInstallDir,
|
|
23
|
+
makiSourceLabel,
|
|
24
|
+
readMakiInstall,
|
|
25
|
+
resolveMakiBinary,
|
|
26
|
+
uninstallMaki,
|
|
27
|
+
} from "../core/hunt/maki-install";
|
|
28
|
+
import {
|
|
29
|
+
effectiveMakiVersion,
|
|
30
|
+
makiAssetFor,
|
|
31
|
+
makiBinaryName,
|
|
32
|
+
makiChecksumsUrl,
|
|
33
|
+
makiDownloadUrl,
|
|
34
|
+
parseChecksums,
|
|
35
|
+
} from "../core/hunt/maki-release";
|
|
36
|
+
|
|
37
|
+
const original = {
|
|
38
|
+
home: process.env.HOUNDSMAN_HOME,
|
|
39
|
+
makiPath: process.env.HOUNDSMAN_MAKI_PATH,
|
|
40
|
+
makiVersion: process.env.HOUNDSMAN_MAKI_VERSION,
|
|
41
|
+
};
|
|
42
|
+
const dirs: string[] = [];
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
restoreEnv("HOUNDSMAN_HOME", original.home);
|
|
46
|
+
restoreEnv("HOUNDSMAN_MAKI_PATH", original.makiPath);
|
|
47
|
+
restoreEnv("HOUNDSMAN_MAKI_VERSION", original.makiVersion);
|
|
48
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function restoreEnv(name: string, value: string | undefined): void {
|
|
52
|
+
if (value === undefined) delete process.env[name];
|
|
53
|
+
else process.env[name] = value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tempDir(prefix: string): string {
|
|
57
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
58
|
+
dirs.push(dir);
|
|
59
|
+
return dir;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("maki-release", () => {
|
|
63
|
+
test("resolve asset por plataforma/arquitetura", () => {
|
|
64
|
+
expect(makiAssetFor("win32", "x64", "0.6.5")?.name).toBe(
|
|
65
|
+
"maki-v0.6.5-x86_64-pc-windows-msvc.zip",
|
|
66
|
+
);
|
|
67
|
+
expect(makiAssetFor("darwin", "arm64", "v0.6.5")?.name).toBe(
|
|
68
|
+
"maki-v0.6.5-aarch64-apple-darwin.tar.gz",
|
|
69
|
+
);
|
|
70
|
+
expect(makiAssetFor("linux", "x64", "0.6.5")?.name).toBe(
|
|
71
|
+
"maki-v0.6.5-x86_64-unknown-linux-musl.tar.gz",
|
|
72
|
+
);
|
|
73
|
+
expect(makiAssetFor("solaris", "x64", "0.6.5")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("versão normaliza v e aceita override por env", () => {
|
|
77
|
+
expect(effectiveMakiVersion("v1.2.3")).toBe("1.2.3");
|
|
78
|
+
process.env.HOUNDSMAN_MAKI_VERSION = "v2.0.0";
|
|
79
|
+
expect(effectiveMakiVersion()).toBe("2.0.0");
|
|
80
|
+
expect(() => effectiveMakiVersion("../../x")).toThrow("Versão do maki inválida");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("URLs e parse de sha256sums", () => {
|
|
84
|
+
expect(makiDownloadUrl("0.6.5", "maki.tar.gz")).toContain(
|
|
85
|
+
"/releases/download/v0.6.5/maki.tar.gz",
|
|
86
|
+
);
|
|
87
|
+
expect(makiChecksumsUrl("0.6.5").endsWith("/v0.6.5/sha256sums.txt")).toBe(true);
|
|
88
|
+
const checksums = parseChecksums(
|
|
89
|
+
[
|
|
90
|
+
`${"a".repeat(64)} maki-a.tar.gz`,
|
|
91
|
+
`${"b".repeat(64)} *maki-b.zip`,
|
|
92
|
+
"linha inválida",
|
|
93
|
+
].join("\n"),
|
|
94
|
+
);
|
|
95
|
+
expect(checksums.get("maki-a.tar.gz")).toBe("a".repeat(64));
|
|
96
|
+
expect(checksums.get("maki-b.zip")).toBe("b".repeat(64));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("resolução do binário", () => {
|
|
101
|
+
test("HOUNDSMAN_MAKI_PATH tem precedência", () => {
|
|
102
|
+
const root = tempDir("houndsman-maki-resolve-");
|
|
103
|
+
const binary = join(root, "maki-custom");
|
|
104
|
+
writeFileSync(binary, "fake");
|
|
105
|
+
process.env.HOUNDSMAN_MAKI_PATH = binary;
|
|
106
|
+
const resolved = resolveMakiBinary();
|
|
107
|
+
expect(resolved?.path).toBe(binary);
|
|
108
|
+
expect(resolved?.source).toBe("env");
|
|
109
|
+
expect(makiSourceLabel("env")).toBe("HOUNDSMAN_MAKI_PATH");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("installMaki (arquivo local)", () => {
|
|
114
|
+
const hasTar = Boolean(Bun.which("tar"));
|
|
115
|
+
|
|
116
|
+
test.skipIf(!hasTar)("extrai, grava o ponteiro e remove", async () => {
|
|
117
|
+
const home = tempDir("houndsman-maki-home-");
|
|
118
|
+
process.env.HOUNDSMAN_HOME = home;
|
|
119
|
+
|
|
120
|
+
const src = tempDir("houndsman-maki-src-");
|
|
121
|
+
const name = makiBinaryName();
|
|
122
|
+
mkdirSync(join(src, "nested"), { recursive: true });
|
|
123
|
+
writeFileSync(join(src, "nested", name), "#!/bin/sh\necho fake\n");
|
|
124
|
+
const archive = join(tempDir("houndsman-maki-archive-"), "maki.tar.gz");
|
|
125
|
+
const tar = Bun.spawnSync(["tar", "-czf", archive, "-C", src, "."], {
|
|
126
|
+
stdout: "pipe",
|
|
127
|
+
stderr: "pipe",
|
|
128
|
+
});
|
|
129
|
+
expect(tar.exitCode).toBe(0);
|
|
130
|
+
|
|
131
|
+
const first = await installMaki({ version: "9.9.9", from: archive });
|
|
132
|
+
expect(first.skipped).toBe(false);
|
|
133
|
+
expect(existsSync(first.path)).toBe(true);
|
|
134
|
+
expect(first.path).toBe(join(makiInstallDir("9.9.9"), name));
|
|
135
|
+
expect(readFileSync(first.path, "utf8")).toContain("echo fake");
|
|
136
|
+
|
|
137
|
+
const manifest = readMakiInstall();
|
|
138
|
+
expect(manifest?.version).toBe("9.9.9");
|
|
139
|
+
expect(manifest?.path).toBe(first.path);
|
|
140
|
+
expect(existsSync(makiCurrentFile())).toBe(true);
|
|
141
|
+
|
|
142
|
+
const resolved = resolveMakiBinary();
|
|
143
|
+
expect(resolved?.source).toBe("managed");
|
|
144
|
+
expect(resolved?.version).toBe("9.9.9");
|
|
145
|
+
|
|
146
|
+
const second = await installMaki({ version: "9.9.9", from: archive });
|
|
147
|
+
expect(second.skipped).toBe(true);
|
|
148
|
+
|
|
149
|
+
const removed = uninstallMaki();
|
|
150
|
+
expect(removed).toBe(join(home, "tools", "maki"));
|
|
151
|
+
expect(existsSync(join(home, "tools", "maki"))).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness local — prompt, parser de saída, runner e política de retry.
|
|
3
|
+
*
|
|
4
|
+
* Testa o `core/hunt` sem processo real: o `MakiProcessRunner` é injetado.
|
|
5
|
+
* A execução via `Bun.spawn` é coberta em plataformas POSIX nos testes abaixo.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { defineHoundsmanConfig } from "../core/config";
|
|
13
|
+
import { type HuntEvent, HuntRunner } from "../core/hunt/hunt-runner";
|
|
14
|
+
import { requireMakiModel, resolveMakiModel } from "../core/hunt/maki-model";
|
|
15
|
+
import { renderHuntPrompt } from "../core/hunt/maki-prompt";
|
|
16
|
+
import {
|
|
17
|
+
extractJsonObject,
|
|
18
|
+
MAKI_DISALLOWED_TOOLS,
|
|
19
|
+
makiArgs,
|
|
20
|
+
parseMakiOutput,
|
|
21
|
+
SpawnMakiRunner,
|
|
22
|
+
} from "../core/hunt/maki-runner";
|
|
23
|
+
import { ProjectConfig } from "../core/project-config";
|
|
24
|
+
import type { ReviewRecord } from "../core/storage/types";
|
|
25
|
+
import { ReviewWorkArea } from "../core/storage/work-area";
|
|
26
|
+
import { defineWatchdog } from "../core/types";
|
|
27
|
+
|
|
28
|
+
const config = defineHoundsmanConfig({
|
|
29
|
+
projectName: "Hunt Test",
|
|
30
|
+
watchdogs: {
|
|
31
|
+
alpha: defineWatchdog({
|
|
32
|
+
id: "alpha",
|
|
33
|
+
name: "Alpha",
|
|
34
|
+
description: "alpha",
|
|
35
|
+
scope: "src/alpha/**",
|
|
36
|
+
classify: [{ value: "^src/alpha/" }],
|
|
37
|
+
rules: [{ severity: "attention", text: "Evitar any." }],
|
|
38
|
+
contextFiles: [],
|
|
39
|
+
}),
|
|
40
|
+
beta: defineWatchdog({
|
|
41
|
+
id: "beta",
|
|
42
|
+
name: "Beta",
|
|
43
|
+
description: "beta",
|
|
44
|
+
scope: "src/beta/**",
|
|
45
|
+
classify: [{ value: "^src/beta/" }],
|
|
46
|
+
rules: [{ severity: "attention", text: "Evitar console.log." }],
|
|
47
|
+
contextFiles: [],
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
exclusions: [],
|
|
51
|
+
docMappings: [],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const project = new ProjectConfig(config);
|
|
55
|
+
|
|
56
|
+
/** Review fake (o staging só precisa de id/branch). */
|
|
57
|
+
function fakeReview(root: string): ReviewRecord {
|
|
58
|
+
const now = "2026-01-01T00:00:00.000Z";
|
|
59
|
+
return {
|
|
60
|
+
id: "review-hunt",
|
|
61
|
+
repoKey: "repo",
|
|
62
|
+
repoRoot: root,
|
|
63
|
+
branch: "main",
|
|
64
|
+
slug: "main",
|
|
65
|
+
status: "reviewing",
|
|
66
|
+
createdAt: now,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Staging temporário com briefs prontos. */
|
|
72
|
+
function makeWork(ids: string[]): {
|
|
73
|
+
root: string;
|
|
74
|
+
work: ReviewWorkArea;
|
|
75
|
+
cleanup: () => void;
|
|
76
|
+
} {
|
|
77
|
+
const root = mkdtempSync(join(tmpdir(), "houndsman-hunt-"));
|
|
78
|
+
const work = new ReviewWorkArea(fakeReview(root), root);
|
|
79
|
+
work.ensure();
|
|
80
|
+
for (const id of ids) work.writeBrief(id, `brief de ${id}`);
|
|
81
|
+
return { root, work, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reportJson(watchdogId: string, findings = 1): string {
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
watchdogId,
|
|
87
|
+
reviewedFiles: [`src/${watchdogId}/a.ts`],
|
|
88
|
+
skipped: [],
|
|
89
|
+
findings: Array.from({ length: findings }, (_, index) => ({
|
|
90
|
+
rule: "Evitar any",
|
|
91
|
+
severity: "attention",
|
|
92
|
+
file: `src/${watchdogId}/a.ts`,
|
|
93
|
+
line: index + 1,
|
|
94
|
+
message: "any detectado",
|
|
95
|
+
action: "tipar",
|
|
96
|
+
})),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("maki — contrato de execução", () => {
|
|
101
|
+
test("args são headless e negam escrita/bash/rede sem yolo/trust", () => {
|
|
102
|
+
const args = makiArgs("deepseek/deepseek-v4-pro");
|
|
103
|
+
expect(args).toEqual([
|
|
104
|
+
"-p",
|
|
105
|
+
"--output-format",
|
|
106
|
+
"json",
|
|
107
|
+
"--model",
|
|
108
|
+
"deepseek/deepseek-v4-pro",
|
|
109
|
+
"--disallowed-tools",
|
|
110
|
+
MAKI_DISALLOWED_TOOLS,
|
|
111
|
+
]);
|
|
112
|
+
expect(args).not.toContain("--yolo");
|
|
113
|
+
expect(args).not.toContain("--trust");
|
|
114
|
+
for (const tool of ["bash", "write", "edit", "task", "webfetch"]) {
|
|
115
|
+
expect(MAKI_DISALLOWED_TOOLS.split(",")).toContain(tool);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("prompt inclui brief e proíbe escrita", () => {
|
|
120
|
+
const prompt = renderHuntPrompt({
|
|
121
|
+
projectName: "Proj",
|
|
122
|
+
watchdogId: "alpha",
|
|
123
|
+
brief: "# Brief alpha",
|
|
124
|
+
});
|
|
125
|
+
expect(prompt).toContain("# Brief alpha");
|
|
126
|
+
expect(prompt).toContain("não escreva arquivos");
|
|
127
|
+
expect(prompt).toContain('"watchdogId": "alpha"');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("extrai JSON puro, cercado por markdown ou texto", () => {
|
|
131
|
+
const payload = { watchdogId: "alpha", findings: [] };
|
|
132
|
+
expect(extractJsonObject(JSON.stringify(payload))).toEqual(payload);
|
|
133
|
+
expect(
|
|
134
|
+
extractJsonObject(`\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``),
|
|
135
|
+
).toEqual(payload);
|
|
136
|
+
expect(extractJsonObject(`Aqui está:\n${JSON.stringify(payload)}\nfim`)).toEqual(
|
|
137
|
+
payload,
|
|
138
|
+
);
|
|
139
|
+
expect(extractJsonObject("sem json")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("parseia o JSON do maki (linha única e multi-linha)", () => {
|
|
143
|
+
const single = '{"type":"result","result":"ok","session_id":"s1"}';
|
|
144
|
+
expect(parseMakiOutput(single)?.result).toBe("ok");
|
|
145
|
+
|
|
146
|
+
const pretty = JSON.stringify(
|
|
147
|
+
{ type: "result", result: "ok", total_cost_usd: 0.5 },
|
|
148
|
+
null,
|
|
149
|
+
2,
|
|
150
|
+
);
|
|
151
|
+
expect(parseMakiOutput(pretty)?.total_cost_usd).toBe(0.5);
|
|
152
|
+
expect(parseMakiOutput(`log\n${single}`)?.session_id).toBe("s1");
|
|
153
|
+
expect(parseMakiOutput("nada")).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("maki — modelo e credencial", () => {
|
|
158
|
+
const original = {
|
|
159
|
+
deepseek: process.env.HOUNDSMAN_DEEP_SEEK_API_KEY,
|
|
160
|
+
openai: process.env.HOUNDSMAN_OPENAI_API_KEY,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
if (original.deepseek === undefined)
|
|
165
|
+
delete process.env.HOUNDSMAN_DEEP_SEEK_API_KEY;
|
|
166
|
+
else process.env.HOUNDSMAN_DEEP_SEEK_API_KEY = original.deepseek;
|
|
167
|
+
if (original.openai === undefined) delete process.env.HOUNDSMAN_OPENAI_API_KEY;
|
|
168
|
+
else process.env.HOUNDSMAN_OPENAI_API_KEY = original.openai;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("deriva deepseek/<model> e injeta DEEPSEEK_API_KEY", () => {
|
|
172
|
+
process.env.HOUNDSMAN_DEEP_SEEK_API_KEY = "sk-deepseek";
|
|
173
|
+
const model = requireMakiModel(project);
|
|
174
|
+
expect(model.spec).toBe("deepseek/deepseek-v4-pro");
|
|
175
|
+
expect(model.apiKeyEnv).toBe("DEEPSEEK_API_KEY");
|
|
176
|
+
expect(model.apiKey).toBe("sk-deepseek");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("prefixo reconhecido troca provider e credencial", () => {
|
|
180
|
+
process.env.HOUNDSMAN_OPENAI_API_KEY = "sk-openai";
|
|
181
|
+
const model = resolveMakiModel(project, "openai/gpt-5");
|
|
182
|
+
expect(model.spec).toBe("openai/gpt-5");
|
|
183
|
+
expect(model.provider).toBe("openai");
|
|
184
|
+
expect(model.apiKeyEnv).toBe("OPENAI_API_KEY");
|
|
185
|
+
expect(model.apiKey).toBe("sk-openai");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("sem chave, requireMakiModel falha com a env correta", () => {
|
|
189
|
+
delete process.env.HOUNDSMAN_DEEP_SEEK_API_KEY;
|
|
190
|
+
expect(() => requireMakiModel(project)).toThrow("HOUNDSMAN_DEEP_SEEK_API_KEY");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("HuntRunner", () => {
|
|
195
|
+
test("grava findings e agrega métricas", async () => {
|
|
196
|
+
const staging = makeWork(["alpha", "beta"]);
|
|
197
|
+
try {
|
|
198
|
+
const runner = new HuntRunner({
|
|
199
|
+
work: staging.work,
|
|
200
|
+
root: staging.root,
|
|
201
|
+
projectName: "Proj",
|
|
202
|
+
model: "deepseek/deepseek-v4-pro",
|
|
203
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
204
|
+
apiKey: "sk",
|
|
205
|
+
concurrency: 2,
|
|
206
|
+
timeoutMs: 10_000,
|
|
207
|
+
retries: 1,
|
|
208
|
+
runner: {
|
|
209
|
+
run: async (request) => {
|
|
210
|
+
const id = request.prompt.includes("brief de alpha")
|
|
211
|
+
? "alpha"
|
|
212
|
+
: "beta";
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
text: reportJson(id),
|
|
216
|
+
metrics: {
|
|
217
|
+
durationMs: 10,
|
|
218
|
+
costUsd: 0.01,
|
|
219
|
+
sessionId: `s-${id}`,
|
|
220
|
+
numTurns: 2,
|
|
221
|
+
usage: {
|
|
222
|
+
totalTokens: 100,
|
|
223
|
+
inputTokens: 60,
|
|
224
|
+
outputTokens: 40,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
stderr: "",
|
|
228
|
+
exitCode: 0,
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const report = await runner.run(["alpha", "beta"]);
|
|
235
|
+
expect(report.totals.watchdogs).toBe(2);
|
|
236
|
+
expect(report.totals.done).toBe(2);
|
|
237
|
+
expect(report.totals.failed).toBe(0);
|
|
238
|
+
expect(report.totals.findings).toBe(2);
|
|
239
|
+
expect(report.totals.costUsd).toBeCloseTo(0.02);
|
|
240
|
+
expect(report.totals.totalTokens).toBe(200);
|
|
241
|
+
expect(staging.work.readFindings(["alpha", "beta"]).pending).toEqual([]);
|
|
242
|
+
} finally {
|
|
243
|
+
staging.cleanup();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("pré-passe de intenção grava o modelo antes dos watchdogs", async () => {
|
|
248
|
+
const staging = makeWork(["alpha"]);
|
|
249
|
+
staging.work.writeBusinessBrief("# Brief de intenção");
|
|
250
|
+
try {
|
|
251
|
+
let intentCalls = 0;
|
|
252
|
+
const runner = new HuntRunner({
|
|
253
|
+
work: staging.work,
|
|
254
|
+
root: staging.root,
|
|
255
|
+
projectName: "Proj",
|
|
256
|
+
model: "deepseek/deepseek-v4-pro",
|
|
257
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
258
|
+
apiKey: "sk",
|
|
259
|
+
concurrency: 1,
|
|
260
|
+
timeoutMs: 10_000,
|
|
261
|
+
retries: 0,
|
|
262
|
+
runner: {
|
|
263
|
+
run: async (request) => {
|
|
264
|
+
if (request.prompt.includes("passo de intenção")) {
|
|
265
|
+
intentCalls++;
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
text: JSON.stringify({
|
|
269
|
+
intent: "Entregar o split",
|
|
270
|
+
flows: [
|
|
271
|
+
{ name: "Renovação", files: ["src/alpha/a.ts"] },
|
|
272
|
+
],
|
|
273
|
+
invariants: ["split sempre presente"],
|
|
274
|
+
contracts: [],
|
|
275
|
+
states: [],
|
|
276
|
+
}),
|
|
277
|
+
metrics: { durationMs: 5 },
|
|
278
|
+
stderr: "",
|
|
279
|
+
exitCode: 0,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
text: reportJson("alpha"),
|
|
285
|
+
metrics: { durationMs: 5 },
|
|
286
|
+
stderr: "",
|
|
287
|
+
exitCode: 0,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const report = await runner.run(["alpha"]);
|
|
294
|
+
expect(intentCalls).toBe(1);
|
|
295
|
+
expect(report.business).toBe("reviewer");
|
|
296
|
+
const summary = staging.work.readBusinessSummary();
|
|
297
|
+
expect(summary?.source).toBe("reviewer");
|
|
298
|
+
expect(summary?.intent).toBe("Entregar o split");
|
|
299
|
+
expect(summary?.invariants).toEqual(["split sempre presente"]);
|
|
300
|
+
} finally {
|
|
301
|
+
staging.cleanup();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("repete uma vez quando a saída não tem JSON válido", async () => {
|
|
306
|
+
const staging = makeWork(["alpha"]);
|
|
307
|
+
try {
|
|
308
|
+
const prompts: string[] = [];
|
|
309
|
+
let calls = 0;
|
|
310
|
+
const runner = new HuntRunner({
|
|
311
|
+
work: staging.work,
|
|
312
|
+
root: staging.root,
|
|
313
|
+
projectName: "Proj",
|
|
314
|
+
model: "deepseek/deepseek-v4-pro",
|
|
315
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
316
|
+
apiKey: "sk",
|
|
317
|
+
concurrency: 1,
|
|
318
|
+
timeoutMs: 10_000,
|
|
319
|
+
retries: 1,
|
|
320
|
+
runner: {
|
|
321
|
+
run: async (request) => {
|
|
322
|
+
prompts.push(request.prompt);
|
|
323
|
+
calls++;
|
|
324
|
+
return calls === 1
|
|
325
|
+
? {
|
|
326
|
+
ok: true,
|
|
327
|
+
text: "não é json",
|
|
328
|
+
metrics: { durationMs: 5 },
|
|
329
|
+
stderr: "",
|
|
330
|
+
exitCode: 0,
|
|
331
|
+
}
|
|
332
|
+
: {
|
|
333
|
+
ok: true,
|
|
334
|
+
text: reportJson("alpha"),
|
|
335
|
+
metrics: { durationMs: 5 },
|
|
336
|
+
stderr: "",
|
|
337
|
+
exitCode: 0,
|
|
338
|
+
};
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const report = await runner.run(["alpha"]);
|
|
344
|
+
expect(calls).toBe(2);
|
|
345
|
+
expect(report.watchdogs[0].attempts).toBe(2);
|
|
346
|
+
expect(report.watchdogs[0].status).toBe("done");
|
|
347
|
+
expect(prompts[1]).toContain("Correção obrigatória");
|
|
348
|
+
} finally {
|
|
349
|
+
staging.cleanup();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("emite evento de retry antes da segunda tentativa", async () => {
|
|
354
|
+
const staging = makeWork(["alpha"]);
|
|
355
|
+
try {
|
|
356
|
+
const events: HuntEvent[] = [];
|
|
357
|
+
let calls = 0;
|
|
358
|
+
const runner = new HuntRunner({
|
|
359
|
+
work: staging.work,
|
|
360
|
+
root: staging.root,
|
|
361
|
+
projectName: "Proj",
|
|
362
|
+
model: "deepseek/deepseek-v4-pro",
|
|
363
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
364
|
+
apiKey: "sk",
|
|
365
|
+
concurrency: 1,
|
|
366
|
+
timeoutMs: 10_000,
|
|
367
|
+
retries: 1,
|
|
368
|
+
onEvent: (event) => events.push(event),
|
|
369
|
+
runner: {
|
|
370
|
+
run: async () => {
|
|
371
|
+
calls++;
|
|
372
|
+
return calls === 1
|
|
373
|
+
? {
|
|
374
|
+
ok: true,
|
|
375
|
+
text: "não é json",
|
|
376
|
+
metrics: { durationMs: 5 },
|
|
377
|
+
stderr: "",
|
|
378
|
+
exitCode: 0,
|
|
379
|
+
}
|
|
380
|
+
: {
|
|
381
|
+
ok: true,
|
|
382
|
+
text: reportJson("alpha"),
|
|
383
|
+
metrics: { durationMs: 5 },
|
|
384
|
+
stderr: "",
|
|
385
|
+
exitCode: 0,
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await runner.run(["alpha"]);
|
|
392
|
+
const retry = events.find((event) => event.type === "retry");
|
|
393
|
+
expect(retry).toMatchObject({
|
|
394
|
+
type: "retry",
|
|
395
|
+
watchdogId: "alpha",
|
|
396
|
+
attempt: 1,
|
|
397
|
+
maxAttempts: 2,
|
|
398
|
+
});
|
|
399
|
+
expect(events.some((event) => event.type === "done")).toBe(true);
|
|
400
|
+
} finally {
|
|
401
|
+
staging.cleanup();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("falha após os retries e não grava findings", async () => {
|
|
406
|
+
const staging = makeWork(["alpha"]);
|
|
407
|
+
try {
|
|
408
|
+
const runner = new HuntRunner({
|
|
409
|
+
work: staging.work,
|
|
410
|
+
root: staging.root,
|
|
411
|
+
projectName: "Proj",
|
|
412
|
+
model: "deepseek/deepseek-v4-pro",
|
|
413
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
414
|
+
apiKey: "sk",
|
|
415
|
+
concurrency: 1,
|
|
416
|
+
timeoutMs: 10_000,
|
|
417
|
+
retries: 1,
|
|
418
|
+
runner: {
|
|
419
|
+
run: async () => ({
|
|
420
|
+
ok: true,
|
|
421
|
+
text: "{}",
|
|
422
|
+
metrics: { durationMs: 1 },
|
|
423
|
+
stderr: "",
|
|
424
|
+
exitCode: 0,
|
|
425
|
+
}),
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const report = await runner.run(["alpha"]);
|
|
430
|
+
expect(report.watchdogs[0].status).toBe("failed");
|
|
431
|
+
expect(report.watchdogs[0].error).toContain("watchdogId");
|
|
432
|
+
expect(existsSync(staging.work.findingFile("alpha"))).toBe(false);
|
|
433
|
+
} finally {
|
|
434
|
+
staging.cleanup();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("watchdogId divergente é rejeitado", async () => {
|
|
439
|
+
const staging = makeWork(["alpha"]);
|
|
440
|
+
try {
|
|
441
|
+
const runner = new HuntRunner({
|
|
442
|
+
work: staging.work,
|
|
443
|
+
root: staging.root,
|
|
444
|
+
projectName: "Proj",
|
|
445
|
+
model: "deepseek/deepseek-v4-pro",
|
|
446
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
447
|
+
apiKey: "sk",
|
|
448
|
+
concurrency: 1,
|
|
449
|
+
timeoutMs: 10_000,
|
|
450
|
+
retries: 0,
|
|
451
|
+
runner: {
|
|
452
|
+
run: async () => ({
|
|
453
|
+
ok: true,
|
|
454
|
+
text: reportJson("beta"),
|
|
455
|
+
metrics: { durationMs: 1 },
|
|
456
|
+
stderr: "",
|
|
457
|
+
exitCode: 0,
|
|
458
|
+
}),
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const report = await runner.run(["alpha"]);
|
|
463
|
+
expect(report.watchdogs[0].status).toBe("failed");
|
|
464
|
+
expect(report.watchdogs[0].error).toContain("não corresponde");
|
|
465
|
+
} finally {
|
|
466
|
+
staging.cleanup();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe("SpawnMakiRunner (POSIX)", () => {
|
|
472
|
+
const dirs: string[] = [];
|
|
473
|
+
|
|
474
|
+
afterEach(() => {
|
|
475
|
+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test.skipIf(process.platform === "win32")(
|
|
479
|
+
"lê o JSON do stdout e respeita o timeout",
|
|
480
|
+
async () => {
|
|
481
|
+
const dir = mkdtempSync(join(tmpdir(), "houndsman-maki-bin-"));
|
|
482
|
+
dirs.push(dir);
|
|
483
|
+
const binary = join(dir, "maki");
|
|
484
|
+
writeFileSync(
|
|
485
|
+
binary,
|
|
486
|
+
[
|
|
487
|
+
"#!/bin/sh",
|
|
488
|
+
"cat > /dev/null",
|
|
489
|
+
`printf '%s\\n' '{"type":"result","result":"{\\"findings\\":[]}","session_id":"s1","total_cost_usd":0.01,"usage":{"total_tokens":10}}'`,
|
|
490
|
+
].join("\n"),
|
|
491
|
+
);
|
|
492
|
+
chmodSync(binary, 0o755);
|
|
493
|
+
|
|
494
|
+
const runner = new SpawnMakiRunner(binary);
|
|
495
|
+
const result = await runner.run({
|
|
496
|
+
prompt: "brief",
|
|
497
|
+
cwd: dir,
|
|
498
|
+
model: "deepseek/deepseek-v4-pro",
|
|
499
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
500
|
+
apiKey: "sk",
|
|
501
|
+
timeoutMs: 5_000,
|
|
502
|
+
});
|
|
503
|
+
expect(result.ok).toBe(true);
|
|
504
|
+
if (result.ok) {
|
|
505
|
+
expect(result.text).toContain('"findings"');
|
|
506
|
+
expect(result.metrics.sessionId).toBe("s1");
|
|
507
|
+
expect(result.metrics.costUsd).toBe(0.01);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const slow = join(dir, "maki-slow");
|
|
511
|
+
writeFileSync(slow, "#!/bin/sh\nsleep 10\n");
|
|
512
|
+
chmodSync(slow, 0o755);
|
|
513
|
+
const timedOut = await new SpawnMakiRunner(slow).run({
|
|
514
|
+
prompt: "brief",
|
|
515
|
+
cwd: dir,
|
|
516
|
+
model: "deepseek/deepseek-v4-pro",
|
|
517
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
518
|
+
apiKey: "sk",
|
|
519
|
+
timeoutMs: 100,
|
|
520
|
+
});
|
|
521
|
+
expect(timedOut.ok).toBe(false);
|
|
522
|
+
if (!timedOut.ok) expect(timedOut.timedOut).toBe(true);
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
});
|
|
@@ -116,11 +116,11 @@ describe("parseInline", () => {
|
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
test("destaca caminhos de arquivo no texto corrido", () => {
|
|
119
|
-
expect(parseInline("veja src/foo/bar.ts e
|
|
119
|
+
expect(parseInline("veja src/foo/bar.ts e manifest.json")).toEqual([
|
|
120
120
|
{ type: "text", text: "veja " },
|
|
121
121
|
{ type: "path", text: "src/foo/bar.ts" },
|
|
122
122
|
{ type: "text", text: " e " },
|
|
123
|
-
{ type: "path", text: "
|
|
123
|
+
{ type: "path", text: "manifest.json" },
|
|
124
124
|
]);
|
|
125
125
|
});
|
|
126
126
|
});
|
|
@@ -128,7 +128,7 @@ describe("parseInline", () => {
|
|
|
128
128
|
describe("isFilePath", () => {
|
|
129
129
|
test("reconhece arquivos e caminhos, sem confundir texto comum", () => {
|
|
130
130
|
expect(isFilePath("src/foo/bar.ts")).toBe(true);
|
|
131
|
-
expect(isFilePath("
|
|
131
|
+
expect(isFilePath("manifest.json")).toBe(true);
|
|
132
132
|
expect(isFilePath("./scripts/ci.sh")).toBe(true);
|
|
133
133
|
expect(isFilePath("M/D")).toBe(false);
|
|
134
134
|
expect(isFilePath("duas palavras.ts")).toBe(false);
|