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.
- package/CHANGELOG.md +87 -0
- package/README.md +2 -2
- package/bin/delimit-cli.js +109 -24
- package/gateway/ai/content_engine.py +3 -4
- package/gateway/ai/inbox_classifier.py +215 -0
- package/gateway/ai/integrations/opensage_wrapper.py +4 -1
- package/gateway/ai/ledger_manager.py +218 -38
- package/gateway/ai/license.py +26 -0
- package/gateway/ai/notify.py +68 -3
- package/gateway/ai/reddit_proxy.py +93 -15
- package/gateway/ai/reddit_scanner.py +36 -18
- package/gateway/ai/server.py +128 -6
- package/gateway/ai/social_capability/__init__.py +6 -0
- package/gateway/ai/social_capability/capability_validator.py +273 -0
- package/gateway/ai/social_capability/current_capabilities.yaml +95 -0
- package/gateway/ai/social_queue.py +307 -0
- package/gateway/ai/supabase_sync.py +14 -2
- package/gateway/ai/swarm.py +29 -11
- package/gateway/ai/tui.py +6 -2
- package/gateway/ai/x_ranker.py +276 -0
- package/lib/attest-mcp.js +487 -0
- package/lib/attest-telemetry.js +48 -0
- package/lib/delimit-home.js +35 -0
- package/lib/delimit-template.js +14 -0
- package/package.json +8 -2
- package/scripts/postinstall.js +89 -40
- package/gateway/ai/content_grounding/__init__.py +0 -98
- package/gateway/ai/content_grounding/build.py +0 -350
- package/gateway/ai/content_grounding/consume.py +0 -280
- package/gateway/ai/content_grounding/features.py +0 -218
- package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +0 -9
- package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +0 -9
- package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +0 -17
- package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +0 -17
- package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +0 -17
- package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +0 -18
- package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +0 -18
- package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +0 -23
- package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +0 -16
- package/gateway/ai/content_grounding/schemas/claim.schema.json +0 -40
- package/gateway/ai/content_grounding/schemas/event.schema.json +0 -23
- package/gateway/ai/content_grounding/schemas.py +0 -276
- package/gateway/ai/content_grounding/telemetry.py +0 -221
- package/gateway/ai/inbox_drafts/__init__.py +0 -61
- package/gateway/ai/inbox_drafts/registry.py +0 -412
- package/gateway/ai/inbox_drafts/schema.py +0 -374
- package/gateway/ai/inbox_executor.py +0 -565
package/gateway/ai/server.py
CHANGED
|
@@ -7302,7 +7302,8 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
|
|
|
7302
7302
|
Categories: tip, changelog, insight, engagement.
|
|
7303
7303
|
Leave text empty to auto-generate from templates.
|
|
7304
7304
|
Every post provides value - tips, insights, governance wisdom.
|
|
7305
|
-
|
|
7305
|
+
Rate cap: 2 original posts per hour, 24 per day (founder-approved
|
|
7306
|
+
2026-04-30). Override via DELIMIT_HOURLY_TWEETS / DELIMIT_DAILY_TWEETS.
|
|
7306
7307
|
|
|
7307
7308
|
IMPORTANT - Platform tone rules (these are DIFFERENT per platform):
|
|
7308
7309
|
- Twitter: confident technical brand. Direct, professional, ALWAYS POSITIVE.
|
|
@@ -7323,10 +7324,10 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
|
|
|
7323
7324
|
draft: If True, save as draft for approval instead of posting immediately.
|
|
7324
7325
|
context: WHY this post should be made. Strategic reasoning shown in the approval email.
|
|
7325
7326
|
"""
|
|
7326
|
-
from ai.social import generate_post, post_tweet,
|
|
7327
|
+
from ai.social import generate_post, post_tweet, should_post_now, save_draft
|
|
7327
7328
|
|
|
7328
|
-
if not draft and not
|
|
7329
|
-
return {"status": "skipped", "reason": "
|
|
7329
|
+
if not draft and not should_post_now():
|
|
7330
|
+
return {"status": "skipped", "reason": "Rate cap hit (2/hr or 24/day). Wait or pass draft=True for email-approval flow."}
|
|
7330
7331
|
|
|
7331
7332
|
post = generate_post(category, text)
|
|
7332
7333
|
|
|
@@ -7443,9 +7444,16 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
|
|
|
7443
7444
|
_lines.append("Reply APPROVED to approve, CANCEL to reject.")
|
|
7444
7445
|
|
|
7445
7446
|
_handle = f"u/{_acct}"
|
|
7447
|
+
# LED-1129 Phase 2 — append [draft_id:<8>] token to subject so
|
|
7448
|
+
# the inbox daemon's draft_id fallback can match the approval
|
|
7449
|
+
# reply even when no LED/STR token is present.
|
|
7450
|
+
_reddit_subject = f"[Reddit Post] {_handle}: {_reddit_title[:60]}..."
|
|
7451
|
+
_reg_id = entry.get("registry_draft_id")
|
|
7452
|
+
if _reg_id:
|
|
7453
|
+
_reddit_subject = f"{_reddit_subject} [draft_id:{_reg_id[:8]}]"
|
|
7446
7454
|
email_result = send_email(
|
|
7447
7455
|
message="\n".join(_lines),
|
|
7448
|
-
subject=
|
|
7456
|
+
subject=_reddit_subject,
|
|
7449
7457
|
event_type="social_draft",
|
|
7450
7458
|
)
|
|
7451
7459
|
if email_result.get("delivered") and email_result.get("message_id"):
|
|
@@ -7487,9 +7495,16 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
|
|
|
7487
7495
|
_subject_type = "Tweet"
|
|
7488
7496
|
|
|
7489
7497
|
_handle = f"u/{_acct}" if platform == "reddit" else f"@{_acct}"
|
|
7498
|
+
# LED-1129 Phase 2 — append [draft_id:<8>] token to subject so the
|
|
7499
|
+
# inbox daemon's draft_id fallback can match the approval reply even
|
|
7500
|
+
# when no LED/STR token is present.
|
|
7501
|
+
_social_subject = f"[{_subject_type}] {_handle}: {post['text'][:60]}..."
|
|
7502
|
+
_reg_id = entry.get("registry_draft_id")
|
|
7503
|
+
if _reg_id:
|
|
7504
|
+
_social_subject = f"{_social_subject} [draft_id:{_reg_id[:8]}]"
|
|
7490
7505
|
email_result = send_email(
|
|
7491
7506
|
message="\n".join(_lines),
|
|
7492
|
-
subject=
|
|
7507
|
+
subject=_social_subject,
|
|
7493
7508
|
event_type="social_draft",
|
|
7494
7509
|
)
|
|
7495
7510
|
# Store the outbound Message-ID on the draft record so the
|
|
@@ -7529,6 +7544,55 @@ def delimit_social_accounts() -> Dict[str, Any]:
|
|
|
7529
7544
|
return _with_next_steps("social_accounts", {"accounts": accounts, "count": len(accounts)})
|
|
7530
7545
|
|
|
7531
7546
|
|
|
7547
|
+
@mcp.tool()
|
|
7548
|
+
def delimit_x_fetch(id_or_url: str = "", ids: str = "") -> Dict[str, Any]:
|
|
7549
|
+
"""LED-825: fetch tweets from X by id or URL via twttr241 (RapidAPI).
|
|
7550
|
+
|
|
7551
|
+
Inherits the LRU + SQLite cache + budget gate already wired for the
|
|
7552
|
+
social-target scanner, so repeated reads of the same tweet are free.
|
|
7553
|
+
|
|
7554
|
+
Args:
|
|
7555
|
+
id_or_url: A single status id ("2048825010371039648") OR a full
|
|
7556
|
+
x.com / twitter.com URL — the id is extracted automatically.
|
|
7557
|
+
Mutually exclusive with `ids`.
|
|
7558
|
+
ids: Comma-separated list of status ids OR URLs for a batch fetch.
|
|
7559
|
+
Each is normalized to a status id and fetched independently.
|
|
7560
|
+
|
|
7561
|
+
Returns:
|
|
7562
|
+
Single-fetch shape: {id, text, author, author_name, created_at,
|
|
7563
|
+
metrics: {favorite_count, retweet_count, reply_count,
|
|
7564
|
+
quote_count, bookmark_count, view_count}, url, from_cache}
|
|
7565
|
+
Batch shape: {tweets: [<single-shape>, ...], count}
|
|
7566
|
+
Errors: {error: <reason>}
|
|
7567
|
+
|
|
7568
|
+
Why this exists: WebFetch hits 402 on x.com (auth-walled), and going
|
|
7569
|
+
around to tweepy + the X API direct creds skips the cache + budget
|
|
7570
|
+
gate. This tool is the cheap, cached, governable read path.
|
|
7571
|
+
"""
|
|
7572
|
+
from ai.social_target import fetch_tweet_by_id, fetch_tweets_by_ids, extract_status_id
|
|
7573
|
+
|
|
7574
|
+
if ids:
|
|
7575
|
+
# Batch mode — accept commas, newlines, or whitespace as separators
|
|
7576
|
+
raw = [r.strip() for r in ids.replace("\n", ",").split(",") if r.strip()]
|
|
7577
|
+
normalized: List[str] = []
|
|
7578
|
+
for item in raw:
|
|
7579
|
+
sid = extract_status_id(item)
|
|
7580
|
+
if sid:
|
|
7581
|
+
normalized.append(sid)
|
|
7582
|
+
if not normalized:
|
|
7583
|
+
return _with_next_steps("x_fetch", {"error": "no valid ids/URLs in `ids`"})
|
|
7584
|
+
results = fetch_tweets_by_ids(normalized)
|
|
7585
|
+
return _with_next_steps("x_fetch", {"tweets": results, "count": len(results)})
|
|
7586
|
+
|
|
7587
|
+
if not id_or_url:
|
|
7588
|
+
return _with_next_steps("x_fetch", {"error": "provide either id_or_url or ids"})
|
|
7589
|
+
|
|
7590
|
+
sid = extract_status_id(id_or_url)
|
|
7591
|
+
if not sid:
|
|
7592
|
+
return _with_next_steps("x_fetch", {"error": f"could not parse status id from {id_or_url!r}"})
|
|
7593
|
+
return _with_next_steps("x_fetch", fetch_tweet_by_id(sid))
|
|
7594
|
+
|
|
7595
|
+
|
|
7532
7596
|
@mcp.tool()
|
|
7533
7597
|
def delimit_social_history(limit: int = 20, platform: str = "",
|
|
7534
7598
|
user: str = "", subreddit: str = "") -> Dict[str, Any]:
|
|
@@ -7985,6 +8049,64 @@ def delimit_social_daemon(action: str = "status") -> Dict[str, Any]:
|
|
|
7985
8049
|
else:
|
|
7986
8050
|
return _with_next_steps("social_daemon", get_daemon_status())
|
|
7987
8051
|
|
|
8052
|
+
|
|
8053
|
+
@mcp.tool()
|
|
8054
|
+
def delimit_self_repair_daemon(action: str = "status") -> Dict[str, Any]:
|
|
8055
|
+
"""Control the self-repair watcher daemon (LED-191, internal).
|
|
8056
|
+
|
|
8057
|
+
Polls function KPIs on a cadence (default 1h) and emits founder
|
|
8058
|
+
alerts on fresh breaches. Higher modes (diagnose/deliberate/apply/
|
|
8059
|
+
verify) chain through the watcher when configured per-function in
|
|
8060
|
+
~/.delimit/self_repair.yaml.
|
|
8061
|
+
|
|
8062
|
+
Idempotent start; circuit-breakered stop after 3 consecutive
|
|
8063
|
+
pass failures. Honors DELIMIT_SELF_REPAIR_PAUSE=1 at every pass
|
|
8064
|
+
without requiring a daemon restart.
|
|
8065
|
+
|
|
8066
|
+
Args:
|
|
8067
|
+
action: 'start' (begin polling), 'stop' (halt polling),
|
|
8068
|
+
'status' (running / last_pass / breaches_emitted /
|
|
8069
|
+
consecutive_failures).
|
|
8070
|
+
"""
|
|
8071
|
+
from ai.self_repair_daemon import (
|
|
8072
|
+
start_daemon as _sr_start,
|
|
8073
|
+
stop_daemon as _sr_stop,
|
|
8074
|
+
get_daemon_status as _sr_status,
|
|
8075
|
+
)
|
|
8076
|
+
|
|
8077
|
+
if action == "start":
|
|
8078
|
+
return _with_next_steps("self_repair_daemon", _sr_start())
|
|
8079
|
+
elif action == "stop":
|
|
8080
|
+
return _with_next_steps("self_repair_daemon", _sr_stop())
|
|
8081
|
+
else:
|
|
8082
|
+
return _with_next_steps("self_repair_daemon", _sr_status())
|
|
8083
|
+
|
|
8084
|
+
|
|
8085
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
8086
|
+
# LED-189: Corp dashboard — single-call session-start synthesis
|
|
8087
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
8088
|
+
|
|
8089
|
+
|
|
8090
|
+
@mcp.tool()
|
|
8091
|
+
def delimit_corp_dashboard() -> Dict[str, Any]:
|
|
8092
|
+
"""One-call corp status — replaces the 6-call session-start ritual (LED-189).
|
|
8093
|
+
|
|
8094
|
+
Returns daemon states (systemd + in-process), self-repair status,
|
|
8095
|
+
social/inbox activity, ledger pending counts, agent queue (audit-only),
|
|
8096
|
+
latest session, and a synthesized one-line summary like:
|
|
8097
|
+
|
|
8098
|
+
"Corp status: 3 daemons active (self-repair, inbox, social),
|
|
8099
|
+
12 ledger open, 2 approvals waiting, 4 breaches in 24h."
|
|
8100
|
+
|
|
8101
|
+
Every sub-section is failure-isolated: a partial failure returns
|
|
8102
|
+
{"error": "..."} for that key only and never crashes the whole call.
|
|
8103
|
+
Gateway-only — not shipped in the npm bundle.
|
|
8104
|
+
"""
|
|
8105
|
+
from ai.corp_dashboard import get_corp_dashboard
|
|
8106
|
+
result = get_corp_dashboard()
|
|
8107
|
+
return _with_next_steps("corp_dashboard", result)
|
|
8108
|
+
|
|
8109
|
+
|
|
7988
8110
|
# ═══════════════════════════════════════════════════════════════════════
|
|
7989
8111
|
# LED-187: Shareable Governance Config - export / import
|
|
7990
8112
|
# ═══════════════════════════════════════════════════════════════════════
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Social outreach capability-currency module (LED-216 Phase 1).
|
|
2
|
+
|
|
3
|
+
Curated capability inventory + draft-emit validator. Lives outside
|
|
4
|
+
``ai/social.py`` so the existing module stays focused on draft generation
|
|
5
|
+
and Python doesn't get a package/module name collision.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Capability-currency validator (LED-216 Phase 1).
|
|
2
|
+
|
|
3
|
+
Validates social drafts against ``current_capabilities.yaml``. The validator
|
|
4
|
+
hard-fails any draft that names a banned surface (literal or regex), and
|
|
5
|
+
warns when a draft references the Delimit product but no canonical phrase
|
|
6
|
+
appears.
|
|
7
|
+
|
|
8
|
+
Wiring: ``ai.social.save_draft`` calls :func:`validate_draft` after the
|
|
9
|
+
existing tone/length checks but BEFORE the file is appended. On hard-fail
|
|
10
|
+
the draft's ``quality`` is overridden to ``"rejected_capability_drift"`` and
|
|
11
|
+
the entry MUST NOT be enqueued for notify. On warn the quality becomes
|
|
12
|
+
``"ready_with_warnings"``. Both outcomes are logged to
|
|
13
|
+
``~/.delimit/social_drafts_validation.jsonl`` for audit / replay.
|
|
14
|
+
|
|
15
|
+
Governance: the underlying ``current_capabilities.yaml`` is gated by the
|
|
16
|
+
LED-1037 banned-vocabulary contract — edits require a unanimous
|
|
17
|
+
``delimit_deliberate`` verdict. This module only consumes the file; it does
|
|
18
|
+
not mutate it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import yaml as _yaml
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
_yaml = None # type: ignore[assignment]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("delimit.ai.social_capability.capability_validator")
|
|
36
|
+
|
|
37
|
+
# Default capabilities path — co-located with this module so the file ships
|
|
38
|
+
# (or doesn't) with the same npm exclusion rule as the rest of
|
|
39
|
+
# ai/social_capability/. The package name is `social_capability` rather
|
|
40
|
+
# than `social` because `ai/social.py` already exists as a top-level
|
|
41
|
+
# module and Python forbids a package with the same name as a sibling
|
|
42
|
+
# module.
|
|
43
|
+
DEFAULT_CAPABILITIES_PATH = Path(__file__).parent / "current_capabilities.yaml"
|
|
44
|
+
|
|
45
|
+
# Audit log for every validator decision (pass / warn / fail). One JSONL
|
|
46
|
+
# line per draft. Used by self-repair to detect over- or under-firing.
|
|
47
|
+
VALIDATION_LOG = Path.home() / ".delimit" / "social_drafts_validation.jsonl"
|
|
48
|
+
|
|
49
|
+
# ── product-mention detection ────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
# Case-insensitive match for "Delimit" as a standalone word OR the
|
|
52
|
+
# @delimit_ai twitter handle. Avoid false positives on hostnames like
|
|
53
|
+
# "delimit.ai/methodology/..." by NOT requiring word boundaries — the URL
|
|
54
|
+
# itself is a product reference, which is exactly the case where we want
|
|
55
|
+
# to nudge the canonical phrase.
|
|
56
|
+
_PRODUCT_MENTION_RE = re.compile(
|
|
57
|
+
r"(?:\bDelimit\b|@delimit_ai|delimit\.ai)",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_capabilities(path: Path) -> Dict[str, Any]:
|
|
63
|
+
"""Load and parse the capabilities YAML.
|
|
64
|
+
|
|
65
|
+
Returns an empty config (no banned, no claims) if the file is missing
|
|
66
|
+
or YAML is unavailable, so the validator fails open in degraded
|
|
67
|
+
environments rather than crashing draft generation. The fail-open is
|
|
68
|
+
intentional: a missing capability file should NOT block legitimate
|
|
69
|
+
drafts; the worst outcome is the draft passes through without
|
|
70
|
+
capability-currency enforcement, which we'll catch via the audit log.
|
|
71
|
+
"""
|
|
72
|
+
if _yaml is None:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"capability_validator: PyYAML not available; validator is a no-op"
|
|
75
|
+
)
|
|
76
|
+
return {}
|
|
77
|
+
if not path.exists():
|
|
78
|
+
logger.warning(
|
|
79
|
+
"capability_validator: capabilities file missing at %s; "
|
|
80
|
+
"validator is a no-op",
|
|
81
|
+
path,
|
|
82
|
+
)
|
|
83
|
+
return {}
|
|
84
|
+
try:
|
|
85
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
86
|
+
data = _yaml.safe_load(f) or {}
|
|
87
|
+
except Exception as exc: # pragma: no cover — corrupt yaml
|
|
88
|
+
logger.error(
|
|
89
|
+
"capability_validator: failed to load %s: %s", path, exc
|
|
90
|
+
)
|
|
91
|
+
return {}
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
logger.error(
|
|
94
|
+
"capability_validator: %s did not parse to a mapping", path
|
|
95
|
+
)
|
|
96
|
+
return {}
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _matched_claims(text: str, claims: List[Dict[str, Any]]) -> List[str]:
|
|
101
|
+
"""Return IDs of allowed_claims whose surface_name appears in text."""
|
|
102
|
+
out: List[str] = []
|
|
103
|
+
lower = text.lower()
|
|
104
|
+
for claim in claims:
|
|
105
|
+
if not isinstance(claim, dict):
|
|
106
|
+
continue
|
|
107
|
+
surface = (claim.get("surface_name") or "").strip()
|
|
108
|
+
cid = (claim.get("id") or "").strip()
|
|
109
|
+
if not surface or not cid:
|
|
110
|
+
continue
|
|
111
|
+
if surface.lower() in lower:
|
|
112
|
+
out.append(cid)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _matched_banned_literal(
|
|
117
|
+
text: str, banned: List[str]
|
|
118
|
+
) -> List[str]:
|
|
119
|
+
"""Return banned surface literals (case-insensitive) found in text."""
|
|
120
|
+
lower = text.lower()
|
|
121
|
+
return [b for b in banned if isinstance(b, str) and b and b.lower() in lower]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _matched_banned_pattern(
|
|
125
|
+
text: str, patterns: List[str]
|
|
126
|
+
) -> List[str]:
|
|
127
|
+
"""Return banned regex patterns that match somewhere in text.
|
|
128
|
+
|
|
129
|
+
Compilation errors on individual patterns are logged and the pattern is
|
|
130
|
+
skipped so one bad regex does not break the whole validator.
|
|
131
|
+
"""
|
|
132
|
+
out: List[str] = []
|
|
133
|
+
for pat in patterns:
|
|
134
|
+
if not isinstance(pat, str) or not pat:
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
if re.search(pat, text, flags=re.IGNORECASE):
|
|
138
|
+
out.append(pat)
|
|
139
|
+
except re.error as exc:
|
|
140
|
+
logger.warning(
|
|
141
|
+
"capability_validator: bad banned_surface_pattern %r: %s",
|
|
142
|
+
pat, exc,
|
|
143
|
+
)
|
|
144
|
+
continue
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _has_canonical_phrase(text: str, phrases: List[str]) -> bool:
|
|
149
|
+
lower = text.lower()
|
|
150
|
+
for p in phrases:
|
|
151
|
+
if isinstance(p, str) and p and p.lower() in lower:
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _mentions_product(text: str) -> bool:
|
|
157
|
+
return bool(_PRODUCT_MENTION_RE.search(text or ""))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _append_audit(record: Dict[str, Any]) -> None:
|
|
161
|
+
"""Append a validation decision to the audit log. Best-effort."""
|
|
162
|
+
try:
|
|
163
|
+
VALIDATION_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
with open(VALIDATION_LOG, "a", encoding="utf-8") as f:
|
|
165
|
+
f.write(json.dumps(record) + "\n")
|
|
166
|
+
except Exception as exc: # pragma: no cover — disk full, etc.
|
|
167
|
+
logger.debug("capability_validator: audit write failed: %s", exc)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def validate_draft(
|
|
171
|
+
text: str,
|
|
172
|
+
capabilities_path: Optional[Path] = None,
|
|
173
|
+
*,
|
|
174
|
+
audit_meta: Optional[Dict[str, Any]] = None,
|
|
175
|
+
log: bool = True,
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
"""Validate a social draft against ``current_capabilities.yaml``.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
text: The candidate draft text.
|
|
181
|
+
capabilities_path: Override path to the capabilities YAML. Defaults
|
|
182
|
+
to the bundled ``current_capabilities.yaml`` next to this
|
|
183
|
+
module.
|
|
184
|
+
audit_meta: Optional fields to embed in the audit log entry
|
|
185
|
+
(e.g. ``{"draft_id": ..., "platform": ...}``). Never required
|
|
186
|
+
for validation logic.
|
|
187
|
+
log: When False, skip the audit log write. Used by tests.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Dict with:
|
|
191
|
+
- ``ok`` (bool): False iff a banned surface (literal or pattern)
|
|
192
|
+
appeared.
|
|
193
|
+
- ``errors`` (list[str]): Hard-fail reasons.
|
|
194
|
+
- ``warnings`` (list[str]): Soft-fail reasons (e.g. product
|
|
195
|
+
mentioned without canonical phrase).
|
|
196
|
+
- ``matched_claims`` (list[str]): IDs of allowed_claims found.
|
|
197
|
+
- ``matched_banned`` (list[str]): Banned surfaces / patterns hit.
|
|
198
|
+
- ``mentions_product`` (bool): Whether the draft references
|
|
199
|
+
Delimit by name or handle.
|
|
200
|
+
- ``has_canonical_phrase`` (bool): Whether at least one canonical
|
|
201
|
+
phrase appears.
|
|
202
|
+
"""
|
|
203
|
+
text = text or ""
|
|
204
|
+
path = capabilities_path or DEFAULT_CAPABILITIES_PATH
|
|
205
|
+
cfg = _load_capabilities(path)
|
|
206
|
+
|
|
207
|
+
allowed_claims = cfg.get("allowed_claims") or []
|
|
208
|
+
banned_literals = cfg.get("banned_surfaces") or []
|
|
209
|
+
banned_patterns = cfg.get("banned_surface_patterns") or []
|
|
210
|
+
required_phrases = cfg.get("required_canonical_phrases") or []
|
|
211
|
+
|
|
212
|
+
matched_claims = _matched_claims(text, allowed_claims)
|
|
213
|
+
matched_literal = _matched_banned_literal(text, banned_literals)
|
|
214
|
+
matched_patterns = _matched_banned_pattern(text, banned_patterns)
|
|
215
|
+
matched_banned = matched_literal + matched_patterns
|
|
216
|
+
|
|
217
|
+
errors: List[str] = []
|
|
218
|
+
for hit in matched_literal:
|
|
219
|
+
errors.append(
|
|
220
|
+
f"banned surface literal: {hit!r} — see ai/social_capability/current_capabilities.yaml"
|
|
221
|
+
)
|
|
222
|
+
for pat in matched_patterns:
|
|
223
|
+
errors.append(
|
|
224
|
+
f"banned surface pattern matched: {pat!r} (tool-count hero "
|
|
225
|
+
f"language is forbidden in social copy)"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
mentions_product = _mentions_product(text)
|
|
229
|
+
has_canonical = _has_canonical_phrase(text, required_phrases)
|
|
230
|
+
|
|
231
|
+
warnings: List[str] = []
|
|
232
|
+
if mentions_product and not has_canonical and required_phrases:
|
|
233
|
+
warnings.append(
|
|
234
|
+
"draft mentions Delimit but does not include a canonical phrase "
|
|
235
|
+
"(merge gate / signed, replayable attestation / AI-written code "
|
|
236
|
+
"/ AI-assisted merge). Founder review recommended."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
ok = not errors
|
|
240
|
+
|
|
241
|
+
result: Dict[str, Any] = {
|
|
242
|
+
"ok": ok,
|
|
243
|
+
"errors": errors,
|
|
244
|
+
"warnings": warnings,
|
|
245
|
+
"matched_claims": matched_claims,
|
|
246
|
+
"matched_banned": matched_banned,
|
|
247
|
+
"mentions_product": mentions_product,
|
|
248
|
+
"has_canonical_phrase": has_canonical,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if log:
|
|
252
|
+
record = {
|
|
253
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
254
|
+
"ok": ok,
|
|
255
|
+
"errors": errors,
|
|
256
|
+
"warnings": warnings,
|
|
257
|
+
"matched_claims": matched_claims,
|
|
258
|
+
"matched_banned": matched_banned,
|
|
259
|
+
"mentions_product": mentions_product,
|
|
260
|
+
"has_canonical_phrase": has_canonical,
|
|
261
|
+
"text_len": len(text),
|
|
262
|
+
"capabilities_path": str(path),
|
|
263
|
+
}
|
|
264
|
+
if audit_meta:
|
|
265
|
+
# Don't let audit_meta clobber computed fields.
|
|
266
|
+
for k, v in audit_meta.items():
|
|
267
|
+
record.setdefault(k, v)
|
|
268
|
+
_append_audit(record)
|
|
269
|
+
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
__all__ = ["validate_draft", "DEFAULT_CAPABILITIES_PATH", "VALIDATION_LOG"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# LED-216 Phase 1 — Capability Currency
|
|
2
|
+
#
|
|
3
|
+
# This file is the single source of truth for what Delimit is allowed to
|
|
4
|
+
# claim in any social draft. The capability_validator.py module reads it
|
|
5
|
+
# at draft emit time and hard-fails any draft that:
|
|
6
|
+
#
|
|
7
|
+
# * names a banned surface (literal substring), OR
|
|
8
|
+
# * matches a banned surface regex (e.g. tool-count hero language).
|
|
9
|
+
#
|
|
10
|
+
# Drafts that mention "Delimit" / "@delimit_ai" but reference NO canonical
|
|
11
|
+
# phrase are warned (not failed) so the founder can still review.
|
|
12
|
+
#
|
|
13
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
14
|
+
# GOVERNANCE (mirror of LED-1037 banned-vocabulary gate)
|
|
15
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
16
|
+
# Edits to this file MUST be gated by `delimit_deliberate` unanimous
|
|
17
|
+
# verdict. Same governance contract as the LED-1037 banned-vocabulary
|
|
18
|
+
# list: any change requires a multi-model panel sign-off (Claude +
|
|
19
|
+
# Gemini + Codex + Grok) before it lands. Silent edits are a policy
|
|
20
|
+
# violation and will be reverted.
|
|
21
|
+
#
|
|
22
|
+
# 30-day review cadence: capabilities rot fast. The `last_reviewed`
|
|
23
|
+
# field below MUST be bumped on every panel-approved edit, and the
|
|
24
|
+
# orchestrator should re-deliberate if `now - last_reviewed >
|
|
25
|
+
# review_cadence_days`.
|
|
26
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
version: 1
|
|
29
|
+
last_reviewed: 2026-05-02
|
|
30
|
+
review_cadence_days: 30
|
|
31
|
+
|
|
32
|
+
allowed_claims:
|
|
33
|
+
# Each entry: {id, surface_name, description, since_version, evidence_link}
|
|
34
|
+
- id: attest_mcp
|
|
35
|
+
surface_name: "delimit attest mcp"
|
|
36
|
+
description: "Local CLI subcommand that runs the 5-check methodology preview."
|
|
37
|
+
since_version: 4.5.0
|
|
38
|
+
evidence_link: https://delimit.ai/methodology/mcp-attestation
|
|
39
|
+
- id: self_repair_v1
|
|
40
|
+
surface_name: "self-repair v1 closed loop"
|
|
41
|
+
description: "Sensor → diagnose → deliberate → apply → verify with founder approval gate."
|
|
42
|
+
since_version: gateway-2026-05-01
|
|
43
|
+
evidence_link: https://delimit.ai/methodology/mcp-attestation
|
|
44
|
+
- id: merge_gate
|
|
45
|
+
surface_name: "merge gate for AI-written code"
|
|
46
|
+
description: "Signed, replayable attestation for every AI-assisted merge."
|
|
47
|
+
since_version: "1.0"
|
|
48
|
+
evidence_link: https://delimit.ai
|
|
49
|
+
- id: multi_model_deliberation
|
|
50
|
+
surface_name: "multi-model deliberation panel"
|
|
51
|
+
description: "delimit_deliberate runs Claude + Gemini + Codex + Grok to consensus."
|
|
52
|
+
since_version: "1.0"
|
|
53
|
+
- id: persistent_context
|
|
54
|
+
surface_name: "persistent context across sessions"
|
|
55
|
+
description: "Memory + ledger survive across sessions and models via delimit_revive / delimit_session_handoff."
|
|
56
|
+
- id: governance_kernel
|
|
57
|
+
surface_name: "fail-closed governance kernel"
|
|
58
|
+
description: "Audit trail + evidence_collect at every gate."
|
|
59
|
+
- id: diff_engine
|
|
60
|
+
surface_name: "27 breaking-change types"
|
|
61
|
+
description: "Deterministic diff engine for OpenAPI spec changes."
|
|
62
|
+
- id: github_action
|
|
63
|
+
surface_name: "delimit-ai/delimit-action GitHub Action"
|
|
64
|
+
description: "On Marketplace, breaking-change detection on PRs."
|
|
65
|
+
- id: corp_dashboard
|
|
66
|
+
surface_name: "delimit_corp_dashboard"
|
|
67
|
+
description: "Single-call corp status synthesis."
|
|
68
|
+
since_version: gateway-2026-05-01
|
|
69
|
+
- id: methodology_v1
|
|
70
|
+
surface_name: "MCP attestation methodology v1"
|
|
71
|
+
description: "Public methodology document at delimit.ai/methodology/mcp-attestation."
|
|
72
|
+
|
|
73
|
+
banned_surfaces:
|
|
74
|
+
# Strings that MUST NOT appear in any social draft. Validator hard-fails.
|
|
75
|
+
- "AI OS"
|
|
76
|
+
- "governance platform"
|
|
77
|
+
- "one workspace for every AI coding assistant"
|
|
78
|
+
- "5 capability domains"
|
|
79
|
+
- "Jamsons OS"
|
|
80
|
+
- "Delimit OS"
|
|
81
|
+
- "186 tools"
|
|
82
|
+
|
|
83
|
+
banned_surface_patterns:
|
|
84
|
+
# Tool-count hero language patterns — match by regex in validator.
|
|
85
|
+
- "\\d+ tools"
|
|
86
|
+
- "\\d+ capability domains"
|
|
87
|
+
|
|
88
|
+
required_canonical_phrases:
|
|
89
|
+
# At least one of these MUST appear in any draft that mentions Delimit's
|
|
90
|
+
# product. If draft mentions Delimit by name and references no canonical
|
|
91
|
+
# phrase, validator warns (does not hard-fail).
|
|
92
|
+
- "merge gate"
|
|
93
|
+
- "signed, replayable attestation"
|
|
94
|
+
- "AI-written code"
|
|
95
|
+
- "AI-assisted merge"
|