pi-jev-guard 0.3.1 → 0.3.3

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
@@ -126,6 +126,15 @@ al cambio account), l'overlay viene ricostruito sopra il provider live prima di
126
126
  ogni turno; in più il gate toglie il suffisso `__jev` da qualunque payload,
127
127
  registrando `jev-gate-bypass` e avvisando in TUI, perché quel turno non è
128
128
  passato dalla verifica.
129
+
130
+ Coabitazione con `pi-accounts`: quell'estensione aborts i turni il cui modello
131
+ non risulta nei modelli disponibili dell'account (`turn_start` → `ctx.abort()`,
132
+ errore istantaneo `This operation was aborted`). Per questo i twin vengono
133
+ registrati già all'evento `input`, prima di qualsiasi `before_agent_start`:
134
+ quando pi-accounts legge il registry trova gli id `__jev` e non arma l'abort.
135
+ Se vedi comunque quell'errore solo sui twin (il modello originale funziona),
136
+ con un avviso `... is not available to account ...`, è quella gara di
137
+ registrazione: apri un issue con le righe `jev-overlay-reassert` della sessione.
129
138
  Le conferme per comandi di rete sospetti attendono invece una decisione esplicita;
130
139
  la normale sequenza tool → risposta successiva continua senza un nuovo prompt.
131
140
 
@@ -159,6 +168,19 @@ avvisa e dice di riferirsi al valore per nome; se è un errore, allega il
159
168
  consiglio per la classe (`transient` → riprova, `code_bug` → fixa il codice…).
160
169
  Non blocca mai; `/jev output` mostra l'ultimo verdetto.
161
170
 
171
+ Sempre in `automatic`, il **grounding** (opt-in, default spento) confronta le
172
+ affermazioni sulla sessione — "ho letto il file", "i test passano", "ho
173
+ eseguito X" — con un registro evidenza costruito dai `tool_result`
174
+ (comandi, test, letture, modifiche; redatto, max 30 voci, solo sessione
175
+ corrente). Due domande Jev strette: contraddizione con l'evidenza e
176
+ affermazione non supportata. Senza evidenza non si giudica: "senza
177
+ evidenza" non significa "falso". Una modifica riuscita invalida i test
178
+ precedenti (stale). Tre modalità (`/jev grounding off|warn|repair|hold`):
179
+ `warn` pubblica con nota, `repair` rigenera una volta, `hold` trattiene solo
180
+ le contraddizioni ad alta confidenza. Fail-open: se il verificatore non
181
+ risponde, il testo passa senza nota. `/jev last` mostra anche l'esito
182
+ grounding (`warned`/`repaired`/`held`).
183
+
162
184
  ## Configurazione
163
185
 
164
186
  `~/.pi/agent/jev-config.json` (o `$JEV_CONFIG`). Env: `JEV_MODE`, `JEV_BACKEND`
@@ -210,3 +232,6 @@ echo '{"requirements":"...","candidate":"..."}' | npm run jev-check
210
232
  - Soglie calibrate su 20 casi (`docs/calibration-2026-09-19.md`): buone per
211
233
  iniziare, da rivalutare con corpus più ampio.
212
234
  - Jev è un giudice probabilistico, non un verificatore formale.
235
+ - Il grounding controlla solo affermazioni verificabili sulla sessione
236
+ (comandi, file, test, modifiche): niente fact-check di fatti esterni,
237
+ 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,6 +110,7 @@ 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
@@ -170,6 +190,7 @@ export default function (pi: ExtensionAPI) {
170
190
  typedHandle = createTypedAsk(configSnapshot(config));
171
191
  outputCache = createVerdictCache<OutputVerdict>({ ttlSeconds: config.outputJudge.cacheSeconds });
172
192
  exfilCache = createVerdictCache<ExfilVerdict>({ ttlSeconds: config.exfilCheck.cacheSeconds });
193
+ groundingCache = createVerdictCache<GroundingEvaluation>({ ttlSeconds: config.grounding.cacheSeconds });
173
194
  return getConfig();
174
195
  }
175
196
 
@@ -211,6 +232,7 @@ export default function (pi: ExtensionAPI) {
211
232
  getConfig,
212
233
  getReview: getGateReview,
213
234
  onVerdict: recordGateVerdict,
235
+ createPostCheck: createGroundingCheck,
214
236
  });
215
237
  pi.registerProvider(overlay);
216
238
  overlays.set(providerId, {
@@ -440,6 +462,23 @@ export default function (pi: ExtensionAPI) {
440
462
  removeTwin,
441
463
  switchToGuarded,
442
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
+ },
443
482
  getLastGate: () => lastGate,
444
483
  });
445
484
 
@@ -494,6 +533,9 @@ export default function (pi: ExtensionAPI) {
494
533
 
495
534
  async function startSession(ctx: ExtensionContext): Promise<void> {
496
535
  restoreMode(ctx);
536
+ // Nuova sessione = nuova evidenza: il ledger non attraversa le sessioni.
537
+ evidenceLedger = [];
538
+ evidenceSeq = 0;
497
539
  const auto = process.env.JEV_AUTO_UPSTREAM?.trim();
498
540
  if (auto && auto !== "0" && auto.toLowerCase() !== "off") {
499
541
  try {
@@ -557,6 +599,59 @@ export default function (pi: ExtensionAPI) {
557
599
  });
558
600
  pi.on("session_tree", async (_event, ctx) => { await startSession(ctx); });
559
601
 
602
+ /**
603
+ * Assicura gli overlay PRIMA di qualsiasi before_agent_start (evento input).
604
+ * Un'altra estensione (pi-accounts) legge il registry nel suo
605
+ * before_agent_start per decidere i modelli disponibili dell'account e,
606
+ * se il twin non c'è, arma l'abort del turno ("This operation was
607
+ * aborted" istantaneo su turn_start). Il seed precoce rende i twin
608
+ * visibili a quella lettura, indipendentemente dall'ordine di load.
609
+ * Silenzioso: nessuna entry diagnostica qui (l'input arriva a ogni
610
+ * prompt); la diagnostica resta nel reassert di before_agent_start.
611
+ * Vale in ogni modalità: anche in on-demand il twin passthrough ha
612
+ * bisogno dell'overlay registrato (altrimenti l'id __jev finisce all'API).
613
+ */
614
+ function ensureOverlays(registry: ModelRegistry): void {
615
+ try {
616
+ const refs = twinRefs(config);
617
+ 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).
621
+ const byProvider = new Map<string, string[]>();
622
+ for (const ref of refs) {
623
+ if (overlays.get(ref.provider)?.twinIds.includes(ref.model)) continue;
624
+ const list = byProvider.get(ref.provider) ?? [];
625
+ if (!list.includes(ref.model)) list.push(ref.model);
626
+ byProvider.set(ref.provider, list);
627
+ }
628
+ for (const [providerId, twinIds] of byProvider) {
629
+ let live: Provider | undefined;
630
+ try {
631
+ live = registry.getProvider(providerId);
632
+ } catch {
633
+ continue;
634
+ }
635
+ if (!live) continue;
636
+ const models = live.getModels();
637
+ if (!twinIds.every((id) => models.some((m) => m.id === id && !isTwinId(m.id)))) {
638
+ continue;
639
+ }
640
+ try {
641
+ registerOverlay(providerId, live, twinIds);
642
+ } catch {
643
+ // Riprova al prossimo hook; mai bloccare l'input.
644
+ }
645
+ }
646
+ } catch {
647
+ // Mai bloccare l'input per il seed.
648
+ }
649
+ }
650
+
651
+ pi.on("input", async (_event, ctx) => {
652
+ ensureOverlays(ctx.modelRegistry);
653
+ });
654
+
560
655
  // Prima di ogni turno: se un'altra estensione ci ha scalzati dal provider,
561
656
  // rimettiamo l'overlay (altrimenti l'id __jev arriverebbe all'API).
562
657
  function noteReassert(fixed: string[]): void {
@@ -797,11 +892,161 @@ export default function (pi: ExtensionAPI) {
797
892
  return allow ? undefined : { block: true, reason: `Bloccato da jev exfil check (${summary}) (declined).` };
798
893
  }
799
894
 
895
+ /** Registra un tool_result nel ledger evidenza (solo automatic, mai bloccare). */
896
+ function recordGroundingResult(event: {
897
+ toolName: string;
898
+ input: unknown;
899
+ content: unknown;
900
+ isError: boolean;
901
+ }): void {
902
+ try {
903
+ if (sessionMode !== "automatic") return;
904
+ if (!config.grounding.enabled) return;
905
+ const entry = recordToolResult({
906
+ toolName: event.toolName,
907
+ input: event.input,
908
+ content: event.content,
909
+ isError: event.isError,
910
+ seq: ++evidenceSeq,
911
+ at: Date.now(),
912
+ excerptChars: config.grounding.excerptChars,
913
+ });
914
+ if (!entry) return;
915
+ recordEvidence(evidenceLedger, entry, {
916
+ maxEntries: config.grounding.maxEntries,
917
+ invalidateTestsOnEdit: config.grounding.invalidateTestsOnEdit,
918
+ });
919
+ } catch {
920
+ // Mai rompere i risultati tool per il ledger.
921
+ }
922
+ }
923
+
924
+ function throttledGroundingError(error: unknown): void {
925
+ const now = Date.now();
926
+ if (now - lastGroundingErrorAt < 60_000) return;
927
+ lastGroundingErrorAt = now;
928
+ try {
929
+ pi.appendEntry("jev-grounding-error", {
930
+ error: error instanceof Error ? error.message : String(error),
931
+ });
932
+ } catch {
933
+ // solo diagnostica
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Factory per-richiesta del controllo grounding: il budget repair vive
939
+ * nella closure (una per runGuardedStream). Ritorna undefined quando il
940
+ * controllo non si applica: il gate pubblica senza nota (fail-open).
941
+ */
942
+ function createGroundingCheck(): PostPublishCheckFn | undefined {
943
+ try {
944
+ if (sessionMode !== "automatic") return undefined;
945
+ if (!config.grounding.enabled) return undefined;
946
+ if (!resolveBackend(config).apiKeyPresent) return undefined;
947
+ const g = config.grounding;
948
+ const mode = g.mode;
949
+ const maxRepairs = g.maxRepairs;
950
+ let repairsUsed = 0;
951
+ return async ({ text, attempts }) => {
952
+ const digest = buildEvidenceDigest(evidenceLedger, {
953
+ maxEntries: g.maxEntries,
954
+ evidenceChars: g.evidenceChars,
955
+ });
956
+ // Senza evidenza non si giudica: "senza evidenza" ≠ "falso".
957
+ if (digest.entries.length === 0) return { action: "publish" };
958
+ const state = buildGroundingState(text, digest, {
959
+ maxCandidateChars: config.limits.maxCandidateChars,
960
+ });
961
+ let evaluation: GroundingEvaluation | undefined;
962
+ try {
963
+ evaluation = await groundingCache.getOr(
964
+ groundingKey(text, state),
965
+ async () => {
966
+ const asked = await typedHandle.ask(state, GROUNDING_QUESTIONS, undefined);
967
+ return evaluateGrounding(asked, {
968
+ contradictionThreshold: g.contradictionThreshold,
969
+ unsupportedThreshold: g.unsupportedThreshold,
970
+ });
971
+ },
972
+ );
973
+ } catch (error) {
974
+ throttledGroundingError(error);
975
+ return { action: "publish" };
976
+ }
977
+ if (!evaluation) return { action: "publish" };
978
+ const decision = decideGroundingAction({
979
+ evaluation,
980
+ mode,
981
+ repairsUsed,
982
+ maxRepairs,
983
+ });
984
+ if (decision.action === "regenerate") {
985
+ repairsUsed++;
986
+ try {
987
+ pi.appendEntry("jev-grounding-repair", { reasons: evaluation.reasons });
988
+ } catch {
989
+ // solo diagnostica
990
+ }
991
+ return {
992
+ action: "regenerate",
993
+ feedback: decision.feedback,
994
+ grounding: { action: "repaired", reasons: evaluation.reasons },
995
+ };
996
+ }
997
+ if (decision.action === "hold") {
998
+ try {
999
+ pi.appendEntry("jev-grounding-hold", {
1000
+ reasons: evaluation.reasons,
1001
+ mode,
1002
+ });
1003
+ } catch {
1004
+ // solo diagnostica
1005
+ }
1006
+ return {
1007
+ action: "hold",
1008
+ holdText: buildGroundingHoldText({ reasons: evaluation.reasons, attempts }),
1009
+ grounding: { action: "held", reasons: evaluation.reasons },
1010
+ };
1011
+ }
1012
+ if (decision.notice) {
1013
+ try {
1014
+ pi.appendEntry("jev-grounding-warn", {
1015
+ reasons: evaluation.reasons,
1016
+ mode,
1017
+ });
1018
+ } catch {
1019
+ // solo diagnostica
1020
+ }
1021
+ return {
1022
+ action: "publish",
1023
+ notice: decision.notice,
1024
+ grounding:
1025
+ repairsUsed > 0
1026
+ ? { action: "repaired", reasons: evaluation.reasons }
1027
+ : { action: "warned", reasons: evaluation.reasons },
1028
+ };
1029
+ }
1030
+ return {
1031
+ action: "publish",
1032
+ grounding:
1033
+ repairsUsed > 0
1034
+ ? { action: "repaired", reasons: evaluation.reasons }
1035
+ : undefined,
1036
+ };
1037
+ };
1038
+ } catch {
1039
+ return undefined;
1040
+ }
1041
+ }
1042
+
800
1043
  // Giudice output (solo automatic, fail-open): dopo l'esecuzione cerca
801
1044
  // secret nel testo e classifica i fallimenti. Non blocca mai: allega
802
1045
  // una riga al risultato che il modello legge.
803
1046
  pi.on("tool_result", async (event, ctx) => {
804
1047
  try {
1048
+ // Ledger prima di tutto: vale anche con outputJudge disattivato.
1049
+ recordGroundingResult(event);
805
1050
  if (sessionMode !== "automatic") return;
806
1051
  if (!config.outputJudge.enabled) return;
807
1052
  if (!config.outputJudge.tools.includes(event.toolName)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jev-guard",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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
+ }