delimit-cli 4.5.1 → 4.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +2 -2
  3. package/bin/delimit-cli.js +109 -24
  4. package/gateway/ai/content_engine.py +3 -4
  5. package/gateway/ai/inbox_classifier.py +215 -0
  6. package/gateway/ai/integrations/opensage_wrapper.py +4 -1
  7. package/gateway/ai/ledger_manager.py +218 -38
  8. package/gateway/ai/license.py +26 -0
  9. package/gateway/ai/notify.py +68 -3
  10. package/gateway/ai/reddit_proxy.py +93 -15
  11. package/gateway/ai/reddit_scanner.py +36 -18
  12. package/gateway/ai/server.py +128 -6
  13. package/gateway/ai/social_capability/__init__.py +6 -0
  14. package/gateway/ai/social_capability/capability_validator.py +273 -0
  15. package/gateway/ai/social_capability/current_capabilities.yaml +95 -0
  16. package/gateway/ai/social_queue.py +307 -0
  17. package/gateway/ai/supabase_sync.py +14 -2
  18. package/gateway/ai/swarm.py +29 -11
  19. package/gateway/ai/tui.py +6 -2
  20. package/gateway/ai/x_ranker.py +276 -0
  21. package/lib/attest-mcp.js +487 -0
  22. package/lib/attest-telemetry.js +48 -0
  23. package/lib/delimit-home.js +35 -0
  24. package/lib/delimit-template.js +14 -0
  25. package/package.json +8 -2
  26. package/scripts/postinstall.js +89 -40
  27. package/gateway/ai/content_grounding/__init__.py +0 -98
  28. package/gateway/ai/content_grounding/build.py +0 -350
  29. package/gateway/ai/content_grounding/consume.py +0 -280
  30. package/gateway/ai/content_grounding/features.py +0 -218
  31. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +0 -9
  32. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +0 -9
  33. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +0 -17
  34. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +0 -17
  35. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +0 -17
  36. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +0 -18
  37. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +0 -18
  38. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +0 -23
  39. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +0 -16
  40. package/gateway/ai/content_grounding/schemas/claim.schema.json +0 -40
  41. package/gateway/ai/content_grounding/schemas/event.schema.json +0 -23
  42. package/gateway/ai/content_grounding/schemas.py +0 -276
  43. package/gateway/ai/content_grounding/telemetry.py +0 -221
  44. package/gateway/ai/inbox_drafts/__init__.py +0 -61
  45. package/gateway/ai/inbox_drafts/registry.py +0 -412
  46. package/gateway/ai/inbox_drafts/schema.py +0 -374
  47. package/gateway/ai/inbox_executor.py +0 -565
@@ -1,47 +1,96 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Postinstall — anonymous install ping + setup hint.
4
+ *
5
+ * v4.5.2 (LED-1188) install hardening:
6
+ * - Top-level try/catch ensures NO postinstall failure can ever block
7
+ * `npm install delimit-cli`. Per the customer-protection rule in
8
+ * /root/.claude/CLAUDE.md, npm publish is a production deploy and a
9
+ * postinstall crash on a Pro user's machine is a customer-facing
10
+ * incident regardless of root cause.
11
+ * - EROFS / EACCES / EPERM / ENOSPC / ENOENT on stdout writes soft-fail
12
+ * silently. (Some sandbox installers redirect stdout to a read-only
13
+ * pipe.)
14
+ * - Network telemetry stays best-effort; no crash if DNS / TLS / proxy
15
+ * misbehaves. DELIMIT_NO_TELEMETRY=1 honored as kill switch.
16
+ * - Idempotent — re-running install is a no-op, never corrupts state.
17
+ * This file does not write to ~/.delimit/; that's bin/delimit-setup.js.
18
+ *
4
19
  * No PII. Silent fail. Never blocks install.
5
20
  */
6
21
 
7
- // Print setup hint with quick start
8
- const v = require('../package.json').version;
9
- console.log('');
10
- console.log(' \x1b[1m\x1b[35mDelimit\x1b[0m v' + v + ' installed');
11
- console.log('');
12
- console.log(' Quick start:');
13
- console.log(' \x1b[32mdelimit doctor\x1b[0m Check your setup, fix what\'s missing');
14
- console.log(' \x1b[32mdelimit simulate\x1b[0m Dry-run: see what governance would block');
15
- console.log(' \x1b[32mdelimit status\x1b[0m Visual dashboard of your governance posture');
16
- console.log(' \x1b[32mdelimit setup\x1b[0m Install MCP governance for AI assistants');
17
- console.log('');
18
- console.log(' Docs: \x1b[36mhttps://delimit.ai/docs\x1b[0m');
19
- console.log(' Star us: \x1b[36mhttps://github.com/delimit-ai/delimit-mcp-server\x1b[0m');
20
- console.log('');
22
+ (function postinstall() {
23
+ 'use strict';
21
24
 
22
- // Anonymous telemetry ping no PII, just "someone installed"
23
- try {
24
- const https = require('https');
25
- const data = JSON.stringify({
26
- event: 'install',
27
- version: require('../package.json').version,
28
- node: process.version,
29
- platform: process.platform,
30
- arch: process.arch,
31
- ts: new Date().toISOString()
32
- });
33
- const req = https.request({
34
- hostname: 'delimit.ai',
35
- path: '/api/telemetry',
36
- method: 'POST',
37
- headers: {
38
- 'Content-Type': 'application/json',
39
- 'Content-Length': Buffer.byteLength(data)
40
- },
41
- timeout: 3000
42
- });
43
- req.on('error', () => {}); // silent fail
44
- req.on('timeout', () => { req.destroy(); });
45
- req.write(data);
46
- req.end();
47
- } catch (e) { /* silent fail */ }
25
+ // --- 1. setup hint ------------------------------------------------------
26
+ // Wrapped in try/catch because console.log can throw on EPIPE / EBADF
27
+ // when the parent npm process closed stdout early.
28
+ let pkg;
29
+ try {
30
+ pkg = require('../package.json');
31
+ } catch (e) {
32
+ // package.json missing or unreadable — nothing to print, nothing
33
+ // to ping. This is a partial-install state; let the install
34
+ // complete so `delimit doctor` can diagnose later.
35
+ return;
36
+ }
37
+ const v = (pkg && pkg.version) || '?';
38
+
39
+ function safeLog(msg) {
40
+ try { process.stdout.write(msg + '\n'); }
41
+ catch (_) { /* EPIPE / EBADF / EROFS on stdout — give up silently */ }
42
+ }
43
+
44
+ try {
45
+ safeLog('');
46
+ safeLog(' \x1b[1m\x1b[35mDelimit\x1b[0m v' + v + ' installed');
47
+ safeLog('');
48
+ safeLog(' Quick start:');
49
+ safeLog(' \x1b[32mdelimit doctor\x1b[0m Check your setup, fix what\'s missing');
50
+ safeLog(' \x1b[32mdelimit simulate\x1b[0m Dry-run: see what governance would block');
51
+ safeLog(' \x1b[32mdelimit status\x1b[0m Visual dashboard of your governance posture');
52
+ safeLog(' \x1b[32mdelimit setup\x1b[0m Install MCP governance for AI assistants');
53
+ safeLog('');
54
+ safeLog(' Docs: \x1b[36mhttps://delimit.ai/docs\x1b[0m');
55
+ safeLog(' Star us: \x1b[36mhttps://github.com/delimit-ai/delimit-mcp-server\x1b[0m');
56
+ safeLog('');
57
+ } catch (_) { /* never block install on a print failure */ }
58
+
59
+ // --- 2. anonymous install telemetry ------------------------------------
60
+ // Honor opt-out and corporate proxy environments. The HTTPS request is
61
+ // silent-fail at every level (DNS / TCP / TLS / write / response).
62
+ const tele = (process.env.DELIMIT_NO_TELEMETRY || '').toLowerCase();
63
+ if (tele === '1' || tele === 'true' || tele === 'yes') return;
64
+
65
+ try {
66
+ const https = require('https');
67
+ const data = JSON.stringify({
68
+ event: 'install',
69
+ version: v,
70
+ node: process.version,
71
+ platform: process.platform,
72
+ arch: process.arch,
73
+ ts: new Date().toISOString()
74
+ });
75
+ const req = https.request({
76
+ hostname: 'delimit.ai',
77
+ path: '/api/telemetry',
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/json',
81
+ 'Content-Length': Buffer.byteLength(data)
82
+ },
83
+ timeout: 3000
84
+ });
85
+ // Catch every error class: ENOTFOUND, ECONNREFUSED, ETIMEDOUT,
86
+ // CERT_HAS_EXPIRED, EPROTO, etc. None should ever propagate.
87
+ req.on('error', () => {});
88
+ req.on('timeout', () => { try { req.destroy(); } catch (_) {} });
89
+ req.write(data);
90
+ req.end();
91
+ } catch (_) { /* silent fail — never block install */ }
92
+ })();
93
+
94
+ // Outermost guard: even if the IIFE above throws synchronously somehow
95
+ // (require() race, V8 bug, etc), don't propagate a non-zero exit code.
96
+ process.on('uncaughtException', () => { /* swallow */ });
@@ -1,98 +0,0 @@
1
- """
2
- Delimit content grounding layer — LED-1084 Week 1.
3
-
4
- Purpose: normalize ledger entries, attestations, and git history into
5
- evidence-backed `GroundedEvent` records with typed atomic `Claim`s.
6
- Every downstream generator (blog, social drafter, storyline) consumes
7
- this layer and MUST NOT fabricate claims that aren't backed by an
8
- evidence_ref.
9
-
10
- Architectural amendments (per 2026-04-24 adversarial rebuttal,
11
- /home/delimit/delimit-private/strategy/CONTENT_GROUNDING_REBUTTAL_2026_04.md):
12
-
13
- A3. Week 1 is strictly NON-PUBLISHING. Publish endpoints are
14
- hard-disabled at the code level (see `_PUBLISH_DISABLED` below).
15
- A5. Claims are typed atomic objects with explicit evidence_refs,
16
- visibility, and optional versioned inference_rule.
17
- A6. Hard bans during Week 1/2: comparative, adoption, customer,
18
- aggregate, roadmap claims reject unless exact text whitelisted
19
- or (for aggregates) backed by structured numeric evidence.
20
- A9. Deterministic extraction gate: extract → classify → map to
21
- allowed claim IDs → reject on any unmatched/uncertain claim →
22
- persist audit record. All content passes through this gate.
23
- A10. One-strike kill semantics: any externally published ungrounded
24
- claim reverts ALL generators to manual-only mode.
25
-
26
- This module never generates public content. It only produces the
27
- grounded event + claim records that generators consume.
28
- """
29
- from .schemas import (
30
- ClaimType,
31
- Visibility,
32
- EventType,
33
- EvidenceRef,
34
- Claim,
35
- GroundedEvent,
36
- GroundingIndex,
37
- )
38
- from .build import (
39
- build_grounding_index,
40
- load_grounded_events,
41
- validate_claims,
42
- persist_grounding_index,
43
- )
44
- from .consume import (
45
- GroundingBundle,
46
- fetch_grounding_bundle,
47
- build_allowed_claim_set,
48
- load_feature_whitelist,
49
- unreleased_feature_detector,
50
- score_draft_grounding,
51
- )
52
- from .features import (
53
- build_feature_set,
54
- build_and_persist_features,
55
- extract_mcp_tools,
56
- extract_cli_commands,
57
- )
58
- from .telemetry import (
59
- summarize as summarize_gate_telemetry,
60
- recent_samples as recent_gate_samples,
61
- )
62
-
63
- __all__ = [
64
- # schemas
65
- "ClaimType",
66
- "Visibility",
67
- "EventType",
68
- "EvidenceRef",
69
- "Claim",
70
- "GroundedEvent",
71
- "GroundingIndex",
72
- # build
73
- "build_grounding_index",
74
- "load_grounded_events",
75
- "validate_claims",
76
- "persist_grounding_index",
77
- # consume (Week 2)
78
- "GroundingBundle",
79
- "fetch_grounding_bundle",
80
- "build_allowed_claim_set",
81
- "load_feature_whitelist",
82
- "unreleased_feature_detector",
83
- "score_draft_grounding",
84
- # features whitelist builder (Week 2)
85
- "build_feature_set",
86
- "build_and_persist_features",
87
- "extract_mcp_tools",
88
- "extract_cli_commands",
89
- # telemetry (Week 2 → Week 3 bridge)
90
- "summarize_gate_telemetry",
91
- "recent_gate_samples",
92
- ]
93
-
94
- # A3: publish paths are OFF. Any attempt to publish grounded content
95
- # externally during Week 1 raises. Flip to True only after Week 2
96
- # hardening (claim-type classifiers, implication detection) and explicit
97
- # founder approval.
98
- _PUBLISH_DISABLED = True
@@ -1,350 +0,0 @@
1
- """
2
- Ingestion + validation for the grounding layer (LED-1084 Week 1).
3
-
4
- Reads three canonical sources:
5
- - ~/.delimit/ledger/*.jsonl → decisions / incidents / outreach / releases
6
- - ~/.delimit/attestations/*.json → HMAC-signed delimit wrap bundles
7
- - `git log` on delimit-gateway → commit events
8
-
9
- Produces a `GroundingIndex` snapshot that downstream generators consume.
10
-
11
- Week 1 posture: ingestion + validation only. No generation, no publishing.
12
- `_PUBLISH_DISABLED = True` in `__init__` enforces this at import time.
13
- """
14
- from __future__ import annotations
15
-
16
- import json
17
- import logging
18
- import os
19
- import re
20
- import subprocess
21
- from dataclasses import asdict
22
- from datetime import datetime, timezone, timedelta
23
- from pathlib import Path
24
- from typing import Any, Dict, List, Optional
25
-
26
- from .schemas import (
27
- Claim,
28
- ClaimType,
29
- EventType,
30
- GroundedEvent,
31
- GroundingIndex,
32
- Visibility,
33
- )
34
-
35
- logger = logging.getLogger("delimit.ai.content_grounding")
36
-
37
- # Default paths — overridable via env for testing.
38
- LEDGER_DIR = Path(os.environ.get("DELIMIT_LEDGER_DIR", str(Path.home() / ".delimit" / "ledger")))
39
- ATTESTATIONS_DIR = Path(os.environ.get("DELIMIT_ATTESTATIONS_DIR", str(Path.home() / ".delimit" / "attestations")))
40
- GATEWAY_REPO = Path(os.environ.get("DELIMIT_GATEWAY_REPO", "/home/delimit/delimit-gateway"))
41
- GROUNDING_OUT = Path(os.environ.get("DELIMIT_GROUNDING_OUT", str(Path.home() / ".delimit" / "content" / "grounding")))
42
-
43
-
44
- # ---------------------------------------------------------------------------
45
- # Ledger ingestion
46
- # ---------------------------------------------------------------------------
47
-
48
- # Ledger item_type → grounded EventType. Items with types not in this map
49
- # fall into DECISION as a safe default.
50
- _LEDGER_TYPE_MAP: Dict[str, EventType] = {
51
- "release": EventType.RELEASE,
52
- "feature": EventType.FEATURE_SHIPPED,
53
- "fix": EventType.INCIDENT_RESOLVED,
54
- "incident": EventType.INCIDENT,
55
- "audit": EventType.DECISION,
56
- "strategy": EventType.DECISION,
57
- "watch": EventType.OUTREACH_EVENT,
58
- "outreach": EventType.OUTREACH_EVENT,
59
- }
60
-
61
-
62
- def _ledger_item_to_event(item: Dict[str, Any]) -> Optional[GroundedEvent]:
63
- """Normalize a ledger JSONL record into a GroundedEvent. Skip on malformed."""
64
- try:
65
- led_id = item.get("id") or item.get("ledger_id") or ""
66
- if not led_id:
67
- return None
68
- title = (item.get("title") or "").strip()
69
- date = item.get("created_at") or item.get("timestamp") or ""
70
- if not date:
71
- return None
72
- venture = (item.get("venture") or "delimit").lower()
73
- item_type = (item.get("item_type") or item.get("type") or "decision").lower()
74
-
75
- event_type = _LEDGER_TYPE_MAP.get(item_type, EventType.DECISION)
76
-
77
- # A ledger item has at minimum its own LED-id as evidence. Link
78
- # field also counts if present.
79
- evidence: List[str] = [f"LED-{led_id.replace('LED-', '')}"]
80
- link = item.get("link") or ""
81
- if link and link.startswith("http"):
82
- evidence.append(f"url:{link}")
83
-
84
- # Build a FEATURE or INCIDENT claim from the title. Claim text
85
- # is the exact title (no paraphrase permitted by Week 1/2 rules).
86
- claims: List[Claim] = []
87
- if title and event_type in (EventType.FEATURE_SHIPPED, EventType.INCIDENT_RESOLVED):
88
- ctype = ClaimType.FEATURE if event_type == EventType.FEATURE_SHIPPED else ClaimType.INCIDENT
89
- claims.append(Claim(
90
- claim_id=f"CLM-{led_id}-title",
91
- type=ctype,
92
- text=title,
93
- evidence_refs=list(evidence),
94
- visibility=Visibility.INTERNAL, # default private; author must promote
95
- ))
96
-
97
- return GroundedEvent(
98
- event_id=f"evt-ledger-{led_id}",
99
- type=event_type,
100
- date=date,
101
- venture=venture,
102
- evidence_refs=list(evidence),
103
- claims=claims,
104
- visibility=Visibility.INTERNAL,
105
- source=f"ledger:{item_type}",
106
- raw={"ledger_id": led_id, "status": item.get("status"), "priority": item.get("priority")},
107
- )
108
- except Exception as e:
109
- logger.debug("skipping malformed ledger item: %s", e)
110
- return None
111
-
112
-
113
- def _ingest_ledger(since: Optional[datetime] = None) -> List[GroundedEvent]:
114
- events: List[GroundedEvent] = []
115
- if not LEDGER_DIR.is_dir():
116
- logger.warning("ledger dir not found: %s", LEDGER_DIR)
117
- return events
118
- for p in sorted(LEDGER_DIR.glob("*.jsonl")):
119
- try:
120
- for line in p.read_text(errors="replace").splitlines():
121
- line = line.strip()
122
- if not line:
123
- continue
124
- try:
125
- item = json.loads(line)
126
- except json.JSONDecodeError:
127
- continue
128
- event = _ledger_item_to_event(item)
129
- if not event:
130
- continue
131
- if since:
132
- try:
133
- evt_dt = datetime.fromisoformat(event.date.replace("Z", "+00:00"))
134
- if evt_dt < since:
135
- continue
136
- except ValueError:
137
- continue
138
- events.append(event)
139
- except Exception as e:
140
- logger.warning("failed to read %s: %s", p, e)
141
- return events
142
-
143
-
144
- # ---------------------------------------------------------------------------
145
- # Attestation ingestion
146
- # ---------------------------------------------------------------------------
147
-
148
- def _attestation_to_event(record: Dict[str, Any]) -> Optional[GroundedEvent]:
149
- try:
150
- att_id = record.get("id") or ""
151
- if not att_id.startswith("att_"):
152
- return None
153
- bundle = record.get("bundle") or {}
154
- started = bundle.get("started_at") or bundle.get("completed_at") or ""
155
- if not started:
156
- return None
157
- kind = bundle.get("kind", "merge_attestation")
158
- event_type = EventType.ATTESTATION
159
- gates = (bundle.get("governance") or {}).get("gates", [])
160
- gate_names = ",".join(g.get("name", "?") for g in gates if isinstance(g, dict))
161
- title = f"{kind}: {bundle.get('wrapped_command', '?')[:60]} | gates: {gate_names or 'none'}"
162
- evidence: List[str] = [f"attest:{att_id}"]
163
- before = bundle.get("before_head")
164
- after = bundle.get("after_head")
165
- if before and len(before) >= 7:
166
- evidence.append(f"git:{before[:12]}")
167
- if after and after != before and len(after) >= 7:
168
- evidence.append(f"git:{after[:12]}")
169
- return GroundedEvent(
170
- event_id=f"evt-att-{att_id}",
171
- type=event_type,
172
- date=started,
173
- venture="delimit", # attestations are all delimit-venture for now
174
- evidence_refs=evidence,
175
- claims=[], # attestations don't produce direct claim text
176
- visibility=Visibility.INTERNAL,
177
- source="attestation",
178
- raw={
179
- "attestation_id": att_id,
180
- "kind": kind,
181
- "wrapped_exit": bundle.get("wrapped_exit"),
182
- "signature_alg": record.get("signature_alg"),
183
- },
184
- )
185
- except Exception as e:
186
- logger.debug("skipping malformed attestation: %s", e)
187
- return None
188
-
189
-
190
- def _ingest_attestations(since: Optional[datetime] = None) -> List[GroundedEvent]:
191
- events: List[GroundedEvent] = []
192
- if not ATTESTATIONS_DIR.is_dir():
193
- return events
194
- for p in sorted(ATTESTATIONS_DIR.glob("att_*.json")):
195
- try:
196
- record = json.loads(p.read_text(errors="replace"))
197
- except Exception:
198
- continue
199
- event = _attestation_to_event(record)
200
- if not event:
201
- continue
202
- if since:
203
- try:
204
- evt_dt = datetime.fromisoformat(event.date.replace("Z", "+00:00"))
205
- if evt_dt < since:
206
- continue
207
- except ValueError:
208
- continue
209
- events.append(event)
210
- return events
211
-
212
-
213
- # ---------------------------------------------------------------------------
214
- # Git log ingestion
215
- # ---------------------------------------------------------------------------
216
-
217
- _RELEASE_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
218
-
219
-
220
- def _ingest_git_commits(since: Optional[datetime] = None, limit: int = 200) -> List[GroundedEvent]:
221
- """Recent commits on delimit-gateway. `since` cuts by date."""
222
- events: List[GroundedEvent] = []
223
- if not (GATEWAY_REPO / ".git").is_dir():
224
- return events
225
- after_arg = []
226
- if since:
227
- after_arg = [f"--since={since.strftime('%Y-%m-%d')}"]
228
- try:
229
- result = subprocess.run(
230
- [
231
- "git", "-C", str(GATEWAY_REPO),
232
- "log", f"--max-count={limit}",
233
- "--pretty=format:%H%x00%aI%x00%s",
234
- *after_arg,
235
- ],
236
- capture_output=True, text=True, timeout=30,
237
- )
238
- if result.returncode != 0:
239
- logger.warning("git log failed: %s", result.stderr[:200])
240
- return events
241
- for line in result.stdout.splitlines():
242
- parts = line.split("\x00")
243
- if len(parts) != 3:
244
- continue
245
- sha, iso_date, subject = parts
246
- events.append(GroundedEvent(
247
- event_id=f"evt-git-{sha[:12]}",
248
- type=EventType.COMMIT,
249
- date=iso_date,
250
- venture="delimit",
251
- evidence_refs=[f"git:{sha[:12]}"],
252
- claims=[], # commit subject is NOT a claim — subjects paraphrase
253
- visibility=Visibility.INTERNAL,
254
- source="git-log",
255
- raw={"subject": subject[:200], "sha": sha},
256
- ))
257
- except Exception as e:
258
- logger.warning("git log exception: %s", e)
259
- return events
260
-
261
-
262
- # ---------------------------------------------------------------------------
263
- # Public API
264
- # ---------------------------------------------------------------------------
265
-
266
- def build_grounding_index(
267
- venture: str = "delimit",
268
- days: int = 30,
269
- whitelist: Optional[frozenset] = None,
270
- ) -> GroundingIndex:
271
- """Build a fresh grounding index over the last `days`.
272
-
273
- Week 1: ingest + normalize + validate. No publishing, no generation.
274
- """
275
- since = datetime.now(timezone.utc) - timedelta(days=days)
276
- events: List[GroundedEvent] = []
277
- events.extend(_ingest_ledger(since=since))
278
- events.extend(_ingest_attestations(since=since))
279
- events.extend(_ingest_git_commits(since=since))
280
-
281
- # Filter to the requested venture. Attestations + git commits are
282
- # `delimit`-venture by construction; ledger items carry their own.
283
- events = [e for e in events if e.venture == venture]
284
-
285
- index = GroundingIndex(
286
- venture=venture,
287
- built_at=datetime.now(timezone.utc).isoformat(),
288
- events=sorted(events, key=lambda e: e.date, reverse=True),
289
- )
290
- # Validation is best-effort at build time — errors get logged but
291
- # do not block index construction. Caller can call `validate_claims`
292
- # for a strict pass.
293
- errs = index.validate(whitelist=whitelist)
294
- if errs:
295
- logger.info(
296
- "build_grounding_index: %d validation warnings (first 5): %s",
297
- len(errs), errs[:5],
298
- )
299
- return index
300
-
301
-
302
- def load_grounded_events(
303
- venture: str = "delimit",
304
- days: int = 30,
305
- visibility: Optional[Visibility] = None,
306
- event_type: Optional[EventType] = None,
307
- whitelist: Optional[frozenset] = None,
308
- ) -> List[GroundedEvent]:
309
- """Filtered view. Generators use this — not `build_grounding_index`."""
310
- idx = build_grounding_index(venture=venture, days=days, whitelist=whitelist)
311
- events = idx.events
312
- if visibility is not None:
313
- events = [e for e in events if e.visibility == visibility]
314
- if event_type is not None:
315
- events = [e for e in events if e.type == event_type]
316
- return events
317
-
318
-
319
- def validate_claims(
320
- claims: List[Claim],
321
- whitelist: Optional[frozenset] = None,
322
- ) -> List[Dict[str, Any]]:
323
- """Strict per-claim validation. Returns a list of {claim_id, errors}.
324
-
325
- Used as the gate in front of any generator output (A9). Callers
326
- MUST fail-closed on any non-empty errors.
327
- """
328
- out: List[Dict[str, Any]] = []
329
- wl = whitelist or frozenset()
330
- for claim in claims:
331
- errs = claim.validate(whitelist=wl)
332
- out.append({"claim_id": claim.claim_id, "errors": errs, "valid": not errs})
333
- return out
334
-
335
-
336
- def persist_grounding_index(index: GroundingIndex, out_dir: Path = GROUNDING_OUT) -> Path:
337
- """Write the index as events.jsonl for consumption. Week 1 artifact."""
338
- out_dir.mkdir(parents=True, exist_ok=True)
339
- events_path = out_dir / f"events-{index.venture}.jsonl"
340
- with open(events_path, "w") as f:
341
- for event in index.events:
342
- f.write(json.dumps(event.to_dict()) + "\n")
343
- meta = {
344
- "venture": index.venture,
345
- "built_at": index.built_at,
346
- "event_count": len(index.events),
347
- "canon_version": index.canon_version,
348
- }
349
- (out_dir / f"meta-{index.venture}.json").write_text(json.dumps(meta, indent=2))
350
- return events_path