pi-jev-guard 0.3.0 → 0.3.2

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 CHANGED
@@ -116,10 +116,16 @@ il guarded nelle sessioni senza uno stato Jev salvato.
116
116
  Un hold `[JEV — OUTPUT NON PUBBLICATO]` interrompe intenzionalmente il turno:
117
117
  controlla `/jev last`. Non viene aggirato con un proseguimento automatico.
118
118
 
119
- Verdetto **incerto** (`review`): il gate tenta prima una riga riparazione privata
119
+ Verdetto **incerto** (`review`): il gate tenta prima una riparazione privata
120
120
  (`automatic.repairOnReview`, default `true`), poi applica la policy configurata:
121
121
  `policy.onUncertain` e `policy.onUnavailable` (`"hold"` default, oppure `"pass"`).
122
122
  Con `"pass"` il testo viene pubblicato con una nota di trasparenza sul verdetto.
123
+
124
+ Se un'altra estensione ri-registra lo stesso provider (caso reale: `pi-accounts`
125
+ al cambio account), l'overlay viene ricostruito sopra il provider live prima di
126
+ ogni turno; in più il gate toglie il suffisso `__jev` da qualunque payload,
127
+ registrando `jev-gate-bypass` e avvisando in TUI, perché quel turno non è
128
+ passato dalla verifica.
123
129
  Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
124
130
  la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
125
131
 
@@ -153,6 +159,19 @@ avvisa e dice di riferirsi al valore per nome; se è un errore, allega il
153
159
  consiglio per la classe (`transient` → riprova, `code_bug` → fixa il codice…).
154
160
  Non blocca mai; `/jev output` mostra l'ultimo verdetto.
155
161
 
162
+ Sempre in `automatic`, il **grounding** (opt-in, default spento) confronta le
163
+ affermazioni sulla sessione — "ho letto il file", "i test passano", "ho
164
+ eseguito X" — con un registro evidenza costruito dai `tool_result`
165
+ (comandi, test, letture, modifiche; redatto, max 30 voci, solo sessione
166
+ corrente). Due domande Jev strette: contraddizione con l'evidenza e
167
+ affermazione non supportata. Senza evidenza non si giudica: "senza
168
+ evidenza" non significa "falso". Una modifica riuscita invalida i test
169
+ precedenti (stale). Tre modalità (`/jev grounding off|warn|repair|hold`):
170
+ `warn` pubblica con nota, `repair` rigenera una volta, `hold` trattiene solo
171
+ le contraddizioni ad alta confidenza. Fail-open: se il verificatore non
172
+ risponde, il testo passa senza nota. `/jev last` mostra anche l'esito
173
+ grounding (`warned`/`repaired`/`held`).
174
+
156
175
  ## Configurazione
157
176
 
158
177
  `~/.pi/agent/jev-config.json` (o `$JEV_CONFIG`). Env: `JEV_MODE`, `JEV_BACKEND`
@@ -204,3 +223,6 @@ echo '{"requirements":"...","candidate":"..."}' | npm run jev-check
204
223
  - Soglie calibrate su 20 casi (`docs/calibration-2026-09-19.md`): buone per
205
224
  iniziare, da rivalutare con corpus più ampio.
206
225
  - Jev è un giudice probabilistico, non un verificatore formale.
226
+ - Il grounding controlla solo affermazioni verificabili sulla sessione
227
+ (comandi, file, test, modifiche): niente fact-check di fatti esterni,
228
+ e con contesti lunghi l'evidenza oltre budget viene elisa.
@@ -80,5 +80,17 @@
80
80
  "minConfidence": 0.5,
81
81
  "cacheSeconds": 120,
82
82
  "blockWithoutUI": false
83
+ },
84
+ "grounding": {
85
+ "enabled": false,
86
+ "mode": "warn",
87
+ "contradictionThreshold": 0.85,
88
+ "unsupportedThreshold": 0.7,
89
+ "maxRepairs": 1,
90
+ "maxEntries": 30,
91
+ "evidenceChars": 2000,
92
+ "excerptChars": 300,
93
+ "cacheSeconds": 120,
94
+ "invalidateTestsOnEdit": true
83
95
  }
84
96
  }
@@ -51,6 +51,20 @@ import {
51
51
  lastUserText,
52
52
  type ExfilVerdict,
53
53
  } from "../src/exfil.ts";
54
+ import {
55
+ buildEvidenceDigest,
56
+ buildGroundingHoldText,
57
+ buildGroundingState,
58
+ decideGroundingAction,
59
+ evaluateGrounding,
60
+ groundingKey,
61
+ recordEvidence,
62
+ recordToolResult,
63
+ GROUNDING_QUESTIONS,
64
+ type EvidenceEntry,
65
+ type GroundingEvaluation,
66
+ } from "../src/grounding.ts";
67
+ import type { PostPublishCheckFn } from "../src/automatic/guardian.ts";
54
68
 
55
69
  export interface GateStatus {
56
70
  active: boolean;
@@ -83,6 +97,11 @@ export default function (pi: ExtensionAPI) {
83
97
  let typedHandle = createTypedAsk(configSnapshot(config));
84
98
  let outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
85
99
  let exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
100
+ let groundingCache = createVerdictCache<GroundingEvaluation>({ ttlSeconds: config.grounding.cacheSeconds });
101
+ // Ledger evidenza per sessione: effetti osservati (tool_result), redatti.
102
+ // Resettato a ogni session_start; solo automatic lo alimenta e lo usa.
103
+ let evidenceLedger: EvidenceEntry[] = [];
104
+ let evidenceSeq = 0;
86
105
  let lastOutput: { tool: string; verdict: OutputVerdict; at: number } | undefined;
87
106
  let lastGate: { report: GateVerdictReport; at: number } | undefined;
88
107
 
@@ -91,13 +110,19 @@ export default function (pi: ExtensionAPI) {
91
110
  }
92
111
  let lastOutputErrorAt = 0;
93
112
  let lastExfilErrorAt = 0;
113
+ let lastGroundingErrorAt = 0;
94
114
  const metrics = createMetrics();
95
115
 
96
116
  // Twin attivi, uno o più per provider. Nessun overlay finché la config non
97
117
  // li attiva (automatic) o l'utente non usa /jev upstream (opt-in).
98
118
  interface OverlayState {
119
+ /** Provider live catturato prima dell'override (auth inclusa). */
99
120
  upstream: Provider;
100
121
  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;
101
126
  }
102
127
  const overlays = new Map<string, OverlayState>();
103
128
 
@@ -165,6 +190,7 @@ export default function (pi: ExtensionAPI) {
165
190
  typedHandle = createTypedAsk(configSnapshot(config));
166
191
  outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
167
192
  exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
193
+ groundingCache = createVerdictCache<GroundingEvaluation>({ ttlSeconds: config.grounding.cacheSeconds });
168
194
  return getConfig();
169
195
  }
170
196
 
@@ -206,9 +232,49 @@ export default function (pi: ExtensionAPI) {
206
232
  getConfig,
207
233
  getReview: getGateReview,
208
234
  onVerdict: recordGateVerdict,
235
+ createPostCheck: createGroundingCheck,
209
236
  });
210
237
  pi.registerProvider(overlay);
211
- overlays.set(providerId, { upstream, twinIds: [...twinIds] });
238
+ overlays.set(providerId, {
239
+ upstream,
240
+ twinIds: [...twinIds],
241
+ provider: overlay,
242
+ });
243
+ }
244
+
245
+ /**
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.
251
+ */
252
+ function reassertOverlays(registry: ModelRegistry): string[] {
253
+ const fixed: string[] = [];
254
+ for (const [providerId, state] of [...overlays]) {
255
+ if (state.reassertFailed) continue;
256
+ let live: Provider | undefined;
257
+ try {
258
+ live = registry.getProvider(providerId);
259
+ } catch {
260
+ continue;
261
+ }
262
+ if (!live || live === state.provider) continue;
263
+ 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
+ }
272
+ } catch {
273
+ const current = overlays.get(providerId);
274
+ if (current) current.reassertFailed = true;
275
+ }
276
+ }
277
+ return fixed;
212
278
  }
213
279
 
214
280
  /**
@@ -396,6 +462,23 @@ export default function (pi: ExtensionAPI) {
396
462
  removeTwin,
397
463
  switchToGuarded,
398
464
  getLastOutput: () => lastOutput,
465
+ groundingStatus: () => ({
466
+ enabled: config.grounding.enabled,
467
+ mode: config.grounding.mode,
468
+ entries: evidenceLedger.length,
469
+ hasStaleTests: evidenceLedger.some((e) => e.kind === "test" && e.stale),
470
+ }),
471
+ setGrounding: (mode) => {
472
+ const next = configSnapshot(config);
473
+ if (mode === "off") {
474
+ next.grounding.enabled = false;
475
+ } else {
476
+ next.grounding.enabled = true;
477
+ next.grounding.mode = mode;
478
+ }
479
+ saveConfig(next);
480
+ return refresh();
481
+ },
399
482
  getLastGate: () => lastGate,
400
483
  });
401
484
 
@@ -450,6 +533,9 @@ export default function (pi: ExtensionAPI) {
450
533
 
451
534
  async function startSession(ctx: ExtensionContext): Promise<void> {
452
535
  restoreMode(ctx);
536
+ // Nuova sessione = nuova evidenza: il ledger non attraversa le sessioni.
537
+ evidenceLedger = [];
538
+ evidenceSeq = 0;
453
539
  const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
454
540
  if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
455
541
  try {
@@ -513,8 +599,62 @@ export default function (pi: ExtensionAPI) {
513
599
  });
514
600
  pi.on("session_tree", async (_event, ctx) => { await startSession(ctx); });
515
601
 
516
- // Normalize persisted aliases even after /jev off removed their overlays.
517
- // The event is a context copy: session history remains unchanged.
602
+ // Prima di ogni turno: se un'altra estensione ci ha scalzati dal provider,
603
+ // rimettiamo l'overlay (altrimenti l'id __jev arriverebbe all'API).
604
+ function noteReassert(fixed: string[]): void {
605
+ if (fixed.length === 0) return;
606
+ try {
607
+ pi.appendEntry("jev-overlay-reassert", { providers: fixed });
608
+ } catch {
609
+ // solo diagnostica
610
+ }
611
+ }
612
+
613
+ pi.on("before_agent_start", async (_event, ctx) => {
614
+ try {
615
+ noteReassert(reassertOverlays(ctx.modelRegistry));
616
+ } catch {
617
+ // mai bloccare il turno
618
+ }
619
+ });
620
+
621
+ // Ultima rete: se per qualunque motivo il payload esce con l'id sintetico,
622
+ // togliamo il suffisso (il modello originale è quello giusto) e registriamo
623
+ // che quel turno NON è passato dal gate.
624
+ let lastBypassNoticeAt = 0;
625
+ pi.on("before_provider_request", async (event, ctx) => {
626
+ try {
627
+ const payload = event.payload as { model?: unknown } | undefined;
628
+ if (!payload || typeof payload.model !== "string" || !isTwinId(payload.model)) {
629
+ return undefined;
630
+ }
631
+ const requested = payload.model;
632
+ payload.model = untwinId(requested);
633
+ try {
634
+ pi.appendEntry("jev-gate-bypass", {
635
+ requested,
636
+ sent: payload.model,
637
+ provider: ctx.model?.provider,
638
+ });
639
+ } catch {
640
+ // solo diagnostica
641
+ }
642
+ const now = Date.now();
643
+ if (ctx.hasUI && now - lastBypassNoticeAt >= 60_000) {
644
+ lastBypassNoticeAt = now;
645
+ ctx.ui.notify(
646
+ `Jev: richiesta non passata dal gate (${requested} → ${payload.model}). Verifica disattivata per questo turno; riprova per riattivarla.`,
647
+ "warning",
648
+ );
649
+ }
650
+ return payload;
651
+ } catch {
652
+ return undefined;
653
+ }
654
+ });
655
+
656
+ // Normalizza gli alias persistiti anche dopo /jev off (overlay rimossi).
657
+ // L'evento context è una copia: la cronologia salvata resta invariata.
518
658
  pi.on("context", async (event) => ({
519
659
  messages: event.messages.map((message) =>
520
660
  message.role === "assistant" && isTwinId(message.model)
@@ -526,6 +666,8 @@ export default function (pi: ExtensionAPI) {
526
666
  // su un modello non guarded (revert immediato al twin).
527
667
  pi.on("model_select", async (event, ctx) => {
528
668
  try {
669
+ // Una selezione account/provider può aver rimpiazzato il nostro overlay.
670
+ noteReassert(reassertOverlays(ctx.modelRegistry));
529
671
  if (sessionMode !== "automatic" || !config.automatic.requireGuardedModel) return;
530
672
  if (overlays.size === 0) return;
531
673
  if (isGuardedModel(event.model.provider, event.model.id)) return;
@@ -697,11 +839,161 @@ export default function (pi: ExtensionAPI) {
697
839
  return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
698
840
  }
699
841
 
842
+ /** Registra un tool_result nel ledger evidenza (solo automatic, mai bloccare). */
843
+ function recordGroundingResult(event: {
844
+ toolName: string;
845
+ input: unknown;
846
+ content: unknown;
847
+ isError: boolean;
848
+ }): void {
849
+ try {
850
+ if (sessionMode !== "automatic") return;
851
+ if (!config.grounding.enabled) return;
852
+ const entry = recordToolResult({
853
+ toolName: event.toolName,
854
+ input: event.input,
855
+ content: event.content,
856
+ isError: event.isError,
857
+ seq: ++evidenceSeq,
858
+ at: Date.now(),
859
+ excerptChars: config.grounding.excerptChars,
860
+ });
861
+ if (!entry) return;
862
+ recordEvidence(evidenceLedger, entry, {
863
+ maxEntries: config.grounding.maxEntries,
864
+ invalidateTestsOnEdit: config.grounding.invalidateTestsOnEdit,
865
+ });
866
+ } catch {
867
+ // Mai rompere i risultati tool per il ledger.
868
+ }
869
+ }
870
+
871
+ function throttledGroundingError(error: unknown): void {
872
+ const now = Date.now();
873
+ if (now - lastGroundingErrorAt < 60_000) return;
874
+ lastGroundingErrorAt = now;
875
+ try {
876
+ pi.appendEntry("jev-grounding-error", {
877
+ error: error instanceof Error ? error.message : String(error),
878
+ });
879
+ } catch {
880
+ // solo diagnostica
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Factory per-richiesta del controllo grounding: il budget repair vive
886
+ * nella closure (una per runGuardedStream). Ritorna undefined quando il
887
+ * controllo non si applica: il gate pubblica senza nota (fail-open).
888
+ */
889
+ function createGroundingCheck(): PostPublishCheckFn | undefined {
890
+ try {
891
+ if (sessionMode !== "automatic") return undefined;
892
+ if (!config.grounding.enabled) return undefined;
893
+ if (!resolveBackend(config).apiKeyPresent) return undefined;
894
+ const g = config.grounding;
895
+ const mode = g.mode;
896
+ const maxRepairs = g.maxRepairs;
897
+ let repairsUsed = 0;
898
+ return async ({ text, attempts }) => {
899
+ const digest = buildEvidenceDigest(evidenceLedger, {
900
+ maxEntries: g.maxEntries,
901
+ evidenceChars: g.evidenceChars,
902
+ });
903
+ // Senza evidenza non si giudica: "senza evidenza" ≠ "falso".
904
+ if (digest.entries.length === 0) return { action: "publish" };
905
+ const state = buildGroundingState(text, digest, {
906
+ maxCandidateChars: config.limits.maxCandidateChars,
907
+ });
908
+ let evaluation: GroundingEvaluation | undefined;
909
+ try {
910
+ evaluation = await groundingCache.getOr(
911
+ groundingKey(text, state),
912
+ async () => {
913
+ const asked = await typedHandle.ask(state, GROUNDING_QUESTIONS, undefined);
914
+ return evaluateGrounding(asked, {
915
+ contradictionThreshold: g.contradictionThreshold,
916
+ unsupportedThreshold: g.unsupportedThreshold,
917
+ });
918
+ },
919
+ );
920
+ } catch (error) {
921
+ throttledGroundingError(error);
922
+ return { action: "publish" };
923
+ }
924
+ if (!evaluation) return { action: "publish" };
925
+ const decision = decideGroundingAction({
926
+ evaluation,
927
+ mode,
928
+ repairsUsed,
929
+ maxRepairs,
930
+ });
931
+ if (decision.action === "regenerate") {
932
+ repairsUsed++;
933
+ try {
934
+ pi.appendEntry("jev-grounding-repair", { reasons: evaluation.reasons });
935
+ } catch {
936
+ // solo diagnostica
937
+ }
938
+ return {
939
+ action: "regenerate",
940
+ feedback: decision.feedback,
941
+ grounding: { action: "repaired", reasons: evaluation.reasons },
942
+ };
943
+ }
944
+ if (decision.action === "hold") {
945
+ try {
946
+ pi.appendEntry("jev-grounding-hold", {
947
+ reasons: evaluation.reasons,
948
+ mode,
949
+ });
950
+ } catch {
951
+ // solo diagnostica
952
+ }
953
+ return {
954
+ action: "hold",
955
+ holdText: buildGroundingHoldText({ reasons: evaluation.reasons, attempts }),
956
+ grounding: { action: "held", reasons: evaluation.reasons },
957
+ };
958
+ }
959
+ if (decision.notice) {
960
+ try {
961
+ pi.appendEntry("jev-grounding-warn", {
962
+ reasons: evaluation.reasons,
963
+ mode,
964
+ });
965
+ } catch {
966
+ // solo diagnostica
967
+ }
968
+ return {
969
+ action: "publish",
970
+ notice: decision.notice,
971
+ grounding:
972
+ repairsUsed > 0
973
+ ? { action: "repaired", reasons: evaluation.reasons }
974
+ : { action: "warned", reasons: evaluation.reasons },
975
+ };
976
+ }
977
+ return {
978
+ action: "publish",
979
+ grounding:
980
+ repairsUsed > 0
981
+ ? { action: "repaired", reasons: evaluation.reasons }
982
+ : undefined,
983
+ };
984
+ };
985
+ } catch {
986
+ return undefined;
987
+ }
988
+ }
989
+
700
990
  // Giudice output (solo automatic, fail-open): dopo l'esecuzione cerca
701
991
  // secret nel testo e classifica i fallimenti. Non blocca mai: allega
702
992
  // una riga al risultato che il modello legge.
703
993
  pi.on("tool_result", async (event, ctx) => {
704
994
  try {
995
+ // Ledger prima di tutto: vale anche con outputJudge disattivato.
996
+ recordGroundingResult(event);
705
997
  if (sessionMode !== "automatic") return;
706
998
  if (!config.outputJudge.enabled) return;
707
999
  if (!config.outputJudge.tools.includes(event.toolName)) return;
@@ -768,6 +1060,9 @@ export default function (pi: ExtensionAPI) {
768
1060
  pi.on("message_end", async (event, ctx) => {
769
1061
  try {
770
1062
  if (ctx.hasUI) ctx.ui.setStatus("jev", undefined);
1063
+ // Dopo la richiesta: se qualcuno ci ha scalzati, rimetti l'overlay
1064
+ // per il turno successivo (il payload resta già normalizzato).
1065
+ noteReassert(reassertOverlays(ctx.modelRegistry));
771
1066
  if (sessionMode !== "automatic") return;
772
1067
  if (event.message.role !== "assistant") return;
773
1068
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": [
@@ -9,6 +9,7 @@ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
9
9
  import type { JevConfig } from "../config.ts";
10
10
  import type { ReviewFn, ReviewResult } from "../reviewer.ts";
11
11
  import { reviewWithCoverage } from "../reviewer.ts";
12
+ import { buildGroundingRepairContext } from "../grounding.ts";
12
13
  import {
13
14
  aggregateUsage,
14
15
  buildRepairContext,
@@ -34,6 +35,33 @@ export interface GuardianDeps {
34
35
  publishedModel: string;
35
36
  /** Chiamato una volta per esito finale con verdetto Jev (pass e hold). */
36
37
  onVerdict?: (report: GateVerdictReport) => void;
38
+ /**
39
+ * Controllo post-safety sul candidato approvato (es. grounding evidenza).
40
+ * Opzionale; se assente o se solleva, il candidato viene pubblicato
41
+ * (fail-open: il safety ha già deciso). Rigenerazioni bounded dal loop.
42
+ */
43
+ postPublishCheck?: PostPublishCheckFn;
44
+ }
45
+
46
+ /** Controllo post-safety: una chiamata per ogni candidato approvato. */
47
+ export type PostPublishCheckFn = (args: {
48
+ message: AssistantMessage;
49
+ text: string;
50
+ /** Tentativo upstream corrente (1-based). */
51
+ attempts: number;
52
+ }) => Promise<PostPublishCheckResult>;
53
+
54
+ /** Esito di un controllo post-safety sul candidato approvato. */
55
+ export interface PostPublishCheckResult {
56
+ action: "publish" | "regenerate" | "hold";
57
+ /** Nota di trasparenza accodata (publish). */
58
+ notice?: string;
59
+ /** Feedback per la rigenerazione (regenerate). */
60
+ feedback?: string;
61
+ /** Testo sicuro da pubblicare (hold): mai il candidato. */
62
+ holdText?: string;
63
+ /** Dettaglio per il report (publish con nota, hold). */
64
+ grounding?: GateVerdictReport["grounding"];
37
65
  }
38
66
 
39
67
  /** Esito finale del gate con verdetto, per `/jev last` e diagnostica. */
@@ -43,6 +71,11 @@ export interface GateVerdictReport {
43
71
  attempts: number;
44
72
  /** Come si è chiuso: pubblicato dopo ok, o trattenuto. */
45
73
  outcome: "published" | "held";
74
+ /** Dettaglio grounding quando un post-check è intervenuto. */
75
+ grounding?: {
76
+ action: "warned" | "repaired" | "held";
77
+ reasons: string[];
78
+ };
46
79
  }
47
80
 
48
81
  export class GuardianLimitError extends Error {
@@ -248,8 +281,16 @@ export function runGuardedStream(
248
281
  }
249
282
  signal?.throwIfAborted();
250
283
 
251
- const publishCandidate = async (notice?: string): Promise<void> => {
252
- deps.onVerdict?.({ verdict, attempts: attempt + 1, outcome: "published" });
284
+ const publishCandidate = async (
285
+ notice?: string,
286
+ grounding?: GateVerdictReport["grounding"],
287
+ ): Promise<void> => {
288
+ deps.onVerdict?.({
289
+ verdict,
290
+ attempts: attempt + 1,
291
+ outcome: "published",
292
+ ...(grounding ? { grounding } : {}),
293
+ });
253
294
  const approved = notice ? withNotice(candidate, notice) : candidate;
254
295
  const aggregated = aggregateUsage(usages);
255
296
  const replayed = replayApproved(approved, {
@@ -266,9 +307,84 @@ export function runGuardedStream(
266
307
  out.end(final);
267
308
  };
268
309
 
310
+ /** Pubblica un testo sicuro (hold): mai il candidato bocciato. */
311
+ const publishHoldText = async (safeText: string): Promise<void> => {
312
+ const aggregated = aggregateUsage(usages);
313
+ const safeMessage: AssistantMessage = {
314
+ role: "assistant",
315
+ content: [{ type: "text", text: "" }],
316
+ api: candidate.api,
317
+ provider: deps.publishedProvider as AssistantMessage["provider"],
318
+ model: deps.publishedModel,
319
+ responseModel: candidate.model,
320
+ usage: aggregated,
321
+ stopReason: "stop",
322
+ timestamp: Date.now(),
323
+ };
324
+ out.push({ type: "start", partial: safeMessage });
325
+ out.push({ type: "text_start", contentIndex: 0, partial: safeMessage });
326
+ const block = safeMessage.content[0];
327
+ if (block && block.type === "text") block.text = safeText;
328
+ out.push({ type: "text_delta", contentIndex: 0, delta: safeText, partial: safeMessage });
329
+ out.push({ type: "text_end", contentIndex: 0, content: safeText, partial: safeMessage });
330
+ out.push({ type: "done", reason: "stop", message: safeMessage });
331
+ out.end(safeMessage);
332
+ };
333
+
334
+ /**
335
+ * Controllo post-safety sul candidato approvato. Ritorna "published"
336
+ * o "held" (stream già chiuso) oppure undefined per rigenerare
337
+ * (attemptContext aggiornato, il loop continua). Fail-open.
338
+ */
339
+ const runPostCheck = async (
340
+ baseNotice?: string,
341
+ ): Promise<"published" | "held" | undefined> => {
342
+ if (!deps.postPublishCheck) {
343
+ await publishCandidate(baseNotice);
344
+ return "published";
345
+ }
346
+ let check: PostPublishCheckResult;
347
+ try {
348
+ check = await deps.postPublishCheck({
349
+ message: candidate,
350
+ text: rawCandidate,
351
+ attempts: attempt + 1,
352
+ });
353
+ } catch {
354
+ await publishCandidate(baseNotice);
355
+ return "published";
356
+ }
357
+ signal?.throwIfAborted();
358
+ if (check.action === "publish") {
359
+ const notice = [baseNotice, check.notice].filter(Boolean).join("") || undefined;
360
+ await publishCandidate(notice, check.grounding);
361
+ return "published";
362
+ }
363
+ if (check.action === "hold") {
364
+ const attempts = attempt + 1;
365
+ deps.onVerdict?.({
366
+ verdict,
367
+ attempts,
368
+ outcome: "held",
369
+ ...(check.grounding ? { grounding: check.grounding } : {}),
370
+ });
371
+ await publishHoldText(check.holdText ?? "[JEV — OUTPUT NON PUBBLICATO]");
372
+ return "held";
373
+ }
374
+ attemptContext = buildGroundingRepairContext({
375
+ attemptContext,
376
+ rejectedText: rawCandidate,
377
+ feedback:
378
+ check.feedback ??
379
+ "Grounding check failed; only assert session activities supported by evidence.",
380
+ });
381
+ return undefined;
382
+ };
383
+
269
384
  if (verdict.status === "pass") {
270
- await publishCandidate();
271
- return;
385
+ const outcome = await runPostCheck();
386
+ if (outcome !== undefined) return;
387
+ continue;
272
388
  }
273
389
 
274
390
  const canRepair =
@@ -291,8 +407,9 @@ export function runGuardedStream(
291
407
  const unavailablePass =
292
408
  verdict.status === "unavailable" && deps.config.policy.onUnavailable === "pass";
293
409
  if (uncertainPass || unavailablePass) {
294
- await publishCandidate(uncertainNotice(verdict));
295
- return;
410
+ const outcome = await runPostCheck(uncertainNotice(verdict));
411
+ if (outcome !== undefined) return;
412
+ continue;
296
413
  }
297
414
 
298
415
  // Hold: pubblica failure sicura, mai il candidato bocciato.
@@ -305,26 +422,7 @@ export function runGuardedStream(
305
422
  ? "Input troncato per la verifica: Jev ha visto solo una parte (vedi truncated)."
306
423
  : undefined,
307
424
  });
308
- const aggregated = aggregateUsage(usages);
309
- const safeMessage: AssistantMessage = {
310
- role: "assistant",
311
- content: [{ type: "text", text: "" }],
312
- api: candidate.api,
313
- provider: deps.publishedProvider as AssistantMessage["provider"],
314
- model: deps.publishedModel,
315
- responseModel: candidate.model,
316
- usage: aggregated,
317
- stopReason: "stop",
318
- timestamp: Date.now(),
319
- };
320
- out.push({ type: "start", partial: safeMessage });
321
- out.push({ type: "text_start", contentIndex: 0, partial: safeMessage });
322
- const block = safeMessage.content[0];
323
- if (block && block.type === "text") block.text = safeText;
324
- out.push({ type: "text_delta", contentIndex: 0, delta: safeText, partial: safeMessage });
325
- out.push({ type: "text_end", contentIndex: 0, content: safeText, partial: safeMessage });
326
- out.push({ type: "done", reason: "stop", message: safeMessage });
327
- out.end(safeMessage);
425
+ await publishHoldText(safeText);
328
426
  return;
329
427
  }
330
428
  } catch (error) {
@@ -10,6 +10,7 @@
10
10
  * per i soli modelli scelti. Gli stream dei gemelli passano dal gate.
11
11
  */
12
12
  import type {
13
+ AssistantMessage,
13
14
  AssistantMessageEventStream,
14
15
  Context,
15
16
  Credential,
@@ -25,7 +26,7 @@ import { emptyUsage } from "./serialize.ts";
25
26
  import type { JevConfig } from "../config.ts";
26
27
  import { configSnapshot } from "../config.ts";
27
28
  import type { ReviewFn } from "../reviewer.ts";
28
- import { runGuardedStream, type GateVerdictReport } from "./guardian.ts";
29
+ import { runGuardedStream, type GateVerdictReport, type PostPublishCheckFn } from "./guardian.ts";
29
30
 
30
31
  export const TWIN_SUFFIX = "__jev";
31
32
 
@@ -80,6 +81,11 @@ export interface OverlayDeps {
80
81
  getReview: () => ReviewFn;
81
82
  /** Riceve l'esito finale di ogni turno sul twin (per `/jev last`). */
82
83
  onVerdict?: (report: GateVerdictReport) => void;
84
+ /**
85
+ * Factory del controllo post-safety, una per richiesta: il budget repair
86
+ * vive nella closure restituita, non nel loop del guardian.
87
+ */
88
+ createPostCheck?: () => PostPublishCheckFn | undefined;
83
89
  /** Override streams upstream per test (altrimenti delega al provider). */
84
90
  upstreamStreams?: {
85
91
  streamSimple: (
@@ -206,6 +212,12 @@ export function buildOverlaidProvider(deps: OverlayDeps): Provider {
206
212
  publishedProvider: model.provider,
207
213
  publishedModel: model.id,
208
214
  ...(deps.onVerdict ? { onVerdict: deps.onVerdict } : {}),
215
+ ...(deps.createPostCheck
216
+ ? (() => {
217
+ const check = deps.createPostCheck();
218
+ return check ? { postPublishCheck: check } : {};
219
+ })()
220
+ : {}),
209
221
  });
210
222
  }
211
223
 
package/src/commands.ts CHANGED
@@ -72,6 +72,13 @@ export interface JevCommandDeps {
72
72
  }>;
73
73
  getLastOutput: () => { tool: string; verdict: OutputVerdict; at: number } | undefined;
74
74
  getLastGate: () => { report: GateVerdictReport; at: number } | undefined;
75
+ groundingStatus: () => {
76
+ enabled: boolean;
77
+ mode: "warn" | "repair" | "hold";
78
+ entries: number;
79
+ hasStaleTests: boolean;
80
+ };
81
+ setGrounding: (mode: "off" | "warn" | "repair" | "hold") => JevConfig;
75
82
  }
76
83
 
77
84
  export const JEV_SUBCOMMANDS = [
@@ -88,6 +95,7 @@ export const JEV_SUBCOMMANDS = [
88
95
  "reload",
89
96
  "output",
90
97
  "last",
98
+ "grounding",
91
99
  "backend",
92
100
  "save-key",
93
101
  "help",
@@ -111,6 +119,8 @@ export const JEV_HELP_TEXT = [
111
119
  " /jev stats Conteggi pass/block/review/unavailable + latenze",
112
120
  " /jev output Ultimo output giudicato: leak + classe errore",
113
121
  " /jev last Ultimo verdetto del gate (twin): esito e controlli",
122
+ " /jev grounding off|warn|repair|hold",
123
+ " Grounding affermazioni sessione (solo automatic, opt-in)",
114
124
  " /jev backend Mostra backend attivo e chiavi rilevate",
115
125
  " /jev backend auto|openrouter|typesafe",
116
126
  " Cambia backend (salvato in jev-config.json)",
@@ -239,6 +249,7 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
239
249
  `Tool policy: ${cfg.toolsPolicy.enabled ? "attiva in automatic (bash/powershell/write/edit)" : "disattiva (config)"}`,
240
250
  `Exfil check: ${cfg.exfilCheck.enabled ? "attivo in automatic (confirm in TUI, fail-open)" : "disattivo (config)"}`,
241
251
  `Output judge: ${cfg.outputJudge.enabled ? `attivo in automatic (tool: ${cfg.outputJudge.tools.join(",") || "—"}, fail-open)` : "disattivo (config)"}`,
252
+ `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)"}`,
242
253
  cfg.mode === "automatic"
243
254
  ? "ATTENZIONE: in automatic testo/codice vengono inviati al verificatore. Crea il twin con /jev upstream e seleziona il modello __jev per enforcement."
244
255
  : "on-demand: nessuna verifica automatica.",
@@ -451,6 +462,9 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
451
462
  [
452
463
  `Gate: ${last.report.outcome === "published" ? "pubblicato" : "trattenuto"} dopo ${last.report.attempts} tentativ${last.report.attempts === 1 ? "o" : "i"}`,
453
464
  `stato: ${v.status}${top ? ` — ${top.ruleId} p=${top.pFlaw.toFixed(2)}` : ""}`,
465
+ last.report.grounding
466
+ ? `grounding: ${last.report.grounding.action} (${last.report.grounding.reasons.join(", ") || "n/a"})`
467
+ : "grounding: non intervenuto",
454
468
  v.errorCode ? `errore: ${v.errorCode}` : `backend: ${v.backend ?? "?"} · modello: ${v.model ?? "?"}`,
455
469
  v.truncated ? `troncato: ${JSON.stringify(v.truncated)}` : "copertura intera",
456
470
  `tempo verifica: ${Math.round(v.elapsedMs)}ms`,
@@ -480,6 +494,33 @@ export function registerJevCommand(pi: ExtensionAPI, deps: JevCommandDeps) {
480
494
  return;
481
495
  }
482
496
 
497
+ if (sub === "grounding") {
498
+ const value = parts[1];
499
+ if (value === undefined) {
500
+ const st = deps.groundingStatus();
501
+ ctx.ui.notify(
502
+ st.enabled
503
+ ? `Grounding attivo in automatic (mode=${st.mode}, voci evidenza=${st.entries}${st.hasStaleTests ? ", test stale presenti" : ""}). Cambia con /jev grounding off|warn|repair|hold.`
504
+ : "Grounding disattivo (opt-in). Attiva con /jev grounding warn|repair|hold (solo automatic).",
505
+ "info",
506
+ );
507
+ return;
508
+ }
509
+ if (value !== "off" && value !== "warn" && value !== "repair" && value !== "hold") {
510
+ ctx.ui.notify("Uso: /jev grounding off|warn|repair|hold", "warning");
511
+ return;
512
+ }
513
+ const cfg = deps.setGrounding(value);
514
+ ctx.ui.notify(
515
+ value === "off"
516
+ ? "Grounding disattivato."
517
+ : `Grounding attivo in automatic (mode=${value}). Le affermazioni sulla sessione vengono confrontate col registro evidenza; senza evidenza non si giudica.`,
518
+ "info",
519
+ );
520
+ void cfg;
521
+ return;
522
+ }
523
+
483
524
  if (sub === "reload") {
484
525
  const cfg = deps.reload();
485
526
  ctx.ui.notify(`Config ricaricata. mode=${cfg.mode} backend=${cfg.jev.backend}`, "info");
package/src/config.ts CHANGED
@@ -75,6 +75,28 @@ export interface JevConfig {
75
75
  /** Riusa verdetti per output identici entro la finestra. */
76
76
  cacheSeconds: number;
77
77
  };
78
+ grounding: {
79
+ /** Grounding affermazioni sulla sessione (solo automatic, opt-in). */
80
+ enabled: boolean;
81
+ /** warn = nota, repair = rigenera, hold = trattiene le contraddizioni. */
82
+ mode: "warn" | "repair" | "hold";
83
+ /** Soglia p(contraddizione evidenza) per scattare. */
84
+ contradictionThreshold: number;
85
+ /** Soglia p(affermazione non supportata) per scattare. */
86
+ unsupportedThreshold: number;
87
+ /** Rigenerazioni grounding per risposta (separate dal safety). */
88
+ maxRepairs: number;
89
+ /** Voci evidenza mantenute per sessione. */
90
+ maxEntries: number;
91
+ /** Budget caratteri del digest evidenza inviato a Jev. */
92
+ evidenceChars: number;
93
+ /** Estratto output per voce evidenza. */
94
+ excerptChars: number;
95
+ /** Riusa verdetti per candidato+evidenza identici entro la finestra. */
96
+ cacheSeconds: number;
97
+ /** Una modifica riuscita invalida i test precedenti (stale). */
98
+ invalidateTestsOnEdit: boolean;
99
+ };
78
100
  exfilCheck: {
79
101
  /** Check semantico sui comandi con tool di rete (solo automatic). */
80
102
  enabled: boolean;
@@ -158,6 +180,20 @@ export const DEFAULT_CONFIG: JevConfig = {
158
180
  minConfidence: 0.6,
159
181
  cacheSeconds: 120,
160
182
  },
183
+ grounding: {
184
+ enabled: false,
185
+ mode: "warn",
186
+ // Soglie alte: "senza evidenza" non significa "falso"; solo le
187
+ // contraddizioni quasi certe trattengono (vedi decideGroundingAction).
188
+ contradictionThreshold: 0.85,
189
+ unsupportedThreshold: 0.7,
190
+ maxRepairs: 1,
191
+ maxEntries: 30,
192
+ evidenceChars: 2000,
193
+ excerptChars: 300,
194
+ cacheSeconds: 120,
195
+ invalidateTestsOnEdit: true,
196
+ },
161
197
  exfilCheck: {
162
198
  enabled: true,
163
199
  tools: ["bash", "powershell"],
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Grounding delle affermazioni sulla sessione (anti "ho fatto X" inventato).
3
+ *
4
+ * Il gate safety vede solo {requirements, candidate}: non sa se il modello ha
5
+ * davvero letto un file, eseguito un comando o superato i test. Questo modulo
6
+ * mantiene un registro temporaneo e redatto degli effetti osservati nella
7
+ * sessione (tool_result) e lo confronta con il candidato prima della
8
+ * pubblicazione. Solo affermazioni verificabili sulla sessione: comandi, file,
9
+ * test, modifiche. Niente fact-check esterno.
10
+ *
11
+ * Solo funzioni pure + tipi: nessun accesso a pi qui.
12
+ */
13
+ import type { Context } from "@earendil-works/pi-ai";
14
+ import { fnvKey } from "./cache.ts";
15
+ import { elideText, toolResultText } from "./output-judge.ts";
16
+ import type {
17
+ TypedAnswer,
18
+ TypedAskResult,
19
+ TypedQuestion,
20
+ } from "./reviewer.ts";
21
+
22
+ export type GroundingMode = "warn" | "repair" | "hold";
23
+
24
+ export type EvidenceKind = "command" | "test" | "read" | "modify";
25
+
26
+ export interface EvidenceEntry {
27
+ seq: number;
28
+ at: number;
29
+ tool: string;
30
+ kind: EvidenceKind;
31
+ /** "bash: npm run test:unit" / "read: src/config.ts" — mai output interi. */
32
+ summary: string;
33
+ ok: boolean;
34
+ exitCode?: number;
35
+ /** Test superato da una modifica successiva (non più probante). */
36
+ stale?: boolean;
37
+ /** Breve estratto redatto dell'output. */
38
+ excerpt?: string;
39
+ }
40
+
41
+ /** Tool osservati per il ledger (risultati di comandi, file, test, modifiche). */
42
+ export const GROUNDING_TOOLS = ["bash", "powershell", "read", "edit", "write"] as const;
43
+
44
+ const TEST_COMMAND =
45
+ /(^|[\s;&|])(npm\s+(test|run\s+test|run\s+test:unit)|npx\s+(vitest|jest)|vitest(\s+run)?|jest|pytest|go\s+test|cargo\s+test|phpunit|rspec|make\s+test)\b/i;
46
+
47
+ /** Riconosce un comando che esegue test (evidenza "test", non generica). */
48
+ export function isTestCommand(command: string): boolean {
49
+ return TEST_COMMAND.test(command);
50
+ }
51
+
52
+ const EXIT_CODE = /Command exited with code (\d+)/;
53
+
54
+ /** Exit code dal testo d'errore pi ("Command exited with code N"). */
55
+ export function parseExitCode(text: string): number | undefined {
56
+ const match = EXIT_CODE.exec(text);
57
+ if (!match) return undefined;
58
+ const code = Number(match[1]);
59
+ return Number.isFinite(code) ? code : undefined;
60
+ }
61
+
62
+ function firstLine(text: string, max: number): string {
63
+ const line = text.split(/\r?\n/)[0]?.trim() ?? "";
64
+ return line.length > max ? `${line.slice(0, max)}…` : line;
65
+ }
66
+
67
+ /**
68
+ * Costruisce una voce di evidenza da un tool_result. Ritorna undefined per i
69
+ * tool non osservati. Redatto: solo riepilogo + breve estratto, mai segreti
70
+ * (l'output judge resta il filtro dedicato per i leak).
71
+ */
72
+ export function recordToolResult(args: {
73
+ toolName: string;
74
+ input: unknown;
75
+ content: unknown;
76
+ isError: boolean;
77
+ seq: number;
78
+ at: number;
79
+ excerptChars: number;
80
+ }): EvidenceEntry | undefined {
81
+ const { toolName } = args;
82
+ if (!(GROUNDING_TOOLS as readonly string[]).includes(toolName)) return undefined;
83
+ const text = toolResultText(args.content);
84
+ const ok = !args.isError;
85
+ const input = (args.input ?? {}) as Record<string, unknown>;
86
+ const excerptSource = text.trim();
87
+ const excerpt = excerptSource
88
+ ? elideText(excerptSource, Math.max(0, args.excerptChars))
89
+ : undefined;
90
+
91
+ if (toolName === "bash" || toolName === "powershell") {
92
+ const command = typeof input.command === "string" ? input.command.trim() : "";
93
+ if (!command) return undefined;
94
+ const kind: EvidenceKind = isTestCommand(command) ? "test" : "command";
95
+ const entry: EvidenceEntry = {
96
+ seq: args.seq,
97
+ at: args.at,
98
+ tool: toolName,
99
+ kind,
100
+ summary: `${toolName}: ${firstLine(command, 160)}`,
101
+ ok,
102
+ };
103
+ const exitCode = parseExitCode(text);
104
+ if (exitCode !== undefined) entry.exitCode = exitCode;
105
+ else if (!ok) entry.exitCode = -1;
106
+ if (excerpt) entry.excerpt = excerpt;
107
+ return entry;
108
+ }
109
+
110
+ if (toolName === "read") {
111
+ const path = typeof input.path === "string" ? input.path.trim() : "";
112
+ if (!path) return undefined;
113
+ const entry: EvidenceEntry = {
114
+ seq: args.seq,
115
+ at: args.at,
116
+ tool: toolName,
117
+ kind: "read",
118
+ summary: `read: ${firstLine(path, 200)}`,
119
+ ok,
120
+ };
121
+ if (excerpt) entry.excerpt = excerpt;
122
+ return entry;
123
+ }
124
+
125
+ // edit / write: modifica osservata (anche fallita: ok=false, non invalida).
126
+ const path = typeof input.path === "string" ? input.path.trim() : "";
127
+ if (!path) return undefined;
128
+ const entry: EvidenceEntry = {
129
+ seq: args.seq,
130
+ at: args.at,
131
+ tool: toolName,
132
+ kind: "modify",
133
+ summary: `${toolName}: ${firstLine(path, 200)}`,
134
+ ok,
135
+ };
136
+ if (excerpt) entry.excerpt = excerpt;
137
+ return entry;
138
+ }
139
+
140
+ /**
141
+ * Accoda una voce al ledger con cap: oltre maxEntries scarta le più vecchie.
142
+ * Dopo una modifica riuscita, i test precedenti diventano stale (il loro esito
143
+ * non prova più lo stato attuale). Ritorna quanti test sono stati invalidati.
144
+ */
145
+ export function recordEvidence(
146
+ ledger: EvidenceEntry[],
147
+ entry: EvidenceEntry,
148
+ opts: { maxEntries: number; invalidateTestsOnEdit: boolean },
149
+ ): { invalidated: number } {
150
+ ledger.push(entry);
151
+ while (ledger.length > Math.max(1, opts.maxEntries)) ledger.shift();
152
+ let invalidated = 0;
153
+ if (opts.invalidateTestsOnEdit && entry.kind === "modify" && entry.ok) {
154
+ for (const prior of ledger) {
155
+ if (prior.kind === "test" && !prior.stale && prior.seq < entry.seq) {
156
+ prior.stale = true;
157
+ invalidated++;
158
+ }
159
+ }
160
+ }
161
+ return { invalidated };
162
+ }
163
+
164
+ export interface GroundingDigest {
165
+ entries: EvidenceEntry[];
166
+ /** True se almeno un test è stale (Jev lo vede: non è più probante). */
167
+ hasStaleTests: boolean;
168
+ }
169
+
170
+ /** Digest pronto per Jev: voci recenti entro il budget caratteri. */
171
+ export function buildEvidenceDigest(
172
+ ledger: readonly EvidenceEntry[],
173
+ opts: { maxEntries: number; evidenceChars: number },
174
+ ): GroundingDigest {
175
+ const recent = ledger.slice(-Math.max(1, opts.maxEntries));
176
+ const entries: EvidenceEntry[] = [];
177
+ let budget = Math.max(0, opts.evidenceChars);
178
+ // Dalle più recenti: l'evidenza fresca conta di più.
179
+ for (let i = recent.length - 1; i >= 0; i--) {
180
+ const entry = recent[i];
181
+ if (!entry) continue;
182
+ const cost =
183
+ entry.summary.length + (entry.excerpt ? entry.excerpt.length : 0) + 48;
184
+ if (cost > budget) {
185
+ const trimmed: EvidenceEntry = {
186
+ ...entry,
187
+ excerpt: undefined,
188
+ };
189
+ if (trimmed.summary.length + 48 <= budget) {
190
+ entries.unshift(trimmed);
191
+ budget -= trimmed.summary.length + 48;
192
+ }
193
+ break;
194
+ }
195
+ entries.unshift(entry);
196
+ budget -= cost;
197
+ }
198
+ return {
199
+ entries,
200
+ hasStaleTests: recent.some((e) => e.kind === "test" && e.stale),
201
+ };
202
+ }
203
+
204
+ export const GROUNDING_QUESTIONS: TypedQuestion[] = [
205
+ {
206
+ kind: "noul",
207
+ id: "contradicts_evidence",
208
+ instructions:
209
+ "Does `candidate` assert a session activity whose outcome is directly contradicted by `evidence`?",
210
+ criteria: {
211
+ true: "The candidate claims a file was read, modified, created, or deleted, a command was run, or tests passed, and `evidence` shows the opposite outcome (failure, error, exit code non-zero, or a stale test result presented as current).",
212
+ false: "No direct contradiction; the candidate only gives generic advice, explains concepts, or its session claims match the recorded evidence.",
213
+ },
214
+ },
215
+ {
216
+ kind: "noul",
217
+ id: "unsupported_session_claim",
218
+ instructions:
219
+ "Does `candidate` claim a session activity (read, modified, executed, or verified something) with no supporting entry in `evidence`?",
220
+ criteria: {
221
+ true: "The candidate states as fact that it read a specific file, ran a specific command, or verified/tested something, and `evidence` contains no entry for that activity. Judge only verifiable session-activity claims.",
222
+ false: "Every session-activity claim has a matching evidence entry, or the candidate makes no verifiable session-activity claims (generic explanations, advice, opinions, or explicitly marked unverified statements).",
223
+ },
224
+ },
225
+ ];
226
+
227
+ export function buildGroundingState(
228
+ candidate: string,
229
+ digest: GroundingDigest,
230
+ opts: { maxCandidateChars: number },
231
+ ): string {
232
+ return JSON.stringify({
233
+ candidate: elideText(candidate, Math.max(1, opts.maxCandidateChars)),
234
+ evidence: digest.entries.map((e) => ({
235
+ tool: e.tool,
236
+ kind: e.kind,
237
+ summary: e.summary,
238
+ ok: e.ok,
239
+ ...(e.exitCode !== undefined ? { exitCode: e.exitCode } : {}),
240
+ ...(e.stale ? { stale: true } : {}),
241
+ ...(e.excerpt ? { excerpt: e.excerpt } : {}),
242
+ })),
243
+ stale_tests_present: digest.hasStaleTests,
244
+ });
245
+ }
246
+
247
+ /** Chiave cache: hash di candidato + digest (niente contenuto in chiaro). */
248
+ export function groundingKey(candidate: string, state: string): string {
249
+ return `${candidate.length}:${state.length}:${fnvKey(`${candidate}\0${state}`)}`;
250
+ }
251
+
252
+ export interface GroundingThresholds {
253
+ contradictionThreshold: number;
254
+ unsupportedThreshold: number;
255
+ }
256
+
257
+ export interface GroundingEvaluation {
258
+ contradiction: number;
259
+ unsupported: number;
260
+ triggered: boolean;
261
+ reasons: string[];
262
+ }
263
+
264
+ function noulById(answers: TypedAnswer[], id: string): number {
265
+ const found = answers.find((a) => a.id === id);
266
+ return found?.type === "noul" ? found.p : 0;
267
+ }
268
+
269
+ export function evaluateGrounding(
270
+ result: TypedAskResult,
271
+ thresholds: GroundingThresholds,
272
+ ): GroundingEvaluation {
273
+ const contradiction = noulById(result.answers, "contradicts_evidence");
274
+ const unsupported = noulById(result.answers, "unsupported_session_claim");
275
+ const reasons: string[] = [];
276
+ if (contradiction >= thresholds.contradictionThreshold) {
277
+ reasons.push(`contradicts_evidence ${contradiction.toFixed(2)}`);
278
+ }
279
+ if (unsupported >= thresholds.unsupportedThreshold) {
280
+ reasons.push(`unsupported_session_claim ${unsupported.toFixed(2)}`);
281
+ }
282
+ return { contradiction, unsupported, triggered: reasons.length > 0, reasons };
283
+ }
284
+
285
+ export type GroundingAction = "publish" | "regenerate" | "hold";
286
+
287
+ export interface GroundingDecision {
288
+ action: GroundingAction;
289
+ /** Nota di trasparenza (publish dopo trigger). */
290
+ notice?: string;
291
+ /** Feedback per la rigenerazione. */
292
+ feedback?: string;
293
+ /** Motivo leggibile (hold). */
294
+ reason?: string;
295
+ }
296
+
297
+ /** Nota accodata al testo pubblicato quando il grounding scatta senza hold. */
298
+ export function groundingNotice(reasons: string[]): string {
299
+ return `\n\n> [jev] grounding: affermazioni sulla sessione non supportate dall'evidenza (${reasons.join(", ")}) — verifica col modello i file/comandi citati.`;
300
+ }
301
+
302
+ /** Feedback per la rigenerazione: correggere, non accusare. */
303
+ export function buildGroundingRepairFeedback(reasons: string[], attempt: number): string {
304
+ return [
305
+ `Grounding check failed (${reasons.join(", ")}), attempt ${attempt}.`,
306
+ "The draft asserts session activities (file reads, commands, tests) that the",
307
+ "recorded evidence does not support or directly contradicts.",
308
+ "Produce a replacement that only asserts session activities present in the",
309
+ "evidence, or explicitly marks the rest as NOT verified in this session.",
310
+ "Do not repeat unsupported claims as facts. 'Not verified' is acceptable.",
311
+ ].join("\n");
312
+ }
313
+
314
+ /** Testo sicuro per l'hold: mai il candidato, solo il motivo. */
315
+ export function buildGroundingHoldText(args: {
316
+ reasons: string[];
317
+ attempts: number;
318
+ }): string {
319
+ return [
320
+ "[JEV — OUTPUT NON PUBBLICATO]",
321
+ "",
322
+ "Risultato: grounding-hold",
323
+ `Controllo: ${args.reasons.join(", ") || "n/a"}`,
324
+ `Tentativi: ${args.attempts}`,
325
+ "",
326
+ "La risposta contraddice l'evidenza della sessione con alta confidenza.",
327
+ ].join("\n");
328
+ }
329
+
330
+ /**
331
+ * Decide l'azione in base alla modalità. "Senza evidenza" non significa
332
+ * "falso": solo le contraddizioni ad alta confidenza trattengono (hold);
333
+ * le affermazioni non supportate avvisano o rigenerano.
334
+ */
335
+ export function decideGroundingAction(args: {
336
+ evaluation: GroundingEvaluation;
337
+ mode: GroundingMode;
338
+ repairsUsed: number;
339
+ maxRepairs: number;
340
+ }): GroundingDecision {
341
+ const { evaluation, mode, repairsUsed, maxRepairs } = args;
342
+ if (!evaluation.triggered) return { action: "publish" };
343
+ const notice = groundingNotice(evaluation.reasons);
344
+ if (mode === "warn") return { action: "publish", notice };
345
+ const canRepair = repairsUsed < Math.max(0, maxRepairs);
346
+ const contradictionHit =
347
+ evaluation.reasons.some((r) => r.startsWith("contradicts_evidence")) &&
348
+ mode === "hold";
349
+ if (contradictionHit) {
350
+ return {
351
+ action: "hold",
352
+ reason: `grounding: ${evaluation.reasons.join(", ")}`,
353
+ };
354
+ }
355
+ if (canRepair) {
356
+ return {
357
+ action: "regenerate",
358
+ feedback: buildGroundingRepairFeedback(evaluation.reasons, repairsUsed + 1),
359
+ };
360
+ }
361
+ return { action: "publish", notice };
362
+ }
363
+
364
+ /** Contesto di repair per il grounding: feedback + bozza scartata. */
365
+ export function buildGroundingRepairContext(args: {
366
+ attemptContext: Context;
367
+ rejectedText: string;
368
+ feedback: string;
369
+ maxChars?: number;
370
+ }): Context {
371
+ const maxChars = args.maxChars ?? 4000;
372
+ return {
373
+ ...args.attemptContext,
374
+ messages: [
375
+ ...args.attemptContext.messages,
376
+ {
377
+ role: "user",
378
+ content: [
379
+ args.feedback,
380
+ "",
381
+ "Rejected draft (untrusted reference, truncated):",
382
+ args.rejectedText.slice(0, maxChars),
383
+ ].join("\n"),
384
+ timestamp: Date.now(),
385
+ },
386
+ ],
387
+ };
388
+ }