@stackbilt/aegis-core 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbilt/aegis-core",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -62,7 +62,12 @@
62
62
  "./contracts/memory-entry": "./src/contracts/memory-entry.contract.ts",
63
63
  "./wiki/client": "./src/wiki/client.ts",
64
64
  "./wiki/types": "./src/wiki/types.ts",
65
- "./kernel/memory-service": "./src/kernel/memory-service.ts"
65
+ "./kernel/memory-service": "./src/kernel/memory-service.ts",
66
+ "./kernel/grounding-layer": "./src/kernel/grounding-layer.ts",
67
+ "./kernel/grounding/verify": "./src/kernel/grounding/verify.ts",
68
+ "./kernel/grounding/fanout": "./src/kernel/grounding/fanout.ts",
69
+ "./kernel/grounding/fabrication-detector": "./src/kernel/grounding/fabrication-detector.ts",
70
+ "./kernel/grounding/semantic-sanhedrin": "./src/kernel/grounding/semantic-sanhedrin.ts"
66
71
  },
67
72
  "scripts": {
68
73
  "dev": "wrangler dev",
@@ -62,6 +62,8 @@ export interface EdgeEnv {
62
62
  blueskyHandle?: string;
63
63
  blueskyAppPassword?: string;
64
64
  authBinding?: import('../types.js').AuthServiceBinding;
65
+ wikiBinding?: Fetcher;
66
+ wikiToken?: string;
65
67
  }
66
68
 
67
69
  // ─── Intent Construction ─────────────────────────────────────
@@ -0,0 +1,377 @@
1
+ // Fabrication detector post-pass for aegis_chat responses.
2
+ //
3
+ // v1 (aegis#447) — mutation-claim class. Scans for present-tense mutation
4
+ // language about agenda items and verifies each against D1 via the
5
+ // #448 helpers. Closes the Chimera failure mode (LLM narrates "resolved #N"
6
+ // while the item is still active).
7
+ //
8
+ // v2 (aegis#500) — referential-claim class. Scans for code-fenced slug-shaped
9
+ // strings asserted as canonical wiki pages and verifies each against the wiki.
10
+ // Note: pattern_id verification is omitted in this core layer (the convergence
11
+ // catalog is daemon/Stackbilt-specific); pattern_id claims are silently skipped.
12
+ //
13
+ // v1 follow-up 1 (aegis#447) — task mutation detection. Scans for UUID-quoted
14
+ // task state claims and verifies each against the cc_tasks D1 table.
15
+ //
16
+ // Shared scope posture for all passes:
17
+ // - Flag, don't strip. Operators see `unverified_claims[]` in the envelope.
18
+ // - Non-fatal on verification error. A single slug lookup failure does not
19
+ // block the other claims from being reported.
20
+
21
+ import type { WikiClientEnv } from '../../wiki/client.js';
22
+ import { verifyAgendaClaim, verifyTaskClaim, verifyWikiPageClaim } from './verify.js';
23
+
24
+ export interface AgendaMutationClaim {
25
+ kind: 'agenda';
26
+ id: number;
27
+ claimedStatus: 'resolved' | 'created' | 'dismissed';
28
+ snippet: string;
29
+ }
30
+
31
+ export interface TaskMutationClaim {
32
+ kind: 'task';
33
+ id: string;
34
+ claimedStatus: 'created' | 'completed' | 'cancelled' | 'running';
35
+ snippet: string;
36
+ }
37
+
38
+ export type MutationClaim = AgendaMutationClaim | TaskMutationClaim;
39
+
40
+ export interface ReferentialClaim {
41
+ kind: 'wiki_page' | 'pattern_id';
42
+ reference: string;
43
+ snippet: string;
44
+ }
45
+
46
+ export interface UnverifiedAgendaMutationClaim {
47
+ kind: 'agenda';
48
+ id: number;
49
+ claimedStatus: AgendaMutationClaim['claimedStatus'];
50
+ actualStatus: string | null;
51
+ snippet: string;
52
+ reason: 'status_mismatch' | 'not_found';
53
+ }
54
+
55
+ export interface UnverifiedTaskMutationClaim {
56
+ kind: 'task';
57
+ id: string;
58
+ claimedStatus: TaskMutationClaim['claimedStatus'];
59
+ actualStatus: string | null;
60
+ snippet: string;
61
+ reason: 'status_mismatch' | 'not_found';
62
+ }
63
+
64
+ export type UnverifiedMutationClaim =
65
+ | UnverifiedAgendaMutationClaim
66
+ | UnverifiedTaskMutationClaim;
67
+
68
+ export interface UnverifiedReferentialClaim {
69
+ kind: ReferentialClaim['kind'];
70
+ reference: string;
71
+ snippet: string;
72
+ reason: 'not_found';
73
+ }
74
+
75
+ export type UnverifiedClaim = UnverifiedMutationClaim | UnverifiedReferentialClaim;
76
+
77
+ // ─── Detection ─────────────────────────────────────────────────────
78
+
79
+ interface AgendaPattern {
80
+ re: RegExp;
81
+ status: AgendaMutationClaim['claimedStatus'];
82
+ }
83
+
84
+ const AGENDA_PATTERNS: AgendaPattern[] = [
85
+ { re: /marked\s+#(\d+)\s+(?:as\s+)?resolved/gi, status: 'resolved' },
86
+ { re: /#(\d+)\s+marked\s+(?:as\s+)?resolved/gi, status: 'resolved' },
87
+ { re: /(?:^|[.\s])resolved\s+#(\d+)/gi, status: 'resolved' },
88
+ { re: /#(\d+)\s+is\s+(?:now\s+)?resolved/gi, status: 'resolved' },
89
+ { re: /(?:^|[.\s])closed\s+#(\d+)/gi, status: 'resolved' },
90
+ { re: /#(\d+)\s+is\s+(?:now\s+)?closed/gi, status: 'resolved' },
91
+ { re: /created\s+(?:agenda\s+item\s+)?#(\d+)/gi, status: 'created' },
92
+ { re: /added\s+(?:agenda\s+item\s+)?#(\d+)/gi, status: 'created' },
93
+ { re: /dismissed\s+#(\d+)/gi, status: 'dismissed' },
94
+ { re: /#(\d+)\s+is\s+(?:now\s+)?dismissed/gi, status: 'dismissed' },
95
+ ];
96
+
97
+ interface TaskPattern {
98
+ re: RegExp;
99
+ status: TaskMutationClaim['claimedStatus'];
100
+ }
101
+
102
+ const UUID_FRAG = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
103
+
104
+ const TASK_PATTERNS: TaskPattern[] = [
105
+ { re: new RegExp(`(?:created|queued|added)\\s+task\\s+\`?(${UUID_FRAG})\`?`, 'gi'), status: 'created' },
106
+ { re: new RegExp(`completed\\s+task\\s+\`?(${UUID_FRAG})\`?`, 'gi'), status: 'completed' },
107
+ { re: new RegExp(`marked\\s+task\\s+\`?(${UUID_FRAG})\`?\\s+(?:as\\s+)?completed`, 'gi'), status: 'completed' },
108
+ { re: new RegExp(`task\\s+\`?(${UUID_FRAG})\`?\\s+(?:is|has)\\s+(?:now\\s+|been\\s+)?completed`, 'gi'), status: 'completed' },
109
+ { re: new RegExp(`cancell?ed\\s+task\\s+\`?(${UUID_FRAG})\`?`, 'gi'), status: 'cancelled' },
110
+ { re: new RegExp(`task\\s+\`?(${UUID_FRAG})\`?\\s+(?:is|was)\\s+(?:now\\s+)?cancell?ed`, 'gi'), status: 'cancelled' },
111
+ { re: new RegExp(`task\\s+\`?(${UUID_FRAG})\`?\\s+is\\s+(?:now\\s+)?running`, 'gi'), status: 'running' },
112
+ ];
113
+
114
+ export function detectMutationClaims(text: string): MutationClaim[] {
115
+ const claims: MutationClaim[] = [];
116
+ const seen = new Set<string>();
117
+
118
+ for (const { re, status } of AGENDA_PATTERNS) {
119
+ for (const m of text.matchAll(re)) {
120
+ const id = Number(m[1]);
121
+ if (!Number.isInteger(id) || id < 1 || id > 1e9) continue;
122
+ const key = `agenda:${id}:${status}`;
123
+ if (seen.has(key)) continue;
124
+ seen.add(key);
125
+ const startContext = Math.max(0, (m.index ?? 0) - 30);
126
+ const endContext = Math.min(text.length, (m.index ?? 0) + m[0].length + 30);
127
+ const snippet = text.slice(startContext, endContext).replace(/\s+/g, ' ').trim();
128
+ claims.push({ kind: 'agenda', id, claimedStatus: status, snippet });
129
+ }
130
+ }
131
+
132
+ for (const { re, status } of TASK_PATTERNS) {
133
+ for (const m of text.matchAll(re)) {
134
+ const id = m[1].toLowerCase();
135
+ const key = `task:${id}:${status}`;
136
+ if (seen.has(key)) continue;
137
+ seen.add(key);
138
+ const startContext = Math.max(0, (m.index ?? 0) - 30);
139
+ const endContext = Math.min(text.length, (m.index ?? 0) + m[0].length + 30);
140
+ const snippet = text.slice(startContext, endContext).replace(/\s+/g, ' ').trim();
141
+ claims.push({ kind: 'task', id, claimedStatus: status, snippet });
142
+ }
143
+ }
144
+
145
+ return claims;
146
+ }
147
+
148
+ // ─── Referential-claim detection (aegis#500 v2) ────────────────────
149
+
150
+ const SLUG_RE = /`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)`/g;
151
+ const PATTERN_KEYWORD_RE = /\bpatterns?\b/i;
152
+ const WIKI_KEYWORD_RE = /\b(?:canonical|wiki[\s-]page|wiki[\s-]concept|concepts?\s+page|canonical[\s-]page)\b/i;
153
+ const MAX_REFERENTIAL_CLAIMS = 10;
154
+
155
+ function sentenceStartBefore(text: string, idx: number): number {
156
+ for (let i = idx - 1; i >= 0; i--) {
157
+ const c = text[i];
158
+ if ((c === '.' || c === '!' || c === '?') && i + 1 < text.length && /\s/.test(text[i + 1])) {
159
+ return i + 2;
160
+ }
161
+ }
162
+ return 0;
163
+ }
164
+
165
+ export function detectReferentialClaims(text: string): ReferentialClaim[] {
166
+ const claims: ReferentialClaim[] = [];
167
+ const seen = new Set<string>();
168
+
169
+ for (const m of text.matchAll(SLUG_RE)) {
170
+ if (claims.length >= MAX_REFERENTIAL_CLAIMS) break;
171
+ const slug = m[1];
172
+ const matchIndex = m.index ?? 0;
173
+ const sentenceStart = sentenceStartBefore(text, matchIndex);
174
+ const window = text.slice(sentenceStart, matchIndex);
175
+
176
+ const isPattern = PATTERN_KEYWORD_RE.test(window);
177
+ const isWiki = WIKI_KEYWORD_RE.test(window);
178
+ if (!isPattern && !isWiki) continue;
179
+
180
+ const kind: ReferentialClaim['kind'] = isPattern ? 'pattern_id' : 'wiki_page';
181
+ const key = `${kind}:${slug}`;
182
+ if (seen.has(key)) continue;
183
+ seen.add(key);
184
+
185
+ const snippetStart = Math.max(0, matchIndex - 30);
186
+ const snippetEnd = Math.min(text.length, matchIndex + m[0].length + 30);
187
+ const snippet = text.slice(snippetStart, snippetEnd).replace(/\s+/g, ' ').trim();
188
+
189
+ claims.push({ kind, reference: slug, snippet });
190
+ }
191
+
192
+ return claims;
193
+ }
194
+
195
+ // ─── Verification ──────────────────────────────────────────────────
196
+
197
+ export interface FabricationReport {
198
+ checked: number;
199
+ unverified: UnverifiedClaim[];
200
+ }
201
+
202
+ export async function verifyMutationClaims(
203
+ claims: MutationClaim[],
204
+ db: D1Database,
205
+ ): Promise<FabricationReport> {
206
+ const unverified: UnverifiedClaim[] = [];
207
+
208
+ for (const claim of claims) {
209
+ try {
210
+ if (claim.kind === 'agenda') {
211
+ const unv = await verifyAgendaMutation(db, claim);
212
+ if (unv) unverified.push(unv);
213
+ } else if (claim.kind === 'task') {
214
+ const unv = await verifyTaskMutation(db, claim);
215
+ if (unv) unverified.push(unv);
216
+ }
217
+ } catch {
218
+ // Verification failure is non-fatal — skip this claim rather than
219
+ // block the entire response.
220
+ }
221
+ }
222
+
223
+ return { checked: claims.length, unverified };
224
+ }
225
+
226
+ async function verifyAgendaMutation(
227
+ db: D1Database,
228
+ claim: AgendaMutationClaim,
229
+ ): Promise<UnverifiedAgendaMutationClaim | null> {
230
+ const r = await verifyAgendaClaim(db, claim.id);
231
+ if (!r.exists) {
232
+ return {
233
+ kind: 'agenda',
234
+ id: claim.id,
235
+ claimedStatus: claim.claimedStatus,
236
+ actualStatus: null,
237
+ snippet: claim.snippet,
238
+ reason: 'not_found',
239
+ };
240
+ }
241
+ const actualStatus = r.item?.status;
242
+ const mismatch =
243
+ (claim.claimedStatus === 'resolved' || claim.claimedStatus === 'dismissed') &&
244
+ actualStatus === 'active';
245
+ if (mismatch) {
246
+ return {
247
+ kind: 'agenda',
248
+ id: claim.id,
249
+ claimedStatus: claim.claimedStatus,
250
+ actualStatus: actualStatus ?? null,
251
+ snippet: claim.snippet,
252
+ reason: 'status_mismatch',
253
+ };
254
+ }
255
+ return null;
256
+ }
257
+
258
+ async function verifyTaskMutation(
259
+ db: D1Database,
260
+ claim: TaskMutationClaim,
261
+ ): Promise<UnverifiedTaskMutationClaim | null> {
262
+ const r = await verifyTaskClaim(db, claim.id);
263
+ if (!r.exists) {
264
+ return {
265
+ kind: 'task',
266
+ id: claim.id,
267
+ claimedStatus: claim.claimedStatus,
268
+ actualStatus: null,
269
+ snippet: claim.snippet,
270
+ reason: 'not_found',
271
+ };
272
+ }
273
+ const actualStatus = r.task?.status;
274
+ let mismatch = false;
275
+ if (claim.claimedStatus === 'completed') {
276
+ mismatch = actualStatus !== 'completed';
277
+ } else if (claim.claimedStatus === 'cancelled') {
278
+ mismatch = actualStatus !== 'cancelled';
279
+ } else if (claim.claimedStatus === 'running') {
280
+ mismatch = actualStatus !== 'running';
281
+ }
282
+ if (mismatch) {
283
+ return {
284
+ kind: 'task',
285
+ id: claim.id,
286
+ claimedStatus: claim.claimedStatus,
287
+ actualStatus: actualStatus ?? null,
288
+ snippet: claim.snippet,
289
+ reason: 'status_mismatch',
290
+ };
291
+ }
292
+ return null;
293
+ }
294
+
295
+ export async function verifyReferentialClaims(
296
+ claims: ReferentialClaim[],
297
+ env: WikiClientEnv,
298
+ ): Promise<FabricationReport> {
299
+ const unverified: UnverifiedClaim[] = [];
300
+
301
+ for (const claim of claims) {
302
+ try {
303
+ if (claim.kind === 'pattern_id') {
304
+ // Pattern catalog is daemon/consumer-specific; skip verification in core.
305
+ continue;
306
+ }
307
+
308
+ // kind === 'wiki_page' — needs a live check. Skip silently if no binding.
309
+ if (!env.wikiBinding || !env.wikiToken) continue;
310
+ const r = await verifyWikiPageClaim(env, claim.reference);
311
+ if (!r.exists) {
312
+ unverified.push({
313
+ kind: 'wiki_page',
314
+ reference: claim.reference,
315
+ snippet: claim.snippet,
316
+ reason: 'not_found',
317
+ });
318
+ }
319
+ } catch {
320
+ // Non-fatal per v1 posture.
321
+ }
322
+ }
323
+
324
+ return { checked: claims.length, unverified };
325
+ }
326
+
327
+ // ─── Full post-pass ────────────────────────────────────────────────
328
+
329
+ export interface FabricationCheckEnv extends WikiClientEnv {
330
+ db: D1Database;
331
+ }
332
+
333
+ export async function fabricationCheck(
334
+ responseText: string,
335
+ env: FabricationCheckEnv,
336
+ ): Promise<FabricationReport> {
337
+ const mutationClaims = detectMutationClaims(responseText);
338
+ const referentialClaims = detectReferentialClaims(responseText);
339
+
340
+ if (mutationClaims.length === 0 && referentialClaims.length === 0) {
341
+ return { checked: 0, unverified: [] };
342
+ }
343
+
344
+ const [mutationReport, referentialReport] = await Promise.all([
345
+ mutationClaims.length ? verifyMutationClaims(mutationClaims, env.db) : Promise.resolve<FabricationReport>({ checked: 0, unverified: [] }),
346
+ referentialClaims.length ? verifyReferentialClaims(referentialClaims, env) : Promise.resolve<FabricationReport>({ checked: 0, unverified: [] }),
347
+ ]);
348
+
349
+ return {
350
+ checked: mutationReport.checked + referentialReport.checked,
351
+ unverified: [...mutationReport.unverified, ...referentialReport.unverified],
352
+ };
353
+ }
354
+
355
+ // ─── Envelope format ───────────────────────────────────────────────
356
+
357
+ export function formatUnverifiedClaims(report: FabricationReport): string[] {
358
+ return report.unverified.map((u) => {
359
+ if (u.kind === 'agenda') {
360
+ if (u.reason === 'not_found') {
361
+ return `agenda#${u.id} (claimed ${u.claimedStatus}, but item does not exist)`;
362
+ }
363
+ return `agenda#${u.id} (claimed ${u.claimedStatus}, actual status: ${u.actualStatus})`;
364
+ }
365
+ if (u.kind === 'task') {
366
+ if (u.reason === 'not_found') {
367
+ return `task \`${u.id}\` (claimed ${u.claimedStatus}, but task does not exist)`;
368
+ }
369
+ return `task \`${u.id}\` (claimed ${u.claimedStatus}, actual status: ${u.actualStatus})`;
370
+ }
371
+ if (u.kind === 'wiki_page') {
372
+ return `wiki page \`${u.reference}\` (claimed canonical, but no such page exists)`;
373
+ }
374
+ // pattern_id — not verified in core, included for type completeness
375
+ return `pattern \`${u.reference}\` (claimed canonical, not in convergence catalog)`;
376
+ });
377
+ }
@@ -0,0 +1,240 @@
1
+ // Grounding fanout — extracts named entities from a raw intent, runs parallel
2
+ // retrieval against D1 (agenda/task claims) and the wiki, assembles a structured
3
+ // grounding block, and returns it to the caller for prompt injection.
4
+ //
5
+ // Decision-entity fanout (BizOps) is omitted from this generic core layer.
6
+ // Consumers that need it should compose at the call site.
7
+
8
+ import { searchPages } from '../../wiki/client.js';
9
+ import type { WikiClientEnv } from '../../wiki/client.js';
10
+ import { verifyAgendaClaim, verifyTaskClaim } from './verify.js';
11
+ import type { AgendaClaimResult, TaskClaimResult } from './verify.js';
12
+
13
+ export interface ExtractedEntities {
14
+ agendaRefs: number[];
15
+ taskRefs: string[];
16
+ namedEntities: string[];
17
+ }
18
+
19
+ export interface GroundingResult {
20
+ entities: ExtractedEntities;
21
+ agendaHits: Array<{ id: number; status: 'verified' | 'unknown'; item?: AgendaClaimResult['item'] }>;
22
+ taskHits: Array<{ id: string; status: 'verified' | 'unknown'; task?: TaskClaimResult['task'] }>;
23
+ wikiHits: Array<{ slug: string; scope?: string; summary?: string }>;
24
+ searched: string[];
25
+ }
26
+
27
+ // ─── Entity extraction ─────────────────────────────────────────────
28
+
29
+ const AGENDA_REF_RE = /(?:^|\s|\()#(\d+)(?=\b)/g;
30
+ const TASK_REF_RE = /(?:^|\s|\()task[:_\s-]?([A-Za-z0-9-]{8,})/gi;
31
+ const ORG_REPO_RE = /\b([A-Za-z][\w-]{2,})\/([A-Za-z][\w.-]{2,})\b/g;
32
+ const QUOTED_RE = /"([^"\n]{3,60})"/g;
33
+
34
+ const STOP_TOKENS = new Set([
35
+ 'it', 'that', 'this', 'the', 'our', 'your', 'their', 'a', 'an',
36
+ 'what', 'which', 'who', 'when', 'where', 'why', 'how',
37
+ 'is', 'are', 'was', 'were', 'do', 'does', 'did',
38
+ ]);
39
+
40
+ export function extractEntities(raw: string): ExtractedEntities {
41
+ const agendaRefs = new Set<number>();
42
+ const taskRefs = new Set<string>();
43
+ const named = new Set<string>();
44
+
45
+ for (const m of raw.matchAll(AGENDA_REF_RE)) {
46
+ const n = Number(m[1]);
47
+ if (Number.isInteger(n) && n > 0 && n < 1e9) agendaRefs.add(n);
48
+ }
49
+
50
+ for (const m of raw.matchAll(TASK_REF_RE)) {
51
+ const id = m[1];
52
+ if (id && id.length >= 8 && id.length <= 64) taskRefs.add(id);
53
+ }
54
+
55
+ for (const m of raw.matchAll(ORG_REPO_RE)) {
56
+ const org = m[1];
57
+ if (!STOP_TOKENS.has(org.toLowerCase())) {
58
+ named.add(`${m[1]}/${m[2]}`);
59
+ }
60
+ }
61
+
62
+ for (const m of raw.matchAll(QUOTED_RE)) {
63
+ const phrase = m[1].trim();
64
+ if (phrase.length >= 3) named.add(phrase);
65
+ }
66
+
67
+ return {
68
+ agendaRefs: [...agendaRefs],
69
+ taskRefs: [...taskRefs],
70
+ namedEntities: [...named],
71
+ };
72
+ }
73
+
74
+ // ─── Fanout ────────────────────────────────────────────────────────
75
+
76
+ export interface GroundingFanoutEnv {
77
+ db: D1Database;
78
+ wiki?: WikiClientEnv;
79
+ }
80
+
81
+ export async function groundIntent(
82
+ raw: string,
83
+ env: GroundingFanoutEnv,
84
+ ): Promise<GroundingResult> {
85
+ const entities = extractEntities(raw);
86
+ const searched: string[] = [];
87
+
88
+ const agendaPromise = (async () => {
89
+ if (entities.agendaRefs.length === 0) return [];
90
+ searched.push('d1.agenda');
91
+ const results = await Promise.all(
92
+ entities.agendaRefs.map(async (id) => {
93
+ try {
94
+ const r = await verifyAgendaClaim(env.db, id);
95
+ return r.exists
96
+ ? { id, status: 'verified' as const, item: r.item }
97
+ : { id, status: 'unknown' as const };
98
+ } catch {
99
+ return { id, status: 'unknown' as const };
100
+ }
101
+ }),
102
+ );
103
+ return results;
104
+ })();
105
+
106
+ const taskPromise = (async () => {
107
+ if (entities.taskRefs.length === 0) return [];
108
+ searched.push('d1.tasks');
109
+ const results = await Promise.all(
110
+ entities.taskRefs.map(async (id) => {
111
+ try {
112
+ const r = await verifyTaskClaim(env.db, id);
113
+ return r.exists
114
+ ? { id, status: 'verified' as const, task: r.task }
115
+ : { id, status: 'unknown' as const };
116
+ } catch {
117
+ return { id, status: 'unknown' as const };
118
+ }
119
+ }),
120
+ );
121
+ return results;
122
+ })();
123
+
124
+ const wikiPromise = (async () => {
125
+ if (!env.wiki || entities.namedEntities.length === 0) return [];
126
+ searched.push('wiki');
127
+ const hits: GroundingResult['wikiHits'] = [];
128
+ for (const entity of entities.namedEntities.slice(0, 5)) {
129
+ try {
130
+ const { results } = await searchPages(env.wiki, entity, { limit: 3 });
131
+ for (const r of results) {
132
+ hits.push({ slug: r.slug, scope: r.scope, summary: r.summary });
133
+ }
134
+ } catch {
135
+ // non-fatal per-entity
136
+ }
137
+ }
138
+ // Dedupe by slug
139
+ const seen = new Set<string>();
140
+ return hits.filter((h) => {
141
+ if (seen.has(h.slug)) return false;
142
+ seen.add(h.slug);
143
+ return true;
144
+ });
145
+ })();
146
+
147
+ const [agendaHits, taskHits, wikiHits] = await Promise.all([
148
+ agendaPromise,
149
+ taskPromise,
150
+ wikiPromise,
151
+ ]);
152
+
153
+ return { entities, agendaHits, taskHits, wikiHits, searched };
154
+ }
155
+
156
+ // ─── Envelope summary ──────────────────────────────────────────────
157
+
158
+ export interface GroundingEnvelope {
159
+ grounded: boolean;
160
+ sources: string[];
161
+ unknowns: string[];
162
+ searched: string[];
163
+ }
164
+
165
+ export function summarizeGrounding(result: GroundingResult): GroundingEnvelope {
166
+ const sources: string[] = [];
167
+ const unknowns: string[] = [];
168
+
169
+ for (const h of result.agendaHits) {
170
+ if (h.status === 'verified') sources.push(`d1:agenda/${h.id}`);
171
+ else unknowns.push(`agenda#${h.id}`);
172
+ }
173
+ for (const h of result.taskHits) {
174
+ if (h.status === 'verified') sources.push(`d1:task/${h.id}`);
175
+ else unknowns.push(`task:${h.id}`);
176
+ }
177
+ for (const h of result.wikiHits) {
178
+ sources.push(`wiki:${h.scope ? `${h.scope}/` : ''}${h.slug}`);
179
+ }
180
+
181
+ return {
182
+ grounded: sources.length > 0,
183
+ sources,
184
+ unknowns,
185
+ searched: [...result.searched],
186
+ };
187
+ }
188
+
189
+ export function formatGroundingBlock(result: GroundingResult): string | null {
190
+ const hasContent =
191
+ result.agendaHits.length > 0 ||
192
+ result.taskHits.length > 0 ||
193
+ result.wikiHits.length > 0;
194
+ if (!hasContent) return null;
195
+
196
+ const lines: string[] = ['[Grounding — verified facts for entities in this query]'];
197
+
198
+ if (result.agendaHits.length > 0) {
199
+ lines.push('');
200
+ lines.push('Agenda items:');
201
+ for (const h of result.agendaHits) {
202
+ if (h.status === 'verified' && h.item) {
203
+ lines.push(
204
+ ` #${h.id} — ${h.item.item} (status: ${h.item.status}, priority: ${h.item.priority}${h.item.resolved_at ? `, resolved ${h.item.resolved_at}` : ''})`,
205
+ );
206
+ } else {
207
+ lines.push(` #${h.id} — UNKNOWN: no such agenda item exists in D1.`);
208
+ }
209
+ }
210
+ }
211
+
212
+ if (result.taskHits.length > 0) {
213
+ lines.push('');
214
+ lines.push('Tasks:');
215
+ for (const h of result.taskHits) {
216
+ if (h.status === 'verified' && h.task) {
217
+ lines.push(
218
+ ` ${h.id} — ${h.task.title} (status: ${h.task.status}${h.task.completed_at ? `, completed ${h.task.completed_at}` : ''})`,
219
+ );
220
+ } else {
221
+ lines.push(` ${h.id} — UNKNOWN: no such task exists in D1.`);
222
+ }
223
+ }
224
+ }
225
+
226
+ if (result.wikiHits.length > 0) {
227
+ lines.push('');
228
+ lines.push('Related wiki pages:');
229
+ for (const h of result.wikiHits.slice(0, 8)) {
230
+ const summary = h.summary ? ` — ${h.summary.slice(0, 140)}` : '';
231
+ lines.push(` ${h.scope ? `${h.scope}/` : ''}${h.slug}${summary}`);
232
+ }
233
+ }
234
+
235
+ lines.push('');
236
+ lines.push(
237
+ '[Instruction: For any entity marked UNKNOWN above, respond "I have no record of X" and do not invent details. Treat verified entries as authoritative; cite wiki pages by slug when used.]',
238
+ );
239
+ return lines.join('\n');
240
+ }
@@ -0,0 +1,163 @@
1
+ // Semantic Sanhedrin — post-generation wiki-contradiction gate (aegis#573).
2
+ //
3
+ // Catches factual claims that slip past the structured fabrication detector
4
+ // (agenda/task mutation language, referential slugs) because they have no
5
+ // regex signature: "our Worker runs at X", "pricing is $Y", "version is Z".
6
+ //
7
+ // Pipeline:
8
+ // 1. Skip short responses (< MIN_RESPONSE_WORDS) — not worth the model call.
9
+ // 2. wiki_search on the first 200 chars of the response to get domain context.
10
+ // 3. Extract candidate claim sentences (factual markers, non-questions, ≥10 words).
11
+ // 4. Workers AI llama-3.1-8b: "do any of these statements contradict wiki facts?"
12
+ // 5. Parse JSON result, filter by confidence ≥ MIN_CONFIDENCE.
13
+ //
14
+ // Posture: flag, don't strip. Non-fatal.
15
+
16
+ import { searchPages } from '../../wiki/client.js';
17
+ import type { WikiClientEnv } from '../../wiki/client.js';
18
+
19
+ const MIN_RESPONSE_WORDS = 150;
20
+ const WIKI_SEARCH_LIMIT = 3;
21
+ const MAX_WIKI_EXCERPT_CHARS = 300;
22
+ const MAX_RESPONSE_CHARS = 800;
23
+ const MAX_CANDIDATE_SENTENCES = 15;
24
+ const MIN_CONFIDENCE = 0.8;
25
+ const SANHEDRIN_MODEL = '@cf/meta/llama-3.1-8b-instruct';
26
+ const MAX_TOKENS = 300;
27
+
28
+ export interface WikiContradiction {
29
+ statement: string;
30
+ wiki_source: string;
31
+ confidence: number;
32
+ }
33
+
34
+ export interface WikiContradictionReport {
35
+ checked: number;
36
+ contradictions: WikiContradiction[];
37
+ }
38
+
39
+ export interface SanhedrinEnv extends WikiClientEnv {
40
+ ai?: Ai;
41
+ }
42
+
43
+ // ─── Candidate sentence extraction ─────────────────────────────────────
44
+
45
+ const FACTUAL_SIGNAL_RE = /\b(?:is|are|was|were|costs?|deployed|version|currently|runs?|uses?|our|aegis|worker|pricing|endpoint|url|located|serves?|hosts?|available)\b/i;
46
+ const QUESTION_RE = /\?/;
47
+ const MIN_SENTENCE_WORDS = 10;
48
+
49
+ export function extractCandidateSentences(text: string): string[] {
50
+ const sentences = text
51
+ .replace(/\n+/g, ' ')
52
+ .split(/(?<=[.!?])\s+/)
53
+ .map(s => s.trim())
54
+ .filter(Boolean);
55
+
56
+ const candidates: string[] = [];
57
+ for (const s of sentences) {
58
+ if (candidates.length >= MAX_CANDIDATE_SENTENCES) break;
59
+ if (QUESTION_RE.test(s)) continue;
60
+ if (s.split(/\s+/).length < MIN_SENTENCE_WORDS) continue;
61
+ if (!FACTUAL_SIGNAL_RE.test(s)) continue;
62
+ candidates.push(s);
63
+ }
64
+ return candidates;
65
+ }
66
+
67
+ // ─── Workers AI call ────────────────────────────────────────────────────
68
+
69
+ function buildPrompt(wikiContext: string, candidates: string[]): string {
70
+ const numbered = candidates.map((s, i) => `${i + 1}. "${s}"`).join('\n');
71
+ return [
72
+ 'You are a factual accuracy auditor. Check if any candidate statements contradict the wiki facts below.',
73
+ '',
74
+ 'WIKI FACTS:',
75
+ wikiContext,
76
+ '',
77
+ 'CANDIDATE STATEMENTS:',
78
+ numbered,
79
+ '',
80
+ 'Respond with ONLY valid JSON — no prose, no markdown fences:',
81
+ '{"contradictions":[{"statement":"...","wiki_source":"page title","confidence":0.0}]}',
82
+ 'Only include contradictions with confidence >= 0.7. If none found: {"contradictions":[]}',
83
+ ].join('\n');
84
+ }
85
+
86
+ function parseAiResponse(raw: string): WikiContradiction[] {
87
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim();
88
+ let parsed: unknown;
89
+ try {
90
+ parsed = JSON.parse(cleaned);
91
+ } catch {
92
+ const match = cleaned.match(/\{[\s\S]*\}/);
93
+ if (!match) return [];
94
+ try {
95
+ parsed = JSON.parse(match[0]);
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as Record<string, unknown>).contradictions)) {
102
+ return [];
103
+ }
104
+
105
+ const raw_list = (parsed as { contradictions: unknown[] }).contradictions;
106
+ const result: WikiContradiction[] = [];
107
+ for (const item of raw_list) {
108
+ if (!item || typeof item !== 'object') continue;
109
+ const c = item as Record<string, unknown>;
110
+ const statement = typeof c.statement === 'string' ? c.statement.trim() : '';
111
+ const wiki_source = typeof c.wiki_source === 'string' ? c.wiki_source.trim() : 'unknown';
112
+ const confidence = typeof c.confidence === 'number' ? c.confidence : 0;
113
+ if (!statement) continue;
114
+ result.push({ statement, wiki_source, confidence });
115
+ }
116
+ return result;
117
+ }
118
+
119
+ async function runWorkersAi(ai: Ai, prompt: string): Promise<WikiContradiction[]> {
120
+ const result = await (ai.run as (model: string, options: unknown) => Promise<{ response?: string }>)(
121
+ SANHEDRIN_MODEL,
122
+ {
123
+ messages: [{ role: 'user', content: prompt }],
124
+ max_tokens: MAX_TOKENS,
125
+ },
126
+ );
127
+ const text = result.response ?? '';
128
+ return parseAiResponse(text);
129
+ }
130
+
131
+ // ─── Public API ─────────────────────────────────────────────────────────
132
+
133
+ export async function semanticSanhedrinCheck(
134
+ responseText: string,
135
+ env: SanhedrinEnv,
136
+ ): Promise<WikiContradictionReport> {
137
+ const wordCount = responseText.trim().split(/\s+/).length;
138
+ if (wordCount < MIN_RESPONSE_WORDS) return { checked: 0, contradictions: [] };
139
+ if (!env.ai || !env.wikiBinding) return { checked: 0, contradictions: [] };
140
+
141
+ const searchQuery = responseText.slice(0, 200).replace(/[^\w\s]/g, ' ').trim();
142
+ const { results } = await searchPages(env, searchQuery, { limit: WIKI_SEARCH_LIMIT });
143
+ if (results.length === 0) return { checked: 0, contradictions: [] };
144
+
145
+ const wikiContext = results
146
+ .map(p => `[${p.title}]\n${(p.summary || p.snippet || '').slice(0, MAX_WIKI_EXCERPT_CHARS)}`)
147
+ .join('\n\n');
148
+
149
+ const candidates = extractCandidateSentences(responseText.slice(0, MAX_RESPONSE_CHARS * 2));
150
+ if (candidates.length === 0) return { checked: 0, contradictions: [] };
151
+
152
+ const prompt = buildPrompt(wikiContext, candidates);
153
+ const all = await runWorkersAi(env.ai, prompt);
154
+ const filtered = all.filter(c => c.confidence >= MIN_CONFIDENCE);
155
+
156
+ return { checked: candidates.length, contradictions: filtered };
157
+ }
158
+
159
+ export function formatContradictions(report: WikiContradictionReport): string[] {
160
+ return report.contradictions.map(c =>
161
+ `semantic: "${c.statement.slice(0, 120)}" — contradicts wiki:${c.wiki_source} (confidence: ${c.confidence.toFixed(2)})`,
162
+ );
163
+ }
@@ -0,0 +1,86 @@
1
+ import type { AgendaPriority, AgendaStatus, TaskStatus } from '../../schema-enums.js';
2
+ import { readPage } from '../../wiki/client.js';
3
+ import type { WikiClientEnv } from '../../wiki/client.js';
4
+
5
+ // ─── Result Types ───────────────────────────────────────────
6
+
7
+ export interface AgendaClaimResult {
8
+ exists: boolean;
9
+ item?: {
10
+ id: number;
11
+ item: string;
12
+ context: string | null;
13
+ priority: AgendaPriority;
14
+ status: AgendaStatus;
15
+ created_at: string;
16
+ resolved_at: string | null;
17
+ business_unit: string;
18
+ };
19
+ }
20
+
21
+ export interface TaskClaimResult {
22
+ exists: boolean;
23
+ task?: {
24
+ id: string;
25
+ title: string;
26
+ prompt: string;
27
+ status: TaskStatus;
28
+ created_at: string;
29
+ completed_at: string | null;
30
+ };
31
+ }
32
+
33
+ // ─── Helpers ────────────────────────────────────────────────
34
+
35
+ export async function verifyAgendaClaim(
36
+ db: D1Database,
37
+ id: number,
38
+ ): Promise<AgendaClaimResult> {
39
+ if (!Number.isInteger(id) || id < 1) return { exists: false };
40
+
41
+ const row = await db
42
+ .prepare(
43
+ 'SELECT id, item, context, priority, status, created_at, resolved_at, business_unit FROM agent_agenda WHERE id = ?',
44
+ )
45
+ .bind(id)
46
+ .first<AgendaClaimResult['item']>();
47
+
48
+ if (!row) return { exists: false };
49
+ return { exists: true, item: row };
50
+ }
51
+
52
+ export async function verifyTaskClaim(
53
+ db: D1Database,
54
+ id: string,
55
+ ): Promise<TaskClaimResult> {
56
+ if (typeof id !== 'string' || id.length === 0) return { exists: false };
57
+
58
+ const row = await db
59
+ .prepare(
60
+ 'SELECT id, title, prompt, status, created_at, completed_at FROM cc_tasks WHERE id = ?',
61
+ )
62
+ .bind(id)
63
+ .first<TaskClaimResult['task']>();
64
+
65
+ if (!row) return { exists: false };
66
+ return { exists: true, task: row };
67
+ }
68
+
69
+ // ─── Wiki existence check (aegis#500) ──────────────────────────
70
+ // Verifies that slug-shaped strings asserted as "canonical wiki pages" actually
71
+ // resolve. Returns { exists: false } on 404. Other errors propagate so the
72
+ // caller can decide (the detector wraps in try/catch + treats verification
73
+ // failure as non-fatal, matching v1 posture).
74
+
75
+ export interface WikiPageClaimResult {
76
+ exists: boolean;
77
+ }
78
+
79
+ export async function verifyWikiPageClaim(
80
+ env: WikiClientEnv,
81
+ slug: string,
82
+ ): Promise<WikiPageClaimResult> {
83
+ if (typeof slug !== 'string' || slug.length === 0) return { exists: false };
84
+ const result = await readPage(env, slug);
85
+ return { exists: result.page !== null };
86
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Grounding layer — owns all the "don't hallucinate" machinery the dispatch
3
+ * loop weaves around an executor call.
4
+ *
5
+ * Three pre-execution augmentations:
6
+ * - augmentWithInsights — CRIX (#106) cross-repo intelligence injection
7
+ * - augmentWithEntityGrounding — (aegis#446) bizops_read + user_correction
8
+ * fanout to D1 + wiki; returns the grounding envelope
9
+ * - augmentWithMemoryRecall — (aegis#457 Phase 5) memory_recall reads
10
+ * from the wiki and prepends a "relevant pages" block
11
+ *
12
+ * Two post-execution passes:
13
+ * - applyFabricationCheck — fabrication-detector (#447 v1)
14
+ * - applyGapSignal — gap-signal bookkeeping (#497)
15
+ *
16
+ * And one outcome adjudicator:
17
+ * - applyGroundingProof — redefines "success" in procedural_memory
18
+ * so fabrications on grounding-gated classes count as partial_failure.
19
+ *
20
+ * Everything non-fatal logs and falls through to the ungrounded path —
21
+ * grounding must never block dispatch.
22
+ *
23
+ * Circuit-breaker wrapping is intentionally absent from this core layer.
24
+ * Consumers wanting auto-disable-after-N-failures should compose their own
25
+ * circuit breaker at the call site.
26
+ */
27
+ import { groundIntent, formatGroundingBlock, summarizeGrounding } from './grounding/fanout.js';
28
+ import type { GroundingEnvelope } from './grounding/fanout.js';
29
+ import { fabricationCheck, formatUnverifiedClaims } from './grounding/fabrication-detector.js';
30
+ import { semanticSanhedrinCheck, formatContradictions } from './grounding/semantic-sanhedrin.js';
31
+ import { searchPages } from '../wiki/client.js';
32
+ import type { WikiClientEnv } from '../wiki/client.js';
33
+ import { memoryServiceFor } from './memory-service.js';
34
+ import type { KernelIntent, DispatchResult } from './types.js';
35
+ import type { EdgeEnv } from './dispatch.js';
36
+
37
+ // ─── Classifications that participate in grounding ─────────
38
+
39
+ export const GROUNDING_GATED_CLASSIFICATIONS = new Set<string>([
40
+ 'bizops_read', 'user_correction', 'memory_recall',
41
+ ]);
42
+
43
+ // ─── CRIX insight cache ─────────────────────────────────────
44
+
45
+ const INSIGHT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
46
+ const MAX_INSIGHTS_PER_DISPATCH = 3;
47
+
48
+ interface InsightEntry { fact: string; type: string; origin: string }
49
+ let insightCache: { entries: InsightEntry[]; fetchedAt: number } | null = null;
50
+
51
+ /** Testing hook — reset the module-level cache between runs. */
52
+ export function __resetInsightCache(): void {
53
+ insightCache = null;
54
+ }
55
+
56
+ async function fetchRelevantInsights(
57
+ env: EdgeEnv,
58
+ _classification: string,
59
+ rawQuery: string,
60
+ ): Promise<string | null> {
61
+ const now = Date.now();
62
+ if (!insightCache || (now - insightCache.fetchedAt) > INSIGHT_CACHE_TTL_MS) {
63
+ try {
64
+ const wikiEnv: WikiClientEnv = { wikiBinding: env.wikiBinding, wikiToken: env.wikiToken };
65
+ const { results } = await searchPages(wikiEnv, 'cross_repo_insights', { limit: 20 });
66
+ insightCache = {
67
+ entries: results.map(page => ({
68
+ fact: page.summary || page.snippet || page.title,
69
+ type: page.type || 'pattern',
70
+ origin: page.scope || 'core',
71
+ })),
72
+ fetchedAt: now,
73
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ if (!insightCache || insightCache.entries.length === 0) return null;
80
+
81
+ const queryWords = new Set(
82
+ rawQuery.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 3),
83
+ );
84
+
85
+ const scored = insightCache.entries
86
+ .map(entry => {
87
+ const factWords = entry.fact.toLowerCase().split(/\s+/);
88
+ const matches = factWords.filter(w => queryWords.has(w)).length;
89
+ return { ...entry, relevance: matches };
90
+ })
91
+ .filter(e => e.relevance > 0);
92
+
93
+ scored.sort((a, b) => b.relevance - a.relevance);
94
+ const top = scored.slice(0, MAX_INSIGHTS_PER_DISPATCH);
95
+ if (top.length === 0) return null;
96
+
97
+ const lines = top.map(i => `- [${i.type}] (from ${i.origin}) ${i.fact}`);
98
+ return `[Cross-Repo Intelligence — validated patterns]\n${lines.join('\n')}`;
99
+ }
100
+
101
+ /**
102
+ * Prepend cross-repo insights to the intent when applicable. In-place mutation
103
+ * of intent.raw. Skips greeting + heartbeat and when no wiki binding is
104
+ * configured. Non-fatal.
105
+ */
106
+ export async function augmentWithInsights(
107
+ intent: KernelIntent,
108
+ classification: string,
109
+ env: EdgeEnv,
110
+ ): Promise<void> {
111
+ if (!env.wikiBinding) return;
112
+ if (classification === 'greeting' || classification === 'heartbeat') return;
113
+ try {
114
+ const insightContext = await fetchRelevantInsights(env, classification, intent.raw);
115
+ if (insightContext) {
116
+ intent.raw = `${insightContext}\n\n${intent.raw}`;
117
+ }
118
+ } catch (err) {
119
+ console.warn('[grounding-layer] Insight fetch failed (non-fatal):', err instanceof Error ? err.message : String(err));
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Fanout entity grounding for bizops_read + user_correction. Extracts entity
125
+ * refs, queries D1 + wiki in parallel, injects a verified-facts block. Returns
126
+ * the grounding envelope when a block was injected, or undefined. Non-fatal.
127
+ */
128
+ export async function augmentWithEntityGrounding(
129
+ intent: KernelIntent,
130
+ classification: string,
131
+ env: EdgeEnv,
132
+ ): Promise<GroundingEnvelope | undefined> {
133
+ if (classification !== 'bizops_read' && classification !== 'user_correction') return undefined;
134
+
135
+ try {
136
+ const wikiEnv: WikiClientEnv | undefined = env.wikiBinding
137
+ ? { wikiBinding: env.wikiBinding, wikiToken: env.wikiToken }
138
+ : undefined;
139
+ const grounding = await groundIntent(intent.raw, {
140
+ db: env.db,
141
+ wiki: wikiEnv,
142
+ });
143
+ const block = formatGroundingBlock(grounding);
144
+ if (block) {
145
+ intent.raw = `${block}\n\n[Operator query]\n${intent.raw}`;
146
+ }
147
+ return summarizeGrounding(grounding);
148
+ } catch (err) {
149
+ console.warn('[grounding-layer] Entity grounding failed (non-fatal):', err instanceof Error ? err.message : String(err));
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ /**
155
+ * Memory_recall augmentation — search the wiki for pages matching the
156
+ * intent.raw and prepend a block of hits for the executor to quote. Non-fatal.
157
+ */
158
+ export async function augmentWithMemoryRecall(
159
+ intent: KernelIntent,
160
+ classification: string,
161
+ env: EdgeEnv,
162
+ ): Promise<void> {
163
+ if (classification !== 'memory_recall') return;
164
+ try {
165
+ if (!env.wikiBinding) {
166
+ console.warn('[grounding-layer] Wiki binding unavailable — skipping memory recall augmentation');
167
+ return;
168
+ }
169
+ const wikiEnv: WikiClientEnv = { wikiBinding: env.wikiBinding, wikiToken: env.wikiToken };
170
+ const { results } = await searchPages(wikiEnv, intent.raw, { limit: 10 });
171
+ if (results.length > 0) {
172
+ const memLines = results.map(p =>
173
+ `- [${p.scope || p.type || 'wiki'}] ${p.summary || p.title} (confidence: ${p.confidence || 'medium'})`,
174
+ ).join('\n');
175
+ intent.raw = `[Relevant wiki pages matching this query]\n${memLines}\n\n[User's question]\n${intent.raw}`;
176
+ }
177
+ } catch (err) {
178
+ console.warn('[grounding-layer] Wiki search failed (non-fatal):', err instanceof Error ? err.message : String(err));
179
+ }
180
+ }
181
+
182
+ // ─── Post-execution passes ─────────────────────────────────
183
+
184
+ /**
185
+ * Downgrade successful outcomes to `partial_failure` when the response
186
+ * carries unverified_claims on a grounding-gated classification. This stops
187
+ * the procedural_memory learning loop from probating procedures upward on
188
+ * fabrications that happen to not throw.
189
+ */
190
+ export function applyGroundingProof(
191
+ execOutcome: 'success' | 'failure' | 'partial_failure',
192
+ classification: string,
193
+ dispatchResult: DispatchResult,
194
+ ): 'success' | 'failure' | 'partial_failure' {
195
+ if (execOutcome !== 'success') return execOutcome;
196
+ if (!GROUNDING_GATED_CLASSIFICATIONS.has(classification)) return execOutcome;
197
+ if (dispatchResult.unverified_claims && dispatchResult.unverified_claims.length > 0) {
198
+ return 'partial_failure';
199
+ }
200
+ return execOutcome;
201
+ }
202
+
203
+ /**
204
+ * Record or clear the gap_signal_count for this procedureKey. Only runs for
205
+ * grounding-gated classes. Non-fatal.
206
+ */
207
+ export async function applyGapSignal(
208
+ dispatchResult: DispatchResult,
209
+ procKey: string,
210
+ classification: string,
211
+ env: EdgeEnv,
212
+ ): Promise<void> {
213
+ if (!GROUNDING_GATED_CLASSIFICATIONS.has(classification)) return;
214
+
215
+ const hasGap =
216
+ (dispatchResult.unverified_claims?.length ?? 0) > 0 ||
217
+ (dispatchResult.unknowns?.length ?? 0) > 0;
218
+
219
+ try {
220
+ const memory = memoryServiceFor(env);
221
+ if (hasGap) {
222
+ await memory.recordGapSignal(procKey);
223
+ } else {
224
+ await memory.clearGapSignal(procKey);
225
+ }
226
+ } catch (err) {
227
+ console.warn('[grounding-layer] Gap signal update failed (non-fatal):', err instanceof Error ? err.message : String(err));
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Fabrication-detector post-pass (#447 v1 + semantic Sanhedrin #573).
233
+ *
234
+ * Runs two checks independently (Promise.allSettled so one failure doesn't
235
+ * cancel the other):
236
+ * - Structured pass: agenda/task mutation claims + referential slug verification
237
+ * - Semantic pass: Workers AI wiki-contradiction gate
238
+ *
239
+ * Contradictions from either pass land in `unverified_claims[]`. Response text
240
+ * is never stripped — diagnostic posture until false-positive rates are known.
241
+ */
242
+ export async function applyFabricationCheck(
243
+ result: DispatchResult,
244
+ responseText: string,
245
+ env: EdgeEnv,
246
+ ): Promise<void> {
247
+ try {
248
+ const [structuredResult, semanticResult] = await Promise.allSettled([
249
+ fabricationCheck(responseText, {
250
+ db: env.db,
251
+ wikiBinding: env.wikiBinding,
252
+ wikiToken: env.wikiToken,
253
+ }),
254
+ semanticSanhedrinCheck(responseText, {
255
+ wikiBinding: env.wikiBinding,
256
+ wikiToken: env.wikiToken,
257
+ ai: env.ai,
258
+ }),
259
+ ]);
260
+
261
+ const structuredReport = structuredResult.status === 'fulfilled'
262
+ ? structuredResult.value
263
+ : { checked: 0, unverified: [] };
264
+ const semanticReport = semanticResult.status === 'fulfilled'
265
+ ? semanticResult.value
266
+ : { checked: 0, contradictions: [] };
267
+
268
+ const allClaims: string[] = [
269
+ ...formatUnverifiedClaims(structuredReport),
270
+ ...formatContradictions(semanticReport),
271
+ ];
272
+
273
+ if (allClaims.length > 0) {
274
+ result.unverified_claims = allClaims;
275
+ result.grounded = false;
276
+ }
277
+ } catch (err) {
278
+ console.warn('[grounding-layer] Fabrication check failed (non-fatal):', err instanceof Error ? err.message : String(err));
279
+ }
280
+ }
@@ -36,8 +36,10 @@ const EXECUTOR_ATTACHMENTS: Record<Executor, readonly BlockId[]> = {
36
36
  gpt_oss: ['identity', 'operator_profile', 'operating_rules'],
37
37
  groq: CORE_BLOCKS,
38
38
  workers_ai: MINIMAL_BLOCKS,
39
- direct: [],
40
- tarotscript: [],
39
+ direct: [],
40
+ tarotscript: [],
41
+ cerebras_mid: CORE_BLOCKS,
42
+ cerebras_reasoning: CORE_BLOCKS,
41
43
  };
42
44
 
43
45
  // ─── CRUD ────────────────────────────────────────────────────
@@ -88,7 +88,7 @@ export interface MemoryEntry {
88
88
 
89
89
  // ─── Execution Plan ──────────────────────────────────────────
90
90
 
91
- export type Executor = 'claude' | 'groq' | 'direct' | 'claude_code' | 'workers_ai' | 'claude_opus' | 'gpt_oss' | 'composite' | 'tarotscript';
91
+ export type Executor = 'claude' | 'groq' | 'direct' | 'claude_code' | 'workers_ai' | 'claude_opus' | 'gpt_oss' | 'composite' | 'tarotscript' | 'cerebras_mid' | 'cerebras_reasoning';
92
92
 
93
93
  export interface ExecutionPlan {
94
94
  executor: Executor;
@@ -147,4 +147,10 @@ export interface DispatchResult {
147
147
  reclassified?: boolean;
148
148
  probeResult?: 'agreed' | 'split' | 'escalated';
149
149
  meta?: unknown;
150
+ // Grounding fields (populated by grounding-layer when active)
151
+ grounded?: boolean;
152
+ sources?: string[];
153
+ unknowns?: string[];
154
+ searched?: string[];
155
+ unverified_claims?: string[];
150
156
  }