@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
|
@@ -8,10 +8,21 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
|
-
import {
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { GENERAL_WATCHDOG_ID } from "../builtins";
|
|
13
|
+
import { type BusinessSummary, parseBusinessSummary } from "../business-summary";
|
|
14
|
+
import { parseWatchdogReport, type WatchdogReport } from "../report-builder";
|
|
12
15
|
import { systemClock } from "../services/clock";
|
|
13
16
|
import type { ReviewWorkArea } from "../storage/work-area";
|
|
14
|
-
import {
|
|
17
|
+
import { renderHuntIntentPrompt, renderIntentRetrySuffix } from "./maki-intent-prompt";
|
|
18
|
+
import {
|
|
19
|
+
renderAdversarialPrompt,
|
|
20
|
+
renderBookkeepingPrompt,
|
|
21
|
+
renderChecklistSuffix,
|
|
22
|
+
renderHuntPrompt,
|
|
23
|
+
renderResumeSuffix,
|
|
24
|
+
renderRetrySuffix,
|
|
25
|
+
} from "./maki-prompt";
|
|
15
26
|
import {
|
|
16
27
|
extractJsonObject,
|
|
17
28
|
type MakiProcessRunner,
|
|
@@ -19,9 +30,51 @@ import {
|
|
|
19
30
|
type MakiUsage,
|
|
20
31
|
} from "./maki-runner";
|
|
21
32
|
import { mapPool } from "./pool";
|
|
22
|
-
|
|
33
|
+
import {
|
|
34
|
+
type AtResolver,
|
|
35
|
+
briefDomainFiles,
|
|
36
|
+
briefRules,
|
|
37
|
+
businessScore,
|
|
38
|
+
type CheckCoverage,
|
|
39
|
+
chooseBusinessSummary,
|
|
40
|
+
expectedReviewItems,
|
|
41
|
+
mergeChecks,
|
|
42
|
+
mergeFindings,
|
|
43
|
+
mergeRuleChecks,
|
|
44
|
+
missingReviewItems,
|
|
45
|
+
missingRuleChecks,
|
|
46
|
+
normalizePath,
|
|
47
|
+
parseReviewChecks,
|
|
48
|
+
parseRuleChecks,
|
|
49
|
+
type ReviewCheck,
|
|
50
|
+
type RuleCheck,
|
|
51
|
+
summarizeChecks,
|
|
52
|
+
summarizeRuleChecks,
|
|
53
|
+
withInferredRule,
|
|
54
|
+
} from "./review-checks";
|
|
23
55
|
/** Evento de progresso (CLI loga; core não escreve saída). */
|
|
24
56
|
export type HuntEvent =
|
|
57
|
+
| {
|
|
58
|
+
type: "intent";
|
|
59
|
+
status: "start" | "done" | "skipped" | "failed";
|
|
60
|
+
source?: BusinessSummary["source"];
|
|
61
|
+
error?: string;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
type: "checklist";
|
|
65
|
+
watchdogId: string;
|
|
66
|
+
missing: number;
|
|
67
|
+
missingRules: number;
|
|
68
|
+
status: "start" | "done" | "failed";
|
|
69
|
+
error?: string;
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
type: "second-opinion";
|
|
73
|
+
watchdogId: string;
|
|
74
|
+
status: "start" | "done" | "failed";
|
|
75
|
+
findings?: number;
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
25
78
|
| { type: "start"; watchdogId: string }
|
|
26
79
|
| {
|
|
27
80
|
type: "retry";
|
|
@@ -59,6 +112,39 @@ export type HuntWatchdogResult = {
|
|
|
59
112
|
costUsd?: number;
|
|
60
113
|
usage?: MakiUsage;
|
|
61
114
|
error?: string;
|
|
115
|
+
/** Cobertura do checklist de intenção (fluxos + invariantes). */
|
|
116
|
+
checks?: CheckCoverage;
|
|
117
|
+
/** Cobertura das regras declaradas no brief (numeradas). */
|
|
118
|
+
rules?: CheckCoverage;
|
|
119
|
+
/** Passada de checklist (custo marginal explícito). */
|
|
120
|
+
checklist?: {
|
|
121
|
+
status: "done" | "failed";
|
|
122
|
+
missing: number;
|
|
123
|
+
missingRules: number;
|
|
124
|
+
costUsd: number;
|
|
125
|
+
numTurns: number;
|
|
126
|
+
};
|
|
127
|
+
/** Resultado da passada adversarial do `general` (com custo marginal). */
|
|
128
|
+
secondOpinion?: {
|
|
129
|
+
status: "done" | "failed";
|
|
130
|
+
findings: number;
|
|
131
|
+
costUsd: number;
|
|
132
|
+
numTurns: number;
|
|
133
|
+
error?: string;
|
|
134
|
+
};
|
|
135
|
+
/** Cobertura declarada dos arquivos do domínio (`reviewedFiles` ∪ `skipped`). */
|
|
136
|
+
coverage?: {
|
|
137
|
+
total: number;
|
|
138
|
+
reviewed: number;
|
|
139
|
+
skipped: number;
|
|
140
|
+
undeclared: number;
|
|
141
|
+
};
|
|
142
|
+
/** Decisão sobre o modelo de intenção devolvido pelo `general`. */
|
|
143
|
+
business?: {
|
|
144
|
+
accepted: boolean;
|
|
145
|
+
source: BusinessSummary["source"];
|
|
146
|
+
score: number;
|
|
147
|
+
};
|
|
62
148
|
};
|
|
63
149
|
|
|
64
150
|
/** Agregados do relatório. */
|
|
@@ -78,6 +164,10 @@ export type HuntReport = {
|
|
|
78
164
|
version: 1;
|
|
79
165
|
engine: "maki";
|
|
80
166
|
model: string;
|
|
167
|
+
/** Fonte do modelo de intenção usado na revisão. */
|
|
168
|
+
business?: BusinessSummary["source"];
|
|
169
|
+
/** Pontuação do modelo de intenção efetivo (fluxos/invariantes/contratos). */
|
|
170
|
+
businessScore?: number;
|
|
81
171
|
startedAt: string;
|
|
82
172
|
finishedAt: string;
|
|
83
173
|
durationMs: number;
|
|
@@ -99,6 +189,16 @@ export type HuntRunnerOptions = {
|
|
|
99
189
|
concurrency: number;
|
|
100
190
|
timeoutMs: number;
|
|
101
191
|
retries: number;
|
|
192
|
+
/**
|
|
193
|
+
* Passada extra por watchdog quando o checklist de intenção não foi coberto
|
|
194
|
+
* (default: ligada). Desligue com `hunt.checklist: false`.
|
|
195
|
+
*/
|
|
196
|
+
checklist?: boolean;
|
|
197
|
+
/**
|
|
198
|
+
* Passada adversarial do `general` (5 classes: credencial/estado, capacidade,
|
|
199
|
+
* identificador, idempotência, contrato). Default: ligada.
|
|
200
|
+
*/
|
|
201
|
+
secondOpinion?: boolean;
|
|
102
202
|
runner: MakiProcessRunner;
|
|
103
203
|
onEvent?: (event: HuntEvent) => void;
|
|
104
204
|
};
|
|
@@ -114,14 +214,23 @@ export class HuntRunner {
|
|
|
114
214
|
async run(activeIds: readonly string[]): Promise<HuntReport> {
|
|
115
215
|
const startedAt = systemClock();
|
|
116
216
|
const startedMs = Date.now();
|
|
217
|
+
// Intenção primeiro: o revisor `general` e os demais watchdogs leem o
|
|
218
|
+
// mesmo `business-summary.json` do staging.
|
|
219
|
+
const businessSource = await this.runIntent();
|
|
220
|
+
// Snapshot único: o `general` pode devolver outro modelo no meio da onda;
|
|
221
|
+
// os demais watchdogs precisam do mesmo contrato do brief que receberam.
|
|
222
|
+
const businessModel = this.options.work.readBusinessSummary();
|
|
117
223
|
const watchdogs = await mapPool(activeIds, this.options.concurrency, (id) =>
|
|
118
|
-
this.runOne(id),
|
|
224
|
+
this.runOne(id, businessModel),
|
|
119
225
|
);
|
|
120
226
|
const finishedAt = systemClock();
|
|
227
|
+
const effective = this.options.work.readBusinessSummary();
|
|
121
228
|
return {
|
|
122
229
|
version: 1,
|
|
123
230
|
engine: "maki",
|
|
124
231
|
model: this.options.model,
|
|
232
|
+
business: effective?.source ?? businessSource,
|
|
233
|
+
businessScore: effective ? businessScore(effective) : undefined,
|
|
125
234
|
startedAt,
|
|
126
235
|
finishedAt,
|
|
127
236
|
durationMs: Date.now() - startedMs,
|
|
@@ -130,7 +239,91 @@ export class HuntRunner {
|
|
|
130
239
|
};
|
|
131
240
|
}
|
|
132
241
|
|
|
133
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Pré-passe de intenção (IA embutida). Pula quando o prepare já gerou o
|
|
244
|
+
* modelo com IA; falha mantém o determinístico (fail-open).
|
|
245
|
+
*/
|
|
246
|
+
private async runIntent(): Promise<BusinessSummary["source"] | undefined> {
|
|
247
|
+
const existing = this.options.work.readBusinessSummary();
|
|
248
|
+
if (existing?.source === "ai") {
|
|
249
|
+
this.emit({ type: "intent", status: "skipped", source: "ai" });
|
|
250
|
+
return "ai";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let brief: string;
|
|
254
|
+
try {
|
|
255
|
+
brief = readFileSync(this.options.work.businessPaths.brief, "utf8");
|
|
256
|
+
} catch {
|
|
257
|
+
return existing?.source;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.emit({ type: "intent", status: "start" });
|
|
261
|
+
const basePrompt = renderHuntIntentPrompt({
|
|
262
|
+
projectName: this.options.projectName,
|
|
263
|
+
brief,
|
|
264
|
+
});
|
|
265
|
+
let lastError = "";
|
|
266
|
+
for (let attempt = 0; attempt <= this.options.retries; attempt++) {
|
|
267
|
+
const prompt =
|
|
268
|
+
attempt === 0
|
|
269
|
+
? basePrompt
|
|
270
|
+
: `${basePrompt}\n${renderIntentRetrySuffix(lastError)}`;
|
|
271
|
+
const result = await this.options.runner.run({
|
|
272
|
+
prompt,
|
|
273
|
+
cwd: this.options.root,
|
|
274
|
+
model: this.options.model,
|
|
275
|
+
apiKeyEnv: this.options.apiKeyEnv,
|
|
276
|
+
apiKey: this.options.apiKey,
|
|
277
|
+
timeoutMs: this.options.timeoutMs,
|
|
278
|
+
});
|
|
279
|
+
if (!result.ok) {
|
|
280
|
+
lastError = result.error;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const raw = extractJsonObject(result.text);
|
|
284
|
+
if (raw === null) {
|
|
285
|
+
lastError = "a resposta final não contém um objeto JSON.";
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const summary = parseBusinessSummary(
|
|
289
|
+
{ ...raw, source: "reviewer" },
|
|
290
|
+
"reviewer",
|
|
291
|
+
);
|
|
292
|
+
if (!summary) {
|
|
293
|
+
lastError = "JSON fora do schema do modelo de intenção.";
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// Modelo sem fluxo nem invariante não sustenta a revisão por
|
|
297
|
+
// invariantes: repete em vez de degradar os briefs.
|
|
298
|
+
if (
|
|
299
|
+
summary.flows.length === 0 &&
|
|
300
|
+
summary.invariants.length + summary.contracts.length < 2
|
|
301
|
+
) {
|
|
302
|
+
lastError =
|
|
303
|
+
"modelo de intenção sem fluxos e com menos de 2 invariantes/contratos.";
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const decision = chooseBusinessSummary(
|
|
307
|
+
this.options.work.readBusinessSummary(),
|
|
308
|
+
summary,
|
|
309
|
+
);
|
|
310
|
+
if (!decision.accepted || !decision.summary) {
|
|
311
|
+
lastError = "modelo de intenção inferior ao já disponível.";
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
this.options.work.writeBusinessSummary(decision.summary);
|
|
315
|
+
this.emit({ type: "intent", status: "done", source: "reviewer" });
|
|
316
|
+
return "reviewer";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.emit({ type: "intent", status: "failed", error: lastError });
|
|
320
|
+
return existing?.source ?? "deterministic";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async runOne(
|
|
324
|
+
watchdogId: string,
|
|
325
|
+
business: BusinessSummary | null,
|
|
326
|
+
): Promise<HuntWatchdogResult> {
|
|
134
327
|
const briefPath = this.options.work.briefFile(watchdogId);
|
|
135
328
|
let brief: string;
|
|
136
329
|
try {
|
|
@@ -147,35 +340,305 @@ export class HuntRunner {
|
|
|
147
340
|
return failedResult(watchdogId, error, 0, { durationMs: 0 });
|
|
148
341
|
}
|
|
149
342
|
|
|
343
|
+
// Revisor de fluxo: a passada base NÃO faz bookkeeping. Ele carrega 37
|
|
344
|
+
// arquivos e um brief de ~60 KB; competir com `checks`/`rules` no mesmo JSON
|
|
345
|
+
// é o que fazia o `general` devolver 2 achados e 10/10 checks sem lastro.
|
|
346
|
+
const isGeneral = watchdogId === GENERAL_WATCHDOG_ID;
|
|
347
|
+
const requestBusiness = isGeneral && business?.source !== "ai";
|
|
150
348
|
const basePrompt = renderHuntPrompt({
|
|
151
349
|
projectName: this.options.projectName,
|
|
152
350
|
watchdogId,
|
|
153
351
|
brief,
|
|
352
|
+
bookkeeping: !isGeneral,
|
|
353
|
+
requestBusiness,
|
|
154
354
|
});
|
|
355
|
+
// Checklist derivado do brief: cada fluxo/invariante do modelo de intenção
|
|
356
|
+
// e cada regra numerada precisa de uma verificação declarada.
|
|
357
|
+
const expected = expectedReviewItems(brief, business, watchdogId);
|
|
358
|
+
const expectedRules = briefRules(brief);
|
|
359
|
+
// `at` só conta como prova quando aponta para arquivo/linha existentes.
|
|
360
|
+
const resolveAt = makeAtResolver(this.options.root);
|
|
155
361
|
|
|
156
362
|
this.emit({ type: "start", watchdogId });
|
|
157
363
|
let attempts = 0;
|
|
158
|
-
let lastError = "";
|
|
159
364
|
const accumulated: MakiRunMetrics = { durationMs: 0 };
|
|
160
365
|
|
|
366
|
+
const first = await this.executePass(watchdogId, basePrompt);
|
|
367
|
+
mergeMetrics(accumulated, first.metrics);
|
|
368
|
+
attempts += first.attempts;
|
|
369
|
+
if (!first.ok) {
|
|
370
|
+
this.emit({
|
|
371
|
+
type: "failed",
|
|
372
|
+
watchdogId,
|
|
373
|
+
error: first.error,
|
|
374
|
+
attempts,
|
|
375
|
+
metrics: accumulated,
|
|
376
|
+
});
|
|
377
|
+
return failedResult(watchdogId, first.error, attempts, accumulated);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let report = first.report;
|
|
381
|
+
let checks = first.checks;
|
|
382
|
+
let ruleChecks = first.ruleChecks;
|
|
383
|
+
let reviewerBusiness = first.reviewerBusiness;
|
|
384
|
+
|
|
385
|
+
let checklist: HuntWatchdogResult["checklist"];
|
|
386
|
+
if (isGeneral && this.options.checklist !== false) {
|
|
387
|
+
// Bookkeeping do revisor de fluxo: sessão nova, tarefa estreita e
|
|
388
|
+
// barata (só vereditos com `at` comprovado).
|
|
389
|
+
const costBefore = accumulated.costUsd ?? 0;
|
|
390
|
+
const turnsBefore = accumulated.numTurns ?? 0;
|
|
391
|
+
this.emit({
|
|
392
|
+
type: "checklist",
|
|
393
|
+
watchdogId,
|
|
394
|
+
missing: expected.length,
|
|
395
|
+
missingRules: expectedRules.length,
|
|
396
|
+
status: "start",
|
|
397
|
+
});
|
|
398
|
+
const verdicts = await this.executePass(
|
|
399
|
+
watchdogId,
|
|
400
|
+
renderBookkeepingPrompt({
|
|
401
|
+
projectName: this.options.projectName,
|
|
402
|
+
watchdogId,
|
|
403
|
+
brief,
|
|
404
|
+
items: expected,
|
|
405
|
+
ruleIds: expectedRules.map((rule) => rule.id),
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
mergeMetrics(accumulated, verdicts.metrics);
|
|
409
|
+
attempts += verdicts.attempts;
|
|
410
|
+
if (verdicts.ok) {
|
|
411
|
+
// Só vereditos: achados desta passada seriam ruído de uma tarefa
|
|
412
|
+
// que não foi pedida (o prompt manda `findings: []`).
|
|
413
|
+
checks = mergeChecks(checks, verdicts.checks);
|
|
414
|
+
ruleChecks = mergeRuleChecks(ruleChecks, verdicts.ruleChecks);
|
|
415
|
+
}
|
|
416
|
+
checklist = {
|
|
417
|
+
status: verdicts.ok ? "done" : "failed",
|
|
418
|
+
missing: missingReviewItems(expected, checks).length,
|
|
419
|
+
missingRules: missingRuleChecks(expectedRules, ruleChecks).length,
|
|
420
|
+
costUsd: (accumulated.costUsd ?? 0) - costBefore,
|
|
421
|
+
numTurns: (accumulated.numTurns ?? 0) - turnsBefore,
|
|
422
|
+
};
|
|
423
|
+
this.emit({
|
|
424
|
+
type: "checklist",
|
|
425
|
+
watchdogId,
|
|
426
|
+
missing: checklist.missing,
|
|
427
|
+
missingRules: checklist.missingRules,
|
|
428
|
+
status: verdicts.ok ? "done" : "failed",
|
|
429
|
+
error: verdicts.ok ? undefined : verdicts.error,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const missing = missingReviewItems(expected, checks);
|
|
434
|
+
const missingRules = missingRuleChecks(expectedRules, ruleChecks);
|
|
435
|
+
if (
|
|
436
|
+
!isGeneral &&
|
|
437
|
+
this.options.checklist !== false &&
|
|
438
|
+
(missing.length > 0 || missingRules.length > 0)
|
|
439
|
+
) {
|
|
440
|
+
const costBefore = accumulated.costUsd ?? 0;
|
|
441
|
+
const turnsBefore = accumulated.numTurns ?? 0;
|
|
442
|
+
this.emit({
|
|
443
|
+
type: "checklist",
|
|
444
|
+
watchdogId,
|
|
445
|
+
missing: missing.length,
|
|
446
|
+
missingRules: missingRules.length,
|
|
447
|
+
status: "start",
|
|
448
|
+
});
|
|
449
|
+
const suffix = renderChecklistSuffix(missing, missingRules);
|
|
450
|
+
// Sessão já tem o brief e as evidências: só pedimos os vereditos.
|
|
451
|
+
const second = accumulated.sessionId
|
|
452
|
+
? await this.executePass(watchdogId, suffix, {
|
|
453
|
+
resume: accumulated.sessionId,
|
|
454
|
+
})
|
|
455
|
+
: await this.executePass(watchdogId, `${basePrompt}\n${suffix}`);
|
|
456
|
+
mergeMetrics(accumulated, second.metrics);
|
|
457
|
+
attempts += second.attempts;
|
|
458
|
+
if (second.ok) {
|
|
459
|
+
// A segunda passada não pode *perder* achados: une as duas.
|
|
460
|
+
report = mergeReport(report, second.report);
|
|
461
|
+
checks = mergeChecks(checks, second.checks);
|
|
462
|
+
ruleChecks = mergeRuleChecks(ruleChecks, second.ruleChecks);
|
|
463
|
+
reviewerBusiness = richerBusiness(
|
|
464
|
+
reviewerBusiness,
|
|
465
|
+
second.reviewerBusiness,
|
|
466
|
+
);
|
|
467
|
+
this.emit({
|
|
468
|
+
type: "checklist",
|
|
469
|
+
watchdogId,
|
|
470
|
+
missing: missingReviewItems(expected, checks).length,
|
|
471
|
+
missingRules: missingRuleChecks(expectedRules, ruleChecks).length,
|
|
472
|
+
status: "done",
|
|
473
|
+
});
|
|
474
|
+
} else {
|
|
475
|
+
this.emit({
|
|
476
|
+
type: "checklist",
|
|
477
|
+
watchdogId,
|
|
478
|
+
missing: missing.length,
|
|
479
|
+
missingRules: missingRules.length,
|
|
480
|
+
status: "failed",
|
|
481
|
+
error: second.error,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
checklist = {
|
|
485
|
+
status: second.ok ? "done" : "failed",
|
|
486
|
+
missing: second.ok
|
|
487
|
+
? missingReviewItems(expected, checks).length
|
|
488
|
+
: missing.length,
|
|
489
|
+
missingRules: second.ok
|
|
490
|
+
? missingRuleChecks(expectedRules, ruleChecks).length
|
|
491
|
+
: missingRules.length,
|
|
492
|
+
costUsd: (accumulated.costUsd ?? 0) - costBefore,
|
|
493
|
+
numTurns: (accumulated.numTurns ?? 0) - turnsBefore,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let secondOpinion: HuntWatchdogResult["secondOpinion"];
|
|
498
|
+
if (this.options.secondOpinion !== false && isGeneral) {
|
|
499
|
+
const costBefore = accumulated.costUsd ?? 0;
|
|
500
|
+
const turnsBefore = accumulated.numTurns ?? 0;
|
|
501
|
+
this.emit({ type: "second-opinion", watchdogId, status: "start" });
|
|
502
|
+
// Sessão NOVA de propósito: retomar a sessão depois do checklist
|
|
503
|
+
// ancorava o modelo nas próprias conclusões (devolvia zero achados).
|
|
504
|
+
const opinion = await this.executePass(
|
|
505
|
+
watchdogId,
|
|
506
|
+
renderAdversarialPrompt({
|
|
507
|
+
projectName: this.options.projectName,
|
|
508
|
+
watchdogId,
|
|
509
|
+
brief,
|
|
510
|
+
candidates: report.findings.map((finding) => ({
|
|
511
|
+
rule: finding.rule,
|
|
512
|
+
severity: finding.severity,
|
|
513
|
+
file: finding.file,
|
|
514
|
+
line: finding.line,
|
|
515
|
+
message: finding.message,
|
|
516
|
+
})),
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
mergeMetrics(accumulated, opinion.metrics);
|
|
520
|
+
attempts += opinion.attempts;
|
|
521
|
+
const costUsd = (accumulated.costUsd ?? 0) - costBefore;
|
|
522
|
+
const numTurns = (accumulated.numTurns ?? 0) - turnsBefore;
|
|
523
|
+
if (opinion.ok) {
|
|
524
|
+
const before = report.findings.length;
|
|
525
|
+
report = mergeReport(report, opinion.report);
|
|
526
|
+
checks = mergeChecks(checks, opinion.checks);
|
|
527
|
+
ruleChecks = mergeRuleChecks(ruleChecks, opinion.ruleChecks);
|
|
528
|
+
reviewerBusiness = richerBusiness(
|
|
529
|
+
reviewerBusiness,
|
|
530
|
+
opinion.reviewerBusiness,
|
|
531
|
+
);
|
|
532
|
+
secondOpinion = {
|
|
533
|
+
status: "done",
|
|
534
|
+
findings: report.findings.length - before,
|
|
535
|
+
costUsd,
|
|
536
|
+
numTurns,
|
|
537
|
+
};
|
|
538
|
+
this.emit({
|
|
539
|
+
type: "second-opinion",
|
|
540
|
+
watchdogId,
|
|
541
|
+
status: "done",
|
|
542
|
+
findings: secondOpinion.findings,
|
|
543
|
+
});
|
|
544
|
+
} else {
|
|
545
|
+
secondOpinion = {
|
|
546
|
+
status: "failed",
|
|
547
|
+
findings: 0,
|
|
548
|
+
costUsd,
|
|
549
|
+
numTurns,
|
|
550
|
+
error: opinion.error,
|
|
551
|
+
};
|
|
552
|
+
this.emit({
|
|
553
|
+
type: "second-opinion",
|
|
554
|
+
watchdogId,
|
|
555
|
+
status: "failed",
|
|
556
|
+
error: opinion.error,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const businessDecision = this.applyReviewerBusiness(watchdogId, reviewerBusiness);
|
|
562
|
+
|
|
563
|
+
// `rule` vazio quebra a identidade do finding na triagem; infere pelo
|
|
564
|
+
// prefixo do `basis` + primeira frase da mensagem.
|
|
565
|
+
report = withInferredRule(report);
|
|
566
|
+
this.options.work.writeFinding(
|
|
567
|
+
watchdogId,
|
|
568
|
+
`${JSON.stringify({ ...report, rules: ruleChecks, checks }, null, 2)}\n`,
|
|
569
|
+
);
|
|
570
|
+
const coverage = summarizeChecks(expected, checks, resolveAt);
|
|
571
|
+
const rulesCoverage = summarizeRuleChecks(expectedRules, ruleChecks, resolveAt);
|
|
572
|
+
const files = coverageOf(brief, report);
|
|
573
|
+
const done: HuntWatchdogResult = {
|
|
574
|
+
watchdogId,
|
|
575
|
+
status: "done",
|
|
576
|
+
findings: report.findings.length,
|
|
577
|
+
reviewedFiles: report.reviewedFiles.length,
|
|
578
|
+
attempts,
|
|
579
|
+
durationMs: accumulated.durationMs,
|
|
580
|
+
sessionId: accumulated.sessionId,
|
|
581
|
+
numTurns: accumulated.numTurns,
|
|
582
|
+
costUsd: accumulated.costUsd,
|
|
583
|
+
usage: accumulated.usage,
|
|
584
|
+
checks: coverage.expected > 0 ? coverage : undefined,
|
|
585
|
+
rules: rulesCoverage.expected > 0 ? rulesCoverage : undefined,
|
|
586
|
+
checklist,
|
|
587
|
+
secondOpinion,
|
|
588
|
+
coverage: files.total > 0 ? files : undefined,
|
|
589
|
+
business: businessDecision,
|
|
590
|
+
};
|
|
591
|
+
this.emit({
|
|
592
|
+
type: "done",
|
|
593
|
+
watchdogId,
|
|
594
|
+
findings: done.findings,
|
|
595
|
+
reviewedFiles: done.reviewedFiles,
|
|
596
|
+
attempts,
|
|
597
|
+
metrics: accumulated,
|
|
598
|
+
});
|
|
599
|
+
return done;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Uma passada do brief, com as retentativas de saída inválida. */
|
|
603
|
+
private async executePass(
|
|
604
|
+
watchdogId: string,
|
|
605
|
+
basePrompt: string,
|
|
606
|
+
options: { resume?: string } = {},
|
|
607
|
+
): Promise<PassOutcome> {
|
|
608
|
+
let attempts = 0;
|
|
609
|
+
let lastError = "";
|
|
610
|
+
/** A última tentativa chegou a produzir resposta? (permite retomar a sessão) */
|
|
611
|
+
let canResume = false;
|
|
612
|
+
const metrics: MakiRunMetrics = { durationMs: 0 };
|
|
613
|
+
|
|
161
614
|
while (attempts <= this.options.retries) {
|
|
162
615
|
attempts++;
|
|
616
|
+
const resume =
|
|
617
|
+
attempts === 1
|
|
618
|
+
? options.resume
|
|
619
|
+
: canResume
|
|
620
|
+
? metrics.sessionId
|
|
621
|
+
: undefined;
|
|
163
622
|
const prompt =
|
|
164
623
|
attempts === 1
|
|
165
624
|
? basePrompt
|
|
166
|
-
:
|
|
625
|
+
: resume
|
|
626
|
+
? renderResumeSuffix(lastError, watchdogId)
|
|
627
|
+
: `${basePrompt}\n${renderRetrySuffix(lastError)}`;
|
|
167
628
|
const result = await this.options.runner.run({
|
|
168
629
|
prompt,
|
|
169
630
|
cwd: this.options.root,
|
|
170
631
|
model: this.options.model,
|
|
171
632
|
apiKeyEnv: this.options.apiKeyEnv,
|
|
172
633
|
apiKey: this.options.apiKey,
|
|
634
|
+
resume,
|
|
173
635
|
timeoutMs: this.options.timeoutMs,
|
|
174
636
|
});
|
|
175
|
-
mergeMetrics(
|
|
637
|
+
mergeMetrics(metrics, result.metrics);
|
|
176
638
|
|
|
177
639
|
if (!result.ok) {
|
|
178
640
|
lastError = result.error;
|
|
641
|
+
canResume = false;
|
|
179
642
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
180
643
|
continue;
|
|
181
644
|
}
|
|
@@ -183,6 +646,7 @@ export class HuntRunner {
|
|
|
183
646
|
const raw = extractJsonObject(result.text);
|
|
184
647
|
if (raw === null) {
|
|
185
648
|
lastError = "a resposta final não contém um objeto JSON.";
|
|
649
|
+
canResume = true;
|
|
186
650
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
187
651
|
continue;
|
|
188
652
|
}
|
|
@@ -193,45 +657,62 @@ export class HuntRunner {
|
|
|
193
657
|
);
|
|
194
658
|
if (!report) {
|
|
195
659
|
lastError = warnings[0] ?? "JSON fora do schema do watchdog.";
|
|
660
|
+
canResume = true;
|
|
196
661
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
197
662
|
continue;
|
|
198
663
|
}
|
|
199
664
|
if (report.watchdogId !== watchdogId) {
|
|
200
665
|
lastError = `watchdogId '${report.watchdogId}' não corresponde a '${watchdogId}'.`;
|
|
666
|
+
canResume = true;
|
|
201
667
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
202
668
|
continue;
|
|
203
669
|
}
|
|
204
670
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
671
|
+
return {
|
|
672
|
+
ok: true,
|
|
673
|
+
report,
|
|
674
|
+
checks: parseReviewChecks(raw),
|
|
675
|
+
ruleChecks: parseRuleChecks(raw),
|
|
676
|
+
// O revisor de fluxo devolve o modelo de intenção no JSON.
|
|
677
|
+
reviewerBusiness:
|
|
678
|
+
watchdogId === GENERAL_WATCHDOG_ID
|
|
679
|
+
? reviewerBusinessOf(raw)
|
|
680
|
+
: undefined,
|
|
214
681
|
attempts,
|
|
215
|
-
|
|
216
|
-
sessionId: accumulated.sessionId,
|
|
217
|
-
numTurns: accumulated.numTurns,
|
|
218
|
-
costUsd: accumulated.costUsd,
|
|
219
|
-
usage: accumulated.usage,
|
|
682
|
+
metrics,
|
|
220
683
|
};
|
|
221
|
-
this.emit({
|
|
222
|
-
type: "done",
|
|
223
|
-
watchdogId,
|
|
224
|
-
findings: done.findings,
|
|
225
|
-
reviewedFiles: done.reviewedFiles,
|
|
226
|
-
attempts,
|
|
227
|
-
metrics: accumulated,
|
|
228
|
-
});
|
|
229
|
-
return done;
|
|
230
684
|
}
|
|
231
685
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
686
|
+
return {
|
|
687
|
+
ok: false,
|
|
688
|
+
error: lastError || "falha desconhecida.",
|
|
689
|
+
attempts,
|
|
690
|
+
metrics,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Aplica o modelo devolvido pelo `general` somente se ele for mais rico que
|
|
696
|
+
* o já gravado — o revisor reconstrói a intenção sem ver o JSON inteiro e já
|
|
697
|
+
* substituiu um modelo da IA (5 fluxos) por um resumo sem fluxos.
|
|
698
|
+
*/
|
|
699
|
+
private applyReviewerBusiness(
|
|
700
|
+
watchdogId: string,
|
|
701
|
+
incoming: BusinessSummary | undefined,
|
|
702
|
+
): HuntWatchdogResult["business"] {
|
|
703
|
+
if (watchdogId !== GENERAL_WATCHDOG_ID || !incoming) return undefined;
|
|
704
|
+
const decision = chooseBusinessSummary(
|
|
705
|
+
this.options.work.readBusinessSummary(),
|
|
706
|
+
incoming,
|
|
707
|
+
);
|
|
708
|
+
if (decision.accepted && decision.summary) {
|
|
709
|
+
this.options.work.writeBusinessSummary(decision.summary);
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
accepted: decision.accepted,
|
|
713
|
+
source: decision.summary?.source ?? incoming.source,
|
|
714
|
+
score: businessScore(incoming),
|
|
715
|
+
};
|
|
235
716
|
}
|
|
236
717
|
|
|
237
718
|
private emit(event: HuntEvent): void {
|
|
@@ -255,6 +736,112 @@ export class HuntRunner {
|
|
|
255
736
|
}
|
|
256
737
|
}
|
|
257
738
|
|
|
739
|
+
/** Resultado de uma passada do brief (com retries internos). */
|
|
740
|
+
type PassOutcome =
|
|
741
|
+
| {
|
|
742
|
+
ok: true;
|
|
743
|
+
report: WatchdogReport;
|
|
744
|
+
checks: ReviewCheck[];
|
|
745
|
+
ruleChecks: RuleCheck[];
|
|
746
|
+
reviewerBusiness?: BusinessSummary;
|
|
747
|
+
attempts: number;
|
|
748
|
+
metrics: MakiRunMetrics;
|
|
749
|
+
}
|
|
750
|
+
| { ok: false; error: string; attempts: number; metrics: MakiRunMetrics };
|
|
751
|
+
|
|
752
|
+
/** Modelo de intenção que o `general` devolve no JSON (se válido). */
|
|
753
|
+
function reviewerBusinessOf(raw: unknown): BusinessSummary | undefined {
|
|
754
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
755
|
+
const summary = (raw as Record<string, unknown>).businessSummary;
|
|
756
|
+
if (typeof summary !== "object" || summary === null) return undefined;
|
|
757
|
+
return (
|
|
758
|
+
parseBusinessSummary(
|
|
759
|
+
{ ...(summary as Record<string, unknown>), source: "reviewer" },
|
|
760
|
+
"reviewer",
|
|
761
|
+
) ?? undefined
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Une as duas passadas: achados por chave, cobertura declarada por união. */
|
|
766
|
+
function mergeReport(first: WatchdogReport, second: WatchdogReport): WatchdogReport {
|
|
767
|
+
return {
|
|
768
|
+
watchdogId: first.watchdogId,
|
|
769
|
+
reviewedFiles: [...new Set([...first.reviewedFiles, ...second.reviewedFiles])],
|
|
770
|
+
skipped: [...new Set([...first.skipped, ...second.skipped])],
|
|
771
|
+
findings: mergeFindings(first.findings, second.findings),
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Cobertura declarada dos arquivos do domínio do brief. */
|
|
776
|
+
function coverageOf(
|
|
777
|
+
brief: string,
|
|
778
|
+
report: WatchdogReport,
|
|
779
|
+
): { total: number; reviewed: number; skipped: number; undeclared: number } {
|
|
780
|
+
const domain = new Set(briefDomainFiles(brief));
|
|
781
|
+
if (domain.size === 0) return { total: 0, reviewed: 0, skipped: 0, undeclared: 0 };
|
|
782
|
+
const reviewed = new Set(
|
|
783
|
+
report.reviewedFiles.map(normalizePath).filter((file) => domain.has(file)),
|
|
784
|
+
).size;
|
|
785
|
+
const skipped = new Set(
|
|
786
|
+
report.skipped
|
|
787
|
+
.map(declaredPath)
|
|
788
|
+
.filter((file): file is string => file !== null && domain.has(file)),
|
|
789
|
+
).size;
|
|
790
|
+
return {
|
|
791
|
+
total: domain.size,
|
|
792
|
+
reviewed,
|
|
793
|
+
skipped,
|
|
794
|
+
undeclared: Math.max(0, domain.size - reviewed - skipped),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Primeiro caminho de arquivo citado numa entrada de `skipped` (ou null). */
|
|
799
|
+
function declaredPath(entry: string): string | null {
|
|
800
|
+
const match = entry.match(/[\w./-]+\.[A-Za-z0-9]{1,5}/);
|
|
801
|
+
return match ? normalizePath(match[0]) : null;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Resolver de `at` contra o disco (cache por arquivo).
|
|
806
|
+
*
|
|
807
|
+
* O checklist é autoavaliado pelo modelo; sem checar o `at`, um `ok` inventado
|
|
808
|
+
* conta como cobertura. Aqui só vale se o arquivo existe na raiz da review e a
|
|
809
|
+
* linha cabe dentro dele. Caminho fora da raiz (`..`) nunca resolve.
|
|
810
|
+
*/
|
|
811
|
+
function makeAtResolver(root: string): AtResolver {
|
|
812
|
+
const cache = new Map<string, number | null>();
|
|
813
|
+
const lineCount = (file: string): number | null => {
|
|
814
|
+
const cached = cache.get(file);
|
|
815
|
+
if (cached !== undefined) return cached;
|
|
816
|
+
let total: number | null = null;
|
|
817
|
+
if (!file.split("/").includes("..")) {
|
|
818
|
+
try {
|
|
819
|
+
const content = readFileSync(join(root, file), "utf8");
|
|
820
|
+
total = content.split("\n").length;
|
|
821
|
+
} catch {
|
|
822
|
+
total = null;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
cache.set(file, total);
|
|
826
|
+
return total;
|
|
827
|
+
};
|
|
828
|
+
return (file, line) => {
|
|
829
|
+
const total = lineCount(file);
|
|
830
|
+
if (total === null) return false;
|
|
831
|
+
return line === undefined || (line >= 1 && line <= total);
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Modelo de intenção mais rico entre duas passadas. */
|
|
836
|
+
function richerBusiness(
|
|
837
|
+
first: BusinessSummary | undefined,
|
|
838
|
+
second: BusinessSummary | undefined,
|
|
839
|
+
): BusinessSummary | undefined {
|
|
840
|
+
if (!second) return first;
|
|
841
|
+
if (!first) return second;
|
|
842
|
+
return businessScore(second) > businessScore(first) ? second : first;
|
|
843
|
+
}
|
|
844
|
+
|
|
258
845
|
/** Soma métricas de várias tentativas no acumulado. */
|
|
259
846
|
function mergeMetrics(target: MakiRunMetrics, source: MakiRunMetrics): void {
|
|
260
847
|
target.durationMs += source.durationMs;
|