pi-jev-guard 0.3.3 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.3.3",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Jev validation guard for pi: on-demand tool + automatic provider gate. Dual backend: TypeSafe direct + OpenRouter.",
6
6
  "keywords": [
@@ -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
- "Grounding check failed; only assert session activities supported by evidence.",
403
+ "Post-publication check failed; correct the draft using the supplied evidence.",
380
404
  });
381
405
  return undefined;
382
406
  };
@@ -128,54 +128,129 @@ function stripTwins(models: readonly Model<Api>[]): Model<Api>[] {
128
128
  }
129
129
 
130
130
  /**
131
- * Costruisce l'overlay. Non registra nulla: la registrazione spetta al
132
- * chiamante (pi.registerProvider), così può fallire senza toccare nulla.
131
+ * Patch in-place del gate gemellato sullo STESSO oggetto provider.
132
+ *
133
+ * Nessuna (ri-)registrazione nel registry: l'identità dell'oggetto e la sua
134
+ * config restano quelle pubblicate dal proprietario (builtin o altre
135
+ * estensioni come pi-accounts), quindi i loro controlli di ownership e i
136
+ * loro snapshot non vedono mai una "configuration changed" e non armano
137
+ * abort. Rieseguibile: se l'oggetto è già patchato riparte dai metodi
138
+ * originali con la nuova lista twin. Ritorna gli id gemello pubblicati.
133
139
  */
134
- export function buildOverlaidProvider(deps: OverlayDeps): Provider {
135
- const upstream = deps.upstream;
136
- // Mappa twin-id -> modello originale (ricostruita a ogni refresh).
137
- let twinToOriginal = new Map<string, Model<Api>>();
138
- let currentModels: Model<Api>[] = [];
140
+ export interface PatchDeps {
141
+ /** Id modelli (originali) da gemellare. */
142
+ twinModelIds: string[];
143
+ getConfig: () => JevConfig;
144
+ getReview: () => ReviewFn;
145
+ /** Riceve l'esito finale di ogni turno sul twin (per `/jev last`). */
146
+ onVerdict?: (report: GateVerdictReport) => void;
147
+ /**
148
+ * Factory del controllo post-safety, una per richiesta: il budget repair
149
+ * vive nella closure restituita, non nel loop del guardian.
150
+ */
151
+ createPostCheck?: () => PostPublishCheckFn | undefined;
152
+ }
153
+
154
+ /** Metodi originali prima della patch (per ripristino su unpatch). */
155
+ interface PatchedOriginals {
156
+ getModels: Provider["getModels"];
157
+ stream: Provider["stream"] | undefined;
158
+ streamSimple: Provider["streamSimple"];
159
+ refreshModels?: Provider["refreshModels"];
160
+ filterModels?: Provider["filterModels"];
161
+ }
162
+
163
+ interface PatchRecord {
164
+ /** Originali veri (mai i nostri wrapper): evolve con le adozioni. */
165
+ orig: PatchedOriginals;
166
+ /** I nostri wrapper, per distinguerli dalle riassegnazioni altrui. */
167
+ wrapGetModels: Provider["getModels"];
168
+ }
169
+
170
+ const patchedOriginals = new WeakMap<object, PatchRecord>();
171
+
172
+ /** True se l'oggetto provider è attualmente patchato da noi. */
173
+ export function isPatched(provider: Provider): boolean {
174
+ return patchedOriginals.has(provider);
175
+ }
139
176
 
140
- function rebuildTwins(rawOriginals: readonly Model<Api>[]): Model<Api>[] {
141
- twinToOriginal = new Map();
142
- const chosen = new Set(deps.twinModelIds);
143
- // Dedup per id: sorgenti miste (builtin + store + statica) possono
144
- // ripetere lo stesso modello e produrrebbero twin duplicati.
145
- const originals = mergeModelsById(stripTwins(rawOriginals), []);
177
+ export function patchProvider(provider: Provider, deps: PatchDeps): string[] {
178
+ const previous = patchedOriginals.get(provider);
179
+ const rawStream: unknown = previous
180
+ ? previous.orig.stream
181
+ : (provider as { stream?: unknown }).stream;
182
+ const base: PatchedOriginals = previous?.orig ?? {
183
+ // Raw, mai bind: invocati con this=provider al momento della chiamata.
184
+ getModels: provider.getModels,
185
+ stream: typeof rawStream === "function"
186
+ ? (rawStream as NonNullable<Provider["stream"]>)
187
+ : undefined,
188
+ streamSimple: provider.streamSimple,
189
+ ...(provider.refreshModels
190
+ ? { refreshModels: provider.refreshModels }
191
+ : {}),
192
+ ...(provider.filterModels
193
+ ? { filterModels: provider.filterModels }
194
+ : {}),
195
+ };
196
+ const twinIds = [...deps.twinModelIds];
197
+ const providerId = provider.id;
198
+
199
+ // Risoluzione pigra: legge SEMPRE i modelli correnti (nessuna snapshot
200
+ // che diventa stale dopo refresh altrui o cataloghi dinamici). Se il
201
+ // proprietario riassegna getModels dopo la patch, la nuova fn viene
202
+ // adottata come base (i nostri wrapper si riconoscono e non si adottano).
203
+ function currentBase(): PatchedOriginals {
204
+ const rec = patchedOriginals.get(provider);
205
+ const orig = rec?.orig ?? base;
206
+ const cur = provider.getModels as Provider["getModels"];
207
+ if (rec && cur !== rec.wrapGetModels && cur !== orig.getModels) {
208
+ orig.getModels = cur;
209
+ }
210
+ return orig;
211
+ }
212
+
213
+ function currentOriginals(): Model<Api>[] {
214
+ return mergeModelsById(stripTwins(currentBase().getModels.call(provider)), []);
215
+ }
216
+
217
+ function resolveTwin(modelId: string): Model<Api> | undefined {
218
+ if (!isTwinId(modelId)) return undefined;
219
+ const wanted = untwinId(modelId);
220
+ if (!twinIds.includes(wanted)) return undefined;
221
+ return currentOriginals().find((m) => m.id === wanted);
222
+ }
223
+
224
+ function twinEntries(): Model<Api>[] {
225
+ const originals = currentOriginals();
226
+ const chosen = new Set(twinIds);
146
227
  const twins: Model<Api>[] = [];
228
+ const seen = new Set<string>();
147
229
  for (const original of originals) {
148
- if (chosen.has(original.id)) {
149
- const twin = twinModelFor(original, upstream.id);
150
- if (twinToOriginal.has(twin.id)) continue;
151
- twinToOriginal.set(twin.id, original);
152
- twins.push(twin);
153
- }
230
+ if (!chosen.has(original.id)) continue;
231
+ const twin = twinModelFor(original, providerId);
232
+ if (seen.has(twin.id)) continue;
233
+ seen.add(twin.id);
234
+ twins.push(twin);
154
235
  }
155
- currentModels = [...originals.map((m) => ({ ...m })), ...twins];
156
- return currentModels;
236
+ return twins;
157
237
  }
158
238
 
159
- rebuildTwins(upstream.getModels());
160
-
161
239
  function upstreamCall(
162
240
  model: Model<Api>,
163
241
  context: Context,
164
242
  options?: SimpleStreamOptions,
165
243
  ): AssistantMessageEventStream {
166
- // Public aliases are not model changes. Preserve reasoning signatures,
167
- // response phases and tool ids when replaying the transcript upstream.
244
+ // Gli alias pubblici non sono cambi di modello: normalizza la cronologia
245
+ // preservando firme reasoning e tool id nel transcript upstream.
168
246
  const originalContext: Context = {
169
247
  ...context,
170
248
  messages: context.messages.map((message) =>
171
- message.role === "assistant" && message.provider === upstream.id && isTwinId(message.model)
249
+ message.role === "assistant" && message.provider === providerId && isTwinId(message.model)
172
250
  ? { ...message, model: untwinId(message.model) }
173
251
  : message),
174
252
  };
175
- if (deps.upstreamStreams) {
176
- return deps.upstreamStreams.streamSimple(model, originalContext, options);
177
- }
178
- return upstream.streamSimple(model, originalContext, options);
253
+ return base.streamSimple.call(provider, model, originalContext, options);
179
254
  }
180
255
 
181
256
  function guardedStreamSimple(
@@ -183,9 +258,9 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
183
258
  context: Context,
184
259
  options?: SimpleStreamOptions,
185
260
  ): AssistantMessageEventStream {
186
- const twinOf = twinToOriginal.get(model.id);
187
- // A stale/unknown alias must never leak to the upstream API, even if a
188
- // dynamic refresh removed its original from the catalogue.
261
+ const twinOf = resolveTwin(model.id);
262
+ // Un alias stale/sconosciuto non deve mai arrivare all'API upstream,
263
+ // anche se un refresh ha rimosso l'originale dal catalogo.
189
264
  if (!twinOf) {
190
265
  if (!isTwinId(model.id)) return upstreamCall(model, context, options);
191
266
  const stream = createAssistantMessageEventStream();
@@ -221,31 +296,29 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
221
296
  });
222
297
  }
223
298
 
224
- const overlay: Provider = {
225
- id: upstream.id,
226
- name: upstream.name,
227
- baseUrl: upstream.baseUrl,
228
- headers: upstream.headers,
229
- // STESSO oggetto auth: stesso slot credenziale, stesso login/OAuth.
230
- auth: upstream.auth,
231
- getModels: () => currentModels,
232
- stream: (model, context, options) =>
299
+ const wrapGetModels: Provider["getModels"] = () => [
300
+ ...currentOriginals().map((m) => ({ ...m })),
301
+ ...twinEntries(),
302
+ ];
303
+ provider.getModels = wrapGetModels;
304
+ if (base.stream) {
305
+ provider.stream = ((model: any, context: any, options: any) =>
233
306
  guardedStreamSimple(
234
307
  model as Model<Api>,
235
308
  context,
236
309
  options as SimpleStreamOptions | undefined,
237
- ),
238
- streamSimple: (model, context, options) =>
239
- guardedStreamSimple(model as Model<Api>, context, options),
240
- };
310
+ )) as Provider["stream"];
311
+ }
312
+ provider.streamSimple = ((model: any, context: any, options: any) =>
313
+ guardedStreamSimple(model as Model<Api>, context, options)) as Provider["streamSimple"];
241
314
 
242
- // Forward refresh con re-gemellamento. Il persist va depurato dai twin:
243
- // lo store è letto anche dal builtin a overlay assente, che li
244
- // scambierebbe per modelli normali (senza gate!).
245
- if (upstream.refreshModels) {
246
- const upstreamRefresh = upstream.refreshModels.bind(upstream);
247
- overlay.refreshModels = async (ctx: RefreshModelsContext): Promise<void> => {
248
- await upstreamRefresh({
315
+ // Forward refresh con persist depurato dai twin: lo store è letto anche
316
+ // dal builtin a overlay assente, che li scambierebbe per modelli normali
317
+ // (senza gate!). La risoluzione pigra non ha snapshot da ricostruire.
318
+ if (base.refreshModels) {
319
+ const upstreamRefresh = base.refreshModels;
320
+ provider.refreshModels = (async (ctx: RefreshModelsContext): Promise<void> => {
321
+ await upstreamRefresh.call(provider, {
249
322
  ...ctx,
250
323
  publish: (publication) =>
251
324
  ctx.publish({
@@ -260,41 +333,60 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
260
333
  },
261
334
  update: publication.update
262
335
  ? () => {
263
- // Applica prima l'update upstream (popola il suo oggetto),
264
- // poi ricostruisci originali + twin da quelli freschi.
265
336
  publication.update?.();
266
- rebuildTwins(upstream.getModels());
267
337
  }
268
338
  : undefined,
269
339
  }),
270
340
  });
271
- };
341
+ }) as Provider["refreshModels"];
272
342
  }
273
343
 
274
344
  // Forward filtro credenziali: si applica agli originali, i twin seguono
275
345
  // il destino dell'originale corrispondente.
276
- if (upstream.filterModels) {
277
- const upstreamFilter = upstream.filterModels.bind(upstream);
278
- overlay.filterModels = (
346
+ if (base.filterModels) {
347
+ const upstreamFilter = base.filterModels;
348
+ provider.filterModels = ((
279
349
  models: readonly Model<Api>[],
280
350
  credential: Credential | undefined,
281
351
  ): readonly Model<Api>[] => {
282
352
  const originals = models.filter((m) => !isTwinId(m.id));
283
353
  const kept = new Set(
284
- upstreamFilter(originals as ModelT<Api>[], credential).map((m) => m.id),
354
+ upstreamFilter.call(provider, originals as ModelT<Api>[], credential).map((m) => m.id),
285
355
  );
286
356
  return models.filter((m) =>
287
357
  isTwinId(m.id) ? kept.has(untwinId(m.id)) : kept.has(m.id),
288
358
  );
289
- };
359
+ }) as Provider["filterModels"];
290
360
  }
291
361
 
292
- if (upstream.fetchDeferred) {
293
- overlay.fetchDeferred = upstream.fetchDeferred.bind(upstream);
294
- }
295
- if (upstream.cancelDeferred) {
296
- overlay.cancelDeferred = upstream.cancelDeferred.bind(upstream);
362
+ if (previous) {
363
+ // Re-patch: i wrapper si ricreano, gli originali veri restano.
364
+ // Aggiorna il riferimento al wrapper corrente o l'adozione
365
+ // scambierebbe il nuovo wrapper per una fn altrui.
366
+ previous.wrapGetModels = wrapGetModels;
367
+ patchedOriginals.set(provider, previous);
368
+ } else {
369
+ patchedOriginals.set(provider, { orig: base, wrapGetModels });
297
370
  }
371
+ return twinEntries().map((t) => t.id);
372
+ }
298
373
 
299
- return overlay;
374
+ /**
375
+ * Ripristina i metodi originali. Ritorna false se l'oggetto non era patchato
376
+ * (es. già sostituito dal proprietario: niente da ripristinare).
377
+ */
378
+ export function unpatchProvider(provider: Provider): boolean {
379
+ const rec = patchedOriginals.get(provider);
380
+ const originals = rec?.orig;
381
+ if (!originals) return false;
382
+ provider.getModels = originals.getModels;
383
+ if (originals.stream) provider.stream = originals.stream;
384
+ else delete (provider as { stream?: unknown }).stream;
385
+ provider.streamSimple = originals.streamSimple;
386
+ if (originals.refreshModels) provider.refreshModels = originals.refreshModels;
387
+ else delete (provider as { refreshModels?: unknown }).refreshModels;
388
+ if (originals.filterModels) provider.filterModels = originals.filterModels;
389
+ else delete (provider as { filterModels?: unknown }).filterModels;
390
+ patchedOriginals.delete(provider);
391
+ return true;
300
392
  }
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 { VerificationMode } from "./verification.ts";
22
23
 
23
24
  export interface GateStatusView {
24
25
  active: boolean;
@@ -79,6 +80,27 @@ 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
+ manifestLoaded: boolean;
88
+ manifestError?: string;
89
+ changedFiles: string[];
90
+ checks: Array<{ id: string; state: "passed" | "failed" | "pending" }>;
91
+ };
92
+ setVerification?: (mode: "off" | VerificationMode) => JevConfig;
93
+ reloadVerificationManifest?: (cwd: string) => {
94
+ ok: boolean;
95
+ error?: string;
96
+ path?: string;
97
+ };
98
+ runVerification?: (args: {
99
+ cwd: string;
100
+ selection: string | undefined;
101
+ signal?: AbortSignal;
102
+ trusted: boolean;
103
+ }) => Promise<{ ok: boolean; message: string; results: unknown[] }>;
82
104
  }
83
105
 
84
106
  export const JEV_SUBCOMMANDS = [
@@ -96,6 +118,8 @@ export const JEV_SUBCOMMANDS = [
96
118
  "output",
97
119
  "last",
98
120
  "grounding",
121
+ "verification",
122
+ "verify",
99
123
  "backend",
100
124
  "save-key",
101
125
  "help",
@@ -121,6 +145,10 @@ export const JEV_HELP_TEXT = [
121
145
  " /jev last Ultimo verdetto del gate (twin): esito e controlli",
122
146
  " /jev grounding off|warn|repair|hold",
123
147
  " Grounding affermazioni sessione (solo automatic, opt-in)",
148
+ " /jev verification off|warn|repair|hold|reload",
149
+ " Footer deterministico + repair dai check progetto (opt-in; reload acquisisce manifest)",
150
+ " /jev verify [recommended|all|id]",
151
+ " Esegue solo check dichiarati in .pi/jev-verification.json",
124
152
  " /jev backend Mostra backend attivo e chiavi rilevate",
125
153
  " /jev backend auto|openrouter|typesafe",
126
154
  " Cambia backend (salvato in jev-config.json)",
@@ -140,7 +168,7 @@ export const JEV_HELP_TEXT = [
140
168
  export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
141
169
  pi.registerCommand("jev", {
142
170
  description:
143
- "Jev guard: verifica con TypeSafe Jev. Sottocomandi: status, mode, models, upstream, off, check, stats, help.",
171
+ "Jev guard: gate, grounding e verifica progetto. Sottocomandi: status, mode, upstream, check, verification, verify, stats, help.",
144
172
  getArgumentCompletions: (argumentPrefix) => {
145
173
  const prefix = argumentPrefix.trim().toLowerCase();
146
174
  return JEV_SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map(
@@ -188,6 +216,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
188
216
  const info = deps.reviewerInfo();
189
217
  const gate = deps.gateStatus();
190
218
  const twinView = deps.twinStatus();
219
+ const verification = deps.verificationStatus?.(ctx.cwd);
191
220
  const authProvider = twinView.primary?.provider ?? cfg.automatic.upstreamProvider;
192
221
  let guardedAuth = "—";
193
222
  if (gate.active) {
@@ -250,6 +279,11 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
250
279
  `Exfil check: ${cfg.exfilCheck.enabled ? "attivo in automatic (confirm in TUI, fail-open)" : "disattivo (config)"}`,
251
280
  `Output judge: ${cfg.outputJudge.enabled ? `attivo in automatic (tool: ${cfg.outputJudge.tools.join(",") || "—"}, fail-open)` : "disattivo (config)"}`,
252
281
  `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)"}`,
282
+ verification
283
+ ? `Verifica progetto: ${verification.enabled
284
+ ? `attiva (mode=${verification.mode}, manifest=${verification.manifestLoaded ? "OK" : verification.manifestError ?? "assente"}, modifiche=${verification.changedFiles.length}, check=${verification.checks.map((check) => `${check.id}:${check.state}`).join(",") || "—"})`
285
+ : `disattiva (opt-in: /jev verification warn; manifest=${verification.manifestPath})`}`
286
+ : "Verifica progetto: non disponibile in questa estensione",
253
287
  cfg.mode === "automatic"
254
288
  ? "ATTENZIONE: in automatic testo/codice vengono inviati al verificatore. Crea il twin con /jev upstream e seleziona il modello __jev per enforcement."
255
289
  : "on-demand: nessuna verifica automatica.",
@@ -465,6 +499,9 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
465
499
  last.report.grounding
466
500
  ? `grounding: ${last.report.grounding.action} (${last.report.grounding.reasons.join(", ") || "n/a"})`
467
501
  : "grounding: non intervenuto",
502
+ last.report.verification
503
+ ? `verifica progetto: ${last.report.verification.action} (file=${last.report.verification.changedFiles.join(",") || "—"}; check=${last.report.verification.checks.map((check) => `${check.id}:${check.state}`).join(",") || "—"})`
504
+ : "verifica progetto: non intervenuta",
468
505
  v.errorCode ? `errore: ${v.errorCode}` : `backend: ${v.backend ?? "?"} · modello: ${v.model ?? "?"}`,
469
506
  v.truncated ? `troncato: ${JSON.stringify(v.truncated)}` : "copertura intera",
470
507
  `tempo verifica: ${Math.round(v.elapsedMs)}ms`,
@@ -521,6 +558,74 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
521
558
  return;
522
559
  }
523
560
 
561
+ if (sub === "verification") {
562
+ if (!deps.verificationStatus || !deps.setVerification) {
563
+ ctx.ui.notify("Verifica progetto non disponibile in questa versione dell'estensione.", "warning");
564
+ return;
565
+ }
566
+ const value = parts[1];
567
+ if (value === undefined) {
568
+ const st = deps.verificationStatus(ctx.cwd);
569
+ ctx.ui.notify(
570
+ st.enabled
571
+ ? [
572
+ `Verifica progetto attiva in automatic (mode=${st.mode}).`,
573
+ `Manifest: ${st.manifestPath} — ${st.manifestLoaded ? "OK" : st.manifestError ?? "non disponibile"}.`,
574
+ `Modifiche osservate: ${st.changedFiles.join(", ") || "nessuna"}.`,
575
+ `Check: ${st.checks.map((check) => `${check.id}:${check.state}`).join(", ") || "nessuno pertinente"}.`,
576
+ "Esegui quelli mancanti con /jev verify recommended. Dopo una modifica manuale del manifest: /jev verification reload.",
577
+ ].join("\n")
578
+ : `Verifica progetto disattiva (opt-in). Manifest previsto: ${st.manifestPath}. Attiva con /jev verification warn|repair|hold.`,
579
+ "info",
580
+ );
581
+ return;
582
+ }
583
+ if (value === "reload") {
584
+ if (!deps.reloadVerificationManifest) {
585
+ ctx.ui.notify("Reload manifest non disponibile in questa versione dell'estensione.", "warning");
586
+ return;
587
+ }
588
+ const reloaded = deps.reloadVerificationManifest(ctx.cwd);
589
+ ctx.ui.notify(
590
+ reloaded.ok
591
+ ? `Manifest verifica acquisito: ${reloaded.path}. I comandi verranno eseguiti solo da questa snapshot.`
592
+ : `Manifest verifica non acquisito: ${reloaded.error}`,
593
+ reloaded.ok ? "info" : "warning",
594
+ );
595
+ return;
596
+ }
597
+ if (value !== "off" && value !== "warn" && value !== "repair" && value !== "hold") {
598
+ ctx.ui.notify("Uso: /jev verification off|warn|repair|hold|reload", "warning");
599
+ return;
600
+ }
601
+ deps.setVerification(value);
602
+ ctx.ui.notify(
603
+ value === "off"
604
+ ? "Verifica progetto disattivata."
605
+ : `Verifica progetto attiva (mode=${value}). Crea ${deps.verificationStatus(ctx.cwd).manifestPath} con i soli comandi approvati, poi usa /jev verify recommended.`,
606
+ "info",
607
+ );
608
+ return;
609
+ }
610
+
611
+ if (sub === "verify") {
612
+ if (!deps.runVerification) {
613
+ ctx.ui.notify("Verifica progetto non disponibile in questa versione dell'estensione.", "warning");
614
+ return;
615
+ }
616
+ const trusted = typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
617
+ ? ctx.isProjectTrusted()
618
+ : false;
619
+ const result = await deps.runVerification({
620
+ cwd: ctx.cwd,
621
+ selection: parts[1],
622
+ signal: ctx.signal ?? undefined,
623
+ trusted,
624
+ });
625
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
626
+ return;
627
+ }
628
+
524
629
  if (sub === "reload") {
525
630
  const cfg = deps.reload();
526
631
  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,17 @@ export const DEFAULT_CONFIG: JevConfig = {
194
196
  cacheSeconds: 120,
195
197
  invalidateTestsOnEdit: true,
196
198
  },
199
+ verification: {
200
+ // Opt-in: il manifest può eseguire solo comandi esplicitamente approvati
201
+ // dal progetto; non abilitarlo silenziosamente in installazioni esistenti.
202
+ enabled: false,
203
+ mode: "warn",
204
+ manifestPath: ".pi/jev-verification.json",
205
+ maxRepairs: 1,
206
+ commandTimeoutMs: 120000,
207
+ outputChars: 6000,
208
+ maxCommands: 3,
209
+ },
197
210
  exfilCheck: {
198
211
  enabled: true,
199
212
  tools: ["bash", "powershell"],