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.
@@ -1,9 +1,9 @@
1
1
  import type { ExtensionAPI, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
2
  import type { Model, Provider } from "@earendil-works/pi-ai";
3
3
  import type { Api } from "@earendil-works/pi-ai";
4
- import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
5
4
  import { registerJevCommand, type UpstreamResult } from "../src/commands.ts";
6
5
  import { registerJevAskTool } from "../src/ask.ts";
6
+ import { registerVerificationTool, type VerificationToolRunResult } from "../src/verification-tool.ts";
7
7
  import {
8
8
  configSnapshot,
9
9
  loadConfig,
@@ -19,15 +19,14 @@ import { createReviewer, createTypedAsk } from "../src/factory.ts";
19
19
  import { createMetrics } from "../src/metrics.ts";
20
20
  import { registerJevTool } from "../src/tools.ts";
21
21
  import {
22
- STATIC_DEEPSEEK_FLASH,
23
- buildOverlaidProvider,
22
+ isPatched,
24
23
  isTwinId,
25
- mergeModelsById,
24
+ patchProvider,
26
25
  twinIdFor,
26
+ unpatchProvider,
27
27
  untwinId,
28
28
  } from "../src/automatic/overlay.ts";
29
29
  import type { GateVerdictReport } from "../src/automatic/guardian.ts";
30
- import { readStoreModels } from "../src/store-models.ts";
31
30
  import { DEFAULT_GATE_RULES, elideWithMarker } from "../src/reviewer.ts";
32
31
  import {
33
32
  TOOL_POLICY_REVISION,
@@ -60,11 +59,33 @@ import {
60
59
  groundingKey,
61
60
  recordEvidence,
62
61
  recordToolResult,
62
+ parseExitCode,
63
63
  GROUNDING_QUESTIONS,
64
64
  type EvidenceEntry,
65
65
  type GroundingEvaluation,
66
66
  } from "../src/grounding.ts";
67
- import type { PostPublishCheckFn } from "../src/automatic/guardian.ts";
67
+ import type { PostPublishCheckFn, PostPublishCheckResult } from "../src/automatic/guardian.ts";
68
+ import {
69
+ buildVerificationStatus,
70
+ createVerificationState,
71
+ isVerificationManifestPath,
72
+ decideVerificationAction,
73
+ loadVerificationManifest,
74
+ manifestCheckForCommand,
75
+ normalizeObservedPath,
76
+ recordObservedModification,
77
+ recordVerificationRun,
78
+ selectVerificationChecks,
79
+ trustVerificationManifest,
80
+ trustedVerificationManifest,
81
+ type VerificationManifestTrust,
82
+ type VerificationStatus,
83
+ } from "../src/verification.ts";
84
+ import {
85
+ formatVerificationExecutions,
86
+ runVerificationChecks,
87
+ type VerificationExecution,
88
+ } from "../src/verification-runner.ts";
68
89
 
69
90
  export interface GateStatus {
70
91
  active: boolean;
@@ -102,6 +123,17 @@ export default function (pi: ExtensionAPI) {
102
123
  // Resettato a ogni session_start; solo automatic lo alimenta e lo usa.
103
124
  let evidenceLedger: EvidenceEntry[] = [];
104
125
  let evidenceSeq = 0;
126
+ // Stato distinto dal grounding: resta utile anche quando il grounding
127
+ // probabilistico è spento. Registra solo edit/write osservati e run di
128
+ // comandi approvati dal manifest del progetto.
129
+ let verificationState = createVerificationState();
130
+ let verificationSeq = 0;
131
+ /** Check bash/powershell iniziati davvero: seq=start, non result order. */
132
+ const verificationCheckStarts = new Map<string, { id: string; command: string; seq: number; source: "bash" | "powershell" }>();
133
+ let verificationCwd: string | undefined;
134
+ // Acquisito solo all'avvio sessione o con un comando utente esplicito.
135
+ // Il modello non può trasformare un edit al manifest in un comando eseguito.
136
+ let verificationManifestTrust: VerificationManifestTrust | undefined;
105
137
  let lastOutput: { tool: string; verdict: OutputVerdict; at: number } | undefined;
106
138
  let lastGate: { report: GateVerdictReport; at: number } | undefined;
107
139
 
@@ -113,16 +145,13 @@ export default function (pi: ExtensionAPI) {
113
145
  let lastGroundingErrorAt = 0;
114
146
  const metrics = createMetrics();
115
147
 
116
- // Twin attivi, uno o più per provider. Nessun overlay finché la config non
117
- // li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
148
+ // Twin attivi, uno o più per provider. Patch in-place sull'oggetto live:
149
+ // nessuna (ri-)registrazione, quindi nessun conflitto di ownership con
150
+ // altre estensioni (es. pi-accounts) e nessuna entry spuria nel registry.
118
151
  interface OverlayState {
119
- /** Provider live catturato prima dell'override (auth inclusa). */
120
- upstream: Provider;
121
152
  twinIds: string[];
122
- /** Il nostro overlay registrato, per accorgersi se viene scalzato. */
123
- provider: Provider;
124
- /** Ripiego dopo un tentativo di ri-registrazione non riuscito. */
125
- reassertFailed?: boolean;
153
+ /** Oggetto live patchato (per unpatch mirato senza registry). */
154
+ live?: Provider;
126
155
  }
127
156
  const overlays = new Map<string, OverlayState>();
128
157
 
@@ -194,12 +223,114 @@ export default function (pi: ExtensionAPI) {
194
223
  return getConfig();
195
224
  }
196
225
 
197
- /** Provider upstream vero: il nostro upstream catturato, o il live dal registry. */
226
+ function captureVerificationManifest(cwd: string): VerificationManifestTrust {
227
+ verificationCwd = cwd;
228
+ verificationManifestTrust = trustVerificationManifest(cwd, config.verification.manifestPath);
229
+ return verificationManifestTrust;
230
+ }
231
+
232
+ function verificationSnapshot(cwd: string): {
233
+ loaded: ReturnType<typeof loadVerificationManifest>;
234
+ status: VerificationStatus;
235
+ } {
236
+ const loaded = trustedVerificationManifest(
237
+ verificationManifestTrust,
238
+ cwd,
239
+ config.verification.manifestPath,
240
+ );
241
+ return { loaded, status: buildVerificationStatus(verificationState, loaded) };
242
+ }
243
+
244
+ function verificationReport(
245
+ status: VerificationStatus,
246
+ action: "passed" | "warned" | "repaired" | "held",
247
+ ): NonNullable<GateVerdictReport["verification"]> {
248
+ return {
249
+ action,
250
+ changedFiles: [...status.changedFiles],
251
+ checks: status.checks.map((item) => ({ id: item.check.id, state: item.state })),
252
+ };
253
+ }
254
+
255
+ function recordVerificationExecution(
256
+ execution: VerificationExecution,
257
+ source: "jev_verify" | "bash" | "powershell" | "manual",
258
+ /** Marker al momento dell'avvio, non al tool_result. */
259
+ startedSeq = ++verificationSeq,
260
+ ): void {
261
+ recordVerificationRun(verificationState, {
262
+ id: execution.id,
263
+ command: execution.command,
264
+ ok: execution.ok,
265
+ ...(execution.exitCode !== undefined ? { exitCode: execution.exitCode } : {}),
266
+ seq: startedSeq,
267
+ source,
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Esegue SOLO check estratti dal manifest locale validato. Questa funzione
273
+ * serve sia al tool del modello sia al comando utente /jev verify.
274
+ */
275
+ async function runProjectVerification(args: {
276
+ cwd: string;
277
+ selection: string | undefined;
278
+ signal?: AbortSignal;
279
+ trusted: boolean;
280
+ }): Promise<VerificationToolRunResult> {
281
+ const settings = config.verification;
282
+ if (!settings.enabled) {
283
+ return {
284
+ ok: false,
285
+ message: "[jev_verify] Verifica progetto disattivata. Attivala con /jev verification warn|repair|hold.",
286
+ results: [],
287
+ };
288
+ }
289
+ if (!args.trusted) {
290
+ return {
291
+ ok: false,
292
+ message: "[jev_verify] Progetto non trusted: nessun comando del manifest è stato eseguito.",
293
+ results: [],
294
+ };
295
+ }
296
+ const { loaded, status } = verificationSnapshot(args.cwd);
297
+ const selected = selectVerificationChecks(
298
+ loaded,
299
+ status,
300
+ args.selection,
301
+ settings.maxCommands,
302
+ );
303
+ if (!selected.ok) {
304
+ return { ok: false, message: `[jev_verify] ${selected.error}`, results: [] };
305
+ }
306
+ // Snapshot dell'ordine PRIMA dell'esecuzione: se un edit concorrente
307
+ // termina mentre il runner è attivo, questi risultati restano
308
+ // conservativamente stale/pending invece di essere falsamente freschi.
309
+ const startedSeq = ++verificationSeq;
310
+ const results = await runVerificationChecks({
311
+ checks: selected.checks,
312
+ cwd: args.cwd,
313
+ settings,
314
+ signal: args.signal,
315
+ });
316
+ for (const result of results) recordVerificationExecution(result, "jev_verify", startedSeq);
317
+ return {
318
+ ok: results.length > 0 && results.every((result) => result.ok),
319
+ message: formatVerificationExecutions(results),
320
+ results,
321
+ };
322
+ }
323
+
324
+ /** Provider live corrente dal registry (sempre fresco, mai snapshot). */
198
325
  function liveUpstreamProvider(
199
326
  registry: ModelRegistry,
200
327
  providerId: string,
201
328
  ): Provider | undefined {
202
- return overlays.get(providerId)?.upstream ?? registry.getProvider(providerId);
329
+ try {
330
+ return registry.getProvider(providerId);
331
+ } catch {
332
+ return undefined;
333
+ }
203
334
  }
204
335
 
205
336
  function snapshotUpstream(
@@ -220,61 +351,50 @@ export default function (pi: ExtensionAPI) {
220
351
  return { provider, model: model as Model<Api> };
221
352
  }
222
353
 
223
- /** Registra (o aggiorna) l'overlay di un provider con i suoi twin. */
224
- function registerOverlay(
225
- providerId: string,
226
- upstream: Provider,
227
- twinIds: string[],
228
- ): void {
229
- const overlay = buildOverlaidProvider({
230
- upstream,
354
+ /** Applica (o aggiorna) la patch gate sull'oggetto provider live. */
355
+ function patchLiveProvider(live: Provider, twinIds: string[]): string[] {
356
+ return patchProvider(live, {
231
357
  twinModelIds: twinIds,
232
358
  getConfig,
233
359
  getReview: getGateReview,
234
360
  onVerdict: recordGateVerdict,
235
- createPostCheck: createGroundingCheck,
236
- });
237
- pi.registerProvider(overlay);
238
- overlays.set(providerId, {
239
- upstream,
240
- twinIds: [...twinIds],
241
- provider: overlay,
361
+ createPostCheck,
242
362
  });
243
363
  }
244
364
 
245
365
  /**
246
- * Un'altra estensione (es. pi-accounts) può ri-registrare lo stesso provider
247
- * id, sostituendo il nostro overlay con un provider che conserva i modelli
248
- * (twin compresi) ma non il gate: l'id `__jev` finirebbe all'API.
249
- * Qui riapplichiamo l'overlay sopra il provider live, preservandone auth e
250
- * config. Se la ri-registrazione non regge, si smette di ritentare.
366
+ * Assicura la patch su tutti i twin attivi contro il provider live
367
+ * corrente. Se un'altra estensione (es. pi-accounts) ha sostituito
368
+ * l'oggetto, il nuovo viene patchato ex-novo; se l'oggetto è lo stesso
369
+ * ma non patchato, viene patchato. Non chiama mai register/unregister:
370
+ * la config del proprietario resta intatta e i suoi controlli di
371
+ * ownership non vedono alcuna "configuration changed".
372
+ * Ritorna i provider (ri-)patchati in questa chiamata.
251
373
  */
252
- function reassertOverlays(registry: ModelRegistry): string[] {
253
- const fixed: string[] = [];
374
+ function ensurePatches(registry: ModelRegistry): string[] {
375
+ // Re-patch sempre (idempotente ed economico): dopo un /reload la factory
376
+ // è nuova ma gli oggetti restano patchati con le closure vecchie, e la
377
+ // lista twin potrebbe essere cambiata. Si segnalano solo i provider che
378
+ // non erano patchati, per non spammare entry diagnostiche a ogni hook.
379
+ const patched: string[] = [];
254
380
  for (const [providerId, state] of [...overlays]) {
255
- if (state.reassertFailed) continue;
256
381
  let live: Provider | undefined;
257
382
  try {
258
383
  live = registry.getProvider(providerId);
259
384
  } catch {
260
385
  continue;
261
386
  }
262
- if (!live || live === state.provider) continue;
387
+ if (!live) continue;
388
+ const was = isPatched(live);
263
389
  try {
264
- registerOverlay(providerId, live, state.twinIds);
265
- const applied = overlays.get(providerId);
266
- if (applied && registry.getProvider(providerId) === applied.provider) {
267
- fixed.push(providerId);
268
- } else if (applied) {
269
- // Il registry restituisce un altro oggetto: non insistere.
270
- applied.reassertFailed = true;
271
- }
390
+ patchLiveProvider(live, state.twinIds);
391
+ state.live = live;
392
+ if (!was) patched.push(providerId);
272
393
  } catch {
273
- const current = overlays.get(providerId);
274
- if (current) current.reassertFailed = true;
394
+ // Riprova al prossimo hook; mai bloccare il flusso.
275
395
  }
276
396
  }
277
- return fixed;
397
+ return patched;
278
398
  }
279
399
 
280
400
  /**
@@ -294,11 +414,13 @@ export default function (pi: ExtensionAPI) {
294
414
  }
295
415
 
296
416
  try {
297
- // Provider non più gemellati: unregister → builtin ripristinato.
417
+ // Provider non più gemellati: ripristina i metodi originali (l'oggetto
418
+ // resta del proprietario: nessuna unregister che spiazzi gli altri).
298
419
  for (const providerId of [...overlays.keys()]) {
299
420
  if (!byProvider.has(providerId)) {
300
421
  try {
301
- pi.unregisterProvider(providerId);
422
+ const live = registry.getProvider(providerId);
423
+ if (live) unpatchProvider(live);
302
424
  } catch {
303
425
  // ignora
304
426
  }
@@ -307,15 +429,23 @@ export default function (pi: ExtensionAPI) {
307
429
  }
308
430
 
309
431
  for (const [providerId, twinIds] of byProvider) {
310
- // Cattura il live PRIMA dell'override (dopo, getProvider dà l'overlay).
311
- const existing = overlays.get(providerId)?.upstream;
312
- const live = existing ?? registry.getProvider(providerId);
432
+ // Sempre il live corrente: con la patch non esistono chain di
433
+ // upstream stale da preservare.
434
+ const live = liveUpstreamProvider(registry, providerId);
313
435
  if (!live) return { ok: false, error: `Provider non trovato: ${providerId}` };
314
436
  for (const model of twinIds) {
315
437
  const has = live.getModels().some((m) => m.id === model && !isTwinId(m.id));
316
438
  if (!has) return { ok: false, error: `Modello non trovato: ${providerId}/${model}` };
317
439
  }
318
- registerOverlay(providerId, live, twinIds);
440
+ try {
441
+ patchLiveProvider(live, twinIds);
442
+ } catch (error) {
443
+ return {
444
+ ok: false,
445
+ error: error instanceof Error ? error.message : String(error),
446
+ };
447
+ }
448
+ overlays.set(providerId, { twinIds: [...twinIds], live });
319
449
  }
320
450
 
321
451
  config = withTwinRefs(config, refs);
@@ -383,21 +513,20 @@ export default function (pi: ExtensionAPI) {
383
513
  };
384
514
  }
385
515
 
386
- /** Rimuove tutti gli overlay (i builtin tornano da soli). */
516
+ /** Rimuove tutte le patch (i metodi originali tornano da soli). */
387
517
  function removeGuarded(): { ok: boolean; error?: string; removed?: number } {
388
518
  if (overlays.size === 0) return { ok: false, error: "nessun guarded attivo" };
389
519
  const removed = overlays.size;
390
- for (const providerId of [...overlays.keys()]) {
520
+ // Nessuna unregister: gli oggetti restano del proprietario, si
521
+ // ripristinano solo i metodi sugli oggetti che abbiamo patchato.
522
+ for (const state of [...overlays.values()]) {
391
523
  try {
392
- pi.unregisterProvider(providerId);
393
- } catch (error) {
394
- return {
395
- ok: false,
396
- error: error instanceof Error ? error.message : String(error),
397
- };
524
+ if (state.live) unpatchProvider(state.live);
525
+ } catch {
526
+ // ignora
398
527
  }
399
- overlays.delete(providerId);
400
528
  }
529
+ overlays.clear();
401
530
  return { ok: true, removed };
402
531
  }
403
532
 
@@ -438,6 +567,8 @@ export default function (pi: ExtensionAPI) {
438
567
  keyPresent: () => resolveBackend(getConfig()).apiKeyPresent,
439
568
  });
440
569
 
570
+ registerVerificationTool(pi, { run: runProjectVerification });
571
+
441
572
  registerJevCommand(pi, {
442
573
  config: getConfig,
443
574
  setMode: (mode) => {
@@ -479,63 +610,66 @@ export default function (pi: ExtensionAPI) {
479
610
  saveConfig(next);
480
611
  return refresh();
481
612
  },
613
+ verificationStatus: (cwd) => {
614
+ const { loaded, status } = verificationSnapshot(cwd);
615
+ return {
616
+ enabled: config.verification.enabled,
617
+ mode: config.verification.mode,
618
+ manifestPath: config.verification.manifestPath,
619
+ ...(loaded.ok ? { manifestLoaded: true } : { manifestLoaded: false, manifestError: loaded.error }),
620
+ changedFiles: status.changedFiles,
621
+ checks: status.checks.map((item) => ({ id: item.check.id, state: item.state })),
622
+ };
623
+ },
624
+ setVerification: (mode) => {
625
+ const next = configSnapshot(config);
626
+ if (mode === "off") {
627
+ next.verification.enabled = false;
628
+ } else {
629
+ next.verification.enabled = true;
630
+ next.verification.mode = mode;
631
+ }
632
+ saveConfig(next);
633
+ return refresh();
634
+ },
635
+ reloadVerificationManifest: (cwd) => {
636
+ const trusted = captureVerificationManifest(cwd);
637
+ return trusted.ok
638
+ ? { ok: true, path: trusted.path }
639
+ : { ok: false, error: trusted.error };
640
+ },
641
+ runVerification: ({ cwd, selection, signal, trusted }) =>
642
+ runProjectVerification({ cwd, selection, signal, trusted }),
482
643
  getLastGate: () => lastGate,
483
644
  });
484
645
 
485
- // Seed all'avvio: pi ripristina il modello di sessione PRIMA di
486
- // session_start, quindi un twin persistito non esisterebbe ancora e
487
- // cadrebbe su "Could not restore model ...__jev". Pre-registriamo un
488
- // overlay per tutti i twin salvati, anche in on-demand: il mode della
489
- // sessione non è disponibile prima di session_start. Un alias non attiva il gate.
490
- const bootAuto = process.env.JEV_AUTO_UPSTREAM?.trim();
491
- const autoOff = bootAuto === "0" || bootAuto?.toLowerCase() === "off";
492
- let seedRefs: TwinRef[] = [];
493
- if (bootAuto && !autoOff) {
494
- const parsed = parseUpstreamRef([bootAuto]);
495
- if (!("error" in parsed)) seedRefs = [parsed];
496
- } else if (!bootAuto) {
497
- seedRefs = twinRefs(config);
498
- }
499
- if (seedRefs.length > 0) {
500
- // Raggruppa per provider: un overlay per provider con tutti i suoi twin.
501
- const byProvider = new Map<string, string[]>();
502
- for (const ref of seedRefs) {
503
- const list = byProvider.get(ref.provider) ?? [];
504
- if (!list.includes(ref.model)) list.push(ref.model);
505
- byProvider.set(ref.provider, list);
506
- }
507
- for (const [providerId, twinIds] of byProvider) {
508
- try {
509
- // providers/all è mappato dagli alias di pi; il subpath del singolo
510
- // provider non lo è e fallirebbe a load (solo installazioni npm).
511
- const base = builtinProviders().find((p) => p.id === providerId);
512
- if (!base) throw new Error(`provider ${providerId} not in builtins`);
513
- // Il catalogo builtin può non avere i modelli salvati: provider
514
- // dinamici (es. openai-codex) li tengono nel models-store, altri
515
- // (deepseek-flash su versioni vecchie) in una definizione statica.
516
- const extra = [
517
- ...(providerId === "deepseek" ? [STATIC_DEEPSEEK_FLASH] : []),
518
- ...readStoreModels(providerId),
519
- ];
520
- const seedUpstream: Provider = {
521
- ...base,
522
- getModels: () => mergeModelsById(base.getModels(), extra),
523
- };
524
- const known = new Set(seedUpstream.getModels().map((m) => m.id));
525
- const usable = twinIds.filter((model) => known.has(model));
526
- if (usable.length === 0) continue;
527
- registerOverlay(providerId, seedUpstream, usable);
528
- } catch {
529
- // Provider non gemellabile all'avvio: session_start riproverà dal registry.
530
- }
531
- }
532
- }
646
+ // NOTA: nessun seed di boot con oggetti fake. La patch lavora SOLO
647
+ // sull'oggetto live del registry: registrare un oggetto nostro
648
+ // cancellerebbe la config del proprietario (es. pi-accounts vedrebbe
649
+ // "configuration changed" e abortirebbe i turni). I twin salvati vengono
650
+ // patchati a session_start (ogni modalità) e il twin automatic viene
651
+ // riselezionato lì; un twin persistito non ripristinabile cade sul modello
652
+ // di default e startSession lo riseleziona.
533
653
 
534
654
  async function startSession(ctx: ExtensionContext): Promise<void> {
535
655
  restoreMode(ctx);
536
656
  // Nuova sessione = nuova evidenza: il ledger non attraversa le sessioni.
537
657
  evidenceLedger = [];
538
658
  evidenceSeq = 0;
659
+ verificationState = createVerificationState();
660
+ verificationSeq = 0;
661
+ verificationCheckStarts.clear();
662
+ captureVerificationManifest(ctx.cwd);
663
+ // Patch dei twin salvati in OGNI modalità (nessun setModel qui): senza,
664
+ // un twin persistito non esisterebbe nel catalogo. Silenzioso: la
665
+ // diagnostica resta agli hook di turno.
666
+ try {
667
+ if (twinRefs(config).length > 0) {
668
+ applyTwins(twinRefs(config), ctx.modelRegistry, false);
669
+ }
670
+ } catch {
671
+ // session_start non deve mai fallire per il seed.
672
+ }
539
673
  const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
540
674
  if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
541
675
  try {
@@ -615,9 +749,9 @@ export default function (pi: ExtensionAPI) {
615
749
  try {
616
750
  const refs = twinRefs(config);
617
751
  if (refs.length === 0) return;
618
- // Ricuci gli overlay noti scalzati contro il provider live corrente.
619
- reassertOverlays(registry);
620
- // Registra ex-novo i provider gemellati senza overlay (seed precoce).
752
+ // Ricuci le patch su oggetti sostituiti dal proprietario.
753
+ ensurePatches(registry);
754
+ // Patch ex-novo i provider gemellati senza stato (seed precoce).
621
755
  const byProvider = new Map<string, string[]>();
622
756
  for (const ref of refs) {
623
757
  if (overlays.get(ref.provider)?.twinIds.includes(ref.model)) continue;
@@ -632,13 +766,17 @@ export default function (pi: ExtensionAPI) {
632
766
  } catch {
633
767
  continue;
634
768
  }
635
- if (!live) continue;
769
+ if (!live || isPatched(live)) {
770
+ if (live) overlays.set(providerId, { twinIds: [...twinIds], live });
771
+ continue;
772
+ }
636
773
  const models = live.getModels();
637
774
  if (!twinIds.every((id) => models.some((m) => m.id === id && !isTwinId(m.id)))) {
638
775
  continue;
639
776
  }
640
777
  try {
641
- registerOverlay(providerId, live, twinIds);
778
+ patchLiveProvider(live, twinIds);
779
+ overlays.set(providerId, { twinIds: [...twinIds], live });
642
780
  } catch {
643
781
  // Riprova al prossimo hook; mai bloccare l'input.
644
782
  }
@@ -663,9 +801,21 @@ export default function (pi: ExtensionAPI) {
663
801
  }
664
802
  }
665
803
 
804
+ // Ultima rete prima della generation: dopo eventuali replace altrui nel
805
+ // before_agent_start, ripatcha in silenzio (mai register/unregister).
806
+ pi.on("turn_start", async (_event, ctx) => {
807
+ try {
808
+ verificationCwd = ctx.cwd;
809
+ ensurePatches(ctx.modelRegistry);
810
+ } catch {
811
+ // Mai bloccare il turno.
812
+ }
813
+ });
814
+
666
815
  pi.on("before_agent_start", async (_event, ctx) => {
667
816
  try {
668
- noteReassert(reassertOverlays(ctx.modelRegistry));
817
+ verificationCwd = ctx.cwd;
818
+ noteReassert(ensurePatches(ctx.modelRegistry));
669
819
  } catch {
670
820
  // mai bloccare il turno
671
821
  }
@@ -719,8 +869,8 @@ export default function (pi: ExtensionAPI) {
719
869
  // su un modello non guarded (revert immediato al twin).
720
870
  pi.on("model_select", async (event, ctx) => {
721
871
  try {
722
- // Una selezione account/provider può aver rimpiazzato il nostro overlay.
723
- noteReassert(reassertOverlays(ctx.modelRegistry));
872
+ // Una selezione account/provider può aver sostituito l'oggetto: ripatcha.
873
+ noteReassert(ensurePatches(ctx.modelRegistry));
724
874
  if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
725
875
  if (overlays.size === 0) return;
726
876
  if (isGuardedModel(event.model.provider, event.model.id)) return;
@@ -769,6 +919,38 @@ export default function (pi: ExtensionAPI) {
769
919
  }
770
920
  });
771
921
 
922
+ /**
923
+ * Marca l'INIZIO reale di un check shell già autorizzato. Non basta
924
+ * l'ordine dei tool_result: bash e edit possono essere concorrenti e un
925
+ * test avviato prima di un edit non deve risultare fresco dopo di esso.
926
+ */
927
+ pi.on("tool_execution_start", async (event, ctx) => {
928
+ try {
929
+ if (sessionMode !== "automatic") return;
930
+ if (event.toolName !== "bash" && event.toolName !== "powershell") return;
931
+ verificationCwd = ctx.cwd;
932
+ const { loaded } = verificationSnapshot(ctx.cwd);
933
+ if (!loaded.ok) return;
934
+ const args = event.args as { command?: unknown };
935
+ const check = manifestCheckForCommand(loaded.manifest, args.command);
936
+ if (!check) return;
937
+ verificationCheckStarts.set(event.toolCallId, {
938
+ id: check.id,
939
+ command: check.command,
940
+ seq: ++verificationSeq,
941
+ source: event.toolName,
942
+ });
943
+ // Difesa memoria contro toolcall senza result (abort esterno).
944
+ while (verificationCheckStarts.size > 100) {
945
+ const oldest = verificationCheckStarts.keys().next().value;
946
+ if (oldest === undefined) break;
947
+ verificationCheckStarts.delete(oldest);
948
+ }
949
+ } catch {
950
+ // Nessun impatto sull'esecuzione del tool.
951
+ }
952
+ });
953
+
772
954
  // Policy pre-esecuzione tool (solo automatic).
773
955
  pi.on("tool_call", async (event, ctx) => {
774
956
  try {
@@ -800,6 +982,20 @@ export default function (pi: ExtensionAPI) {
800
982
 
801
983
  if (event.toolName === "write" || event.toolName === "edit") {
802
984
  const input = event.input as { path?: unknown };
985
+ if (isVerificationManifestPath(input.path, ctx.cwd, config.verification.manifestPath)) {
986
+ pi.appendEntry("jev-tool-block", {
987
+ tool: event.toolName,
988
+ ruleId: "verification-manifest-immutable",
989
+ revision: TOOL_POLICY_REVISION,
990
+ });
991
+ if (ctx.hasUI) {
992
+ ctx.ui.notify("Jev tool policy: il modello non può modificare il manifest che autorizza i suoi check.", "warning");
993
+ }
994
+ return {
995
+ block: true,
996
+ reason: "Bloccato da jev tool policy (verification-manifest-immutable): modifica il manifest manualmente e poi usa /jev verification reload.",
997
+ };
998
+ }
803
999
  const verdict = checkFilePath(input.path);
804
1000
  if (verdict.blocked) {
805
1001
  pi.appendEntry("jev-tool-block", {
@@ -892,6 +1088,60 @@ export default function (pi: ExtensionAPI) {
892
1088
  return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
893
1089
  }
894
1090
 
1091
+ /**
1092
+ * Registra modifiche e risultati dei check configurati. È deliberatamente
1093
+ * separato dal grounding: il footer deterministico deve funzionare anche
1094
+ * senza una chiamata Jev aggiuntiva. I bash nativi contano soltanto se il
1095
+ * loro comando coincide ESATTAMENTE con un id del manifest.
1096
+ */
1097
+ function recordVerificationResult(event: {
1098
+ toolCallId?: string;
1099
+ toolName: string;
1100
+ input: unknown;
1101
+ content: unknown;
1102
+ isError: boolean;
1103
+ }, cwd: string): void {
1104
+ try {
1105
+ // Raccolta locale senza costo anche se il footer è spento: attivarlo a
1106
+ // metà sessione conserva gli edit/write già osservati. Pubblicazione,
1107
+ // repair ed esecuzione restano rigorosamente opt-in in config.
1108
+ if (sessionMode !== "automatic") return;
1109
+ verificationCwd = cwd;
1110
+ if (event.toolName === "edit" || event.toolName === "write") {
1111
+ if (event.isError) return;
1112
+ const input = event.input as { path?: unknown };
1113
+ const path = normalizeObservedPath(input.path, cwd);
1114
+ if (path) recordObservedModification(verificationState, path, ++verificationSeq);
1115
+ return;
1116
+ }
1117
+ if (event.toolName !== "bash" && event.toolName !== "powershell") return;
1118
+ const input = event.input as { command?: unknown };
1119
+ const started = event.toolCallId
1120
+ ? verificationCheckStarts.get(event.toolCallId)
1121
+ : undefined;
1122
+ if (event.toolCallId) verificationCheckStarts.delete(event.toolCallId);
1123
+ const { loaded } = verificationSnapshot(cwd);
1124
+ if (!loaded.ok) return;
1125
+ const check = manifestCheckForCommand(loaded.manifest, input.command);
1126
+ if (!check) return;
1127
+ const output = toolResultText(event.content);
1128
+ const exitCode = parseExitCode(output);
1129
+ recordVerificationRun(verificationState, {
1130
+ id: check.id,
1131
+ command: check.command,
1132
+ ok: !event.isError,
1133
+ ...(exitCode !== undefined ? { exitCode } : {}),
1134
+ // In runtime il marker esiste perché tool_execution_start precede il
1135
+ // result. Fallback conservativo per adapter/test legacy senza evento:
1136
+ // allocato qui, mai riusa un pass precedente.
1137
+ seq: started?.seq ?? ++verificationSeq,
1138
+ source: event.toolName,
1139
+ });
1140
+ } catch {
1141
+ // Un footer non deve mai cambiare il risultato di un tool.
1142
+ }
1143
+ }
1144
+
895
1145
  /** Registra un tool_result nel ledger evidenza (solo automatic, mai bloccare). */
896
1146
  function recordGroundingResult(event: {
897
1147
  toolName: string;
@@ -1040,12 +1290,127 @@ export default function (pi: ExtensionAPI) {
1040
1290
  }
1041
1291
  }
1042
1292
 
1293
+ /**
1294
+ * Post-check deterministico del codice: non dichiara il codice corretto,
1295
+ * ma allega il fatto osservato (edit/write + check manifest). In repair il
1296
+ * modello ha un solo giro per invocare `jev_verify` e correggere l'errore;
1297
+ * in hold una failure concreta non viene pubblicata.
1298
+ */
1299
+ function createVerificationCheck(): PostPublishCheckFn | undefined {
1300
+ try {
1301
+ if (sessionMode !== "automatic" || !config.verification.enabled) return undefined;
1302
+ const cwd = verificationCwd;
1303
+ if (!cwd) return undefined;
1304
+ const mode = config.verification.mode;
1305
+ const maxRepairs = config.verification.maxRepairs;
1306
+ let repairsUsed = 0;
1307
+ return async () => {
1308
+ const { status } = verificationSnapshot(cwd);
1309
+ if (status.changedFiles.length === 0) return { action: "publish" };
1310
+ const decision = decideVerificationAction({
1311
+ status,
1312
+ mode,
1313
+ repairsUsed,
1314
+ maxRepairs,
1315
+ });
1316
+ if (decision.action === "regenerate") {
1317
+ repairsUsed++;
1318
+ try {
1319
+ pi.appendEntry("jev-verification-repair", {
1320
+ files: status.changedFiles,
1321
+ checks: status.checks.map((item) => ({ id: item.check.id, state: item.state })),
1322
+ });
1323
+ } catch {
1324
+ // solo diagnostica
1325
+ }
1326
+ return {
1327
+ action: "regenerate",
1328
+ feedback: decision.feedback,
1329
+ verification: verificationReport(status, "repaired"),
1330
+ };
1331
+ }
1332
+ if (decision.action === "hold") {
1333
+ try {
1334
+ pi.appendEntry("jev-verification-hold", {
1335
+ files: status.changedFiles,
1336
+ checks: status.checks.map((item) => ({ id: item.check.id, state: item.state })),
1337
+ });
1338
+ } catch {
1339
+ // solo diagnostica
1340
+ }
1341
+ return {
1342
+ action: "hold",
1343
+ holdText: decision.holdText,
1344
+ verification: verificationReport(status, "held"),
1345
+ };
1346
+ }
1347
+ // "repaired" solo se il giro di repair è arrivato davvero a check
1348
+ // freschi tutti passati; altrimenti resta un warning, mai un successo
1349
+ // inventato solo perché il modello ha ricevuto feedback.
1350
+ const action = status.allPassed
1351
+ ? repairsUsed > 0 ? "repaired" : "passed"
1352
+ : "warned";
1353
+ try {
1354
+ pi.appendEntry("jev-verification-status", {
1355
+ action,
1356
+ files: status.changedFiles,
1357
+ checks: status.checks.map((item) => ({ id: item.check.id, state: item.state })),
1358
+ ...(status.manifestError ? { manifestError: status.manifestError } : {}),
1359
+ });
1360
+ } catch {
1361
+ // solo diagnostica
1362
+ }
1363
+ return {
1364
+ action: "publish",
1365
+ ...(decision.notice ? { notice: decision.notice } : {}),
1366
+ verification: verificationReport(status, action),
1367
+ };
1368
+ };
1369
+ } catch {
1370
+ // Fail-open: errore del footer non deve bloccare il gate.
1371
+ return undefined;
1372
+ }
1373
+ }
1374
+
1375
+ /** Compone grounding probabilistico e verifica deterministica senza perderne le note. */
1376
+ function createPostCheck(): PostPublishCheckFn | undefined {
1377
+ const grounding = createGroundingCheck();
1378
+ const verification = createVerificationCheck();
1379
+ if (!grounding && !verification) return undefined;
1380
+ return async (args) => {
1381
+ let groundingResult: PostPublishCheckResult | undefined;
1382
+ if (grounding) {
1383
+ groundingResult = await grounding(args);
1384
+ if (groundingResult.action !== "publish") return groundingResult;
1385
+ }
1386
+ let verificationResult: PostPublishCheckResult | undefined;
1387
+ if (verification) {
1388
+ verificationResult = await verification(args);
1389
+ if (verificationResult.action !== "publish") {
1390
+ return {
1391
+ ...verificationResult,
1392
+ ...(groundingResult?.grounding ? { grounding: groundingResult.grounding } : {}),
1393
+ };
1394
+ }
1395
+ }
1396
+ const notices = [groundingResult?.notice, verificationResult?.notice]
1397
+ .filter((notice): notice is string => Boolean(notice));
1398
+ return {
1399
+ action: "publish",
1400
+ ...(notices.length > 0 ? { notice: notices.join("") } : {}),
1401
+ ...(groundingResult?.grounding ? { grounding: groundingResult.grounding } : {}),
1402
+ ...(verificationResult?.verification ? { verification: verificationResult.verification } : {}),
1403
+ };
1404
+ };
1405
+ }
1406
+
1043
1407
  // Giudice output (solo automatic, fail-open): dopo l'esecuzione cerca
1044
1408
  // secret nel testo e classifica i fallimenti. Non blocca mai: allega
1045
1409
  // una riga al risultato che il modello legge.
1046
1410
  pi.on("tool_result", async (event, ctx) => {
1047
1411
  try {
1048
1412
  // Ledger prima di tutto: vale anche con outputJudge disattivato.
1413
+ recordVerificationResult(event, ctx.cwd);
1049
1414
  recordGroundingResult(event);
1050
1415
  if (sessionMode !== "automatic") return;
1051
1416
  if (!config.outputJudge.enabled) return;
@@ -1113,9 +1478,9 @@ export default function (pi: ExtensionAPI) {
1113
1478
  pi.on("message_end", async (event, ctx) => {
1114
1479
  try {
1115
1480
  if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
1116
- // Dopo la richiesta: se qualcuno ci ha scalzati, rimetti l'overlay
1481
+ // Dopo la richiesta: se qualcuno ha sostituito l'oggetto, ripatcha
1117
1482
  // per il turno successivo (il payload resta già normalizzato).
1118
- noteReassert(reassertOverlays(ctx.modelRegistry));
1483
+ noteReassert(ensurePatches(ctx.modelRegistry));
1119
1484
  if (sessionMode !== "automatic") return;
1120
1485
  if (event.message.role !== "assistant") return;
1121
1486