delimit-cli 4.5.1 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +15 -5
  3. package/bin/delimit-cli.js +109 -24
  4. package/gateway/ai/content_engine.py +3 -4
  5. package/gateway/ai/inbox_classifier.py +215 -0
  6. package/gateway/ai/integrations/opensage_wrapper.py +4 -1
  7. package/gateway/ai/ledger_manager.py +218 -38
  8. package/gateway/ai/license.py +26 -0
  9. package/gateway/ai/notify.py +68 -3
  10. package/gateway/ai/reddit_proxy.py +93 -15
  11. package/gateway/ai/reddit_scanner.py +36 -18
  12. package/gateway/ai/remote_resolve.py +422 -0
  13. package/gateway/ai/server.py +301 -117
  14. package/gateway/ai/social_capability/__init__.py +6 -0
  15. package/gateway/ai/social_capability/capability_validator.py +367 -0
  16. package/gateway/ai/social_capability/current_capabilities.yaml +95 -0
  17. package/gateway/ai/social_capability/fit_floor.py +360 -0
  18. package/gateway/ai/social_queue.py +307 -0
  19. package/gateway/ai/supabase_sync.py +14 -2
  20. package/gateway/ai/swarm.py +29 -11
  21. package/gateway/ai/tui.py +6 -2
  22. package/gateway/ai/vendor_news/__init__.py +14 -0
  23. package/gateway/ai/vendor_news/drafter.py +562 -0
  24. package/gateway/ai/vendor_news/sensor.py +509 -0
  25. package/gateway/ai/vendor_news/watchlist.yaml +71 -0
  26. package/gateway/ai/x_ranker.py +417 -0
  27. package/lib/attest-mcp.js +487 -0
  28. package/lib/attest-telemetry.js +48 -0
  29. package/lib/delimit-home.js +35 -0
  30. package/lib/delimit-template.js +14 -0
  31. package/package.json +25 -3
  32. package/scripts/postinstall.js +89 -40
  33. package/adapters/codex-security.js +0 -64
  34. package/adapters/codex-skill.js +0 -78
  35. package/gateway/ai/content_grounding/__init__.py +0 -98
  36. package/gateway/ai/content_grounding/build.py +0 -350
  37. package/gateway/ai/content_grounding/consume.py +0 -280
  38. package/gateway/ai/content_grounding/features.py +0 -218
  39. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +0 -9
  40. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +0 -9
  41. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +0 -17
  42. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +0 -17
  43. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +0 -17
  44. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +0 -18
  45. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +0 -18
  46. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +0 -23
  47. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +0 -16
  48. package/gateway/ai/content_grounding/schemas/claim.schema.json +0 -40
  49. package/gateway/ai/content_grounding/schemas/event.schema.json +0 -23
  50. package/gateway/ai/content_grounding/schemas.py +0 -276
  51. package/gateway/ai/content_grounding/telemetry.py +0 -221
  52. package/gateway/ai/inbox_drafts/__init__.py +0 -61
  53. package/gateway/ai/inbox_drafts/registry.py +0 -412
  54. package/gateway/ai/inbox_drafts/schema.py +0 -374
  55. package/gateway/ai/inbox_executor.py +0 -565
@@ -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
1484
 
1452
- chain: Dict[str, Any] = {"id": "lint_chain", "steps": []}
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)
1453
1490
 
1454
- if lint_result.get("error"):
1455
- lint_result["chain"] = chain
1456
- return _with_next_steps("lint", lint_result)
1457
-
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
1463
-
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)})
1491
+ bump = str(semver_result.get("bump", "")).upper()
1482
1492
 
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 ───────────────────────────────────────────────────────────
@@ -7302,7 +7364,8 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
7302
7364
  Categories: tip, changelog, insight, engagement.
7303
7365
  Leave text empty to auto-generate from templates.
7304
7366
  Every post provides value - tips, insights, governance wisdom.
7305
- Max 2 posts per day to stay authentic.
7367
+ Rate cap: 2 original posts per hour, 24 per day (founder-approved
7368
+ 2026-04-30). Override via DELIMIT_HOURLY_TWEETS / DELIMIT_DAILY_TWEETS.
7306
7369
 
7307
7370
  IMPORTANT - Platform tone rules (these are DIFFERENT per platform):
7308
7371
  - Twitter: confident technical brand. Direct, professional, ALWAYS POSITIVE.
@@ -7323,10 +7386,10 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
7323
7386
  draft: If True, save as draft for approval instead of posting immediately.
7324
7387
  context: WHY this post should be made. Strategic reasoning shown in the approval email.
7325
7388
  """
7326
- from ai.social import generate_post, post_tweet, should_post_today, save_draft
7389
+ from ai.social import generate_post, post_tweet, should_post_now, save_draft
7327
7390
 
7328
- if not draft and not should_post_today():
7329
- return {"status": "skipped", "reason": "Already posted twice today. Authenticity > volume."}
7391
+ if not draft and not should_post_now():
7392
+ return {"status": "skipped", "reason": "Rate cap hit (2/hr or 24/day). Wait or pass draft=True for email-approval flow."}
7330
7393
 
7331
7394
  post = generate_post(category, text)
7332
7395
 
@@ -7443,9 +7506,16 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
7443
7506
  _lines.append("Reply APPROVED to approve, CANCEL to reject.")
7444
7507
 
7445
7508
  _handle = f"u/{_acct}"
7509
+ # LED-1129 Phase 2 — append [draft_id:<8>] token to subject so
7510
+ # the inbox daemon's draft_id fallback can match the approval
7511
+ # reply even when no LED/STR token is present.
7512
+ _reddit_subject = f"[Reddit Post] {_handle}: {_reddit_title[:60]}..."
7513
+ _reg_id = entry.get("registry_draft_id")
7514
+ if _reg_id:
7515
+ _reddit_subject = f"{_reddit_subject} [draft_id:{_reg_id[:8]}]"
7446
7516
  email_result = send_email(
7447
7517
  message="\n".join(_lines),
7448
- subject=f"[Reddit Post] {_handle}: {_reddit_title[:60]}...",
7518
+ subject=_reddit_subject,
7449
7519
  event_type="social_draft",
7450
7520
  )
7451
7521
  if email_result.get("delivered") and email_result.get("message_id"):
@@ -7487,9 +7557,16 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
7487
7557
  _subject_type = "Tweet"
7488
7558
 
7489
7559
  _handle = f"u/{_acct}" if platform == "reddit" else f"@{_acct}"
7560
+ # LED-1129 Phase 2 — append [draft_id:<8>] token to subject so the
7561
+ # inbox daemon's draft_id fallback can match the approval reply even
7562
+ # when no LED/STR token is present.
7563
+ _social_subject = f"[{_subject_type}] {_handle}: {post['text'][:60]}..."
7564
+ _reg_id = entry.get("registry_draft_id")
7565
+ if _reg_id:
7566
+ _social_subject = f"{_social_subject} [draft_id:{_reg_id[:8]}]"
7490
7567
  email_result = send_email(
7491
7568
  message="\n".join(_lines),
7492
- subject=f"[{_subject_type}] {_handle}: {post['text'][:60]}...",
7569
+ subject=_social_subject,
7493
7570
  event_type="social_draft",
7494
7571
  )
7495
7572
  # Store the outbound Message-ID on the draft record so the
@@ -7529,6 +7606,55 @@ def delimit_social_accounts() -> Dict[str, Any]:
7529
7606
  return _with_next_steps("social_accounts", {"accounts": accounts, "count": len(accounts)})
7530
7607
 
7531
7608
 
7609
+ @mcp.tool()
7610
+ def delimit_x_fetch(id_or_url: str = "", ids: str = "") -> Dict[str, Any]:
7611
+ """LED-825: fetch tweets from X by id or URL via twttr241 (RapidAPI).
7612
+
7613
+ Inherits the LRU + SQLite cache + budget gate already wired for the
7614
+ social-target scanner, so repeated reads of the same tweet are free.
7615
+
7616
+ Args:
7617
+ id_or_url: A single status id ("2048825010371039648") OR a full
7618
+ x.com / twitter.com URL — the id is extracted automatically.
7619
+ Mutually exclusive with `ids`.
7620
+ ids: Comma-separated list of status ids OR URLs for a batch fetch.
7621
+ Each is normalized to a status id and fetched independently.
7622
+
7623
+ Returns:
7624
+ Single-fetch shape: {id, text, author, author_name, created_at,
7625
+ metrics: {favorite_count, retweet_count, reply_count,
7626
+ quote_count, bookmark_count, view_count}, url, from_cache}
7627
+ Batch shape: {tweets: [<single-shape>, ...], count}
7628
+ Errors: {error: <reason>}
7629
+
7630
+ Why this exists: WebFetch hits 402 on x.com (auth-walled), and going
7631
+ around to tweepy + the X API direct creds skips the cache + budget
7632
+ gate. This tool is the cheap, cached, governable read path.
7633
+ """
7634
+ from ai.social_target import fetch_tweet_by_id, fetch_tweets_by_ids, extract_status_id
7635
+
7636
+ if ids:
7637
+ # Batch mode — accept commas, newlines, or whitespace as separators
7638
+ raw = [r.strip() for r in ids.replace("\n", ",").split(",") if r.strip()]
7639
+ normalized: List[str] = []
7640
+ for item in raw:
7641
+ sid = extract_status_id(item)
7642
+ if sid:
7643
+ normalized.append(sid)
7644
+ if not normalized:
7645
+ return _with_next_steps("x_fetch", {"error": "no valid ids/URLs in `ids`"})
7646
+ results = fetch_tweets_by_ids(normalized)
7647
+ return _with_next_steps("x_fetch", {"tweets": results, "count": len(results)})
7648
+
7649
+ if not id_or_url:
7650
+ return _with_next_steps("x_fetch", {"error": "provide either id_or_url or ids"})
7651
+
7652
+ sid = extract_status_id(id_or_url)
7653
+ if not sid:
7654
+ return _with_next_steps("x_fetch", {"error": f"could not parse status id from {id_or_url!r}"})
7655
+ return _with_next_steps("x_fetch", fetch_tweet_by_id(sid))
7656
+
7657
+
7532
7658
  @mcp.tool()
7533
7659
  def delimit_social_history(limit: int = 20, platform: str = "",
7534
7660
  user: str = "", subreddit: str = "") -> Dict[str, Any]:
@@ -7985,6 +8111,64 @@ def delimit_social_daemon(action: str = "status") -> Dict[str, Any]:
7985
8111
  else:
7986
8112
  return _with_next_steps("social_daemon", get_daemon_status())
7987
8113
 
8114
+
8115
+ @mcp.tool()
8116
+ def delimit_self_repair_daemon(action: str = "status") -> Dict[str, Any]:
8117
+ """Control the self-repair watcher daemon (LED-191, internal).
8118
+
8119
+ Polls function KPIs on a cadence (default 1h) and emits founder
8120
+ alerts on fresh breaches. Higher modes (diagnose/deliberate/apply/
8121
+ verify) chain through the watcher when configured per-function in
8122
+ ~/.delimit/self_repair.yaml.
8123
+
8124
+ Idempotent start; circuit-breakered stop after 3 consecutive
8125
+ pass failures. Honors DELIMIT_SELF_REPAIR_PAUSE=1 at every pass
8126
+ without requiring a daemon restart.
8127
+
8128
+ Args:
8129
+ action: 'start' (begin polling), 'stop' (halt polling),
8130
+ 'status' (running / last_pass / breaches_emitted /
8131
+ consecutive_failures).
8132
+ """
8133
+ from ai.self_repair_daemon import (
8134
+ start_daemon as _sr_start,
8135
+ stop_daemon as _sr_stop,
8136
+ get_daemon_status as _sr_status,
8137
+ )
8138
+
8139
+ if action == "start":
8140
+ return _with_next_steps("self_repair_daemon", _sr_start())
8141
+ elif action == "stop":
8142
+ return _with_next_steps("self_repair_daemon", _sr_stop())
8143
+ else:
8144
+ return _with_next_steps("self_repair_daemon", _sr_status())
8145
+
8146
+
8147
+ # ═══════════════════════════════════════════════════════════════════════
8148
+ # LED-189: Corp dashboard — single-call session-start synthesis
8149
+ # ═══════════════════════════════════════════════════════════════════════
8150
+
8151
+
8152
+ @mcp.tool()
8153
+ def delimit_corp_dashboard() -> Dict[str, Any]:
8154
+ """One-call corp status — replaces the 6-call session-start ritual (LED-189).
8155
+
8156
+ Returns daemon states (systemd + in-process), self-repair status,
8157
+ social/inbox activity, ledger pending counts, agent queue (audit-only),
8158
+ latest session, and a synthesized one-line summary like:
8159
+
8160
+ "Corp status: 3 daemons active (self-repair, inbox, social),
8161
+ 12 ledger open, 2 approvals waiting, 4 breaches in 24h."
8162
+
8163
+ Every sub-section is failure-isolated: a partial failure returns
8164
+ {"error": "..."} for that key only and never crashes the whole call.
8165
+ Gateway-only — not shipped in the npm bundle.
8166
+ """
8167
+ from ai.corp_dashboard import get_corp_dashboard
8168
+ result = get_corp_dashboard()
8169
+ return _with_next_steps("corp_dashboard", result)
8170
+
8171
+
7988
8172
  # ═══════════════════════════════════════════════════════════════════════
7989
8173
  # LED-187: Shareable Governance Config - export / import
7990
8174
  # ═══════════════════════════════════════════════════════════════════════
@@ -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
+ """