delimit-cli 4.5.2 → 4.5.4

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.
@@ -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
- # Step 1: Core lint
1440
- lint_result = _safe_call(run_lint, old_spec=old_spec, new_spec=new_spec, policy_file=policy_file)
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
- # Dry-run mode: return raw lint + semver, skip all chains and governance
1443
- if dry_run:
1444
- lint_result["dry_run"] = True
1445
- lint_result["simulated"] = True
1446
- # Still classify semver (informational, no side effects)
1447
- semver_result = _safe_call(run_semver, old_spec=old_spec, new_spec=new_spec)
1448
- if not semver_result.get("error"):
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
- if lint_result.get("error"):
1455
- lint_result["chain"] = chain
1456
- return _with_next_steps("lint", lint_result)
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
- # Step 2: Auto-classify semver bump (non-blocking on failure)
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
- if _chain_is_error(semver_result):
1465
- chain["status"] = "semver_failed_nonfatal"
1466
- lint_result["chain"] = chain
1467
- return _with_next_steps("lint", lint_result)
1468
-
1469
- bump = str(semver_result.get("bump", "")).upper()
1470
-
1471
- # Step 2b: Impact-based notification routing (LED-233, non-blocking)
1472
- try:
1473
- from ai.notify import route_by_impact
1474
- all_changes = lint_result.get("all_changes", lint_result.get("violations", []))
1475
- if all_changes:
1476
- routing_result = route_by_impact(all_changes, dry_run=False)
1477
- chain["steps"].append({"step": "impact_routing", "ok": True})
1478
- lint_result["impact_routing"] = routing_result
1479
- except Exception as e:
1480
- logger.debug("Impact routing non-fatal error: %s", e)
1481
- chain["steps"].append({"step": "impact_routing", "ok": False, "error": str(e)})
1482
-
1483
- if bump != "MAJOR":
1484
- chain["status"] = f"complete_{bump.lower() or 'none'}"
1485
- lint_result["chain"] = chain
1486
- return _with_next_steps("lint", lint_result)
1487
-
1488
- # Step 3: MAJOR bump detected -- evaluate governance
1489
- # Note: _delimit_gov_impl has its own Pro gate. Free-tier gets lint+semver only.
1490
- gov_result = _delimit_gov_impl(
1491
- action="evaluate",
1492
- eval_action="api_breaking_change",
1493
- context={
1494
- "tool": "delimit_lint",
1495
- "old_spec": old_spec,
1496
- "new_spec": new_spec,
1497
- "semver_bump": bump,
1498
- "breaking_changes": lint_result.get("breaking", []),
1499
- },
1500
- repo=".",
1501
- )
1502
- chain["steps"].append({"step": "gov_evaluate", "ok": not _chain_is_error(gov_result)})
1503
- lint_result["gov_evaluate"] = gov_result
1504
-
1505
- # If Pro gate blocked governance, return gracefully with lint+semver
1506
- if gov_result.get("status") == "premium_required":
1507
- chain["status"] = "governance_skipped_free_tier"
1508
- lint_result["chain"] = chain
1509
- return _with_next_steps("lint", lint_result)
1510
-
1511
- # Step 4: If governance blocked, record in ledger (best-effort)
1512
- gov_blocked = (
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
- if gov_blocked:
1518
- from ai.ledger_manager import add_item
1519
- ledger_result = _chain_call(
1520
- "lint", "ledger_add", add_item,
1521
- required=False,
1522
- title=f"Governance blocked: MAJOR API change in {new_spec}",
1523
- ledger="ops",
1524
- item_type="fix",
1525
- priority="P0",
1526
- description="MAJOR semver bump detected. Governance blocked the change.",
1527
- source="chain:lint:gov_blocked",
1528
- )
1529
- chain["steps"].append({"step": "ledger_add", "ok": not _chain_is_error(ledger_result)})
1530
- lint_result["governance_blocked"] = True
1531
- else:
1532
- lint_result["governance_blocked"] = False
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
- chain["status"] = "major_change_evaluated"
1535
- lint_result["chain"] = chain
1536
- return _with_next_steps("lint", lint_result)
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 _safe_call(analyze, target=target)
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 _safe_call(config_validate, target=target)
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 _safe_call(config_audit, target=target)
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 names a banned surface (literal or regex), and
5
- warns when a draft references the Delimit product but no canonical phrase
6
- appears.
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 (e.g. product
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
- 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
- )
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
  }