delimit-cli 4.3.4 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +25 -18
- package/adapters/codex-security.js +64 -0
- package/adapters/codex-skill.js +78 -0
- package/adapters/cursor-rules.js +73 -0
- package/bin/delimit-setup.js +23 -0
- package/gateway/ai/backends/governance_bridge.py +168 -2
- package/gateway/ai/backends/memory_bridge.py +218 -3
- package/gateway/ai/backends/tools_design.py +563 -83
- package/gateway/ai/backends/tools_infra.py +21 -7
- package/gateway/ai/backends/tools_real.py +3 -1
- package/gateway/ai/content_grounding/__init__.py +98 -0
- package/gateway/ai/content_grounding/build.py +350 -0
- package/gateway/ai/content_grounding/consume.py +280 -0
- package/gateway/ai/content_grounding/features.py +218 -0
- package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
- package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
- package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
- package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
- package/gateway/ai/content_grounding/schemas.py +276 -0
- package/gateway/ai/content_grounding/telemetry.py +221 -0
- package/gateway/ai/governance.py +89 -0
- package/gateway/ai/hot_reload.py +148 -7
- package/gateway/ai/inbox_drafts/__init__.py +61 -0
- package/gateway/ai/inbox_drafts/registry.py +412 -0
- package/gateway/ai/inbox_drafts/schema.py +374 -0
- package/gateway/ai/inbox_executor.py +565 -0
- package/gateway/ai/ledger_manager.py +1483 -25
- package/gateway/ai/license_core.py +3 -1
- package/gateway/ai/mcp_bridge.py +1 -1
- package/gateway/ai/reddit_proxy.py +8 -6
- package/gateway/ai/server.py +451 -9
- package/gateway/ai/supabase_sync.py +47 -7
- package/gateway/ai/swarm.py +1 -1
- package/gateway/ai/workers/executor.py +1 -1
- package/gateway/core/diff_engine_v2.py +45 -10
- package/gateway/core/zero_spec/express_extractor.py +1 -1
- package/lib/delimit-template.js +5 -0
- package/package.json +1 -1
|
@@ -65,20 +65,27 @@ _CREDENTIAL_FALSE_POSITIVES = re.compile(
|
|
|
65
65
|
r"-demo['\"]|"
|
|
66
66
|
# Function-call RHS (reading from parsed JSON, env, getters, slicing strings)
|
|
67
67
|
r"json\.loads|\.read_text\(|\.slice\(|"
|
|
68
|
-
r"
|
|
68
|
+
r"\w+\.get\(|token\s*=\s*_make_token|"
|
|
69
69
|
# RHS that is a parameter reference like token=tokens.get("access_token"...
|
|
70
|
-
r"=\s
|
|
70
|
+
r"=\s*\w+\.get\(|"
|
|
71
71
|
# Dict index dereference: token_data["token"], result["secret"], etc.
|
|
72
|
-
r"_data\[|_result\[
|
|
72
|
+
r"_data\[|_result\[|"
|
|
73
|
+
# Bare `if not <var>:` and similar control-flow lines that mention
|
|
74
|
+
# the credential variable name but contain no value.
|
|
75
|
+
r"if\s+not\s+\w+:|"
|
|
76
|
+
# Python control-flow block-opener: a colon immediately followed by
|
|
77
|
+
# a newline (no quoted value on the same line). Such a colon is an
|
|
78
|
+
# if/while/def/class block-opener, not a key-value separator.
|
|
79
|
+
r":\s*\n)",
|
|
73
80
|
re.IGNORECASE,
|
|
74
81
|
)
|
|
75
82
|
|
|
76
83
|
# Dangerous code patterns: name -> (regex, description, severity)
|
|
77
84
|
ANTI_PATTERNS = {
|
|
78
|
-
"eval_usage": (r"\beval\s*\(", "Use of eval() — potential code injection", "high"),
|
|
79
|
-
"exec_usage": (r"\bexec\s*\(", "Use of exec() — potential code injection", "high"),
|
|
85
|
+
"eval_usage": (r"\beval\s*\(", "Use of eval() — potential code injection", "high"), # nosec B-eval_usage: regex-pattern DEFINITION string (not runtime eval)
|
|
86
|
+
"exec_usage": (r"\bexec\s*\(", "Use of exec() — potential code injection", "high"), # nosec B-exec_usage: regex-pattern DEFINITION string (not runtime exec)
|
|
80
87
|
"sql_concat": (r"""(?:execute|cursor\.execute|query)\s*\(\s*(?:f['\"]|['\"].*%s|.*\+\s*['\"])""", "SQL string concatenation — potential SQL injection", "critical"),
|
|
81
|
-
"dangerous_innerHTML": (r"dangerouslySetInnerHTML", "dangerouslySetInnerHTML — potential XSS", "high"),
|
|
88
|
+
"dangerous_innerHTML": (r"dangerouslySetInnerHTML", "dangerouslySetInnerHTML — potential XSS", "high"), # nosec B-dangerous_innerHTML: regex-pattern DEFINITION string
|
|
82
89
|
"subprocess_shell": (r"subprocess\.\w+\([^)]*shell\s*=\s*True", "subprocess with shell=True — potential command injection", "medium"),
|
|
83
90
|
"pickle_load": (r"pickle\.loads?\(", "pickle.load — potential arbitrary code execution", "high"),
|
|
84
91
|
"yaml_unsafe_load": (r"yaml\.load\([^)]*(?!Loader)", "yaml.load without safe Loader", "medium"),
|
|
@@ -309,10 +316,17 @@ def security_audit(target: str = ".") -> Dict[str, Any]:
|
|
|
309
316
|
})
|
|
310
317
|
severity_counts["critical"] += 1
|
|
311
318
|
|
|
312
|
-
# Anti-pattern detection
|
|
319
|
+
# Anti-pattern detection with industry-standard suppression markers.
|
|
320
|
+
# Skip a match if the matched line contains `# nosec`, `// nosec`,
|
|
321
|
+
# `# delimit:nosec`, or `// delimit:nosec` (anywhere on that line).
|
|
322
|
+
# This matches bandit's convention for Python and is widely understood.
|
|
323
|
+
content_lines = content.splitlines()
|
|
313
324
|
for ap_name, (pattern, desc, sev) in ANTI_PATTERNS.items():
|
|
314
325
|
for match in re.finditer(pattern, content):
|
|
315
326
|
line_num = content[:match.start()].count("\n") + 1
|
|
327
|
+
line_text = content_lines[line_num - 1] if 0 < line_num <= len(content_lines) else ""
|
|
328
|
+
if re.search(r"(#|//)\s*(delimit:)?nosec\b", line_text):
|
|
329
|
+
continue
|
|
316
330
|
anti_patterns_found.append({
|
|
317
331
|
"file": rel,
|
|
318
332
|
"line": line_num,
|
|
@@ -357,7 +357,9 @@ def test_smoke(project_path: str, test_suite: Optional[str] = None) -> Dict[str,
|
|
|
357
357
|
# If a specific suite is requested, validate and append
|
|
358
358
|
if test_suite:
|
|
359
359
|
# Sanitize: only allow alphanumeric, slashes, dots, underscores, hyphens, colons
|
|
360
|
-
import re
|
|
360
|
+
# LED-1077: removed redundant local `import re` — module imports re at the top,
|
|
361
|
+
# and the local import shadowed it, causing "local variable 're' referenced before assignment"
|
|
362
|
+
# on any code path that didn't pass through this branch before reaching re.search below.
|
|
361
363
|
if not re.match(r'^[\w/.\-:*\[\]]+$', test_suite):
|
|
362
364
|
return {"tool": "test.smoke", "status": "error", "error": f"Invalid test_suite: {test_suite}"}
|
|
363
365
|
cmd_list.append(test_suite)
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
|
@@ -0,0 +1,350 @@
|
|
|
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
|