pi-jev-guard 0.4.0 → 0.6.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 +102 -0
- package/config/jev-config.example.json +10 -0
- package/config/jev-verification.example.json +22 -0
- package/extensions/index.ts +410 -2
- package/package.json +1 -1
- package/src/automatic/guardian.ts +26 -2
- package/src/commands.ts +111 -1
- package/src/config.ts +31 -0
- package/src/verification-runner.ts +177 -0
- package/src/verification-tool.ts +65 -0
- package/src/verification.ts +926 -0
|
@@ -62,6 +62,8 @@ export interface PostPublishCheckResult {
|
|
|
62
62
|
holdText?: string;
|
|
63
63
|
/** Dettaglio per il report (publish con nota, hold). */
|
|
64
64
|
grounding?: GateVerdictReport["grounding"];
|
|
65
|
+
/** Stato deterministico delle verifiche progetto osservate. */
|
|
66
|
+
verification?: GateVerdictReport["verification"];
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** Esito finale del gate con verdetto, per `/jev last` e diagnostica. */
|
|
@@ -76,6 +78,12 @@ export interface GateVerdictReport {
|
|
|
76
78
|
action: "warned" | "repaired" | "held";
|
|
77
79
|
reasons: string[];
|
|
78
80
|
};
|
|
81
|
+
/** Mai generato dal modello: file edit/write + check configurati osservati. */
|
|
82
|
+
verification?: {
|
|
83
|
+
action: "passed" | "warned" | "repaired" | "held";
|
|
84
|
+
changedFiles: string[];
|
|
85
|
+
checks: Array<{ id: string; state: "passed" | "failed" | "pending" }>;
|
|
86
|
+
};
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export class GuardianLimitError extends Error {
|
|
@@ -183,6 +191,10 @@ function withNotice(message: AssistantMessage, notice: string): AssistantMessage
|
|
|
183
191
|
return { ...message, content };
|
|
184
192
|
}
|
|
185
193
|
|
|
194
|
+
function hasToolCalls(message: AssistantMessage): boolean {
|
|
195
|
+
return message.content.some((block) => block.type === "toolCall");
|
|
196
|
+
}
|
|
197
|
+
|
|
186
198
|
function assertCompleteCandidate(message: AssistantMessage): void {
|
|
187
199
|
// Rifiuta terminali non supportati per pubblicazione.
|
|
188
200
|
if (
|
|
@@ -284,12 +296,14 @@ export function runGuardedStream(
|
|
|
284
296
|
const publishCandidate = async (
|
|
285
297
|
notice?: string,
|
|
286
298
|
grounding?: GateVerdictReport["grounding"],
|
|
299
|
+
verification?: GateVerdictReport["verification"],
|
|
287
300
|
): Promise<void> => {
|
|
288
301
|
deps.onVerdict?.({
|
|
289
302
|
verdict,
|
|
290
303
|
attempts: attempt + 1,
|
|
291
304
|
outcome: "published",
|
|
292
305
|
...(grounding ? { grounding } : {}),
|
|
306
|
+
...(verification ? { verification } : {}),
|
|
293
307
|
});
|
|
294
308
|
const approved = notice ? withNotice(candidate, notice) : candidate;
|
|
295
309
|
const aggregated = aggregateUsage(usages);
|
|
@@ -339,6 +353,15 @@ export function runGuardedStream(
|
|
|
339
353
|
const runPostCheck = async (
|
|
340
354
|
baseNotice?: string,
|
|
341
355
|
): Promise<"published" | "held" | undefined> => {
|
|
356
|
+
// I toolcall devono raggiungere il runtime: un post-check su una
|
|
357
|
+
// risposta intermedia potrebbe rigenerarla/holdarla e impedire il
|
|
358
|
+
// check stesso (es. `jev_verify`) prima che venga eseguito. Safety
|
|
359
|
+
// e tool policy restano già applicate; il post-check corre sul
|
|
360
|
+
// successivo messaggio finale dopo i tool result.
|
|
361
|
+
if (hasToolCalls(candidate)) {
|
|
362
|
+
await publishCandidate(baseNotice);
|
|
363
|
+
return "published";
|
|
364
|
+
}
|
|
342
365
|
if (!deps.postPublishCheck) {
|
|
343
366
|
await publishCandidate(baseNotice);
|
|
344
367
|
return "published";
|
|
@@ -357,7 +380,7 @@ export function runGuardedStream(
|
|
|
357
380
|
signal?.throwIfAborted();
|
|
358
381
|
if (check.action === "publish") {
|
|
359
382
|
const notice = [baseNotice, check.notice].filter(Boolean).join("") || undefined;
|
|
360
|
-
await publishCandidate(notice, check.grounding);
|
|
383
|
+
await publishCandidate(notice, check.grounding, check.verification);
|
|
361
384
|
return "published";
|
|
362
385
|
}
|
|
363
386
|
if (check.action === "hold") {
|
|
@@ -367,6 +390,7 @@ export function runGuardedStream(
|
|
|
367
390
|
attempts,
|
|
368
391
|
outcome: "held",
|
|
369
392
|
...(check.grounding ? { grounding: check.grounding } : {}),
|
|
393
|
+
...(check.verification ? { verification: check.verification } : {}),
|
|
370
394
|
});
|
|
371
395
|
await publishHoldText(check.holdText ?? "[JEV — OUTPUT NON PUBBLICATO]");
|
|
372
396
|
return "held";
|
|
@@ -376,7 +400,7 @@ export function runGuardedStream(
|
|
|
376
400
|
rejectedText: rawCandidate,
|
|
377
401
|
feedback:
|
|
378
402
|
check.feedback ??
|
|
379
|
-
"
|
|
403
|
+
"Post-publication check failed; correct the draft using the supplied evidence.",
|
|
380
404
|
});
|
|
381
405
|
return undefined;
|
|
382
406
|
};
|
package/src/commands.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { reviewWithCoverage } from "./reviewer.ts";
|
|
|
19
19
|
import type { Metrics } from "./metrics.ts";
|
|
20
20
|
import type { OutputVerdict } from "./output-judge.ts";
|
|
21
21
|
import type { GateVerdictReport } from "./automatic/guardian.ts";
|
|
22
|
+
import type { VerificationDefaultProfile, VerificationManifestSource, VerificationMode } from "./verification.ts";
|
|
22
23
|
|
|
23
24
|
export interface GateStatusView {
|
|
24
25
|
active: boolean;
|
|
@@ -79,6 +80,31 @@ export interface JevCommandDeps {
|
|
|
79
80
|
hasStaleTests: boolean;
|
|
80
81
|
};
|
|
81
82
|
setGrounding: (mode: "off" | "warn" | "repair" | "hold") => JevConfig;
|
|
83
|
+
verificationStatus?: (cwd: string) => {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
mode: VerificationMode;
|
|
86
|
+
manifestPath: string;
|
|
87
|
+
defaultProfile: VerificationDefaultProfile;
|
|
88
|
+
manifestLoaded: boolean;
|
|
89
|
+
manifestSource?: VerificationManifestSource;
|
|
90
|
+
manifestSourceLabel?: string;
|
|
91
|
+
manifestError?: string;
|
|
92
|
+
changedFiles: string[];
|
|
93
|
+
checks: Array<{ id: string; state: "passed" | "failed" | "pending" }>;
|
|
94
|
+
};
|
|
95
|
+
setVerification?: (mode: "off" | VerificationMode) => JevConfig;
|
|
96
|
+
reloadVerificationManifest?: (cwd: string) => {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
error?: string;
|
|
99
|
+
path?: string;
|
|
100
|
+
sourceLabel?: string;
|
|
101
|
+
};
|
|
102
|
+
runVerification?: (args: {
|
|
103
|
+
cwd: string;
|
|
104
|
+
selection: string | undefined;
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
trusted: boolean;
|
|
107
|
+
}) => Promise<{ ok: boolean; message: string; results: unknown[] }>;
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
export const JEV_SUBCOMMANDS = [
|
|
@@ -96,6 +122,8 @@ export const JEV_SUBCOMMANDS = [
|
|
|
96
122
|
"output",
|
|
97
123
|
"last",
|
|
98
124
|
"grounding",
|
|
125
|
+
"verification",
|
|
126
|
+
"verify",
|
|
99
127
|
"backend",
|
|
100
128
|
"save-key",
|
|
101
129
|
"help",
|
|
@@ -121,6 +149,10 @@ export const JEV_HELP_TEXT = [
|
|
|
121
149
|
" /jev last Ultimo verdetto del gate (twin): esito e controlli",
|
|
122
150
|
" /jev grounding off|warn|repair|hold",
|
|
123
151
|
" Grounding affermazioni sessione (solo automatic, opt-in)",
|
|
152
|
+
" /jev verification off|warn|repair|hold|reload",
|
|
153
|
+
" Footer deterministico; locale > manifest globale > profilo common (opt-in)",
|
|
154
|
+
" /jev verify [recommended|all|id]",
|
|
155
|
+
" Esegue solo check della policy acquisita, mai shell arbitraria",
|
|
124
156
|
" /jev backend Mostra backend attivo e chiavi rilevate",
|
|
125
157
|
" /jev backend auto|openrouter|typesafe",
|
|
126
158
|
" Cambia backend (salvato in jev-config.json)",
|
|
@@ -140,7 +172,7 @@ export const JEV_HELP_TEXT = [
|
|
|
140
172
|
export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
141
173
|
pi.registerCommand("jev", {
|
|
142
174
|
description:
|
|
143
|
-
"Jev guard:
|
|
175
|
+
"Jev guard: gate, grounding e verifica progetto. Sottocomandi: status, mode, upstream, check, verification, verify, stats, help.",
|
|
144
176
|
getArgumentCompletions: (argumentPrefix) => {
|
|
145
177
|
const prefix = argumentPrefix.trim().toLowerCase();
|
|
146
178
|
return JEV_SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map(
|
|
@@ -188,6 +220,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
188
220
|
const info = deps.reviewerInfo();
|
|
189
221
|
const gate = deps.gateStatus();
|
|
190
222
|
const twinView = deps.twinStatus();
|
|
223
|
+
const verification = deps.verificationStatus?.(ctx.cwd);
|
|
191
224
|
const authProvider = twinView.primary?.provider ?? cfg.automatic.upstreamProvider;
|
|
192
225
|
let guardedAuth = "—";
|
|
193
226
|
if (gate.active) {
|
|
@@ -250,6 +283,11 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
250
283
|
`Exfil check: ${cfg.exfilCheck.enabled ? "attivo in automatic (confirm in TUI, fail-open)" : "disattivo (config)"}`,
|
|
251
284
|
`Output judge: ${cfg.outputJudge.enabled ? `attivo in automatic (tool: ${cfg.outputJudge.tools.join(",") || "—"}, fail-open)` : "disattivo (config)"}`,
|
|
252
285
|
`Grounding: ${cfg.grounding.enabled ? `attivo in automatic (mode=${cfg.grounding.mode}, evidenza=${deps.groundingStatus().entries}${deps.groundingStatus().hasStaleTests ? ", test stale" : ""})` : "disattivo (opt-in: /jev grounding warn)"}`,
|
|
286
|
+
verification
|
|
287
|
+
? `Verifica progetto: ${verification.enabled
|
|
288
|
+
? `attiva (mode=${verification.mode}, policy=${verification.manifestLoaded ? verification.manifestSourceLabel ?? "OK" : verification.manifestError ?? "assente"}, modifiche=${verification.changedFiles.length}, check=${verification.checks.map((check) => `${check.id}:${check.state}`).join(",") || "—"})`
|
|
289
|
+
: `disattiva (opt-in: /jev verification warn; locale=${verification.manifestPath}, fallback=${verification.defaultProfile})`}`
|
|
290
|
+
: "Verifica progetto: non disponibile in questa estensione",
|
|
253
291
|
cfg.mode === "automatic"
|
|
254
292
|
? "ATTENZIONE: in automatic testo/codice vengono inviati al verificatore. Crea il twin con /jev upstream e seleziona il modello __jev per enforcement."
|
|
255
293
|
: "on-demand: nessuna verifica automatica.",
|
|
@@ -465,6 +503,9 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
465
503
|
last.report.grounding
|
|
466
504
|
? `grounding: ${last.report.grounding.action} (${last.report.grounding.reasons.join(", ") || "n/a"})`
|
|
467
505
|
: "grounding: non intervenuto",
|
|
506
|
+
last.report.verification
|
|
507
|
+
? `verifica progetto: ${last.report.verification.action} (file=${last.report.verification.changedFiles.join(",") || "—"}; check=${last.report.verification.checks.map((check) => `${check.id}:${check.state}`).join(",") || "—"})`
|
|
508
|
+
: "verifica progetto: non intervenuta",
|
|
468
509
|
v.errorCode ? `errore: ${v.errorCode}` : `backend: ${v.backend ?? "?"} · modello: ${v.model ?? "?"}`,
|
|
469
510
|
v.truncated ? `troncato: ${JSON.stringify(v.truncated)}` : "copertura intera",
|
|
470
511
|
`tempo verifica: ${Math.round(v.elapsedMs)}ms`,
|
|
@@ -521,6 +562,75 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
|
|
|
521
562
|
return;
|
|
522
563
|
}
|
|
523
564
|
|
|
565
|
+
if (sub === "verification") {
|
|
566
|
+
if (!deps.verificationStatus || !deps.setVerification) {
|
|
567
|
+
ctx.ui.notify("Verifica progetto non disponibile in questa versione dell'estensione.", "warning");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const value = parts[1];
|
|
571
|
+
if (value === undefined) {
|
|
572
|
+
const st = deps.verificationStatus(ctx.cwd);
|
|
573
|
+
ctx.ui.notify(
|
|
574
|
+
st.enabled
|
|
575
|
+
? [
|
|
576
|
+
`Verifica progetto attiva in automatic (mode=${st.mode}).`,
|
|
577
|
+
`Policy: ${st.manifestLoaded ? st.manifestSourceLabel ?? "OK" : st.manifestError ?? "non disponibile"}.`,
|
|
578
|
+
`Override locale: ${st.manifestPath}; fallback integrato: ${st.defaultProfile}.`,
|
|
579
|
+
`Modifiche osservate: ${st.changedFiles.join(", ") || "nessuna"}.`,
|
|
580
|
+
`Check: ${st.checks.map((check) => `${check.id}:${check.state}`).join(", ") || "nessuno pertinente"}.`,
|
|
581
|
+
"Esegui quelli mancanti con /jev verify recommended. Dopo una modifica manuale della policy: /jev verification reload.",
|
|
582
|
+
].join("\n")
|
|
583
|
+
: `Verifica progetto disattiva (opt-in). Override locale: ${st.manifestPath}; fallback: ${st.defaultProfile}. Attiva con /jev verification warn|repair|hold.`,
|
|
584
|
+
"info",
|
|
585
|
+
);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (value === "reload") {
|
|
589
|
+
if (!deps.reloadVerificationManifest) {
|
|
590
|
+
ctx.ui.notify("Reload manifest non disponibile in questa versione dell'estensione.", "warning");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const reloaded = deps.reloadVerificationManifest(ctx.cwd);
|
|
594
|
+
ctx.ui.notify(
|
|
595
|
+
reloaded.ok
|
|
596
|
+
? `Policy verifica acquisita: ${reloaded.sourceLabel ?? reloaded.path}. I comandi verranno eseguiti solo da questa snapshot.`
|
|
597
|
+
: `Policy verifica non acquisita: ${reloaded.error}`,
|
|
598
|
+
reloaded.ok ? "info" : "warning",
|
|
599
|
+
);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (value !== "off" && value !== "warn" && value !== "repair" && value !== "hold") {
|
|
603
|
+
ctx.ui.notify("Uso: /jev verification off|warn|repair|hold|reload", "warning");
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
deps.setVerification(value);
|
|
607
|
+
ctx.ui.notify(
|
|
608
|
+
value === "off"
|
|
609
|
+
? "Verifica progetto disattivata."
|
|
610
|
+
: `Verifica progetto attiva (mode=${value}). Usa il profilo globale common automaticamente; un manifest locale in ${deps.verificationStatus(ctx.cwd).manifestPath} lo sovrascrive. Poi usa /jev verify recommended.`,
|
|
611
|
+
"info",
|
|
612
|
+
);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (sub === "verify") {
|
|
617
|
+
if (!deps.runVerification) {
|
|
618
|
+
ctx.ui.notify("Verifica progetto non disponibile in questa versione dell'estensione.", "warning");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const trusted = typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
|
|
622
|
+
? ctx.isProjectTrusted()
|
|
623
|
+
: false;
|
|
624
|
+
const result = await deps.runVerification({
|
|
625
|
+
cwd: ctx.cwd,
|
|
626
|
+
selection: parts[1],
|
|
627
|
+
signal: ctx.signal ?? undefined,
|
|
628
|
+
trusted,
|
|
629
|
+
});
|
|
630
|
+
ctx.ui.notify(result.message, result.ok ? "info" : "warning");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
524
634
|
if (sub === "reload") {
|
|
525
635
|
const cfg = deps.reload();
|
|
526
636
|
ctx.ui.notify(`Config ricaricata. mode=${cfg.mode} backend=${cfg.jev.backend}`, "info");
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import type { JevBackend } from "./reviewer.ts";
|
|
5
|
+
import type { VerificationSettings } from "./verification.ts";
|
|
5
6
|
|
|
6
7
|
export type JevMode = "on-demand" | "automatic";
|
|
7
8
|
export type JevBackendSetting = JevBackend | "auto";
|
|
@@ -97,6 +98,7 @@ export interface JevConfig {
|
|
|
97
98
|
/** Una modifica riuscita invalida i test precedenti (stale). */
|
|
98
99
|
invalidateTestsOnEdit: boolean;
|
|
99
100
|
};
|
|
101
|
+
verification: VerificationSettings;
|
|
100
102
|
exfilCheck: {
|
|
101
103
|
/** Check semantico sui comandi con tool di rete (solo automatic). */
|
|
102
104
|
enabled: boolean;
|
|
@@ -194,6 +196,20 @@ export const DEFAULT_CONFIG: JevConfig = {
|
|
|
194
196
|
cacheSeconds: 120,
|
|
195
197
|
invalidateTestsOnEdit: true,
|
|
196
198
|
},
|
|
199
|
+
verification: {
|
|
200
|
+
// Opt-in: solo una policy acquisita può autorizzare check; non abilitarla
|
|
201
|
+
// silenziosamente in installazioni esistenti.
|
|
202
|
+
enabled: false,
|
|
203
|
+
mode: "warn",
|
|
204
|
+
manifestPath: ".pi/jev-verification.json",
|
|
205
|
+
// Profilo built-in per nuovi progetti: si attiva solo dopo il consenso
|
|
206
|
+
// esplicito /jev verification warn|repair|hold (enabled resta false).
|
|
207
|
+
defaultProfile: "common",
|
|
208
|
+
maxRepairs: 1,
|
|
209
|
+
commandTimeoutMs: 120000,
|
|
210
|
+
outputChars: 6000,
|
|
211
|
+
maxCommands: 4,
|
|
212
|
+
},
|
|
197
213
|
exfilCheck: {
|
|
198
214
|
enabled: true,
|
|
199
215
|
tools: ["bash", "powershell"],
|
|
@@ -241,6 +257,21 @@ export function loadConfig(configPath?: string): JevConfig {
|
|
|
241
257
|
fileConfig,
|
|
242
258
|
) as JevConfig;
|
|
243
259
|
|
|
260
|
+
// Normalizza il fallback verifica: config precedenti non lo contengono;
|
|
261
|
+
// valori manuali ignoti non devono disattivare/alterare la policy a caso.
|
|
262
|
+
if (!isRecord(loaded.verification)) {
|
|
263
|
+
loaded.verification = { ...DEFAULT_CONFIG.verification };
|
|
264
|
+
}
|
|
265
|
+
if (typeof loaded.verification.manifestPath !== "string" || !loaded.verification.manifestPath.trim()) {
|
|
266
|
+
loaded.verification.manifestPath = DEFAULT_CONFIG.verification.manifestPath;
|
|
267
|
+
}
|
|
268
|
+
if (loaded.verification.defaultProfile !== "common" && loaded.verification.defaultProfile !== "off") {
|
|
269
|
+
loaded.verification.defaultProfile = DEFAULT_CONFIG.verification.defaultProfile;
|
|
270
|
+
}
|
|
271
|
+
if (typeof loaded.verification.globalManifestPath !== "string") {
|
|
272
|
+
delete loaded.verification.globalManifestPath;
|
|
273
|
+
}
|
|
274
|
+
|
|
244
275
|
// Normalizza la lista twin (config scritta a mano o da versioni vecchie):
|
|
245
276
|
// dedup, scarta voci malformate, garantisce che il primario sia incluso.
|
|
246
277
|
let merged = withTwinRefs(loaded, twinRefs(loaded));
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Esecuzione bounded dei soli comandi dichiarati dalla policy di verifica acquisita. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import type { VerificationCheck, VerificationSettings } from "./verification.ts";
|
|
4
|
+
|
|
5
|
+
export interface VerificationExecution {
|
|
6
|
+
id: string;
|
|
7
|
+
command: string;
|
|
8
|
+
ok: boolean;
|
|
9
|
+
exitCode?: number;
|
|
10
|
+
timedOut?: boolean;
|
|
11
|
+
aborted?: boolean;
|
|
12
|
+
/** Solo output fallito, bounded; il successo non riversa log inutili nel contesto. */
|
|
13
|
+
output?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function boundedTimeout(value: unknown): number {
|
|
17
|
+
// Config manuale può essere malformata: mai trasformare NaN in timeout 0.
|
|
18
|
+
const numeric = typeof value === "number" && Number.isFinite(value) ? value : 120_000;
|
|
19
|
+
// Non permettere manifest accidentali da ore: per check oltre 10 min l'utente
|
|
20
|
+
// può aumentare il setting ma resta una scelta esplicita e limitata.
|
|
21
|
+
return Math.max(1000, Math.min(600_000, Math.floor(numeric)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function boundedOutputChars(value: unknown): number {
|
|
25
|
+
const numeric = typeof value === "number" && Number.isFinite(value) ? value : 6_000;
|
|
26
|
+
return Math.max(0, Math.min(100_000, Math.floor(numeric)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function appendBounded(state: { text: string; elided: number }, chunk: Buffer, maxChars: number): void {
|
|
30
|
+
const value = chunk.toString("utf8");
|
|
31
|
+
const remaining = Math.max(0, maxChars - state.text.length);
|
|
32
|
+
state.text += value.slice(0, remaining);
|
|
33
|
+
state.elided += Math.max(0, value.length - remaining);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Esegue un comando scelto dalla policy. `command` non arriva mai dal modello;
|
|
38
|
+
* il chiamante deve aver già selezionato un VerificationCheck acquisito.
|
|
39
|
+
*/
|
|
40
|
+
export async function runVerificationCheck(args: {
|
|
41
|
+
check: VerificationCheck;
|
|
42
|
+
cwd: string;
|
|
43
|
+
settings: VerificationSettings;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}): Promise<VerificationExecution> {
|
|
46
|
+
const { check, cwd, settings, signal } = args;
|
|
47
|
+
const configuredTimeout = boundedTimeout(settings.commandTimeoutMs);
|
|
48
|
+
const timeoutMs = Math.min(
|
|
49
|
+
check.timeoutMs === undefined ? configuredTimeout : boundedTimeout(check.timeoutMs),
|
|
50
|
+
configuredTimeout,
|
|
51
|
+
);
|
|
52
|
+
const outputChars = boundedOutputChars(settings.outputChars);
|
|
53
|
+
const captured = { text: "", elided: 0 };
|
|
54
|
+
let timedOut = false;
|
|
55
|
+
let aborted = false;
|
|
56
|
+
|
|
57
|
+
return new Promise<VerificationExecution>((resolveResult) => {
|
|
58
|
+
let settled = false;
|
|
59
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
let forceKill: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
let child: ReturnType<typeof spawn> | undefined;
|
|
62
|
+
const killTree = (signalName: NodeJS.Signals) => {
|
|
63
|
+
if (!child?.pid) return;
|
|
64
|
+
try {
|
|
65
|
+
// Su POSIX il comando shell e i suoi figli sono un process group
|
|
66
|
+
// dedicato (detached:true): evitare che un npm/test appeso sopravviva
|
|
67
|
+
// al timeout del guard. Su Windows il fallback uccide almeno shell.
|
|
68
|
+
if (process.platform !== "win32") process.kill(-child.pid, signalName);
|
|
69
|
+
else child.kill(signalName);
|
|
70
|
+
} catch {
|
|
71
|
+
try {
|
|
72
|
+
child.kill(signalName);
|
|
73
|
+
} catch {
|
|
74
|
+
// Il processo può essere già terminato.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const settle = (result: VerificationExecution) => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
if (timeout) clearTimeout(timeout);
|
|
82
|
+
if (forceKill) clearTimeout(forceKill);
|
|
83
|
+
signal?.removeEventListener("abort", onAbort);
|
|
84
|
+
resolveResult(result);
|
|
85
|
+
};
|
|
86
|
+
const terminate = () => {
|
|
87
|
+
killTree("SIGTERM");
|
|
88
|
+
forceKill ??= setTimeout(() => killTree("SIGKILL"), 1_000);
|
|
89
|
+
};
|
|
90
|
+
const output = () => {
|
|
91
|
+
const base = captured.text.trim();
|
|
92
|
+
const suffix = captured.elided > 0 ? `\n…[${captured.elided} chars elided]` : "";
|
|
93
|
+
return `${base}${suffix}`.trim();
|
|
94
|
+
};
|
|
95
|
+
const onAbort = () => {
|
|
96
|
+
aborted = true;
|
|
97
|
+
terminate();
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// shell è necessario per comandi normali come `npm run typecheck`; la
|
|
102
|
+
// fiducia arriva dalla policy acquisita + project trust.
|
|
103
|
+
child = spawn(check.command, {
|
|
104
|
+
cwd,
|
|
105
|
+
shell: true,
|
|
106
|
+
windowsHide: true,
|
|
107
|
+
detached: process.platform !== "win32",
|
|
108
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
settle({
|
|
112
|
+
id: check.id,
|
|
113
|
+
command: check.command,
|
|
114
|
+
ok: false,
|
|
115
|
+
output: error instanceof Error ? error.message : String(error),
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
child.stdout?.on("data", (chunk: Buffer) => appendBounded(captured, chunk, outputChars));
|
|
121
|
+
child.stderr?.on("data", (chunk: Buffer) => appendBounded(captured, chunk, outputChars));
|
|
122
|
+
child.on("error", (error) => {
|
|
123
|
+
settle({
|
|
124
|
+
id: check.id,
|
|
125
|
+
command: check.command,
|
|
126
|
+
ok: false,
|
|
127
|
+
output: error.message,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
child.on("close", (exitCode) => {
|
|
131
|
+
const ok = exitCode === 0 && !timedOut && !aborted;
|
|
132
|
+
settle({
|
|
133
|
+
id: check.id,
|
|
134
|
+
command: check.command,
|
|
135
|
+
ok,
|
|
136
|
+
...(typeof exitCode === "number" ? { exitCode } : {}),
|
|
137
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
138
|
+
...(aborted ? { aborted: true } : {}),
|
|
139
|
+
...(!ok ? { output: output() || (timedOut ? `Timeout after ${timeoutMs}ms` : aborted ? "Aborted" : "Command failed without output") } : {}),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
timeout = setTimeout(() => {
|
|
144
|
+
timedOut = true;
|
|
145
|
+
terminate();
|
|
146
|
+
}, timeoutMs);
|
|
147
|
+
// Child può chiudersi molto rapidamente prima dell'assegnazione del timer.
|
|
148
|
+
if (settled && timeout) clearTimeout(timeout);
|
|
149
|
+
if (signal?.aborted) onAbort();
|
|
150
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function runVerificationChecks(args: {
|
|
155
|
+
checks: VerificationCheck[];
|
|
156
|
+
cwd: string;
|
|
157
|
+
settings: VerificationSettings;
|
|
158
|
+
signal?: AbortSignal;
|
|
159
|
+
}): Promise<VerificationExecution[]> {
|
|
160
|
+
const results: VerificationExecution[] = [];
|
|
161
|
+
for (const check of args.checks) {
|
|
162
|
+
if (args.signal?.aborted) break;
|
|
163
|
+
results.push(await runVerificationCheck({ ...args, check }));
|
|
164
|
+
}
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Testo destinato al modello: esiti concreti, output diagnostico solo su errore. */
|
|
169
|
+
export function formatVerificationExecutions(results: readonly VerificationExecution[]): string {
|
|
170
|
+
if (results.length === 0) return "[jev_verify] Nessun check eseguito.";
|
|
171
|
+
return results.map((result) => {
|
|
172
|
+
const header = result.ok
|
|
173
|
+
? `[jev_verify] ${result.id}: superato${result.exitCode !== undefined ? ` (exit ${result.exitCode})` : ""}.`
|
|
174
|
+
: `[jev_verify] ${result.id}: fallito${result.exitCode !== undefined ? ` (exit ${result.exitCode})` : ""}${result.timedOut ? " (timeout)" : ""}${result.aborted ? " (annullato)" : ""}.`;
|
|
175
|
+
return result.output ? `${header}\n${result.output}` : header;
|
|
176
|
+
}).join("\n\n");
|
|
177
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { VerificationExecution } from "./verification-runner.ts";
|
|
4
|
+
|
|
5
|
+
export interface VerificationToolRunResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
message: string;
|
|
8
|
+
results: VerificationExecution[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tool ristretto: il modello seleziona solo un id del manifest (o
|
|
13
|
+
* recommended/all), mai una stringa shell arbitraria.
|
|
14
|
+
*/
|
|
15
|
+
export function registerVerificationTool(
|
|
16
|
+
pi: ExtensionAPI,
|
|
17
|
+
deps: {
|
|
18
|
+
run: (args: {
|
|
19
|
+
cwd: string;
|
|
20
|
+
selection: string | undefined;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
trusted: boolean;
|
|
23
|
+
}) => Promise<VerificationToolRunResult>;
|
|
24
|
+
},
|
|
25
|
+
): void {
|
|
26
|
+
pi.registerTool({
|
|
27
|
+
name: "jev_verify",
|
|
28
|
+
label: "Jev project verification",
|
|
29
|
+
description:
|
|
30
|
+
"Run a typecheck, lint, or test authorized by the acquired local/global verification policy. " +
|
|
31
|
+
"It never accepts arbitrary shell commands.",
|
|
32
|
+
promptSnippet: "Run approved project verification checks after code edits.",
|
|
33
|
+
promptGuidelines: [
|
|
34
|
+
"After a successful edit/write, call jev_verify with check='recommended' when verification is enabled.",
|
|
35
|
+
"Use only this tool's declared check ids; never claim code compiles or tests pass without its observed result.",
|
|
36
|
+
"If a check fails, inspect its actual output, repair the code, then run the check again.",
|
|
37
|
+
],
|
|
38
|
+
parameters: Type.Object({
|
|
39
|
+
check: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
minLength: 1,
|
|
42
|
+
maxLength: 64,
|
|
43
|
+
description: "Check id, or 'recommended' (default) / 'all'",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
}),
|
|
47
|
+
executionMode: "sequential",
|
|
48
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
49
|
+
const result = await deps.run({
|
|
50
|
+
cwd: ctx.cwd,
|
|
51
|
+
selection: params.check,
|
|
52
|
+
signal: signal ?? undefined,
|
|
53
|
+
trusted: ctx.isProjectTrusted(),
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: result.message }],
|
|
57
|
+
details: {
|
|
58
|
+
kind: "jev-verification",
|
|
59
|
+
ok: result.ok,
|
|
60
|
+
results: result.results,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|