delimit-cli 4.5.2 → 4.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/gateway/ai/remote_resolve.py +422 -0
- package/gateway/ai/server.py +173 -111
- package/gateway/ai/social_capability/capability_validator.py +107 -13
- package/gateway/ai/social_capability/fit_floor.py +360 -0
- package/gateway/ai/vendor_news/__init__.py +14 -0
- package/gateway/ai/vendor_news/drafter.py +562 -0
- package/gateway/ai/vendor_news/sensor.py +509 -0
- package/gateway/ai/vendor_news/watchlist.yaml +71 -0
- package/gateway/ai/x_ranker.py +146 -5
- package/package.json +18 -2
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
package/gateway/ai/server.py
CHANGED
|
@@ -1428,112 +1428,144 @@ def delimit_lint(old_spec: str, new_spec: str, policy_file: Optional[str] = None
|
|
|
1428
1428
|
recording evidence, triggering notifications, or enforcing governance.
|
|
1429
1429
|
Useful for CI preview comments ("what would block") without side effects.
|
|
1430
1430
|
|
|
1431
|
+
Spec arguments accept local paths or http(s) URLs. URLs are fetched
|
|
1432
|
+
once into a tempfile (size cap, SSRF guard) and the resolved local
|
|
1433
|
+
path is used for the rest of the pipeline.
|
|
1434
|
+
|
|
1431
1435
|
Args:
|
|
1432
|
-
old_spec: Path to the old (baseline) OpenAPI spec file.
|
|
1433
|
-
new_spec: Path to the new (proposed) OpenAPI spec file.
|
|
1436
|
+
old_spec: Path or URL to the old (baseline) OpenAPI spec file.
|
|
1437
|
+
new_spec: Path or URL to the new (proposed) OpenAPI spec file.
|
|
1434
1438
|
policy_file: Optional path to a .delimit/policies.yml file.
|
|
1435
1439
|
dry_run: If True, return violations without side effects (no evidence, no chains).
|
|
1436
1440
|
"""
|
|
1437
1441
|
from backends.gateway_core import run_lint, run_semver
|
|
1442
|
+
from ai.remote_resolve import RemoteResolveError, resolve_spec_input
|
|
1438
1443
|
|
|
1439
|
-
#
|
|
1440
|
-
|
|
1444
|
+
# Resolve any URL inputs up-front. Both contextmanagers share a
|
|
1445
|
+
# single ExitStack so cleanup happens once at the end.
|
|
1446
|
+
import contextlib as _contextlib
|
|
1447
|
+
try:
|
|
1448
|
+
with _contextlib.ExitStack() as stack:
|
|
1449
|
+
old_resolved, old_meta = stack.enter_context(resolve_spec_input(old_spec))
|
|
1450
|
+
new_resolved, new_meta = stack.enter_context(resolve_spec_input(new_spec))
|
|
1451
|
+
resolved_from = [old_meta["resolved_from"], new_meta["resolved_from"]]
|
|
1452
|
+
|
|
1453
|
+
# Step 1: Core lint (against resolved local paths)
|
|
1454
|
+
lint_result = _safe_call(
|
|
1455
|
+
run_lint,
|
|
1456
|
+
old_spec=old_resolved,
|
|
1457
|
+
new_spec=new_resolved,
|
|
1458
|
+
policy_file=policy_file,
|
|
1459
|
+
)
|
|
1441
1460
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1461
|
+
# Dry-run mode: return raw lint + semver, skip all chains and governance
|
|
1462
|
+
if dry_run:
|
|
1463
|
+
lint_result["dry_run"] = True
|
|
1464
|
+
lint_result["simulated"] = True
|
|
1465
|
+
# Still classify semver (informational, no side effects)
|
|
1466
|
+
semver_result = _safe_call(run_semver, old_spec=old_resolved, new_spec=new_resolved)
|
|
1467
|
+
if not semver_result.get("error"):
|
|
1468
|
+
lint_result["semver"] = semver_result
|
|
1469
|
+
lint_result["resolved_from"] = resolved_from
|
|
1470
|
+
return lint_result
|
|
1471
|
+
|
|
1472
|
+
chain: Dict[str, Any] = {"id": "lint_chain", "steps": []}
|
|
1473
|
+
|
|
1474
|
+
if lint_result.get("error"):
|
|
1475
|
+
lint_result["chain"] = chain
|
|
1476
|
+
lint_result["resolved_from"] = resolved_from
|
|
1477
|
+
return _with_next_steps("lint", lint_result)
|
|
1478
|
+
|
|
1479
|
+
# Step 2: Auto-classify semver bump (non-blocking on failure)
|
|
1480
|
+
semver_result = _chain_call("lint", "semver", run_semver,
|
|
1481
|
+
required=False, old_spec=old_resolved, new_spec=new_resolved)
|
|
1482
|
+
chain["steps"].append({"step": "semver", "ok": not _chain_is_error(semver_result)})
|
|
1449
1483
|
lint_result["semver"] = semver_result
|
|
1450
|
-
return lint_result
|
|
1451
|
-
|
|
1452
|
-
chain: Dict[str, Any] = {"id": "lint_chain", "steps": []}
|
|
1453
1484
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1485
|
+
if _chain_is_error(semver_result):
|
|
1486
|
+
chain["status"] = "semver_failed_nonfatal"
|
|
1487
|
+
lint_result["chain"] = chain
|
|
1488
|
+
lint_result["resolved_from"] = resolved_from
|
|
1489
|
+
return _with_next_steps("lint", lint_result)
|
|
1457
1490
|
|
|
1458
|
-
|
|
1459
|
-
semver_result = _chain_call("lint", "semver", run_semver,
|
|
1460
|
-
required=False, old_spec=old_spec, new_spec=new_spec)
|
|
1461
|
-
chain["steps"].append({"step": "semver", "ok": not _chain_is_error(semver_result)})
|
|
1462
|
-
lint_result["semver"] = semver_result
|
|
1491
|
+
bump = str(semver_result.get("bump", "")).upper()
|
|
1463
1492
|
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
"
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
str(gov_result.get("status", "")).lower() == "blocked"
|
|
1514
|
-
or gov_result.get("governance", {}).get("action") == "policy_blocked"
|
|
1515
|
-
)
|
|
1493
|
+
# Step 2b: Impact-based notification routing (LED-233, non-blocking)
|
|
1494
|
+
try:
|
|
1495
|
+
from ai.notify import route_by_impact
|
|
1496
|
+
all_changes = lint_result.get("all_changes", lint_result.get("violations", []))
|
|
1497
|
+
if all_changes:
|
|
1498
|
+
routing_result = route_by_impact(all_changes, dry_run=False)
|
|
1499
|
+
chain["steps"].append({"step": "impact_routing", "ok": True})
|
|
1500
|
+
lint_result["impact_routing"] = routing_result
|
|
1501
|
+
except Exception as e:
|
|
1502
|
+
logger.debug("Impact routing non-fatal error: %s", e)
|
|
1503
|
+
chain["steps"].append({"step": "impact_routing", "ok": False, "error": str(e)})
|
|
1504
|
+
|
|
1505
|
+
if bump != "MAJOR":
|
|
1506
|
+
chain["status"] = f"complete_{bump.lower() or 'none'}"
|
|
1507
|
+
lint_result["chain"] = chain
|
|
1508
|
+
lint_result["resolved_from"] = resolved_from
|
|
1509
|
+
return _with_next_steps("lint", lint_result)
|
|
1510
|
+
|
|
1511
|
+
# Step 3: MAJOR bump detected -- evaluate governance
|
|
1512
|
+
# Note: _delimit_gov_impl has its own Pro gate. Free-tier gets lint+semver only.
|
|
1513
|
+
# Pass the *original* user inputs (not the tempfile paths) into the
|
|
1514
|
+
# governance context so audit trails capture the real spec source.
|
|
1515
|
+
gov_result = _delimit_gov_impl(
|
|
1516
|
+
action="evaluate",
|
|
1517
|
+
eval_action="api_breaking_change",
|
|
1518
|
+
context={
|
|
1519
|
+
"tool": "delimit_lint",
|
|
1520
|
+
"old_spec": old_spec,
|
|
1521
|
+
"new_spec": new_spec,
|
|
1522
|
+
"semver_bump": bump,
|
|
1523
|
+
"breaking_changes": lint_result.get("breaking", []),
|
|
1524
|
+
},
|
|
1525
|
+
repo=".",
|
|
1526
|
+
)
|
|
1527
|
+
chain["steps"].append({"step": "gov_evaluate", "ok": not _chain_is_error(gov_result)})
|
|
1528
|
+
lint_result["gov_evaluate"] = gov_result
|
|
1529
|
+
|
|
1530
|
+
# If Pro gate blocked governance, return gracefully with lint+semver
|
|
1531
|
+
if gov_result.get("status") == "premium_required":
|
|
1532
|
+
chain["status"] = "governance_skipped_free_tier"
|
|
1533
|
+
lint_result["chain"] = chain
|
|
1534
|
+
lint_result["resolved_from"] = resolved_from
|
|
1535
|
+
return _with_next_steps("lint", lint_result)
|
|
1536
|
+
|
|
1537
|
+
# Step 4: If governance blocked, record in ledger (best-effort)
|
|
1538
|
+
gov_blocked = (
|
|
1539
|
+
str(gov_result.get("status", "")).lower() == "blocked"
|
|
1540
|
+
or gov_result.get("governance", {}).get("action") == "policy_blocked"
|
|
1541
|
+
)
|
|
1516
1542
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1543
|
+
if gov_blocked:
|
|
1544
|
+
from ai.ledger_manager import add_item
|
|
1545
|
+
ledger_result = _chain_call(
|
|
1546
|
+
"lint", "ledger_add", add_item,
|
|
1547
|
+
required=False,
|
|
1548
|
+
title=f"Governance blocked: MAJOR API change in {new_spec}",
|
|
1549
|
+
ledger="ops",
|
|
1550
|
+
item_type="fix",
|
|
1551
|
+
priority="P0",
|
|
1552
|
+
description="MAJOR semver bump detected. Governance blocked the change.",
|
|
1553
|
+
source="chain:lint:gov_blocked",
|
|
1554
|
+
)
|
|
1555
|
+
chain["steps"].append({"step": "ledger_add", "ok": not _chain_is_error(ledger_result)})
|
|
1556
|
+
lint_result["governance_blocked"] = True
|
|
1557
|
+
else:
|
|
1558
|
+
lint_result["governance_blocked"] = False
|
|
1533
1559
|
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1560
|
+
chain["status"] = "major_change_evaluated"
|
|
1561
|
+
lint_result["chain"] = chain
|
|
1562
|
+
lint_result["resolved_from"] = resolved_from
|
|
1563
|
+
return _with_next_steps("lint", lint_result)
|
|
1564
|
+
except RemoteResolveError as e:
|
|
1565
|
+
out = e.to_dict()
|
|
1566
|
+
out["old_spec"] = old_spec
|
|
1567
|
+
out["new_spec"] = new_spec
|
|
1568
|
+
return out
|
|
1537
1569
|
|
|
1538
1570
|
|
|
1539
1571
|
@mcp.tool()
|
|
@@ -2911,49 +2943,79 @@ def delimit_repo_diagnose(target: str = ".") -> Dict[str, Any]:
|
|
|
2911
2943
|
return _safe_call(diagnose, target=target)
|
|
2912
2944
|
|
|
2913
2945
|
|
|
2946
|
+
def _run_repo_tool_with_remote(
|
|
2947
|
+
target: str,
|
|
2948
|
+
backend_fn,
|
|
2949
|
+
pro_capability: str,
|
|
2950
|
+
) -> Dict[str, Any]:
|
|
2951
|
+
"""Shared wrapper for repo-bridge tools with remote-input support (LED-1237).
|
|
2952
|
+
|
|
2953
|
+
Resolves ``target`` (local path, ``owner/repo`` shorthand, or
|
|
2954
|
+
GitHub URL) into a local path, calls the backend, and merges the
|
|
2955
|
+
resolution metadata into the response so panel members can see
|
|
2956
|
+
what got cloned.
|
|
2957
|
+
"""
|
|
2958
|
+
from ai.license import require_premium
|
|
2959
|
+
gate = require_premium(pro_capability)
|
|
2960
|
+
if gate:
|
|
2961
|
+
return gate
|
|
2962
|
+
|
|
2963
|
+
from ai.remote_resolve import RemoteResolveError, resolve_repo_target
|
|
2964
|
+
|
|
2965
|
+
try:
|
|
2966
|
+
with resolve_repo_target(target) as (resolved_path, meta):
|
|
2967
|
+
result = _safe_call(backend_fn, target=resolved_path)
|
|
2968
|
+
if isinstance(result, dict):
|
|
2969
|
+
# Don't clobber a backend-supplied resolved_from field.
|
|
2970
|
+
for k, v in meta.items():
|
|
2971
|
+
result.setdefault(k, v)
|
|
2972
|
+
return result
|
|
2973
|
+
except RemoteResolveError as e:
|
|
2974
|
+
out = e.to_dict()
|
|
2975
|
+
# Preserve the user's original input for debuggability.
|
|
2976
|
+
out["target"] = target
|
|
2977
|
+
return out
|
|
2978
|
+
|
|
2979
|
+
|
|
2914
2980
|
@mcp.tool()
|
|
2915
2981
|
def delimit_repo_analyze(target: str = ".") -> Dict[str, Any]:
|
|
2916
2982
|
"""Analyze repository structure and quality (experimental) (Pro).
|
|
2917
2983
|
|
|
2984
|
+
Accepts a local path, an ``owner/repo`` shorthand
|
|
2985
|
+
(e.g. ``calcom/cal.com``), or a full GitHub URL. Remote inputs
|
|
2986
|
+
are shallow-cloned into a tempdir for the duration of the call.
|
|
2987
|
+
|
|
2918
2988
|
Args:
|
|
2919
|
-
target: Repository path.
|
|
2989
|
+
target: Repository path, owner/repo, or GitHub URL.
|
|
2920
2990
|
"""
|
|
2921
|
-
from ai.license import require_premium
|
|
2922
|
-
gate = require_premium("repo_analyze")
|
|
2923
|
-
if gate:
|
|
2924
|
-
return gate
|
|
2925
2991
|
from backends.repo_bridge import analyze
|
|
2926
|
-
return
|
|
2992
|
+
return _run_repo_tool_with_remote(target, analyze, "repo_analyze")
|
|
2927
2993
|
|
|
2928
2994
|
|
|
2929
2995
|
@mcp.tool()
|
|
2930
2996
|
def delimit_repo_config_validate(target: str = ".") -> Dict[str, Any]:
|
|
2931
2997
|
"""Validate configuration files (experimental) (Pro).
|
|
2932
2998
|
|
|
2999
|
+
Accepts a local path, ``owner/repo`` shorthand, or a GitHub URL.
|
|
3000
|
+
|
|
2933
3001
|
Args:
|
|
2934
|
-
target: Repository or config path.
|
|
3002
|
+
target: Repository or config path, owner/repo, or GitHub URL.
|
|
2935
3003
|
"""
|
|
2936
|
-
from ai.license import require_premium
|
|
2937
|
-
gate = require_premium("repo_config_validate")
|
|
2938
|
-
if gate:
|
|
2939
|
-
return gate
|
|
2940
3004
|
from backends.repo_bridge import config_validate
|
|
2941
|
-
return
|
|
3005
|
+
return _run_repo_tool_with_remote(target, config_validate, "repo_config_validate")
|
|
2942
3006
|
|
|
2943
3007
|
|
|
2944
3008
|
@mcp.tool()
|
|
2945
3009
|
def delimit_repo_config_audit(target: str = ".") -> Dict[str, Any]:
|
|
2946
3010
|
"""Audit configuration compliance (experimental) (Pro).
|
|
2947
3011
|
|
|
3012
|
+
Accepts a local path, ``owner/repo`` shorthand, or a GitHub URL.
|
|
3013
|
+
|
|
2948
3014
|
Args:
|
|
2949
|
-
target: Repository or config path.
|
|
3015
|
+
target: Repository or config path, owner/repo, or GitHub URL.
|
|
2950
3016
|
"""
|
|
2951
|
-
from ai.license import require_premium
|
|
2952
|
-
gate = require_premium("repo_config_audit")
|
|
2953
|
-
if gate:
|
|
2954
|
-
return gate
|
|
2955
3017
|
from backends.repo_bridge import config_audit
|
|
2956
|
-
return
|
|
3018
|
+
return _run_repo_tool_with_remote(target, config_audit, "repo_config_audit")
|
|
2957
3019
|
|
|
2958
3020
|
|
|
2959
3021
|
# ─── Security ───────────────────────────────────────────────────────────
|
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
"""Capability-currency validator (LED-216 Phase 1).
|
|
1
|
+
"""Capability-currency validator (LED-216 Phase 1, tightened LED-1240).
|
|
2
2
|
|
|
3
3
|
Validates social drafts against ``current_capabilities.yaml``. The validator
|
|
4
|
-
hard-fails any draft that
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
hard-fails any draft that:
|
|
5
|
+
|
|
6
|
+
* names a banned surface (literal or regex), OR
|
|
7
|
+
* mentions the Delimit product without anchoring to ground truth (no
|
|
8
|
+
canonical phrase AND no matched_claim from allowed_claims), OR
|
|
9
|
+
* mentions the Delimit product on a long-form platform (reddit, hn, devto,
|
|
10
|
+
etc.) without a delimit.ai URL anchor.
|
|
11
|
+
|
|
12
|
+
The 2026-05-05 tightening (LED-1240) was a response to founder feedback that
|
|
13
|
+
generic-claim drafts were leaking through with only a soft warning. The fix
|
|
14
|
+
reclassifies "mentions product, names no specific claim, links no artifact"
|
|
15
|
+
as a hard-fail — drafts that talk about Delimit must either (a) name a
|
|
16
|
+
mechanism from allowed_claims, or (b) link to a delimit.ai resource (the
|
|
17
|
+
methodology, a worked-example report, the attestation replay UI, etc.).
|
|
18
|
+
|
|
19
|
+
Twitter (and other ≤280-char platforms) gets a deliberate carve-out: a URL
|
|
20
|
+
won't always fit, so for ``platform="twitter"`` the URL requirement is
|
|
21
|
+
relaxed to "draft must contain at least one matched_claim". Reddit, HN,
|
|
22
|
+
devto, and any unspecified platform keep the URL requirement.
|
|
7
23
|
|
|
8
24
|
Wiring: ``ai.social.save_draft`` calls :func:`validate_draft` after the
|
|
9
25
|
existing tone/length checks but BEFORE the file is appended. On hard-fail
|
|
@@ -58,6 +74,21 @@ _PRODUCT_MENTION_RE = re.compile(
|
|
|
58
74
|
re.IGNORECASE,
|
|
59
75
|
)
|
|
60
76
|
|
|
77
|
+
# Match a delimit.ai URL anchor — either a recognized known path, or a bare
|
|
78
|
+
# delimit.ai reference. Used for the LED-1240 long-form URL-grounding gate.
|
|
79
|
+
# The known-path list is recognised by the validator and any other
|
|
80
|
+
# delimit.ai/<segment> URL also counts as grounding (we only need to know
|
|
81
|
+
# the draft is anchored to a real artifact on the public site).
|
|
82
|
+
_DELIMIT_URL_RE = re.compile(
|
|
83
|
+
r"\bdelimit\.ai(?:/(?:methodology|reports|att|docs|trust|pricing)\b|/\S+|\b)",
|
|
84
|
+
re.IGNORECASE,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Platforms that get the short-form URL relaxation. Anything not in this set
|
|
88
|
+
# is treated as long-form and must carry a delimit.ai URL anchor when the
|
|
89
|
+
# draft mentions Delimit by name.
|
|
90
|
+
_SHORT_FORM_PLATFORMS = {"twitter", "x"}
|
|
91
|
+
|
|
61
92
|
|
|
62
93
|
def _load_capabilities(path: Path) -> Dict[str, Any]:
|
|
63
94
|
"""Load and parse the capabilities YAML.
|
|
@@ -157,6 +188,17 @@ def _mentions_product(text: str) -> bool:
|
|
|
157
188
|
return bool(_PRODUCT_MENTION_RE.search(text or ""))
|
|
158
189
|
|
|
159
190
|
|
|
191
|
+
def _has_delimit_url(text: str) -> bool:
|
|
192
|
+
"""True iff the draft contains any delimit.ai URL anchor.
|
|
193
|
+
|
|
194
|
+
Matches both the curated path list (delimit.ai/methodology, /reports,
|
|
195
|
+
/att, /docs, /trust, /pricing) and any other delimit.ai/<path> URL.
|
|
196
|
+
Bare 'delimit.ai' is also accepted — the goal is grounding, not exact
|
|
197
|
+
path validation.
|
|
198
|
+
"""
|
|
199
|
+
return bool(_DELIMIT_URL_RE.search(text or ""))
|
|
200
|
+
|
|
201
|
+
|
|
160
202
|
def _append_audit(record: Dict[str, Any]) -> None:
|
|
161
203
|
"""Append a validation decision to the audit log. Best-effort."""
|
|
162
204
|
try:
|
|
@@ -171,6 +213,7 @@ def validate_draft(
|
|
|
171
213
|
text: str,
|
|
172
214
|
capabilities_path: Optional[Path] = None,
|
|
173
215
|
*,
|
|
216
|
+
platform: str = "",
|
|
174
217
|
audit_meta: Optional[Dict[str, Any]] = None,
|
|
175
218
|
log: bool = True,
|
|
176
219
|
) -> Dict[str, Any]:
|
|
@@ -181,6 +224,12 @@ def validate_draft(
|
|
|
181
224
|
capabilities_path: Override path to the capabilities YAML. Defaults
|
|
182
225
|
to the bundled ``current_capabilities.yaml`` next to this
|
|
183
226
|
module.
|
|
227
|
+
platform: Platform string ("twitter", "reddit", "hn", "devto", ...).
|
|
228
|
+
Twitter / X get a short-form carve-out: drafts that mention the
|
|
229
|
+
product without a delimit.ai URL still pass IF they cite a
|
|
230
|
+
specific allowed_claim. Long-form platforms must carry both a
|
|
231
|
+
specific claim AND a URL anchor when they mention the product.
|
|
232
|
+
Empty string defaults to long-form behavior (strictest gate).
|
|
184
233
|
audit_meta: Optional fields to embed in the audit log entry
|
|
185
234
|
(e.g. ``{"draft_id": ..., "platform": ...}``). Never required
|
|
186
235
|
for validation logic.
|
|
@@ -189,16 +238,20 @@ def validate_draft(
|
|
|
189
238
|
Returns:
|
|
190
239
|
Dict with:
|
|
191
240
|
- ``ok`` (bool): False iff a banned surface (literal or pattern)
|
|
192
|
-
appeared
|
|
241
|
+
appeared, OR the draft mentions the product but anchors to no
|
|
242
|
+
ground truth (LED-1240).
|
|
193
243
|
- ``errors`` (list[str]): Hard-fail reasons.
|
|
194
|
-
- ``warnings`` (list[str]): Soft-fail reasons
|
|
195
|
-
mentioned without canonical phrase).
|
|
244
|
+
- ``warnings`` (list[str]): Soft-fail reasons.
|
|
196
245
|
- ``matched_claims`` (list[str]): IDs of allowed_claims found.
|
|
197
246
|
- ``matched_banned`` (list[str]): Banned surfaces / patterns hit.
|
|
198
247
|
- ``mentions_product`` (bool): Whether the draft references
|
|
199
248
|
Delimit by name or handle.
|
|
200
249
|
- ``has_canonical_phrase`` (bool): Whether at least one canonical
|
|
201
250
|
phrase appears.
|
|
251
|
+
- ``has_delimit_url`` (bool): Whether the draft contains a
|
|
252
|
+
delimit.ai URL anchor.
|
|
253
|
+
- ``platform`` (str): The normalized platform string used for the
|
|
254
|
+
short-form carve-out decision.
|
|
202
255
|
"""
|
|
203
256
|
text = text or ""
|
|
204
257
|
path = capabilities_path or DEFAULT_CAPABILITIES_PATH
|
|
@@ -227,14 +280,51 @@ def validate_draft(
|
|
|
227
280
|
|
|
228
281
|
mentions_product = _mentions_product(text)
|
|
229
282
|
has_canonical = _has_canonical_phrase(text, required_phrases)
|
|
283
|
+
has_url = _has_delimit_url(text)
|
|
284
|
+
platform_norm = (platform or "").strip().lower()
|
|
285
|
+
is_short_form = platform_norm in _SHORT_FORM_PLATFORMS
|
|
230
286
|
|
|
231
287
|
warnings: List[str] = []
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
288
|
+
|
|
289
|
+
# ── LED-1240 grounding gate ──────────────────────────────────────
|
|
290
|
+
# Tightened 2026-05-05: a draft that mentions Delimit but anchors to
|
|
291
|
+
# no ground truth is now a hard-fail, not a soft warning. The two
|
|
292
|
+
# rules are:
|
|
293
|
+
# (a) Product mention + no canonical phrase + no matched_claim
|
|
294
|
+
# ⇒ hard-fail on every platform. The draft is naming the
|
|
295
|
+
# product without grounding to anything in the allow list.
|
|
296
|
+
# (b) Product mention on a long-form platform without a delimit.ai
|
|
297
|
+
# URL anchor ⇒ hard-fail. Twitter / X are exempt because a URL
|
|
298
|
+
# won't always fit in 280 chars; for them, a matched_claim is
|
|
299
|
+
# sufficient grounding.
|
|
300
|
+
# The carve-out preserves the existing twitter draft contract while
|
|
301
|
+
# raising the floor for reddit / HN / devto / etc.
|
|
302
|
+
if mentions_product and required_phrases:
|
|
303
|
+
if not has_canonical and not matched_claims:
|
|
304
|
+
errors.append(
|
|
305
|
+
"draft mentions Delimit but cites no canonical phrase and no "
|
|
306
|
+
"specific allowed_claim. Anchor the claim to a mechanism in "
|
|
307
|
+
"current_capabilities.yaml or rewrite without naming the "
|
|
308
|
+
"product (LED-1240)."
|
|
309
|
+
)
|
|
310
|
+
elif not is_short_form and not has_url:
|
|
311
|
+
errors.append(
|
|
312
|
+
"draft mentions Delimit on a long-form platform "
|
|
313
|
+
f"(platform={platform_norm or 'unspecified'}) without a "
|
|
314
|
+
"delimit.ai URL anchor. Cite a specific artifact "
|
|
315
|
+
"(delimit.ai/methodology, /reports, /att, ...) so the claim "
|
|
316
|
+
"is verifiable (LED-1240)."
|
|
317
|
+
)
|
|
318
|
+
elif not has_canonical:
|
|
319
|
+
# Has a matched_claim but still missing a canonical phrase —
|
|
320
|
+
# downgrade to a warning (existing soft-fail behavior). The
|
|
321
|
+
# claim is grounded; the framing isn't on-message yet.
|
|
322
|
+
warnings.append(
|
|
323
|
+
"draft mentions Delimit and cites a specific claim but does "
|
|
324
|
+
"not include a canonical phrase (merge gate / signed, "
|
|
325
|
+
"replayable attestation / AI-written code / AI-assisted "
|
|
326
|
+
"merge). Founder review recommended."
|
|
327
|
+
)
|
|
238
328
|
|
|
239
329
|
ok = not errors
|
|
240
330
|
|
|
@@ -246,6 +336,8 @@ def validate_draft(
|
|
|
246
336
|
"matched_banned": matched_banned,
|
|
247
337
|
"mentions_product": mentions_product,
|
|
248
338
|
"has_canonical_phrase": has_canonical,
|
|
339
|
+
"has_delimit_url": has_url,
|
|
340
|
+
"platform": platform_norm,
|
|
249
341
|
}
|
|
250
342
|
|
|
251
343
|
if log:
|
|
@@ -258,6 +350,8 @@ def validate_draft(
|
|
|
258
350
|
"matched_banned": matched_banned,
|
|
259
351
|
"mentions_product": mentions_product,
|
|
260
352
|
"has_canonical_phrase": has_canonical,
|
|
353
|
+
"has_delimit_url": has_url,
|
|
354
|
+
"platform": platform_norm,
|
|
261
355
|
"text_len": len(text),
|
|
262
356
|
"capabilities_path": str(path),
|
|
263
357
|
}
|