@payconductor/houndsman 0.14.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/package.json +1 -1
- package/src/cli/commands/golden-command.ts +92 -0
- package/src/cli/commands/hunt-command.ts +85 -1
- package/src/cli/help.ts +3 -0
- package/src/cli/index.ts +2 -0
- package/src/core/brief.ts +35 -7
- package/src/core/commands/compare.ts +33 -0
- package/src/core/commands/golden.ts +176 -0
- package/src/core/commands/scaffold.ts +2 -0
- package/src/core/compare.ts +67 -0
- package/src/core/config.ts +10 -0
- package/src/core/engine/finalize-pipeline.ts +25 -1
- package/src/core/findings-state.ts +9 -1
- package/src/core/golden.ts +346 -0
- package/src/core/hunt/hunt-runner.ts +546 -49
- package/src/core/hunt/index.ts +38 -1
- package/src/core/hunt/maki-prompt.ts +289 -8
- package/src/core/hunt/maki-runner.ts +93 -16
- package/src/core/hunt/review-checks.ts +460 -0
- package/src/core/project-config.ts +10 -0
- package/src/core/registry-check.ts +30 -6
- package/src/core/report-builder.ts +9 -1
- package/src/core/severity.ts +245 -0
- package/src/core/storage/types.ts +1 -0
- package/src/index.ts +22 -0
- package/src/skill/OUTPUT-FORMAT.md +20 -1
- package/src/skill/README.md +1 -0
- package/src/skill/hunt.md +60 -0
- package/src/skill/train.md +3 -0
- package/src/tests/__snapshots__/baseline.test.ts.snap +24 -9
- package/src/tests/compare.test.ts +23 -0
- package/src/tests/findings-state.test.ts +82 -0
- package/src/tests/golden.test.ts +195 -0
- package/src/tests/hunt-maki.test.ts +652 -2
- package/src/tests/review-scripts.test.ts +19 -0
- package/src/tests/severity.test.ts +235 -0
|
@@ -8,13 +8,21 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
11
12
|
import { GENERAL_WATCHDOG_ID } from "../builtins";
|
|
12
13
|
import { type BusinessSummary, parseBusinessSummary } from "../business-summary";
|
|
13
|
-
import { parseWatchdogReport } from "../report-builder";
|
|
14
|
+
import { parseWatchdogReport, type WatchdogReport } from "../report-builder";
|
|
14
15
|
import { systemClock } from "../services/clock";
|
|
15
16
|
import type { ReviewWorkArea } from "../storage/work-area";
|
|
16
17
|
import { renderHuntIntentPrompt, renderIntentRetrySuffix } from "./maki-intent-prompt";
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
renderAdversarialPrompt,
|
|
20
|
+
renderBookkeepingPrompt,
|
|
21
|
+
renderChecklistSuffix,
|
|
22
|
+
renderHuntPrompt,
|
|
23
|
+
renderResumeSuffix,
|
|
24
|
+
renderRetrySuffix,
|
|
25
|
+
} from "./maki-prompt";
|
|
18
26
|
import {
|
|
19
27
|
extractJsonObject,
|
|
20
28
|
type MakiProcessRunner,
|
|
@@ -22,7 +30,28 @@ import {
|
|
|
22
30
|
type MakiUsage,
|
|
23
31
|
} from "./maki-runner";
|
|
24
32
|
import { mapPool } from "./pool";
|
|
25
|
-
|
|
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";
|
|
26
55
|
/** Evento de progresso (CLI loga; core não escreve saída). */
|
|
27
56
|
export type HuntEvent =
|
|
28
57
|
| {
|
|
@@ -31,6 +60,21 @@ export type HuntEvent =
|
|
|
31
60
|
source?: BusinessSummary["source"];
|
|
32
61
|
error?: string;
|
|
33
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
|
+
}
|
|
34
78
|
| { type: "start"; watchdogId: string }
|
|
35
79
|
| {
|
|
36
80
|
type: "retry";
|
|
@@ -68,6 +112,39 @@ export type HuntWatchdogResult = {
|
|
|
68
112
|
costUsd?: number;
|
|
69
113
|
usage?: MakiUsage;
|
|
70
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
|
+
};
|
|
71
148
|
};
|
|
72
149
|
|
|
73
150
|
/** Agregados do relatório. */
|
|
@@ -89,6 +166,8 @@ export type HuntReport = {
|
|
|
89
166
|
model: string;
|
|
90
167
|
/** Fonte do modelo de intenção usado na revisão. */
|
|
91
168
|
business?: BusinessSummary["source"];
|
|
169
|
+
/** Pontuação do modelo de intenção efetivo (fluxos/invariantes/contratos). */
|
|
170
|
+
businessScore?: number;
|
|
92
171
|
startedAt: string;
|
|
93
172
|
finishedAt: string;
|
|
94
173
|
durationMs: number;
|
|
@@ -110,6 +189,16 @@ export type HuntRunnerOptions = {
|
|
|
110
189
|
concurrency: number;
|
|
111
190
|
timeoutMs: number;
|
|
112
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;
|
|
113
202
|
runner: MakiProcessRunner;
|
|
114
203
|
onEvent?: (event: HuntEvent) => void;
|
|
115
204
|
};
|
|
@@ -127,16 +216,21 @@ export class HuntRunner {
|
|
|
127
216
|
const startedMs = Date.now();
|
|
128
217
|
// Intenção primeiro: o revisor `general` e os demais watchdogs leem o
|
|
129
218
|
// mesmo `business-summary.json` do staging.
|
|
130
|
-
const
|
|
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();
|
|
131
223
|
const watchdogs = await mapPool(activeIds, this.options.concurrency, (id) =>
|
|
132
|
-
this.runOne(id),
|
|
224
|
+
this.runOne(id, businessModel),
|
|
133
225
|
);
|
|
134
226
|
const finishedAt = systemClock();
|
|
227
|
+
const effective = this.options.work.readBusinessSummary();
|
|
135
228
|
return {
|
|
136
229
|
version: 1,
|
|
137
230
|
engine: "maki",
|
|
138
231
|
model: this.options.model,
|
|
139
|
-
business,
|
|
232
|
+
business: effective?.source ?? businessSource,
|
|
233
|
+
businessScore: effective ? businessScore(effective) : undefined,
|
|
140
234
|
startedAt,
|
|
141
235
|
finishedAt,
|
|
142
236
|
durationMs: Date.now() - startedMs,
|
|
@@ -199,7 +293,25 @@ export class HuntRunner {
|
|
|
199
293
|
lastError = "JSON fora do schema do modelo de intenção.";
|
|
200
294
|
continue;
|
|
201
295
|
}
|
|
202
|
-
|
|
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);
|
|
203
315
|
this.emit({ type: "intent", status: "done", source: "reviewer" });
|
|
204
316
|
return "reviewer";
|
|
205
317
|
}
|
|
@@ -208,7 +320,10 @@ export class HuntRunner {
|
|
|
208
320
|
return existing?.source ?? "deterministic";
|
|
209
321
|
}
|
|
210
322
|
|
|
211
|
-
private async runOne(
|
|
323
|
+
private async runOne(
|
|
324
|
+
watchdogId: string,
|
|
325
|
+
business: BusinessSummary | null,
|
|
326
|
+
): Promise<HuntWatchdogResult> {
|
|
212
327
|
const briefPath = this.options.work.briefFile(watchdogId);
|
|
213
328
|
let brief: string;
|
|
214
329
|
try {
|
|
@@ -225,35 +340,305 @@ export class HuntRunner {
|
|
|
225
340
|
return failedResult(watchdogId, error, 0, { durationMs: 0 });
|
|
226
341
|
}
|
|
227
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";
|
|
228
348
|
const basePrompt = renderHuntPrompt({
|
|
229
349
|
projectName: this.options.projectName,
|
|
230
350
|
watchdogId,
|
|
231
351
|
brief,
|
|
352
|
+
bookkeeping: !isGeneral,
|
|
353
|
+
requestBusiness,
|
|
232
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);
|
|
233
361
|
|
|
234
362
|
this.emit({ type: "start", watchdogId });
|
|
235
363
|
let attempts = 0;
|
|
236
|
-
let lastError = "";
|
|
237
364
|
const accumulated: MakiRunMetrics = { durationMs: 0 };
|
|
238
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
|
+
|
|
239
614
|
while (attempts <= this.options.retries) {
|
|
240
615
|
attempts++;
|
|
616
|
+
const resume =
|
|
617
|
+
attempts === 1
|
|
618
|
+
? options.resume
|
|
619
|
+
: canResume
|
|
620
|
+
? metrics.sessionId
|
|
621
|
+
: undefined;
|
|
241
622
|
const prompt =
|
|
242
623
|
attempts === 1
|
|
243
624
|
? basePrompt
|
|
244
|
-
:
|
|
625
|
+
: resume
|
|
626
|
+
? renderResumeSuffix(lastError, watchdogId)
|
|
627
|
+
: `${basePrompt}\n${renderRetrySuffix(lastError)}`;
|
|
245
628
|
const result = await this.options.runner.run({
|
|
246
629
|
prompt,
|
|
247
630
|
cwd: this.options.root,
|
|
248
631
|
model: this.options.model,
|
|
249
632
|
apiKeyEnv: this.options.apiKeyEnv,
|
|
250
633
|
apiKey: this.options.apiKey,
|
|
634
|
+
resume,
|
|
251
635
|
timeoutMs: this.options.timeoutMs,
|
|
252
636
|
});
|
|
253
|
-
mergeMetrics(
|
|
637
|
+
mergeMetrics(metrics, result.metrics);
|
|
254
638
|
|
|
255
639
|
if (!result.ok) {
|
|
256
640
|
lastError = result.error;
|
|
641
|
+
canResume = false;
|
|
257
642
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
258
643
|
continue;
|
|
259
644
|
}
|
|
@@ -261,6 +646,7 @@ export class HuntRunner {
|
|
|
261
646
|
const raw = extractJsonObject(result.text);
|
|
262
647
|
if (raw === null) {
|
|
263
648
|
lastError = "a resposta final não contém um objeto JSON.";
|
|
649
|
+
canResume = true;
|
|
264
650
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
265
651
|
continue;
|
|
266
652
|
}
|
|
@@ -271,57 +657,62 @@ export class HuntRunner {
|
|
|
271
657
|
);
|
|
272
658
|
if (!report) {
|
|
273
659
|
lastError = warnings[0] ?? "JSON fora do schema do watchdog.";
|
|
660
|
+
canResume = true;
|
|
274
661
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
275
662
|
continue;
|
|
276
663
|
}
|
|
277
664
|
if (report.watchdogId !== watchdogId) {
|
|
278
665
|
lastError = `watchdogId '${report.watchdogId}' não corresponde a '${watchdogId}'.`;
|
|
666
|
+
canResume = true;
|
|
279
667
|
this.emitRetry(watchdogId, attempts, lastError);
|
|
280
668
|
continue;
|
|
281
669
|
}
|
|
282
670
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
this.options.work.writeFinding(
|
|
296
|
-
watchdogId,
|
|
297
|
-
`${JSON.stringify(report, null, 2)}\n`,
|
|
298
|
-
);
|
|
299
|
-
const done: HuntWatchdogResult = {
|
|
300
|
-
watchdogId,
|
|
301
|
-
status: "done",
|
|
302
|
-
findings: report.findings.length,
|
|
303
|
-
reviewedFiles: report.reviewedFiles.length,
|
|
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,
|
|
304
681
|
attempts,
|
|
305
|
-
|
|
306
|
-
sessionId: accumulated.sessionId,
|
|
307
|
-
numTurns: accumulated.numTurns,
|
|
308
|
-
costUsd: accumulated.costUsd,
|
|
309
|
-
usage: accumulated.usage,
|
|
682
|
+
metrics,
|
|
310
683
|
};
|
|
311
|
-
this.emit({
|
|
312
|
-
type: "done",
|
|
313
|
-
watchdogId,
|
|
314
|
-
findings: done.findings,
|
|
315
|
-
reviewedFiles: done.reviewedFiles,
|
|
316
|
-
attempts,
|
|
317
|
-
metrics: accumulated,
|
|
318
|
-
});
|
|
319
|
-
return done;
|
|
320
684
|
}
|
|
321
685
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
};
|
|
325
716
|
}
|
|
326
717
|
|
|
327
718
|
private emit(event: HuntEvent): void {
|
|
@@ -345,6 +736,112 @@ export class HuntRunner {
|
|
|
345
736
|
}
|
|
346
737
|
}
|
|
347
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
|
+
|
|
348
845
|
/** Soma métricas de várias tentativas no acumulado. */
|
|
349
846
|
function mergeMetrics(target: MakiRunMetrics, source: MakiRunMetrics): void {
|
|
350
847
|
target.durationMs += source.durationMs;
|