@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
package/src/core/hunt/index.ts
CHANGED
|
@@ -41,7 +41,20 @@ export {
|
|
|
41
41
|
requireMakiModel,
|
|
42
42
|
resolveMakiModel,
|
|
43
43
|
} from "./maki-model";
|
|
44
|
-
export {
|
|
44
|
+
export type {
|
|
45
|
+
AdversarialCandidate,
|
|
46
|
+
AdversarialPromptInput,
|
|
47
|
+
BookkeepingPromptInput,
|
|
48
|
+
} from "./maki-prompt";
|
|
49
|
+
export {
|
|
50
|
+
ADVERSARIAL_CLASSES,
|
|
51
|
+
renderAdversarialPrompt,
|
|
52
|
+
renderBookkeepingPrompt,
|
|
53
|
+
renderChecklistSuffix,
|
|
54
|
+
renderHuntPrompt,
|
|
55
|
+
renderResumeSuffix,
|
|
56
|
+
renderRetrySuffix,
|
|
57
|
+
} from "./maki-prompt";
|
|
45
58
|
export type {
|
|
46
59
|
MakiJsonOutput,
|
|
47
60
|
MakiProcessRunner,
|
|
@@ -53,9 +66,33 @@ export type {
|
|
|
53
66
|
export {
|
|
54
67
|
extractJsonObject,
|
|
55
68
|
MAKI_DISALLOWED_TOOLS,
|
|
69
|
+
MAKI_REVIEW_SYSTEM_PROMPT,
|
|
56
70
|
makiArgs,
|
|
57
71
|
metricsFrom,
|
|
58
72
|
parseMakiOutput,
|
|
59
73
|
SpawnMakiRunner,
|
|
60
74
|
} from "./maki-runner";
|
|
61
75
|
export { mapPool } from "./pool";
|
|
76
|
+
export type {
|
|
77
|
+
BusinessDecision,
|
|
78
|
+
CheckCoverage,
|
|
79
|
+
CheckVerdict,
|
|
80
|
+
ReviewCheck,
|
|
81
|
+
ReviewRule,
|
|
82
|
+
RuleCheck,
|
|
83
|
+
} from "./review-checks";
|
|
84
|
+
export {
|
|
85
|
+
briefRules,
|
|
86
|
+
businessScore,
|
|
87
|
+
chooseBusinessSummary,
|
|
88
|
+
expectedReviewItems,
|
|
89
|
+
mergeFindings,
|
|
90
|
+
mergeRuleChecks,
|
|
91
|
+
missingReviewItems,
|
|
92
|
+
missingRuleChecks,
|
|
93
|
+
parseReviewChecks,
|
|
94
|
+
parseRuleChecks,
|
|
95
|
+
summarizeChecks,
|
|
96
|
+
summarizeRuleChecks,
|
|
97
|
+
withInferredRule,
|
|
98
|
+
} from "./review-checks";
|
|
@@ -7,22 +7,58 @@
|
|
|
7
7
|
* não altera o brief usado pela skill `/houndsman hunt`.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { GENERAL_WATCHDOG_ID } from "../builtins";
|
|
11
|
-
|
|
12
10
|
/** Dados do prompt de um watchdog. */
|
|
13
11
|
export type HuntPromptInput = {
|
|
14
12
|
projectName: string;
|
|
15
13
|
watchdogId: string;
|
|
16
14
|
/** Conteúdo do brief renderizado pelo prepare. */
|
|
17
15
|
brief: string;
|
|
16
|
+
/**
|
|
17
|
+
* `false` no revisor de fluxo (`general`): a passada base só caça achados e
|
|
18
|
+
* não gasta atenção preenchendo `checks`/`rules` — o bookkeeping roda numa
|
|
19
|
+
* passada própria (mais barata) depois.
|
|
20
|
+
*/
|
|
21
|
+
bookkeeping?: boolean;
|
|
22
|
+
/** Pede o modelo de intenção no JSON (só quando não há modelo da IA). */
|
|
23
|
+
requestBusiness?: boolean;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
/** Schema compacto repetido no prompt (evita depender de arquivo externo). */
|
|
21
|
-
function outputSchema(
|
|
27
|
+
function outputSchema(
|
|
28
|
+
watchdogId: string,
|
|
29
|
+
options: { bookkeeping: boolean; business: boolean },
|
|
30
|
+
): string {
|
|
31
|
+
const bookkeeping = options.bookkeeping
|
|
32
|
+
? `,
|
|
33
|
+
"rules": [
|
|
34
|
+
{
|
|
35
|
+
"id": 1,
|
|
36
|
+
"verdict": "ok | gap | n/a",
|
|
37
|
+
"at": "arquivo:linha"
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"checks": [
|
|
41
|
+
{
|
|
42
|
+
"item": "fluxo ou invariante copiado do brief",
|
|
43
|
+
"verdict": "ok | gap | n/a",
|
|
44
|
+
"at": "arquivo:linha ou arquivo · símbolo"
|
|
45
|
+
}
|
|
46
|
+
]`
|
|
47
|
+
: "";
|
|
48
|
+
const business = options.business
|
|
49
|
+
? `,
|
|
50
|
+
"businessSummary": {
|
|
51
|
+
"flows": [{ "name": "fluxo", "files": ["caminho"] }],
|
|
52
|
+
"invariants": ["invariante"],
|
|
53
|
+
"contracts": ["contrato"],
|
|
54
|
+
"states": ["estado"],
|
|
55
|
+
"source": "reviewer"
|
|
56
|
+
}`
|
|
57
|
+
: "";
|
|
22
58
|
return `{
|
|
23
59
|
"watchdogId": "${watchdogId}",
|
|
24
60
|
"reviewedFiles": ["caminho/relativo.ts"],
|
|
25
|
-
"skipped": ["arquivo não lido + motivo"],
|
|
61
|
+
"skipped": ["arquivo não lido + motivo"]${bookkeeping},
|
|
26
62
|
"findings": [
|
|
27
63
|
{
|
|
28
64
|
"rule": "regra do brief",
|
|
@@ -34,15 +70,57 @@ function outputSchema(watchdogId: string): string {
|
|
|
34
70
|
"evidence": "trecho que comprova",
|
|
35
71
|
"scenario": "gatilho concreto que expõe o gap (fora de regra)",
|
|
36
72
|
"impact": "consequência observável",
|
|
73
|
+
"impactClass": "flow-break | contract-drift | degradation | cosmetic",
|
|
37
74
|
"confidence": 0.8,
|
|
38
75
|
"basis": "rule | logic | domain"
|
|
39
76
|
}
|
|
40
|
-
]
|
|
77
|
+
]${business}
|
|
41
78
|
}`;
|
|
42
79
|
}
|
|
43
80
|
|
|
81
|
+
/** Protocolo de revisão do harness (independe do domínio do watchdog). */
|
|
82
|
+
const PROTOCOL: readonly string[] = [
|
|
83
|
+
"1. **INTENÇÃO** — liste os fluxos e invariantes do *Modelo de intenção* do brief. Para cada um, localize o trecho que o implementa (ou a ausência dele).",
|
|
84
|
+
"2. **ESCOPO** — só é achado a `line` dentro das faixas do *Escopo do diff*. Abra primeiro os arquivos com `… [trecho truncado]`: o restante não está no brief.",
|
|
85
|
+
"3. **RASTREIO** — para cada símbolo alterado, siga chamadores, adapters, enums e tipos compartilhados (seção *Estrutura (AST)*/importadores) e confira os dois lados do contrato.",
|
|
86
|
+
"4. **PROVA** — gatilho → comportamento observado → invariante/regra violada. Sem prova no brief ou no orçamento de exploração, descarte.",
|
|
87
|
+
"5. **AUTOVERIFICAÇÃO** — antes de responder, reconfira cada finding: linha no escopo, `evidence` como trecho literal, `severity` compatível com o `impact`.",
|
|
88
|
+
"6. **COBERTURA** — `reviewedFiles` ∪ `skipped` cobre todo *Arquivos do seu domínio*; não declare arquivo que você não abriu.",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/** Calibração do bookkeeping (checks/rules) — só quando o prompt o pede. */
|
|
92
|
+
const CALIBRATION: readonly string[] = [
|
|
93
|
+
"Cada fluxo ou invariante do brief deve aparecer em `checks` com o texto copiado do brief e um veredito: `ok` (implementado), `gap` (ausente/divergente) ou `n/a` (não se aplica ao domínio).",
|
|
94
|
+
"`rules` cobre **cada regra numerada** de `## Regras (por severidade)` — um item `{ id, verdict }` por número, sem repetir o texto. `gap` numa regra exige o finding correspondente.",
|
|
95
|
+
"`ok` exige `at` com `arquivo:linha` real (onde o item é implementado); sem localização comprovada, use `n/a`.",
|
|
96
|
+
"`gap` sem finding correspondente é inconsistência: ou inclua o finding, ou corrija o veredito.",
|
|
97
|
+
"O checklist cobre a **intenção**; ele não substitui as regras do brief — percorra cada regra nas linhas do escopo e reporte a violação.",
|
|
98
|
+
"`rule` nunca fica vazio: finding de regra copia o texto da regra do brief (sem o número); emergente usa `Lógica: <descrição>` ou `Domínio: <descrição>` (com `impactClass`).",
|
|
99
|
+
"Calibre pelo impacto comprovado: dinheiro/credencial roteada para outra conta, cobrança perdida ou corrupção de estado, com `scenario` + `evidence` e linha no escopo, é `blocking`. Não rebaixe para `attention` só por incerteza — nesse caso reduza `confidence`.",
|
|
100
|
+
"Não pare no primeiro achado de um arquivo: fluxos de pagamento têm vários lados (criação, renovação, cancelamento, webhook, erro).",
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Calibração do revisor de fluxo quando ele **não** faz bookkeeping.
|
|
105
|
+
*
|
|
106
|
+
* Concentra o que o modelo headless mais erra: parar no caminho feliz, tratar
|
|
107
|
+
* cada arquivo isoladamente e aceitar divergência entre caminhos parecidos.
|
|
108
|
+
*/
|
|
109
|
+
const FLOW_CALIBRATION: readonly string[] = [
|
|
110
|
+
"Seu produto é **achado**, não checklist: `checks`/`rules` são preenchidos em uma passada posterior. Não gaste a resposta listando vereditos.",
|
|
111
|
+
"Para cada fluxo do *Modelo de intenção*: onde começa, onde termina, quem chama, que estado muda e quem lê esse estado depois. A ausência do trecho é achado tanto quanto o trecho errado.",
|
|
112
|
+
"Percorra os **caminhos irmãos** do fluxo e compare-os entre si: criação × renovação × cancelamento × webhook × retry/erro. Divergência de payload, credencial, split, id ou status entre irmãos é o achado mais comum e o mais caro.",
|
|
113
|
+
"Siga o **contrato até o consumidor**: mapa de status, enum, `Record<Enum, X>`, schema de model, tipo compartilhado. Um membro novo de enum sem consumidor atualizado quebra em tempo de compilação ou em runtime.",
|
|
114
|
+
"Procure **estado compartilhado**: campo de instância/módulo que guarda credencial, contexto ou id entre requisições, fallback silencioso para a credencial mestre e reset tardio.",
|
|
115
|
+
"`rule` nunca fica vazio: finding de regra copia o texto da regra do brief (sem o número); emergente usa `Lógica: <descrição>` ou `Domínio: <descrição>` (com `impactClass`).",
|
|
116
|
+
"Calibre pelo impacto comprovado: dinheiro/credencial roteada para outra conta, cobrança perdida ou corrupção de estado, com `scenario` + `evidence` e linha no escopo, é `blocking`. Não rebaixe para `attention` só por incerteza — nesse caso reduza `confidence`.",
|
|
117
|
+
"Não pare no primeiro achado de um arquivo: fluxos de pagamento têm vários lados (criação, renovação, cancelamento, webhook, erro).",
|
|
118
|
+
];
|
|
119
|
+
|
|
44
120
|
/** Monta o prompt enviado ao maki para um brief. */
|
|
45
121
|
export function renderHuntPrompt(input: HuntPromptInput): string {
|
|
122
|
+
const bookkeeping = input.bookkeeping !== false;
|
|
123
|
+
const business = input.requestBusiness === true;
|
|
46
124
|
return [
|
|
47
125
|
`Você é o watchdog \`${input.watchdogId}\` do projeto ${input.projectName}.`,
|
|
48
126
|
"Execute o brief abaixo e devolva os achados como JSON.",
|
|
@@ -55,15 +133,24 @@ export function renderHuntPrompt(input: HuntPromptInput): string {
|
|
|
55
133
|
"- `watchdogId` deve ser exatamente o id acima; use `findings: []` quando não houver achado.",
|
|
56
134
|
"- `line` deve estar dentro do escopo do diff descrito no brief.",
|
|
57
135
|
'- Fora de regra, use `basis: "logic" | "domain"`, com `scenario` + `evidence`; sem os dois a severidade máxima é `attention`.',
|
|
58
|
-
|
|
136
|
+
"- Finding de regra: copie `rule` do brief — a severidade é a declarada na regra. Emergente: declare `impactClass`; a severidade vem daí.",
|
|
137
|
+
...(business
|
|
59
138
|
? [
|
|
60
|
-
'-
|
|
139
|
+
'- Inclua `businessSummary` (modelo de intenção reconstruído, `source: "reviewer"`) no JSON.',
|
|
61
140
|
]
|
|
62
141
|
: []),
|
|
63
142
|
"",
|
|
143
|
+
"## Protocolo de revisão",
|
|
144
|
+
"",
|
|
145
|
+
...PROTOCOL,
|
|
146
|
+
"",
|
|
147
|
+
"## Calibração",
|
|
148
|
+
"",
|
|
149
|
+
...(bookkeeping ? CALIBRATION : FLOW_CALIBRATION).map((line) => `- ${line}`),
|
|
150
|
+
"",
|
|
64
151
|
"### Schema",
|
|
65
152
|
"",
|
|
66
|
-
outputSchema(input.watchdogId),
|
|
153
|
+
outputSchema(input.watchdogId, { bookkeeping, business }),
|
|
67
154
|
"",
|
|
68
155
|
"## Brief",
|
|
69
156
|
"",
|
|
@@ -81,3 +168,197 @@ export function renderRetrySuffix(error: string): string {
|
|
|
81
168
|
"Responda APENAS com o objeto JSON completo, no schema acima, sem markdown.",
|
|
82
169
|
].join("\n");
|
|
83
170
|
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Correção em sessão retomada: o agente já leu o brief e explorou o código.
|
|
174
|
+
*
|
|
175
|
+
* Repetir o brief inteiro num processo novo custava uma nova exploração
|
|
176
|
+
* completa (~30 turnos) só porque a resposta final não era JSON parseável.
|
|
177
|
+
* Aqui só pedimos a saída, com o schema compacto.
|
|
178
|
+
*/
|
|
179
|
+
export function renderResumeSuffix(error: string, watchdogId: string): string {
|
|
180
|
+
return [
|
|
181
|
+
"",
|
|
182
|
+
"## Correção obrigatória (sessão retomada)",
|
|
183
|
+
"",
|
|
184
|
+
`A resposta anterior não pôde ser lida: ${error}`,
|
|
185
|
+
"Não leia mais arquivos nem rode buscas. Responda apenas com o objeto JSON",
|
|
186
|
+
"completo no schema abaixo — sem markdown, sem texto antes ou depois.",
|
|
187
|
+
"",
|
|
188
|
+
outputSchema(watchdogId, { bookkeeping: true, business: false }),
|
|
189
|
+
].join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sufixo da passada de checklist: regras e itens do modelo de intenção que a
|
|
194
|
+
* primeira resposta não verificou.
|
|
195
|
+
*/
|
|
196
|
+
export function renderChecklistSuffix(
|
|
197
|
+
missing: readonly string[],
|
|
198
|
+
missingRules: readonly number[] = [],
|
|
199
|
+
): string {
|
|
200
|
+
return [
|
|
201
|
+
"",
|
|
202
|
+
"## Verificação obrigatória (checklist)",
|
|
203
|
+
"",
|
|
204
|
+
"A resposta anterior não verificou os itens abaixo. Para **cada um**: localize",
|
|
205
|
+
"o trecho no código (ou a ausência), registre o veredito (`ok`/`gap`/`n/a`) e,",
|
|
206
|
+
"quando `gap`, inclua o finding correspondente.",
|
|
207
|
+
"",
|
|
208
|
+
...(missingRules.length > 0
|
|
209
|
+
? [
|
|
210
|
+
`Regras não verificadas (ids de \`## Regras\`): ${missingRules.join(", ")}.`,
|
|
211
|
+
"",
|
|
212
|
+
]
|
|
213
|
+
: []),
|
|
214
|
+
...(missing.length > 0
|
|
215
|
+
? [
|
|
216
|
+
"Fluxos/invariantes (texto copiado do brief):",
|
|
217
|
+
"",
|
|
218
|
+
...missing.map((item) => `- ${item}`),
|
|
219
|
+
"",
|
|
220
|
+
]
|
|
221
|
+
: []),
|
|
222
|
+
"Devolva o relatório **completo**: todos os findings já encontrados + os novos,",
|
|
223
|
+
"`rules`/`checks` completos e `reviewedFiles`/`skipped` atualizados.",
|
|
224
|
+
].join("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Cinco classes que o modelo headless mais deixa passar.
|
|
229
|
+
*
|
|
230
|
+
* A ordem é a da taxa de acerto observada: credencial/estado compartilhado
|
|
231
|
+
* (o caso clássico de `sellerApiKey` de instância) é o que mais escapava.
|
|
232
|
+
*/
|
|
233
|
+
export const ADVERSARIAL_CLASSES: readonly string[] = [
|
|
234
|
+
"1. **Credencial e contexto compartilhado** — estado mutável de instância/módulo usado entre requisições (apiKey, token, seller, subconta), fallback silencioso para a credencial mestre, reset tardio.",
|
|
235
|
+
"2. **Capacidade anunciada** — feature/método/evento declarado que algum lado não honra (feature herdada, paridade criação/renovação/cancelamento, webhook).",
|
|
236
|
+
"3. **Identificador persistido** — o id gravado corresponde à entidade que o contrato espera (authorizationId × paymentId, externalId, endToEnd, externalReference).",
|
|
237
|
+
"4. **Idempotência e transição de estado** — evento repetido/atrasado, transição sem validar o estado atual, efeito colateral duplicado.",
|
|
238
|
+
"5. **Contrato entre lados** — mapper/schema/model divergindo do consumidor (validação, status, campos, unidade).",
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
/** Achado já encontrado, resumido para a passada adversarial. */
|
|
242
|
+
export type AdversarialCandidate = {
|
|
243
|
+
rule: string;
|
|
244
|
+
severity: string;
|
|
245
|
+
file: string;
|
|
246
|
+
line?: number;
|
|
247
|
+
message: string;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Dados da segunda opinião em sessão nova. */
|
|
251
|
+
export type AdversarialPromptInput = {
|
|
252
|
+
projectName: string;
|
|
253
|
+
watchdogId: string;
|
|
254
|
+
brief: string;
|
|
255
|
+
/** Achados da primeira passada: contexto para não duplicar, não para ancorar. */
|
|
256
|
+
candidates: readonly AdversarialCandidate[];
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Segunda opinião em **sessão nova**.
|
|
261
|
+
*
|
|
262
|
+
* Reexecutar a passada na mesma sessão, depois do checklist, ancora o modelo nas
|
|
263
|
+
* próprias conclusões (na prática devolvia zero achados novos). Aqui o revisor
|
|
264
|
+
* vê o brief de novo, recebe os achados apenas como lista de "já cobertos" e é
|
|
265
|
+
* cobrado a atacar as cinco classes com prova.
|
|
266
|
+
*/
|
|
267
|
+
export function renderAdversarialPrompt(input: AdversarialPromptInput): string {
|
|
268
|
+
const covered =
|
|
269
|
+
input.candidates.length === 0
|
|
270
|
+
? ["Nenhum achado na primeira passada — o brief está **inteiro** em aberto."]
|
|
271
|
+
: [
|
|
272
|
+
"Já cobertos (NÃO repita; use como pista do que o revisor anterior olhou):",
|
|
273
|
+
"",
|
|
274
|
+
...input.candidates.map((candidate) => {
|
|
275
|
+
const line =
|
|
276
|
+
candidate.line !== undefined ? `:${candidate.line}` : "";
|
|
277
|
+
return `- ${candidate.severity} · \`${candidate.file}${line}\` — ${candidate.rule} — ${candidate.message.slice(0, 200)}`;
|
|
278
|
+
}),
|
|
279
|
+
];
|
|
280
|
+
return [
|
|
281
|
+
`Você é o revisor adversarial do fluxo do projeto ${input.projectName} (watchdog \`${input.watchdogId}\`).`,
|
|
282
|
+
"Esta é uma **sessão nova**: ninguém aqui revisou estes arquivos ainda. O objetivo é achar o que a primeira passada não achou.",
|
|
283
|
+
"",
|
|
284
|
+
"## Contrato do harness local",
|
|
285
|
+
"",
|
|
286
|
+
"- Use apenas leitura e busca; **não escreva arquivos** e não rode comandos.",
|
|
287
|
+
"- Responda **somente** com um objeto JSON válido (sem markdown, sem texto antes/depois).",
|
|
288
|
+
`- \`watchdogId\` deve ser exatamente \`${input.watchdogId}\`.`,
|
|
289
|
+
"- `line` deve estar dentro do escopo do diff descrito no brief.",
|
|
290
|
+
"- Devolva **apenas os achados novos**; não repita os já cobertos.",
|
|
291
|
+
"",
|
|
292
|
+
...covered,
|
|
293
|
+
"",
|
|
294
|
+
"## Classes a atacar",
|
|
295
|
+
"",
|
|
296
|
+
"Percorra **cada classe** abaixo e, para cada uma, traga o achado com",
|
|
297
|
+
"`arquivo:linha` e prova — ou conclua que não há ocorrência no escopo.",
|
|
298
|
+
"",
|
|
299
|
+
...ADVERSARIAL_CLASSES,
|
|
300
|
+
"",
|
|
301
|
+
"Regras da passada:",
|
|
302
|
+
"- Sem `scenario` + `evidence` o achado não passa de `attention`.",
|
|
303
|
+
"- Não repita os achados já cobertos acima e não invente para preencher classe.",
|
|
304
|
+
"- Achado de regra copia o texto da regra do brief; fora de regra use",
|
|
305
|
+
" `Lógica:`/`Domínio:` com `impactClass`.",
|
|
306
|
+
"",
|
|
307
|
+
"### Schema",
|
|
308
|
+
"",
|
|
309
|
+
outputSchema(input.watchdogId, { bookkeeping: false, business: false }),
|
|
310
|
+
"",
|
|
311
|
+
"## Brief",
|
|
312
|
+
"",
|
|
313
|
+
input.brief.trim(),
|
|
314
|
+
].join("\n");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Dados da passada de bookkeeping (só vereditos). */
|
|
318
|
+
export type BookkeepingPromptInput = {
|
|
319
|
+
projectName: string;
|
|
320
|
+
watchdogId: string;
|
|
321
|
+
brief: string;
|
|
322
|
+
/** Itens do modelo de intenção ainda sem veredito. */
|
|
323
|
+
items: readonly string[];
|
|
324
|
+
/** Regras numeradas do brief ainda sem veredito. */
|
|
325
|
+
ruleIds: readonly number[];
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Passada de bookkeeping do revisor de fluxo (sessão nova e barata).
|
|
330
|
+
*
|
|
331
|
+
* Antes, o `general` tinha que devolver achados **e** o checklist no mesmo JSON
|
|
332
|
+
* de 61 KB — na prática ele entregava 2 achados e 10/10 checks "ok" sem lastro.
|
|
333
|
+
* Separar os papéis deixa a passada forte focada em achar e esta em provar.
|
|
334
|
+
*/
|
|
335
|
+
export function renderBookkeepingPrompt(input: BookkeepingPromptInput): string {
|
|
336
|
+
return [
|
|
337
|
+
`Você é o verificador de checklist do projeto ${input.projectName} (watchdog \`${input.watchdogId}\`).`,
|
|
338
|
+
"Sua única tarefa é **verificar** os itens abaixo no código e devolver os vereditos.",
|
|
339
|
+
"",
|
|
340
|
+
"## Contrato",
|
|
341
|
+
"",
|
|
342
|
+
"- Use apenas leitura e busca; não escreva arquivos e não rode comandos.",
|
|
343
|
+
"- Responda **somente** com um objeto JSON válido (sem markdown).",
|
|
344
|
+
"- `findings` deve ser sempre `[]`: esta passada não caça achados.",
|
|
345
|
+
"- Para cada item, `at` é obrigatório quando o veredito é `ok` (`arquivo:linha` real do código); use `n/a` quando o item não se aplica ao domínio.",
|
|
346
|
+
"- `gap` exige `at` apontando onde o item deveria estar (ou o símbolo ausente).",
|
|
347
|
+
"",
|
|
348
|
+
"## Itens de intenção a verificar",
|
|
349
|
+
"",
|
|
350
|
+
...input.items.map((item) => `- ${item}`),
|
|
351
|
+
"",
|
|
352
|
+
"## Regras numeradas a verificar",
|
|
353
|
+
"",
|
|
354
|
+
...(input.ruleIds.length > 0 ? [input.ruleIds.join(", ")] : ["(nenhuma)"]),
|
|
355
|
+
"",
|
|
356
|
+
"### Schema",
|
|
357
|
+
"",
|
|
358
|
+
outputSchema(input.watchdogId, { bookkeeping: true, business: false }),
|
|
359
|
+
"",
|
|
360
|
+
"## Brief",
|
|
361
|
+
"",
|
|
362
|
+
input.brief.trim(),
|
|
363
|
+
].join("\n");
|
|
364
|
+
}
|
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
export const MAKI_DISALLOWED_TOOLS =
|
|
12
12
|
"bash,webfetch,websearch,write,edit,multiedit,edit_lines,insert_lines,task";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Complemento ao system prompt do maki.
|
|
16
|
+
*
|
|
17
|
+
* O prompt nativo pede para "minimizar tokens e tool calls"; numa revisão isso
|
|
18
|
+
* produz leitura rasa. Aqui a prioridade é invertida: profundidade e prova
|
|
19
|
+
* vencem brevidade.
|
|
20
|
+
*/
|
|
21
|
+
export const MAKI_REVIEW_SYSTEM_PROMPT = [
|
|
22
|
+
"Esta sessão é uma revisão de código de um watchdog do houndsman.",
|
|
23
|
+
"Profundidade vence brevidade: leia os arquivos necessários, siga chamadores",
|
|
24
|
+
"e confirme cada achado com evidência; não economize chamadas de ferramenta.",
|
|
25
|
+
"A resposta final é sempre o objeto JSON do contrato, sem texto fora dele.",
|
|
26
|
+
].join(" ");
|
|
27
|
+
|
|
14
28
|
/** Pedido de execução de um watchdog. */
|
|
15
29
|
export type MakiRunRequest = {
|
|
16
30
|
/** Prompt completo (brief + contrato). */
|
|
@@ -23,6 +37,8 @@ export type MakiRunRequest = {
|
|
|
23
37
|
apiKeyEnv: string;
|
|
24
38
|
/** Valor da chave (injetado só no processo filho). */
|
|
25
39
|
apiKey: string;
|
|
40
|
+
/** Sessão a retomar (retry compacto depois de saída inválida). */
|
|
41
|
+
resume?: string;
|
|
26
42
|
/** Timeout do processo (ms). */
|
|
27
43
|
timeoutMs: number;
|
|
28
44
|
};
|
|
@@ -87,7 +103,7 @@ export type MakiJsonOutput = {
|
|
|
87
103
|
};
|
|
88
104
|
|
|
89
105
|
/** Argumentos do processo (o prompt vai por stdin). */
|
|
90
|
-
export function makiArgs(model: string): string[] {
|
|
106
|
+
export function makiArgs(model: string, resume?: string): string[] {
|
|
91
107
|
return [
|
|
92
108
|
"-p",
|
|
93
109
|
"--output-format",
|
|
@@ -96,6 +112,9 @@ export function makiArgs(model: string): string[] {
|
|
|
96
112
|
model,
|
|
97
113
|
"--disallowed-tools",
|
|
98
114
|
MAKI_DISALLOWED_TOOLS,
|
|
115
|
+
"--append-system-prompt",
|
|
116
|
+
MAKI_REVIEW_SYSTEM_PROMPT,
|
|
117
|
+
...(resume ? ["--resume", resume] : []),
|
|
99
118
|
];
|
|
100
119
|
}
|
|
101
120
|
|
|
@@ -111,13 +130,16 @@ export class SpawnMakiRunner implements MakiProcessRunner {
|
|
|
111
130
|
const started = Date.now();
|
|
112
131
|
let timedOut = false;
|
|
113
132
|
try {
|
|
114
|
-
const proc = Bun.spawn(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
const proc = Bun.spawn(
|
|
134
|
+
[this.binary, ...makiArgs(request.model, request.resume)],
|
|
135
|
+
{
|
|
136
|
+
cwd: request.cwd,
|
|
137
|
+
env: { ...process.env, [request.apiKeyEnv]: request.apiKey },
|
|
138
|
+
stdin: "pipe",
|
|
139
|
+
stdout: "pipe",
|
|
140
|
+
stderr: "pipe",
|
|
141
|
+
},
|
|
142
|
+
);
|
|
121
143
|
|
|
122
144
|
try {
|
|
123
145
|
proc.stdin.write(request.prompt);
|
|
@@ -201,6 +223,18 @@ export function parseMakiOutput(stdout: string): MakiJsonOutput | null {
|
|
|
201
223
|
const whole = tryParseJson(stdout.trim());
|
|
202
224
|
if (whole.ok && isObject(whole.value)) return whole.value as MakiJsonOutput;
|
|
203
225
|
|
|
226
|
+
// Objeto indentado precedido por log: o scan por linha não enxerga o
|
|
227
|
+
// objeto inteiro, então usamos os objetos balanceados do texto.
|
|
228
|
+
const objects = balancedObjects(stdout);
|
|
229
|
+
for (let index = objects.length - 1; index >= 0; index--) {
|
|
230
|
+
const parsed = tryParseJson(objects[index]);
|
|
231
|
+
if (!parsed.ok || !isObject(parsed.value)) continue;
|
|
232
|
+
const value = parsed.value as Record<string, unknown>;
|
|
233
|
+
if (typeof value.type === "string" || typeof value.result === "string") {
|
|
234
|
+
return value as MakiJsonOutput;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
204
238
|
const lines = stdout.split(/\r?\n/);
|
|
205
239
|
for (let index = lines.length - 1; index >= 0; index--) {
|
|
206
240
|
const line = lines[index].trim();
|
|
@@ -213,7 +247,11 @@ export function parseMakiOutput(stdout: string): MakiJsonOutput | null {
|
|
|
213
247
|
|
|
214
248
|
/**
|
|
215
249
|
* Extrai o JSON dos findings da resposta final do agente.
|
|
216
|
-
*
|
|
250
|
+
*
|
|
251
|
+
* No `--output-format json` o maki devolve em `result` **todo** o texto do
|
|
252
|
+
* agente (narração + blocos intermediários + resposta final), não só a última
|
|
253
|
+
* mensagem. Por isso a busca é do fim para o começo: o relatório válido é o
|
|
254
|
+
* último objeto, não o primeiro `{` do texto.
|
|
217
255
|
*/
|
|
218
256
|
export function extractJsonObject(text: string): unknown | null {
|
|
219
257
|
const trimmed = text.trim();
|
|
@@ -222,21 +260,60 @@ export function extractJsonObject(text: string): unknown | null {
|
|
|
222
260
|
const direct = tryParseJson(trimmed);
|
|
223
261
|
if (direct.ok) return direct.value;
|
|
224
262
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
const parsed = tryParseJson(
|
|
263
|
+
const fences = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
|
|
264
|
+
for (let index = fences.length - 1; index >= 0; index--) {
|
|
265
|
+
const parsed = tryParseJson(fences[index][1].trim());
|
|
228
266
|
if (parsed.ok) return parsed.value;
|
|
229
267
|
}
|
|
230
268
|
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const parsed = tryParseJson(trimmed.slice(start, end + 1));
|
|
269
|
+
const objects = balancedObjects(trimmed);
|
|
270
|
+
for (let index = objects.length - 1; index >= 0; index--) {
|
|
271
|
+
const parsed = tryParseJson(objects[index]);
|
|
235
272
|
if (parsed.ok) return parsed.value;
|
|
236
273
|
}
|
|
237
274
|
return null;
|
|
238
275
|
}
|
|
239
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Objetos `{…}` de nível superior no texto (ignora chaves dentro de strings).
|
|
279
|
+
*
|
|
280
|
+
* Sem isso, o fallback "primeiro `{` até último `}`" atravessava várias
|
|
281
|
+
* mensagens concatenadas e produzia um JSON inválido.
|
|
282
|
+
*/
|
|
283
|
+
function balancedObjects(text: string): string[] {
|
|
284
|
+
const objects: string[] = [];
|
|
285
|
+
let depth = 0;
|
|
286
|
+
let start = -1;
|
|
287
|
+
let inString = false;
|
|
288
|
+
let escaped = false;
|
|
289
|
+
for (let index = 0; index < text.length; index++) {
|
|
290
|
+
const char = text[index];
|
|
291
|
+
if (inString) {
|
|
292
|
+
if (escaped) escaped = false;
|
|
293
|
+
else if (char === "\\") escaped = true;
|
|
294
|
+
else if (char === '"') inString = false;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (char === '"') {
|
|
298
|
+
inString = true;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (char === "{") {
|
|
302
|
+
if (depth === 0) start = index;
|
|
303
|
+
depth++;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (char === "}" && depth > 0) {
|
|
307
|
+
depth--;
|
|
308
|
+
if (depth === 0 && start >= 0) {
|
|
309
|
+
objects.push(text.slice(start, index + 1));
|
|
310
|
+
start = -1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return objects;
|
|
315
|
+
}
|
|
316
|
+
|
|
240
317
|
/** Normaliza as métricas do JSON do maki. */
|
|
241
318
|
export function metricsFrom(
|
|
242
319
|
output: MakiJsonOutput | null,
|