@payconductor/houndsman 0.13.0 → 0.16.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 +6 -1
- 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/findings-command.ts +11 -1
- package/src/cli/commands/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +105 -4
- package/src/cli/commands/review-command.ts +38 -0
- package/src/cli/commands/status-command.ts +3 -2
- package/src/cli/help.ts +6 -1
- package/src/cli/index.ts +5 -1
- package/src/cli/progress.ts +7 -2
- package/src/core/ai/ai-client.ts +38 -2
- 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 +8 -0
- 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/usage-recorder.ts +53 -1
- package/src/core/brief.ts +278 -16
- package/src/core/builtins.ts +108 -0
- package/src/core/business-summary.ts +583 -0
- package/src/core/commands/compare.ts +266 -0
- package/src/core/commands/context.ts +6 -2
- package/src/core/commands/findings.ts +38 -30
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/review.ts +6 -0
- package/src/core/commands/scaffold.ts +29 -0
- package/src/core/commands/status.ts +9 -4
- package/src/core/compare.ts +310 -0
- package/src/core/concurrency.ts +30 -0
- package/src/core/config.ts +41 -3
- package/src/core/defaults.ts +50 -4
- 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 +371 -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 +14 -1
- package/src/core/engine/review-session.ts +11 -13
- package/src/core/engine/run-artifacts.ts +196 -0
- package/src/core/engine/run-format.ts +4 -0
- package/src/core/findings-state.ts +22 -2
- package/src/core/github-review.ts +5 -2
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +621 -34
- package/src/core/hunt/index.ts +40 -1
- package/src/core/hunt/maki-intent-prompt.ts +50 -0
- package/src/core/hunt/maki-prompt.ts +298 -5
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/pool.ts +4 -26
- package/src/core/hunt/review-checks.ts +460 -0
- package/src/core/logging/index.ts +6 -1
- package/src/core/logging/progress.ts +85 -1
- package/src/core/orchestrator.ts +33 -1289
- 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 +18 -1
- package/src/core/propagation.ts +127 -0
- package/src/core/registry-check.ts +90 -27
- package/src/core/renderers/manifest-writer.ts +13 -0
- package/src/core/renderers/resume-renderer.ts +151 -11
- package/src/core/report-builder.ts +116 -278
- package/src/core/severity.ts +245 -0
- 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 +51 -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 +55 -10
- package/src/skill/README.md +19 -14
- package/src/skill/SETUP.md +1 -0
- package/src/skill/SKILL.md +13 -4
- package/src/skill/example-resume.md +43 -0
- package/src/skill/hunt.md +205 -58
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +208 -184
- package/src/tests/ai-core.test.ts +46 -0
- 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/compare.test.ts +256 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/fixtures/fake-model-provider.ts +2 -1
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +710 -2
- package/src/tests/markdown.test.ts +3 -3
- package/src/tests/progress.test.ts +29 -1
- package/src/tests/propagation.test.ts +119 -0
- package/src/tests/review-resume.test.ts +60 -1
- package/src/tests/review-scope.test.ts +38 -29
- package/src/tests/review-scripts.test.ts +125 -218
- package/src/tests/server.test.ts +7 -4
- package/src/tests/severity.test.ts +235 -0
- 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
|
@@ -6,20 +6,47 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
10
17
|
import { tmpdir } from "node:os";
|
|
11
18
|
import { join } from "node:path";
|
|
12
19
|
import { defineHoundsmanConfig } from "../core/config";
|
|
13
20
|
import { type HuntEvent, HuntRunner } from "../core/hunt/hunt-runner";
|
|
14
21
|
import { requireMakiModel, resolveMakiModel } from "../core/hunt/maki-model";
|
|
15
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
renderAdversarialPrompt,
|
|
24
|
+
renderBookkeepingPrompt,
|
|
25
|
+
renderChecklistSuffix,
|
|
26
|
+
renderHuntPrompt,
|
|
27
|
+
renderResumeSuffix,
|
|
28
|
+
} from "../core/hunt/maki-prompt";
|
|
16
29
|
import {
|
|
17
30
|
extractJsonObject,
|
|
18
31
|
MAKI_DISALLOWED_TOOLS,
|
|
32
|
+
MAKI_REVIEW_SYSTEM_PROMPT,
|
|
19
33
|
makiArgs,
|
|
20
34
|
parseMakiOutput,
|
|
21
35
|
SpawnMakiRunner,
|
|
22
36
|
} from "../core/hunt/maki-runner";
|
|
37
|
+
import {
|
|
38
|
+
briefRules,
|
|
39
|
+
businessScore,
|
|
40
|
+
chooseBusinessSummary,
|
|
41
|
+
expectedReviewItems,
|
|
42
|
+
mergeFindings,
|
|
43
|
+
missingReviewItems,
|
|
44
|
+
missingRuleChecks,
|
|
45
|
+
parseReviewChecks,
|
|
46
|
+
parseRuleChecks,
|
|
47
|
+
summarizeRuleChecks,
|
|
48
|
+
withInferredRule,
|
|
49
|
+
} from "../core/hunt/review-checks";
|
|
23
50
|
import { ProjectConfig } from "../core/project-config";
|
|
24
51
|
import type { ReviewRecord } from "../core/storage/types";
|
|
25
52
|
import { ReviewWorkArea } from "../core/storage/work-area";
|
|
@@ -77,6 +104,12 @@ function makeWork(ids: string[]): {
|
|
|
77
104
|
const root = mkdtempSync(join(tmpdir(), "houndsman-hunt-"));
|
|
78
105
|
const work = new ReviewWorkArea(fakeReview(root), root);
|
|
79
106
|
work.ensure();
|
|
107
|
+
// Arquivos reais: o checklist agora só aceita `at` que existe em disco.
|
|
108
|
+
const files = new Set(["src/alpha/a.ts", ...ids.map((id) => `src/${id}/a.ts`)]);
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
mkdirSync(join(root, "src", file.split("/")[1]), { recursive: true });
|
|
111
|
+
writeFileSync(join(root, file), "linha 1\nlinha 2\nlinha 3\n", "utf8");
|
|
112
|
+
}
|
|
80
113
|
for (const id of ids) work.writeBrief(id, `brief de ${id}`);
|
|
81
114
|
return { root, work, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
82
115
|
}
|
|
@@ -108,9 +141,12 @@ describe("maki — contrato de execução", () => {
|
|
|
108
141
|
"deepseek/deepseek-v4-pro",
|
|
109
142
|
"--disallowed-tools",
|
|
110
143
|
MAKI_DISALLOWED_TOOLS,
|
|
144
|
+
"--append-system-prompt",
|
|
145
|
+
MAKI_REVIEW_SYSTEM_PROMPT,
|
|
111
146
|
]);
|
|
112
147
|
expect(args).not.toContain("--yolo");
|
|
113
148
|
expect(args).not.toContain("--trust");
|
|
149
|
+
expect(MAKI_REVIEW_SYSTEM_PROMPT).toContain("Profundidade vence brevidade");
|
|
114
150
|
for (const tool of ["bash", "write", "edit", "task", "webfetch"]) {
|
|
115
151
|
expect(MAKI_DISALLOWED_TOOLS.split(",")).toContain(tool);
|
|
116
152
|
}
|
|
@@ -127,6 +163,16 @@ describe("maki — contrato de execução", () => {
|
|
|
127
163
|
expect(prompt).toContain('"watchdogId": "alpha"');
|
|
128
164
|
});
|
|
129
165
|
|
|
166
|
+
test("resume entra nos args do maki e o sufixo é compacto", () => {
|
|
167
|
+
expect(makiArgs("deepseek/deepseek-v4-pro", "sess-1")).toContain("--resume");
|
|
168
|
+
expect(makiArgs("deepseek/deepseek-v4-pro", "sess-1")).toContain("sess-1");
|
|
169
|
+
expect(makiArgs("deepseek/deepseek-v4-pro")).not.toContain("--resume");
|
|
170
|
+
const suffix = renderResumeSuffix("sem JSON", "alpha");
|
|
171
|
+
expect(suffix).toContain("sessão retomada");
|
|
172
|
+
expect(suffix).toContain('"watchdogId": "alpha"');
|
|
173
|
+
expect(suffix).not.toContain("## Brief");
|
|
174
|
+
});
|
|
175
|
+
|
|
130
176
|
test("extrai JSON puro, cercado por markdown ou texto", () => {
|
|
131
177
|
const payload = { watchdogId: "alpha", findings: [] };
|
|
132
178
|
expect(extractJsonObject(JSON.stringify(payload))).toEqual(payload);
|
|
@@ -139,6 +185,21 @@ describe("maki — contrato de execução", () => {
|
|
|
139
185
|
expect(extractJsonObject("sem json")).toBeNull();
|
|
140
186
|
});
|
|
141
187
|
|
|
188
|
+
test("extrai o último objeto quando o result concatena a narração do agente", () => {
|
|
189
|
+
const first = { businessSummary: { flows: [] }, note: "rascunho" };
|
|
190
|
+
const last = { watchdogId: "general", findings: [] };
|
|
191
|
+
const transcript = [
|
|
192
|
+
"Vou revisar o diff. {isso não é JSON}",
|
|
193
|
+
JSON.stringify(first),
|
|
194
|
+
"Agora tenho evidências. Segue o relatório:",
|
|
195
|
+
`\`\`\`json\n${JSON.stringify(last, null, 2)}\n\`\`\``,
|
|
196
|
+
].join("\n");
|
|
197
|
+
expect(extractJsonObject(transcript)).toEqual(last);
|
|
198
|
+
|
|
199
|
+
const withoutFence = `narração com { e } no meio\n${JSON.stringify(first)}\nfinal:\n${JSON.stringify(last)}`;
|
|
200
|
+
expect(extractJsonObject(withoutFence)).toEqual(last);
|
|
201
|
+
});
|
|
202
|
+
|
|
142
203
|
test("parseia o JSON do maki (linha única e multi-linha)", () => {
|
|
143
204
|
const single = '{"type":"result","result":"ok","session_id":"s1"}';
|
|
144
205
|
expect(parseMakiOutput(single)?.result).toBe("ok");
|
|
@@ -150,6 +211,7 @@ describe("maki — contrato de execução", () => {
|
|
|
150
211
|
);
|
|
151
212
|
expect(parseMakiOutput(pretty)?.total_cost_usd).toBe(0.5);
|
|
152
213
|
expect(parseMakiOutput(`log\n${single}`)?.session_id).toBe("s1");
|
|
214
|
+
expect(parseMakiOutput(`log antes\n${pretty}\nlog depois`)?.result).toBe("ok");
|
|
153
215
|
expect(parseMakiOutput("nada")).toBeNull();
|
|
154
216
|
});
|
|
155
217
|
});
|
|
@@ -244,6 +306,64 @@ describe("HuntRunner", () => {
|
|
|
244
306
|
}
|
|
245
307
|
});
|
|
246
308
|
|
|
309
|
+
test("pré-passe de intenção grava o modelo antes dos watchdogs", async () => {
|
|
310
|
+
const staging = makeWork(["alpha"]);
|
|
311
|
+
staging.work.writeBusinessBrief("# Brief de intenção");
|
|
312
|
+
try {
|
|
313
|
+
let intentCalls = 0;
|
|
314
|
+
const runner = new HuntRunner({
|
|
315
|
+
work: staging.work,
|
|
316
|
+
root: staging.root,
|
|
317
|
+
projectName: "Proj",
|
|
318
|
+
model: "deepseek/deepseek-v4-pro",
|
|
319
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
320
|
+
apiKey: "sk",
|
|
321
|
+
concurrency: 1,
|
|
322
|
+
timeoutMs: 10_000,
|
|
323
|
+
retries: 0,
|
|
324
|
+
runner: {
|
|
325
|
+
run: async (request) => {
|
|
326
|
+
if (request.prompt.includes("passo de intenção")) {
|
|
327
|
+
intentCalls++;
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
text: JSON.stringify({
|
|
331
|
+
intent: "Entregar o split",
|
|
332
|
+
flows: [
|
|
333
|
+
{ name: "Renovação", files: ["src/alpha/a.ts"] },
|
|
334
|
+
],
|
|
335
|
+
invariants: ["split sempre presente"],
|
|
336
|
+
contracts: [],
|
|
337
|
+
states: [],
|
|
338
|
+
}),
|
|
339
|
+
metrics: { durationMs: 5 },
|
|
340
|
+
stderr: "",
|
|
341
|
+
exitCode: 0,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
ok: true,
|
|
346
|
+
text: reportJson("alpha"),
|
|
347
|
+
metrics: { durationMs: 5 },
|
|
348
|
+
stderr: "",
|
|
349
|
+
exitCode: 0,
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const report = await runner.run(["alpha"]);
|
|
356
|
+
expect(intentCalls).toBe(1);
|
|
357
|
+
expect(report.business).toBe("reviewer");
|
|
358
|
+
const summary = staging.work.readBusinessSummary();
|
|
359
|
+
expect(summary?.source).toBe("reviewer");
|
|
360
|
+
expect(summary?.intent).toBe("Entregar o split");
|
|
361
|
+
expect(summary?.invariants).toEqual(["split sempre presente"]);
|
|
362
|
+
} finally {
|
|
363
|
+
staging.cleanup();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
247
367
|
test("repete uma vez quando a saída não tem JSON válido", async () => {
|
|
248
368
|
const staging = makeWork(["alpha"]);
|
|
249
369
|
try {
|
|
@@ -410,6 +530,594 @@ describe("HuntRunner", () => {
|
|
|
410
530
|
});
|
|
411
531
|
});
|
|
412
532
|
|
|
533
|
+
/** Brief mínimo com a seção de arquivos do domínio (usada pelo checklist). */
|
|
534
|
+
function briefWithFiles(id: string, files: string[], rules: string[] = []): string {
|
|
535
|
+
return [
|
|
536
|
+
`# Brief — ${id}`,
|
|
537
|
+
"",
|
|
538
|
+
`## Arquivos do seu domínio (${files.length})`,
|
|
539
|
+
"",
|
|
540
|
+
...files.map((file) => `- \`${file}\` (modified, +10/−0)`),
|
|
541
|
+
"",
|
|
542
|
+
...(rules.length > 0
|
|
543
|
+
? [
|
|
544
|
+
"## Regras (por severidade)",
|
|
545
|
+
"",
|
|
546
|
+
...rules.map(
|
|
547
|
+
(rule, index) => `- ${index + 1}. ⚠️ **attention** — ${rule}`,
|
|
548
|
+
),
|
|
549
|
+
"",
|
|
550
|
+
]
|
|
551
|
+
: []),
|
|
552
|
+
"## Procedimento",
|
|
553
|
+
"",
|
|
554
|
+
].join("\n");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
describe("harness — checklist de intenção", () => {
|
|
558
|
+
const business = {
|
|
559
|
+
source: "ai" as const,
|
|
560
|
+
intent: "Entregar split",
|
|
561
|
+
flows: [
|
|
562
|
+
{ name: "Criação de subconta", files: ["src/alpha/a.ts"] },
|
|
563
|
+
{ name: "Fluxo beta", files: ["src/beta/b.ts"] },
|
|
564
|
+
],
|
|
565
|
+
invariants: ["Split sempre presente"],
|
|
566
|
+
contracts: [],
|
|
567
|
+
states: [],
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
test("prompt declara protocolo, calibração e o campo checks", () => {
|
|
571
|
+
const prompt = renderHuntPrompt({
|
|
572
|
+
projectName: "Proj",
|
|
573
|
+
watchdogId: "alpha",
|
|
574
|
+
brief: "# Brief alpha",
|
|
575
|
+
});
|
|
576
|
+
expect(prompt).toContain("## Protocolo de revisão");
|
|
577
|
+
expect(prompt).toContain("AUTOVERIFICAÇÃO");
|
|
578
|
+
expect(prompt).toContain('"checks"');
|
|
579
|
+
expect(prompt).toContain("blocking`");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("sufixo de checklist lista os itens pendentes", () => {
|
|
583
|
+
const suffix = renderChecklistSuffix(["Split sempre presente"]);
|
|
584
|
+
expect(suffix).toContain("Verificação obrigatória (checklist)");
|
|
585
|
+
expect(suffix).toContain("- Split sempre presente");
|
|
586
|
+
expect(suffix).toContain("relatório **completo**");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("itens esperados recortam fluxos por domínio e mantêm invariantes", () => {
|
|
590
|
+
const brief = briefWithFiles("alpha", ["src/alpha/a.ts"]);
|
|
591
|
+
expect(expectedReviewItems(brief, business, "alpha")).toEqual([
|
|
592
|
+
"Criação de subconta",
|
|
593
|
+
"Split sempre presente",
|
|
594
|
+
]);
|
|
595
|
+
expect(expectedReviewItems(brief, business, "general")).toEqual([
|
|
596
|
+
"Criação de subconta",
|
|
597
|
+
"Fluxo beta",
|
|
598
|
+
"Split sempre presente",
|
|
599
|
+
]);
|
|
600
|
+
expect(expectedReviewItems(brief, null, "alpha")).toEqual([]);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("checklist ignora acento/caixa e lê variações de veredito", () => {
|
|
604
|
+
const checks = parseReviewChecks({
|
|
605
|
+
checks: [
|
|
606
|
+
{ item: "split SEMPRE presente", verdict: "OK", at: "a.ts:1" },
|
|
607
|
+
{ item: "Outro item", verdict: "gap" },
|
|
608
|
+
"Item sem objeto",
|
|
609
|
+
],
|
|
610
|
+
});
|
|
611
|
+
expect(checks[0]).toMatchObject({ verdict: "ok", at: "a.ts:1" });
|
|
612
|
+
expect(checks[1].verdict).toBe("gap");
|
|
613
|
+
expect(checks[2].verdict).toBe("n/a");
|
|
614
|
+
expect(missingReviewItems(["Split sempre presente"], checks)).toEqual([]);
|
|
615
|
+
expect(missingReviewItems(["Invariante ausente"], checks)).toEqual([
|
|
616
|
+
"Invariante ausente",
|
|
617
|
+
]);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("merge de findings preserva a maior severidade e deduplica", () => {
|
|
621
|
+
const suggestion = {
|
|
622
|
+
rule: "Evitar any",
|
|
623
|
+
severity: "suggestion" as const,
|
|
624
|
+
file: "src/alpha/a.ts",
|
|
625
|
+
line: 4,
|
|
626
|
+
message: "any",
|
|
627
|
+
action: "tipar",
|
|
628
|
+
};
|
|
629
|
+
const blocking = { ...suggestion, severity: "blocking" as const };
|
|
630
|
+
expect(mergeFindings([suggestion], [blocking])).toHaveLength(1);
|
|
631
|
+
expect(mergeFindings([suggestion], [blocking])[0].severity).toBe("blocking");
|
|
632
|
+
expect(mergeFindings([suggestion], [suggestion])).toHaveLength(1);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("lê as regras numeradas do brief e valida a cobertura", () => {
|
|
636
|
+
const brief = briefWithFiles(
|
|
637
|
+
"alpha",
|
|
638
|
+
["src/alpha/a.ts"],
|
|
639
|
+
["Evitar any.", "Promover: validar a entrada."],
|
|
640
|
+
);
|
|
641
|
+
const rules = briefRules(brief);
|
|
642
|
+
expect(rules).toEqual([
|
|
643
|
+
{ id: 1, severity: "attention", text: "Evitar any." },
|
|
644
|
+
{ id: 2, severity: "attention", text: "Promover: validar a entrada." },
|
|
645
|
+
]);
|
|
646
|
+
const checks = parseRuleChecks({
|
|
647
|
+
rules: [{ id: 1, verdict: "ok", at: "src/alpha/a.ts:1" }, "2: gap"],
|
|
648
|
+
});
|
|
649
|
+
expect(checks).toMatchObject([
|
|
650
|
+
{ id: 1, verdict: "ok" },
|
|
651
|
+
{ id: 2, verdict: "gap" },
|
|
652
|
+
]);
|
|
653
|
+
expect(missingRuleChecks(rules, checks)).toEqual([]);
|
|
654
|
+
// Verdicto sem `at` não é prova: só a regra 1 comprova localização.
|
|
655
|
+
expect(summarizeRuleChecks(rules, checks)).toEqual({
|
|
656
|
+
expected: 2,
|
|
657
|
+
verified: 1,
|
|
658
|
+
missing: 0,
|
|
659
|
+
notApplicable: 0,
|
|
660
|
+
gaps: 1,
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("checklist dispara por regra não verificada e retoma a sessão", async () => {
|
|
665
|
+
const staging = makeWork(["alpha"]);
|
|
666
|
+
staging.work.writeBrief(
|
|
667
|
+
"alpha",
|
|
668
|
+
briefWithFiles(
|
|
669
|
+
"alpha",
|
|
670
|
+
["src/alpha/a.ts"],
|
|
671
|
+
["Evitar any.", "Evitar console.log."],
|
|
672
|
+
),
|
|
673
|
+
);
|
|
674
|
+
const requests: { prompt: string; resume?: string }[] = [];
|
|
675
|
+
try {
|
|
676
|
+
let calls = 0;
|
|
677
|
+
const runner = new HuntRunner({
|
|
678
|
+
work: staging.work,
|
|
679
|
+
root: staging.root,
|
|
680
|
+
projectName: "Proj",
|
|
681
|
+
model: "deepseek/deepseek-v4-pro",
|
|
682
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
683
|
+
apiKey: "sk",
|
|
684
|
+
concurrency: 1,
|
|
685
|
+
timeoutMs: 10_000,
|
|
686
|
+
retries: 0,
|
|
687
|
+
runner: {
|
|
688
|
+
run: async (request) => {
|
|
689
|
+
requests.push({ prompt: request.prompt, resume: request.resume });
|
|
690
|
+
calls++;
|
|
691
|
+
return calls === 1
|
|
692
|
+
? {
|
|
693
|
+
ok: true,
|
|
694
|
+
text: reportJson("alpha"),
|
|
695
|
+
metrics: {
|
|
696
|
+
durationMs: 5,
|
|
697
|
+
sessionId: "sess-1",
|
|
698
|
+
costUsd: 0.01,
|
|
699
|
+
},
|
|
700
|
+
stderr: "",
|
|
701
|
+
exitCode: 0,
|
|
702
|
+
}
|
|
703
|
+
: {
|
|
704
|
+
ok: true,
|
|
705
|
+
text: JSON.stringify({
|
|
706
|
+
watchdogId: "alpha",
|
|
707
|
+
reviewedFiles: ["src/alpha/a.ts"],
|
|
708
|
+
skipped: [],
|
|
709
|
+
rules: [
|
|
710
|
+
{
|
|
711
|
+
id: 1,
|
|
712
|
+
verdict: "ok",
|
|
713
|
+
at: "src/alpha/a.ts:1",
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
id: 2,
|
|
717
|
+
verdict: "ok",
|
|
718
|
+
at: "src/alpha/a.ts:2",
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
checks: [],
|
|
722
|
+
findings: [],
|
|
723
|
+
}),
|
|
724
|
+
metrics: {
|
|
725
|
+
durationMs: 5,
|
|
726
|
+
sessionId: "sess-1",
|
|
727
|
+
costUsd: 0.005,
|
|
728
|
+
},
|
|
729
|
+
stderr: "",
|
|
730
|
+
exitCode: 0,
|
|
731
|
+
};
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const report = await runner.run(["alpha"]);
|
|
737
|
+
expect(requests).toHaveLength(2);
|
|
738
|
+
expect(requests[1].resume).toBe("sess-1");
|
|
739
|
+
expect(requests[1].prompt).toContain("Regras não verificadas");
|
|
740
|
+
expect(requests[1].prompt).toContain("1, 2");
|
|
741
|
+
expect(report.watchdogs[0].findings).toBe(1);
|
|
742
|
+
expect(report.watchdogs[0].rules).toEqual({
|
|
743
|
+
expected: 2,
|
|
744
|
+
verified: 2,
|
|
745
|
+
missing: 0,
|
|
746
|
+
notApplicable: 0,
|
|
747
|
+
gaps: 0,
|
|
748
|
+
});
|
|
749
|
+
expect(report.watchdogs[0].checklist?.costUsd).toBeCloseTo(0.005);
|
|
750
|
+
expect(report.watchdogs[0].checklist?.numTurns).toBe(0);
|
|
751
|
+
} finally {
|
|
752
|
+
staging.cleanup();
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("segunda opinião do general roda em sessão nova com os achados já cobertos", async () => {
|
|
757
|
+
const staging = makeWork(["general"]);
|
|
758
|
+
staging.work.writeBrief("general", briefWithFiles("general", ["src/alpha/a.ts"]));
|
|
759
|
+
const requests: { prompt: string; resume?: string }[] = [];
|
|
760
|
+
try {
|
|
761
|
+
let calls = 0;
|
|
762
|
+
const runner = new HuntRunner({
|
|
763
|
+
work: staging.work,
|
|
764
|
+
root: staging.root,
|
|
765
|
+
projectName: "Proj",
|
|
766
|
+
model: "deepseek/deepseek-v4-pro",
|
|
767
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
768
|
+
apiKey: "sk",
|
|
769
|
+
concurrency: 1,
|
|
770
|
+
timeoutMs: 10_000,
|
|
771
|
+
retries: 0,
|
|
772
|
+
runner: {
|
|
773
|
+
run: async (request) => {
|
|
774
|
+
requests.push({ prompt: request.prompt, resume: request.resume });
|
|
775
|
+
calls++;
|
|
776
|
+
// 1 = fluxo, 2 = bookkeeping (só vereditos), 3 = adversarial.
|
|
777
|
+
const text =
|
|
778
|
+
calls === 2
|
|
779
|
+
? JSON.stringify({
|
|
780
|
+
watchdogId: "general",
|
|
781
|
+
reviewedFiles: ["src/general/a.ts"],
|
|
782
|
+
skipped: [],
|
|
783
|
+
rules: [],
|
|
784
|
+
checks: [],
|
|
785
|
+
findings: [],
|
|
786
|
+
})
|
|
787
|
+
: reportJson("general", calls === 1 ? 1 : 2);
|
|
788
|
+
return {
|
|
789
|
+
ok: true,
|
|
790
|
+
text,
|
|
791
|
+
metrics: {
|
|
792
|
+
durationMs: 5,
|
|
793
|
+
sessionId: `sess-${calls}`,
|
|
794
|
+
costUsd: 0.02,
|
|
795
|
+
numTurns: 3,
|
|
796
|
+
},
|
|
797
|
+
stderr: "",
|
|
798
|
+
exitCode: 0,
|
|
799
|
+
};
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const report = await runner.run(["general"]);
|
|
805
|
+
expect(requests).toHaveLength(3);
|
|
806
|
+
// Passada base: fluxo puro, sem bookkeeping no schema.
|
|
807
|
+
expect(requests[0].prompt).toContain("Seu produto é **achado**");
|
|
808
|
+
expect(requests[0].prompt).not.toContain('"checks"');
|
|
809
|
+
// Bookkeeping: sessão nova, tarefa estreita.
|
|
810
|
+
expect(requests[1].prompt).toContain("verificador de checklist");
|
|
811
|
+
expect(requests[1].resume).toBeUndefined();
|
|
812
|
+
// Adversarial: sessão nova, com os achados já cobertos.
|
|
813
|
+
expect(requests[2].prompt).toContain("Classes a atacar");
|
|
814
|
+
expect(requests[2].prompt).toContain("Já cobertos");
|
|
815
|
+
expect(requests[2].resume).toBeUndefined();
|
|
816
|
+
expect(report.watchdogs[0].findings).toBe(2);
|
|
817
|
+
expect(report.watchdogs[0].secondOpinion).toMatchObject({
|
|
818
|
+
status: "done",
|
|
819
|
+
findings: 1,
|
|
820
|
+
});
|
|
821
|
+
expect(report.watchdogs[0].secondOpinion?.costUsd).toBeCloseTo(0.02);
|
|
822
|
+
expect(report.watchdogs[0].secondOpinion?.numTurns).toBe(3);
|
|
823
|
+
} finally {
|
|
824
|
+
staging.cleanup();
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
test("prompt adversarial em sessão nova cita as cinco classes e não pede bookkeeping", () => {
|
|
829
|
+
const prompt = renderAdversarialPrompt({
|
|
830
|
+
projectName: "Proj",
|
|
831
|
+
watchdogId: "general",
|
|
832
|
+
brief: "# Brief general",
|
|
833
|
+
candidates: [
|
|
834
|
+
{
|
|
835
|
+
rule: "Evitar any",
|
|
836
|
+
severity: "attention",
|
|
837
|
+
file: "a.ts",
|
|
838
|
+
line: 1,
|
|
839
|
+
message: "m",
|
|
840
|
+
},
|
|
841
|
+
],
|
|
842
|
+
});
|
|
843
|
+
for (const term of [
|
|
844
|
+
"Credencial e contexto compartilhado",
|
|
845
|
+
"Capacidade anunciada",
|
|
846
|
+
"Identificador persistido",
|
|
847
|
+
"Idempotência e transição de estado",
|
|
848
|
+
"Contrato entre lados",
|
|
849
|
+
]) {
|
|
850
|
+
expect(prompt).toContain(term);
|
|
851
|
+
}
|
|
852
|
+
expect(prompt).toContain("sessão nova");
|
|
853
|
+
expect(prompt).toContain("Já cobertos");
|
|
854
|
+
expect(prompt).not.toContain('"checks"');
|
|
855
|
+
// Não pode herdar o pedido de "relatório completo" da passada em sessão.
|
|
856
|
+
expect(prompt).not.toContain("findings anteriores + novos");
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test("prompt de bookkeeping pede só vereditos com `at`", () => {
|
|
860
|
+
const prompt = renderBookkeepingPrompt({
|
|
861
|
+
projectName: "Proj",
|
|
862
|
+
watchdogId: "general",
|
|
863
|
+
brief: "# Brief general",
|
|
864
|
+
items: ["Criação de subconta"],
|
|
865
|
+
ruleIds: [1],
|
|
866
|
+
});
|
|
867
|
+
expect(prompt).toContain("- Criação de subconta");
|
|
868
|
+
expect(prompt).toContain('"findings"');
|
|
869
|
+
expect(prompt).toContain("`findings` deve ser sempre `[]`");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
test("rule vazio é inferido com o prefixo do basis", () => {
|
|
873
|
+
const report = withInferredRule({
|
|
874
|
+
watchdogId: "alpha",
|
|
875
|
+
reviewedFiles: [],
|
|
876
|
+
skipped: [],
|
|
877
|
+
findings: [
|
|
878
|
+
{
|
|
879
|
+
rule: "",
|
|
880
|
+
severity: "attention",
|
|
881
|
+
file: "src/alpha/a.ts",
|
|
882
|
+
line: 1,
|
|
883
|
+
message: "Split ausente na criação. Detalhe adicional.",
|
|
884
|
+
action: "enviar split",
|
|
885
|
+
basis: "domain",
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
rule: " ",
|
|
889
|
+
severity: "attention",
|
|
890
|
+
file: "src/alpha/a.ts",
|
|
891
|
+
line: 2,
|
|
892
|
+
message: "Outro problema",
|
|
893
|
+
action: "corrigir",
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
});
|
|
897
|
+
expect(report.findings[0].rule).toBe("Domínio: Split ausente na criação");
|
|
898
|
+
expect(report.findings[1].rule).toBe("Lógica: Outro problema");
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
test("retry de conteúdo retoma a sessão e envia só o sufixo", async () => {
|
|
902
|
+
const staging = makeWork(["alpha"]);
|
|
903
|
+
try {
|
|
904
|
+
const requests: { prompt: string; resume?: string }[] = [];
|
|
905
|
+
let calls = 0;
|
|
906
|
+
const runner = new HuntRunner({
|
|
907
|
+
work: staging.work,
|
|
908
|
+
root: staging.root,
|
|
909
|
+
projectName: "Proj",
|
|
910
|
+
model: "deepseek/deepseek-v4-pro",
|
|
911
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
912
|
+
apiKey: "sk",
|
|
913
|
+
concurrency: 1,
|
|
914
|
+
timeoutMs: 10_000,
|
|
915
|
+
retries: 1,
|
|
916
|
+
runner: {
|
|
917
|
+
run: async (request) => {
|
|
918
|
+
requests.push({ prompt: request.prompt, resume: request.resume });
|
|
919
|
+
calls++;
|
|
920
|
+
return calls === 1
|
|
921
|
+
? {
|
|
922
|
+
ok: true,
|
|
923
|
+
text: "narração sem JSON",
|
|
924
|
+
metrics: { durationMs: 5, sessionId: "sess-1" },
|
|
925
|
+
stderr: "",
|
|
926
|
+
exitCode: 0,
|
|
927
|
+
}
|
|
928
|
+
: {
|
|
929
|
+
ok: true,
|
|
930
|
+
text: reportJson("alpha"),
|
|
931
|
+
metrics: { durationMs: 5, sessionId: "sess-1" },
|
|
932
|
+
stderr: "",
|
|
933
|
+
exitCode: 0,
|
|
934
|
+
};
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
const report = await runner.run(["alpha"]);
|
|
940
|
+
expect(report.watchdogs[0].status).toBe("done");
|
|
941
|
+
expect(requests[1].resume).toBe("sess-1");
|
|
942
|
+
expect(requests[1].prompt).toContain("sessão retomada");
|
|
943
|
+
expect(requests[1].prompt).not.toContain("brief de alpha");
|
|
944
|
+
} finally {
|
|
945
|
+
staging.cleanup();
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test("modelo de intenção mais fraco não substitui o da IA", () => {
|
|
950
|
+
const weak = {
|
|
951
|
+
...business,
|
|
952
|
+
source: "reviewer" as const,
|
|
953
|
+
flows: [],
|
|
954
|
+
invariants: ["Split sempre presente"],
|
|
955
|
+
};
|
|
956
|
+
expect(businessScore(weak)).toBeLessThan(businessScore(business));
|
|
957
|
+
const kept = chooseBusinessSummary(business, weak);
|
|
958
|
+
expect(kept.accepted).toBe(false);
|
|
959
|
+
expect(kept.summary).toBe(business);
|
|
960
|
+
|
|
961
|
+
const deterministic = { ...business, source: "deterministic" as const };
|
|
962
|
+
expect(chooseBusinessSummary(deterministic, weak).accepted).toBe(true);
|
|
963
|
+
expect(chooseBusinessSummary(null, weak).accepted).toBe(true);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("runner repete o brief para cobrir itens não verificados e une achados", async () => {
|
|
967
|
+
const staging = makeWork(["alpha"]);
|
|
968
|
+
staging.work.writeBrief("alpha", briefWithFiles("alpha", ["src/alpha/a.ts"]));
|
|
969
|
+
staging.work.writeBusinessSummary(business);
|
|
970
|
+
const prompts: string[] = [];
|
|
971
|
+
try {
|
|
972
|
+
const runner = new HuntRunner({
|
|
973
|
+
work: staging.work,
|
|
974
|
+
root: staging.root,
|
|
975
|
+
projectName: "Proj",
|
|
976
|
+
model: "deepseek/deepseek-v4-pro",
|
|
977
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
978
|
+
apiKey: "sk",
|
|
979
|
+
concurrency: 1,
|
|
980
|
+
timeoutMs: 10_000,
|
|
981
|
+
retries: 0,
|
|
982
|
+
runner: {
|
|
983
|
+
run: async (request) => {
|
|
984
|
+
prompts.push(request.prompt);
|
|
985
|
+
if (prompts.length === 1) {
|
|
986
|
+
return {
|
|
987
|
+
ok: true,
|
|
988
|
+
text: reportJson("alpha"),
|
|
989
|
+
metrics: { durationMs: 5 },
|
|
990
|
+
stderr: "",
|
|
991
|
+
exitCode: 0,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
ok: true,
|
|
996
|
+
text: JSON.stringify({
|
|
997
|
+
watchdogId: "alpha",
|
|
998
|
+
reviewedFiles: ["src/alpha/a.ts"],
|
|
999
|
+
skipped: [],
|
|
1000
|
+
checks: [
|
|
1001
|
+
{
|
|
1002
|
+
item: "Criação de subconta",
|
|
1003
|
+
verdict: "ok",
|
|
1004
|
+
at: "src/alpha/a.ts:1",
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
item: "Split sempre presente",
|
|
1008
|
+
verdict: "gap",
|
|
1009
|
+
at: "src/alpha/a.ts:2",
|
|
1010
|
+
},
|
|
1011
|
+
],
|
|
1012
|
+
findings: [
|
|
1013
|
+
{
|
|
1014
|
+
rule: "Evitar any",
|
|
1015
|
+
severity: "attention",
|
|
1016
|
+
file: "src/alpha/a.ts",
|
|
1017
|
+
line: 2,
|
|
1018
|
+
message: "split ausente",
|
|
1019
|
+
action: "enviar split",
|
|
1020
|
+
},
|
|
1021
|
+
],
|
|
1022
|
+
}),
|
|
1023
|
+
metrics: { durationMs: 5 },
|
|
1024
|
+
stderr: "",
|
|
1025
|
+
exitCode: 0,
|
|
1026
|
+
};
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
const report = await runner.run(["alpha"]);
|
|
1032
|
+
expect(prompts).toHaveLength(2);
|
|
1033
|
+
expect(prompts[1]).toContain("Verificação obrigatória (checklist)");
|
|
1034
|
+
expect(prompts[1]).toContain("Criação de subconta");
|
|
1035
|
+
expect(report.watchdogs[0].findings).toBe(2);
|
|
1036
|
+
expect(report.watchdogs[0].checks).toEqual({
|
|
1037
|
+
expected: 2,
|
|
1038
|
+
verified: 2,
|
|
1039
|
+
missing: 0,
|
|
1040
|
+
notApplicable: 0,
|
|
1041
|
+
gaps: 1,
|
|
1042
|
+
});
|
|
1043
|
+
} finally {
|
|
1044
|
+
staging.cleanup();
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("businessSummary do general só substitui um modelo mais fraco", async () => {
|
|
1049
|
+
const staging = makeWork(["general"]);
|
|
1050
|
+
staging.work.writeBrief("general", briefWithFiles("general", ["src/alpha/a.ts"]));
|
|
1051
|
+
staging.work.writeBusinessSummary(business);
|
|
1052
|
+
try {
|
|
1053
|
+
const runner = new HuntRunner({
|
|
1054
|
+
work: staging.work,
|
|
1055
|
+
root: staging.root,
|
|
1056
|
+
projectName: "Proj",
|
|
1057
|
+
model: "deepseek/deepseek-v4-pro",
|
|
1058
|
+
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
1059
|
+
apiKey: "sk",
|
|
1060
|
+
concurrency: 1,
|
|
1061
|
+
timeoutMs: 10_000,
|
|
1062
|
+
retries: 0,
|
|
1063
|
+
runner: {
|
|
1064
|
+
run: async () => ({
|
|
1065
|
+
ok: true,
|
|
1066
|
+
text: JSON.stringify({
|
|
1067
|
+
watchdogId: "general",
|
|
1068
|
+
reviewedFiles: ["src/alpha/a.ts"],
|
|
1069
|
+
skipped: [],
|
|
1070
|
+
checks: [
|
|
1071
|
+
{
|
|
1072
|
+
item: "Criação de subconta",
|
|
1073
|
+
verdict: "ok",
|
|
1074
|
+
at: "src/alpha/a.ts:1",
|
|
1075
|
+
},
|
|
1076
|
+
{ item: "Fluxo beta", verdict: "n/a" },
|
|
1077
|
+
{
|
|
1078
|
+
item: "Split sempre presente",
|
|
1079
|
+
verdict: "ok",
|
|
1080
|
+
at: "src/alpha/a.ts:3",
|
|
1081
|
+
},
|
|
1082
|
+
],
|
|
1083
|
+
findings: [],
|
|
1084
|
+
businessSummary: {
|
|
1085
|
+
invariants: ["Split sempre presente"],
|
|
1086
|
+
flows: [],
|
|
1087
|
+
contracts: [],
|
|
1088
|
+
states: [],
|
|
1089
|
+
},
|
|
1090
|
+
}),
|
|
1091
|
+
metrics: { durationMs: 5 },
|
|
1092
|
+
stderr: "",
|
|
1093
|
+
exitCode: 0,
|
|
1094
|
+
}),
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
const report = await runner.run(["general"]);
|
|
1099
|
+
expect(report.watchdogs[0].business).toMatchObject({
|
|
1100
|
+
accepted: false,
|
|
1101
|
+
source: "ai",
|
|
1102
|
+
});
|
|
1103
|
+
expect(report.business).toBe("ai");
|
|
1104
|
+
const summary = staging.work.readBusinessSummary();
|
|
1105
|
+
expect(summary?.source).toBe("ai");
|
|
1106
|
+
expect(summary?.flows).toHaveLength(2);
|
|
1107
|
+
// `n/a` é veredito, não prova: 2 itens comprovados + 1 não aplicável.
|
|
1108
|
+
expect(report.watchdogs[0].checks).toEqual({
|
|
1109
|
+
expected: 3,
|
|
1110
|
+
verified: 2,
|
|
1111
|
+
missing: 0,
|
|
1112
|
+
notApplicable: 1,
|
|
1113
|
+
gaps: 0,
|
|
1114
|
+
});
|
|
1115
|
+
} finally {
|
|
1116
|
+
staging.cleanup();
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
413
1121
|
describe("SpawnMakiRunner (POSIX)", () => {
|
|
414
1122
|
const dirs: string[] = [];
|
|
415
1123
|
|