devrel-origin 0.2.14__tar.gz → 0.2.16__tar.gz

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 (138) hide show
  1. {devrel_origin-0.2.14/src/devrel_origin.egg-info → devrel_origin-0.2.16}/PKG-INFO +1 -1
  2. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/pyproject.toml +1 -1
  3. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/doctor.py +21 -0
  4. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/argus.py +45 -12
  5. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/atlas.py +60 -14
  6. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/vox.py +52 -2
  7. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/state.py +110 -1
  8. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/quality/editorial.py +8 -9
  9. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/analytics.py +20 -0
  10. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/api_client.py +85 -0
  11. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/run_report.py +4 -0
  12. {devrel_origin-0.2.14 → devrel_origin-0.2.16/src/devrel_origin.egg-info}/PKG-INFO +1 -1
  13. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_argus.py +27 -0
  14. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_atlas.py +15 -0
  15. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_integration.py +14 -1
  16. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_vox.py +17 -1
  17. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/LICENSE +0 -0
  18. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/README.md +0 -0
  19. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/setup.cfg +0 -0
  20. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/__init__.py +0 -0
  21. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/__init__.py +0 -0
  22. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/_common.py +0 -0
  23. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/analytics.py +0 -0
  24. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/argus.py +0 -0
  25. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/auth.py +0 -0
  26. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/config.py +0 -0
  27. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/content.py +0 -0
  28. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/cost.py +0 -0
  29. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/cro.py +0 -0
  30. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/deliverables.py +0 -0
  31. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/docs.py +0 -0
  32. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/experiment.py +0 -0
  33. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/growth.py +0 -0
  34. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/init.py +0 -0
  35. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/intel.py +0 -0
  36. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/kb.py +0 -0
  37. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/listen.py +0 -0
  38. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/marketing.py +0 -0
  39. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/migrate.py +0 -0
  40. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/run.py +0 -0
  41. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/sales.py +0 -0
  42. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/schedule.py +0 -0
  43. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/synthesize.py +0 -0
  44. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/triage.py +0 -0
  45. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/cli/video.py +0 -0
  46. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/__init__.py +0 -0
  47. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/agent_config.py +0 -0
  48. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/base.py +0 -0
  49. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/cyra.py +0 -0
  50. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/dex.py +0 -0
  51. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/echo.py +0 -0
  52. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/growth/__init__.py +0 -0
  53. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/growth/recommendations.py +0 -0
  54. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/growth/target_kinds.py +0 -0
  55. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/iris.py +0 -0
  56. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/kai.py +0 -0
  57. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/llm.py +0 -0
  58. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/llm_backends.py +0 -0
  59. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/mox.py +0 -0
  60. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/nova.py +0 -0
  61. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/pax.py +0 -0
  62. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/rex.py +0 -0
  63. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/sage.py +0 -0
  64. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/sentinel.py +0 -0
  65. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/types.py +0 -0
  66. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/video/__init__.py +0 -0
  67. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/video/assembler.py +0 -0
  68. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/video/browser_recorder.py +0 -0
  69. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/video/desktop_recorder.py +0 -0
  70. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/video/overlay_renderer.py +0 -0
  71. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/video/script_parser.py +0 -0
  72. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/video/tts_engine.py +0 -0
  73. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/core/watchdog.py +0 -0
  74. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/__init__.py +0 -0
  75. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/config.py +0 -0
  76. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/cost_sink.py +0 -0
  77. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/init.py +0 -0
  78. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/paths.py +0 -0
  79. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/templates/__init__.py +0 -0
  80. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/templates/config.toml +0 -0
  81. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/templates/devrel.gitignore +0 -0
  82. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/templates/slop-blocklist.md +0 -0
  83. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/templates/style.md +0 -0
  84. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/project/templates/voice.md +0 -0
  85. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/quality/__init__.py +0 -0
  86. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/quality/persona.py +0 -0
  87. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/quality/readability.py +0 -0
  88. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/quality/slop.py +0 -0
  89. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/quality/style.py +0 -0
  90. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/quality/voice.py +0 -0
  91. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/__init__.py +0 -0
  92. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/apollo_client.py +0 -0
  93. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/code_validator.py +0 -0
  94. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/github_tools.py +0 -0
  95. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/instantly_client.py +0 -0
  96. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/kb_harvester.py +0 -0
  97. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/mcp_server.py +0 -0
  98. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/notifications.py +0 -0
  99. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/scheduler.py +0 -0
  100. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/search_tools.py +0 -0
  101. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/self_improve.py +0 -0
  102. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin/tools/sheets.py +0 -0
  103. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin.egg-info/SOURCES.txt +0 -0
  104. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin.egg-info/dependency_links.txt +0 -0
  105. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin.egg-info/entry_points.txt +0 -0
  106. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin.egg-info/requires.txt +0 -0
  107. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/src/devrel_origin.egg-info/top_level.txt +0 -0
  108. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_agent_edge_cases.py +0 -0
  109. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_analytics_collectors.py +0 -0
  110. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_api_client.py +0 -0
  111. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_apollo_client.py +0 -0
  112. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_atlas_replies.py +0 -0
  113. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_base_agent.py +0 -0
  114. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_code_validator.py +0 -0
  115. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_config.py +0 -0
  116. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_cyra.py +0 -0
  117. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_dex.py +0 -0
  118. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_echo.py +0 -0
  119. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_github_tools.py +0 -0
  120. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_instantly_client.py +0 -0
  121. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_iris.py +0 -0
  122. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_kai.py +0 -0
  123. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_llm.py +0 -0
  124. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_llm_backends.py +0 -0
  125. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_llm_cost_sink.py +0 -0
  126. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_llm_cost_tracking.py +0 -0
  127. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_mcp_server.py +0 -0
  128. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_mox.py +0 -0
  129. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_mox_instantly.py +0 -0
  130. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_nova.py +0 -0
  131. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_pax.py +0 -0
  132. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_pax_apollo.py +0 -0
  133. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_pax_instantly.py +0 -0
  134. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_rex.py +0 -0
  135. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_rex_apollo.py +0 -0
  136. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_sage.py +0 -0
  137. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_search_tools.py +0 -0
  138. {devrel_origin-0.2.14 → devrel_origin-0.2.16}/tests/test_sentinel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devrel-origin
3
- Version: 0.2.14
3
+ Version: 0.2.16
4
4
  Summary: A 15-agent CLI that runs DevRel, sales, and marketing on your repo. BYO Anthropic or OpenRouter key.
5
5
  Author-email: Daria Dovzhikova <dovzhikova@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devrel-origin"
7
- version = "0.2.14"
7
+ version = "0.2.16"
8
8
  description = "A 15-agent CLI that runs DevRel, sales, and marketing on your repo. BYO Anthropic or OpenRouter key."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,8 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import shutil
7
8
  import sys
8
9
  from dataclasses import asdict, dataclass
10
+ from importlib.util import find_spec
9
11
 
10
12
  import typer
11
13
  from rich.console import Console
@@ -118,6 +120,25 @@ def _run_checks(paths: ProjectPaths) -> list[CheckResult]:
118
120
  else:
119
121
  results.append(CheckResult("kb_files", "warn", "kb/ missing"))
120
122
 
123
+ # Optional video toolchain for Vox. Missing pieces should never block the
124
+ # core content pipeline, but doctor should tell users the exact render fix.
125
+ if shutil.which("ffmpeg"):
126
+ results.append(CheckResult("video_ffmpeg", "pass", "installed"))
127
+ else:
128
+ results.append(
129
+ CheckResult("video_ffmpeg", "warn", "not installed; run `brew install ffmpeg`")
130
+ )
131
+ if find_spec("playwright") is not None:
132
+ results.append(CheckResult("video_playwright", "pass", "installed"))
133
+ else:
134
+ results.append(
135
+ CheckResult(
136
+ "video_playwright",
137
+ "warn",
138
+ "not installed; run `pip install 'devrel-origin\\[video]' && python -m playwright install chromium`",
139
+ )
140
+ )
141
+
121
142
  return results
122
143
 
123
144
 
@@ -76,6 +76,10 @@ class Recommendation:
76
76
  source_ids: list[str] = field(default_factory=list)
77
77
  first_seen_period: str | None = None # set by _persist_sync; ISO timestamp
78
78
 
79
+ def __post_init__(self) -> None:
80
+ self.evidence = _coerce_str_list(self.evidence)
81
+ self.source_ids = _coerce_str_list(self.source_ids)
82
+
79
83
 
80
84
  @dataclass
81
85
  class PerformanceReport:
@@ -114,15 +118,40 @@ def _metric_to_jsonable(m: PerformanceMetric) -> dict:
114
118
  }
115
119
 
116
120
 
121
+ def _coerce_str_list(value: Any) -> list[str]:
122
+ if value is None:
123
+ return []
124
+ if isinstance(value, str):
125
+ return [value]
126
+ if isinstance(value, (list, tuple, set)):
127
+ items = value
128
+ else:
129
+ items = [value]
130
+
131
+ out: list[str] = []
132
+ for item in items:
133
+ if item is None:
134
+ continue
135
+ if isinstance(item, str):
136
+ text = item
137
+ elif isinstance(item, (dict, list, tuple, set)):
138
+ text = json.dumps(item, sort_keys=True)
139
+ else:
140
+ text = str(item)
141
+ if text:
142
+ out.append(text)
143
+ return out
144
+
145
+
117
146
  def _rec_to_jsonable(r: Recommendation) -> dict:
118
147
  return {
119
148
  "action": r.action,
120
149
  "target": r.target,
121
150
  "target_type": r.target_type,
122
151
  "rationale": r.rationale,
123
- "evidence": list(r.evidence),
152
+ "evidence": _coerce_str_list(r.evidence),
124
153
  "confidence": r.confidence,
125
- "source_ids": list(r.source_ids),
154
+ "source_ids": _coerce_str_list(r.source_ids),
126
155
  "first_seen_period": r.first_seen_period,
127
156
  }
128
157
 
@@ -133,7 +162,7 @@ def _report_to_jsonable(r: PerformanceReport) -> dict:
133
162
  "period_end": r.period_end.isoformat(),
134
163
  "top_performers": [_metric_to_jsonable(m) for m in r.top_performers],
135
164
  "bottom_performers": [_metric_to_jsonable(m) for m in r.bottom_performers],
136
- "trend_signals": list(r.trend_signals),
165
+ "trend_signals": _coerce_str_list(r.trend_signals),
137
166
  "recommendations": [_rec_to_jsonable(rec) for rec in r.recommendations],
138
167
  "sources_ok": dict(r.sources_ok),
139
168
  "insufficient_data": r.insufficient_data,
@@ -374,14 +403,16 @@ def _render_brief(rec: Recommendation, period: str) -> str:
374
403
  lines.append("## Why")
375
404
  lines.append(rec.rationale)
376
405
  lines.append("")
377
- if rec.evidence:
406
+ evidence = _coerce_str_list(rec.evidence)
407
+ if evidence:
378
408
  lines.append("## Evidence")
379
- for ev in rec.evidence:
409
+ for ev in evidence:
380
410
  lines.append(f"- {ev}")
381
411
  lines.append("")
382
- if rec.source_ids:
412
+ source_ids = _coerce_str_list(rec.source_ids)
413
+ if source_ids:
383
414
  lines.append("## Source content")
384
- for sid in rec.source_ids:
415
+ for sid in source_ids:
385
416
  lines.append(f"- `{sid}`")
386
417
  lines.append("")
387
418
  lines.append("## Next step")
@@ -441,9 +472,10 @@ def _render_markdown(report: PerformanceReport) -> str:
441
472
  lines.append("")
442
473
 
443
474
  lines.append("## Trend signals")
444
- if not report.trend_signals:
475
+ trend_signals = _coerce_str_list(report.trend_signals)
476
+ if not trend_signals:
445
477
  lines.append("_None._")
446
- for sig in report.trend_signals:
478
+ for sig in trend_signals:
447
479
  lines.append(f"- {sig}")
448
480
  lines.append("")
449
481
 
@@ -472,9 +504,10 @@ def _render_markdown(report: PerformanceReport) -> str:
472
504
  lines.append(
473
505
  f"- **{r.target}** (conf {r.confidence:.2f}){stale_tag} — {r.rationale}"
474
506
  )
475
- if r.source_ids:
476
- lines.append(f" - sources: {', '.join(r.source_ids)}")
477
- for ev in r.evidence:
507
+ source_ids = _coerce_str_list(r.source_ids)
508
+ if source_ids:
509
+ lines.append(f" - sources: {', '.join(source_ids)}")
510
+ for ev in _coerce_str_list(r.evidence):
478
511
  lines.append(f" - evidence: {ev}")
479
512
  lines.append("")
480
513
  return "\n".join(lines).rstrip() + "\n"
@@ -13,6 +13,7 @@ import random
13
13
  import re
14
14
  import shutil
15
15
  import subprocess
16
+ import time
16
17
  from contextlib import nullcontext as _nullcontext, suppress
17
18
  from dataclasses import dataclass, field
18
19
  from datetime import datetime, timedelta, timezone
@@ -675,6 +676,27 @@ class Atlas:
675
676
  ],
676
677
  }
677
678
 
679
+ @staticmethod
680
+ def _build_kai_task(content_brief: dict[str, Any]) -> str:
681
+ """Build Kai's task without turning absent evidence into requirements."""
682
+ has_issues = bool(content_brief.get("github_issues"))
683
+ has_source_files = bool(content_brief.get("source_files"))
684
+ issue_instruction = (
685
+ "Reference real GitHub issues from Sage's triage."
686
+ if has_issues
687
+ else "Avoid GitHub issue claims unless Sage supplied real issue evidence."
688
+ )
689
+ source_instruction = (
690
+ "Use actual file paths, commands, and APIs from the source code."
691
+ if has_source_files
692
+ else "Avoid source-code and file-path claims unless Dex supplied source evidence."
693
+ )
694
+ return (
695
+ "Write a technical tutorial addressing the #1 developer pain point. "
696
+ "Ground the content in the knowledge base and Dex's architecture analysis. "
697
+ f"{issue_instruction} {source_instruction}"
698
+ )
699
+
678
700
  def _slug(self, value: str, fallback: str) -> str:
679
701
  slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
680
702
  return slug[:80] or fallback
@@ -734,13 +756,38 @@ class Atlas:
734
756
  the last completed checkpoint instead of re-running everything.
735
757
  Produces a run report with timing, cost, and quality data.
736
758
  """
737
- from devrel_origin.tools.run_report import RunReport
759
+ from devrel_origin.tools.run_report import AgentTiming, RunReport
738
760
 
739
761
  run_report = RunReport(
740
762
  week_of=self.context.week_of,
741
763
  started_at=datetime.now().isoformat(),
742
764
  )
743
765
 
766
+ async def timed_delegate(
767
+ stage: int,
768
+ agent_name: str,
769
+ task: str,
770
+ context: Optional[dict[str, Any]] = None,
771
+ ) -> DelegationResult:
772
+ started_at = datetime.now(timezone.utc)
773
+ t0 = time.monotonic()
774
+ result = await self.delegate(agent_name, task, context)
775
+ completed_at = datetime.now(timezone.utc)
776
+ run_report.agent_timings.append(
777
+ AgentTiming(
778
+ agent=agent_name,
779
+ stage=stage,
780
+ started_at=started_at.isoformat(),
781
+ completed_at=completed_at.isoformat(),
782
+ duration_seconds=time.monotonic() - t0,
783
+ success=result.success,
784
+ error=result.error or "",
785
+ )
786
+ )
787
+ if not result.success and result.error:
788
+ run_report.errors.append(f"{agent_name}: {result.error}")
789
+ return result
790
+
744
791
  # Check for existing checkpoint to resume from
745
792
  checkpoint = self._load_checkpoint(self.archive_dir, self.context.week_of)
746
793
  resume_stage = 0
@@ -765,7 +812,8 @@ class Atlas:
765
812
 
766
813
  # Stage 0: Watchdog health check (pre-flight)
767
814
  if resume_stage <= 0 and "watchdog" not in completed_agents:
768
- watchdog_result = await self.delegate(
815
+ watchdog_result = await timed_delegate(
816
+ 0,
769
817
  "watchdog",
770
818
  "Run system health check. Verify all integrations are "
771
819
  "reachable and check for stale agent outputs from last cycle.",
@@ -793,7 +841,7 @@ class Atlas:
793
841
  "Produce an architecture overview and API reference."
794
842
  ),
795
843
  }
796
- coros = [self.delegate(a, tasks_1[a]) for a in stage_1_pending]
844
+ coros = [timed_delegate(1, a, tasks_1[a]) for a in stage_1_pending]
797
845
  results = await asyncio.gather(*coros)
798
846
  for agent_name, res in zip(stage_1_pending, results, strict=True):
799
847
  if res.success:
@@ -822,7 +870,7 @@ class Atlas:
822
870
  "severity."
823
871
  ),
824
872
  }
825
- coros = [self.delegate(a, tasks_2[a]) for a in stage_2_pending]
873
+ coros = [timed_delegate(2, a, tasks_2[a]) for a in stage_2_pending]
826
874
  results = await asyncio.gather(*coros)
827
875
  for agent_name, res in zip(stage_2_pending, results, strict=True):
828
876
  if res.success:
@@ -838,21 +886,17 @@ class Atlas:
838
886
  stage_3_agents = ["nova", "kai"]
839
887
  stage_3_pending = [a for a in stage_3_agents if a not in completed_agents]
840
888
  if stage_3_pending:
889
+ content_brief = self._build_content_brief()
841
890
  tasks_3 = {
842
891
  "nova": (
843
892
  "Design activation experiments based on the top pain points. "
844
893
  "Include pre-registration, power analysis, and success criteria."
845
894
  ),
846
- "kai": (
847
- "Write a technical tutorial addressing the #1 developer pain point. "
848
- "Ground the content in the knowledge base and Dex's architecture "
849
- "analysis. Reference real GitHub issues from Sage's triage. "
850
- "Use actual file paths, commands, and APIs from the source code."
851
- ),
895
+ "kai": self._build_kai_task(content_brief),
852
896
  }
853
- content_brief = self._build_content_brief()
854
897
  coros = [
855
- self.delegate(
898
+ timed_delegate(
899
+ 3,
856
900
  a,
857
901
  tasks_3[a],
858
902
  context={"content_brief": content_brief} if a == "kai" else None,
@@ -871,7 +915,8 @@ class Atlas:
871
915
 
872
916
  # Stage 4: Vox (uses Kai's content)
873
917
  if resume_stage <= 4 and "vox" not in completed_agents:
874
- video_result = await self.delegate(
918
+ video_result = await timed_delegate(
919
+ 4,
875
920
  "vox",
876
921
  "Generate a video tutorial from Kai's written content. "
877
922
  "Record screen walkthrough with narration and overlays.",
@@ -883,7 +928,8 @@ class Atlas:
883
928
 
884
929
  # Stage 5: Sentinel brand audit — audit all generated content
885
930
  if resume_stage <= 5 and "sentinel" not in completed_agents:
886
- sentinel_result = await self.delegate(
931
+ sentinel_result = await timed_delegate(
932
+ 5,
887
933
  "sentinel",
888
934
  "Audit all generated content for brand voice consistency, "
889
935
  "ICP alignment, messaging coherence, and technical accuracy.",
@@ -44,6 +44,40 @@ def _check_playwright() -> bool:
44
44
  return False
45
45
 
46
46
 
47
+ def _missing_video_dependencies(
48
+ *,
49
+ has_ffmpeg: bool,
50
+ has_playwright: bool,
51
+ has_openai_key: bool,
52
+ ) -> list[dict[str, str]]:
53
+ missing: list[dict[str, str]] = []
54
+ if not has_ffmpeg:
55
+ missing.append(
56
+ {
57
+ "name": "ffmpeg",
58
+ "fix": "Install FFmpeg, for example `brew install ffmpeg` on macOS.",
59
+ }
60
+ )
61
+ if not has_playwright:
62
+ missing.append(
63
+ {
64
+ "name": "playwright",
65
+ "fix": (
66
+ "Install video extras and browsers: "
67
+ "`pip install 'devrel-origin[video]' && python -m playwright install chromium`."
68
+ ),
69
+ }
70
+ )
71
+ if not has_openai_key:
72
+ missing.append(
73
+ {
74
+ "name": "OPENAI_API_KEY",
75
+ "fix": "Set `OPENAI_API_KEY` or run `devrel auth` before rendering video.",
76
+ }
77
+ )
78
+ return missing
79
+
80
+
47
81
  class Vox:
48
82
  """
49
83
  Video Tutorial agent that produces screen-recorded tutorials.
@@ -94,7 +128,11 @@ Keep narration concise and developer-focused. Show, don't tell."""
94
128
  if not self._has_ffmpeg:
95
129
  logger.warning("FFmpeg not found — video rendering will be skipped")
96
130
  if not self._has_playwright:
97
- logger.warning("Playwright not installed — recording will be skipped")
131
+ logger.warning(
132
+ "Playwright not installed — recording will be skipped. "
133
+ "Install video extras and browsers: `pip install 'devrel-origin[video]' "
134
+ "&& python -m playwright install chromium`."
135
+ )
98
136
 
99
137
  async def execute(
100
138
  self,
@@ -170,17 +208,29 @@ Keep narration concise and developer-focused. Show, don't tell."""
170
208
  "status": "script_only",
171
209
  }
172
210
 
173
- can_render = self._has_ffmpeg and self._has_playwright and self.openai_api_key
211
+ missing_dependencies = _missing_video_dependencies(
212
+ has_ffmpeg=self._has_ffmpeg,
213
+ has_playwright=self._has_playwright,
214
+ has_openai_key=bool(self.openai_api_key),
215
+ )
216
+ result["video_produced"] = False
217
+ result["recording_skipped"] = bool(missing_dependencies)
218
+ result["missing_dependencies"] = missing_dependencies
219
+
220
+ can_render = not missing_dependencies
174
221
  if can_render:
175
222
  try:
176
223
  output_path = await self._run_full_pipeline(tutorial)
177
224
  result["status"] = "generated"
178
225
  result["output_path"] = str(output_path)
179
226
  result["total_duration"] = tutorial.total_duration
227
+ result["video_produced"] = True
228
+ result["recording_skipped"] = False
180
229
  except Exception as exc:
181
230
  logger.error(f"Video pipeline failed: {exc}")
182
231
  result["status"] = "script_only"
183
232
  result["pipeline_error"] = str(exc)
233
+ result["recording_skipped"] = True
184
234
 
185
235
  return result
186
236
 
@@ -15,7 +15,7 @@ from contextlib import contextmanager
15
15
  from pathlib import Path
16
16
  from typing import Iterator
17
17
 
18
- SCHEMA_VERSION = 5
18
+ SCHEMA_VERSION = 6
19
19
 
20
20
  SCHEMA = """
21
21
  CREATE TABLE IF NOT EXISTS schema_meta (
@@ -159,9 +159,56 @@ CREATE TABLE IF NOT EXISTS cro_funnel_metrics (
159
159
 
160
160
  CREATE INDEX IF NOT EXISTS idx_cro_funnel_period
161
161
  ON cro_funnel_metrics(funnel_id, period_end DESC);
162
+
163
+ CREATE TABLE IF NOT EXISTS social_mentions (
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
165
+ platform TEXT NOT NULL,
166
+ post_id TEXT NOT NULL,
167
+ title TEXT,
168
+ url TEXT,
169
+ author TEXT,
170
+ content TEXT,
171
+ sentiment TEXT,
172
+ posted_at TEXT NOT NULL,
173
+ subreddit TEXT,
174
+ upvotes INTEGER NOT NULL DEFAULT 0,
175
+ comments INTEGER NOT NULL DEFAULT 0,
176
+ engagement_score REAL NOT NULL DEFAULT 0,
177
+ is_own_post INTEGER NOT NULL DEFAULT 0,
178
+ is_question INTEGER NOT NULL DEFAULT 0,
179
+ requires_response INTEGER NOT NULL DEFAULT 0,
180
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
181
+ UNIQUE (platform, post_id)
182
+ );
183
+
184
+ CREATE INDEX IF NOT EXISTS idx_social_mentions_posted_at
185
+ ON social_mentions(posted_at DESC);
186
+
187
+ CREATE INDEX IF NOT EXISTS idx_social_mentions_own_period
188
+ ON social_mentions(is_own_post, posted_at DESC);
162
189
  """
163
190
 
164
191
 
192
+ _SOCIAL_MENTIONS_COLUMNS: dict[str, str] = {
193
+ "platform": "TEXT NOT NULL DEFAULT ''",
194
+ "post_id": "TEXT NOT NULL DEFAULT ''",
195
+ "title": "TEXT",
196
+ "url": "TEXT",
197
+ "author": "TEXT",
198
+ "content": "TEXT",
199
+ "sentiment": "TEXT",
200
+ "posted_at": "TEXT NOT NULL DEFAULT ''",
201
+ "subreddit": "TEXT",
202
+ "upvotes": "INTEGER NOT NULL DEFAULT 0",
203
+ "comments": "INTEGER NOT NULL DEFAULT 0",
204
+ "engagement_score": "REAL NOT NULL DEFAULT 0",
205
+ "is_own_post": "INTEGER NOT NULL DEFAULT 0",
206
+ "is_question": "INTEGER NOT NULL DEFAULT 0",
207
+ "requires_response": "INTEGER NOT NULL DEFAULT 0",
208
+ "created_at": "TEXT NOT NULL DEFAULT ''",
209
+ }
210
+
211
+
165
212
  def _migrate_to_v5(conn: sqlite3.Connection) -> None:
166
213
  """Add pillar + target_kind columns to analytics_recommendations if absent.
167
214
 
@@ -200,6 +247,67 @@ def _migrate_to_v5(conn: sqlite3.Connection) -> None:
200
247
  )
201
248
 
202
249
 
250
+ def _migrate_to_v6(conn: sqlite3.Connection) -> None:
251
+ """Ensure Echo/Argus social mention storage exists and has v6 columns."""
252
+ conn.execute(
253
+ """
254
+ CREATE TABLE IF NOT EXISTS social_mentions (
255
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
256
+ platform TEXT NOT NULL,
257
+ post_id TEXT NOT NULL,
258
+ title TEXT,
259
+ url TEXT,
260
+ author TEXT,
261
+ content TEXT,
262
+ sentiment TEXT,
263
+ posted_at TEXT NOT NULL,
264
+ subreddit TEXT,
265
+ upvotes INTEGER NOT NULL DEFAULT 0,
266
+ comments INTEGER NOT NULL DEFAULT 0,
267
+ engagement_score REAL NOT NULL DEFAULT 0,
268
+ is_own_post INTEGER NOT NULL DEFAULT 0,
269
+ is_question INTEGER NOT NULL DEFAULT 0,
270
+ requires_response INTEGER NOT NULL DEFAULT 0,
271
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
272
+ UNIQUE (platform, post_id)
273
+ )
274
+ """
275
+ )
276
+ cur = conn.execute("PRAGMA table_info(social_mentions)")
277
+ cols = {row[1] for row in cur.fetchall()}
278
+ for col, ddl in _SOCIAL_MENTIONS_COLUMNS.items():
279
+ if col not in cols:
280
+ conn.execute(f"ALTER TABLE social_mentions ADD COLUMN {col} {ddl}")
281
+
282
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(social_mentions)").fetchall()}
283
+ if "score" in cols:
284
+ conn.execute(
285
+ "UPDATE social_mentions "
286
+ "SET engagement_score = COALESCE(NULLIF(engagement_score, 0), score, 0)"
287
+ )
288
+ if "engagement" in cols:
289
+ conn.execute(
290
+ "UPDATE social_mentions "
291
+ "SET engagement_score = COALESCE(NULLIF(engagement_score, 0), engagement, 0), "
292
+ " upvotes = COALESCE(NULLIF(upvotes, 0), engagement, 0)"
293
+ )
294
+ if "url" in cols:
295
+ fallback_id = "CAST(id AS TEXT)" if "id" in cols else "rowid"
296
+ conn.execute(
297
+ "UPDATE social_mentions "
298
+ f"SET post_id = COALESCE(NULLIF(post_id, ''), NULLIF(url, ''), {fallback_id}) "
299
+ "WHERE post_id IS NULL OR post_id = ''"
300
+ )
301
+ conn.execute(
302
+ "CREATE INDEX IF NOT EXISTS idx_social_mentions_posted_at "
303
+ "ON social_mentions(posted_at DESC)"
304
+ )
305
+ conn.execute(
306
+ "CREATE INDEX IF NOT EXISTS idx_social_mentions_own_period "
307
+ "ON social_mentions(is_own_post, posted_at DESC)"
308
+ )
309
+
310
+
203
311
  def init_db(db_path: Path) -> None:
204
312
  """Create the DB file and apply the schema. Idempotent: preserves
205
313
  existing data and bumps schema_meta to the current SCHEMA_VERSION."""
@@ -207,6 +315,7 @@ def init_db(db_path: Path) -> None:
207
315
  with sqlite3.connect(db_path) as conn:
208
316
  conn.executescript(SCHEMA)
209
317
  _migrate_to_v5(conn)
318
+ _migrate_to_v6(conn)
210
319
  conn.execute(
211
320
  "INSERT OR REPLACE INTO schema_meta (version, applied_at) VALUES (?, datetime('now'))",
212
321
  (SCHEMA_VERSION,),
@@ -10,7 +10,7 @@ Stage flow:
10
10
  6. Persona — Haiku score 1-10 + weak sections
11
11
  7. Readability — pure-Python FRE/sentence-stats/jargon check
12
12
  → If 6 or 7 fail: re-run stage 4 once with the failed rubric, then
13
- re-run 5/6/7 once. Second failure of 6/7 logs and ships flagged.
13
+ re-run 5/6/7 once. Second persona failure aborts loudly.
14
14
  8. Brand audit — Sentinel (caller's responsibility; orchestrator
15
15
  does not invoke Sentinel because it lives in
16
16
  core/sentinel.py and would create a quality→core
@@ -328,17 +328,16 @@ async def run_pipeline(
328
328
  readability2 = _readability_stage(text=text, content_type=content_type, style_md=style_md)
329
329
  stages.append(readability2)
330
330
 
331
- # Readability re-runs are informational only short test/mock text
332
- # often fails MSL but the persona pass is what gates "ship vs flag".
333
- # Only persona2 failure flips the flagged bit.
331
+ # Readability re-runs are informational only because short test/mock
332
+ # text often fails MSL. Persona is the hard ship/no-ship gate.
334
333
  if persona2.issues:
335
- logger.warning(
336
- "editorial pipeline shipping with flagged=True for content_type=%s "
337
- "(persona score %s)",
334
+ issue_text = "; ".join(persona2.issues)
335
+ logger.error(
336
+ "editorial pipeline aborting for content_type=%s after persona repair failed: %s",
338
337
  content_type,
339
- persona2.score,
338
+ issue_text,
340
339
  )
341
- flagged = True
340
+ raise AbortLoud(f"Persona gate failed after repair for {content_type}: {issue_text}")
342
341
 
343
342
  revision_trace = {
344
343
  "content_type": content_type,
@@ -54,14 +54,17 @@ class PostHogCollector:
54
54
 
55
55
  def __init__(self, client: "PostHogClient"):
56
56
  self.client = client
57
+ self.last_ok = True
57
58
 
58
59
  async def collect(self, period: Period) -> list[PerformanceMetric]:
59
60
  _start, end = period
60
61
  try:
61
62
  rows = await self.client.fetch_events_by_url(start=_start, end=end)
62
63
  except Exception as exc: # noqa: BLE001
64
+ self.last_ok = False
63
65
  logger.warning("PostHogCollector failed: %s", exc)
64
66
  return []
67
+ self.last_ok = True
65
68
 
66
69
  metrics: list[PerformanceMetric] = []
67
70
  for row in rows:
@@ -95,14 +98,19 @@ class GitHubCollector:
95
98
 
96
99
  def __init__(self, client):
97
100
  self.client = client
101
+ self.last_ok = True
98
102
 
99
103
  async def collect(self, period: Period) -> list[PerformanceMetric]:
100
104
  _start, end = period
101
105
  try:
102
106
  stats = await self.client.get_repo_stats()
107
+ if not isinstance(stats, dict):
108
+ raise TypeError("github stats payload is not a dict")
103
109
  except Exception as exc: # noqa: BLE001
110
+ self.last_ok = False
104
111
  logger.warning("GitHubCollector failed: %s", exc)
105
112
  return []
113
+ self.last_ok = True
106
114
 
107
115
  repo = getattr(self.client, "repo_full_name", "unknown/unknown")
108
116
  return [
@@ -152,14 +160,22 @@ class InstantlyCollector:
152
160
 
153
161
  def __init__(self, client):
154
162
  self.client = client
163
+ self.last_ok = True
155
164
 
156
165
  async def collect(self, period: Period) -> list[PerformanceMetric]:
166
+ if self.client is None:
167
+ self.last_ok = False
168
+ logger.info("InstantlyCollector: client not configured, skipping")
169
+ return []
170
+
157
171
  start, end = period
158
172
  try:
159
173
  rows = await self.client.list_campaigns_with_analytics()
160
174
  except Exception as exc: # noqa: BLE001
175
+ self.last_ok = False
161
176
  logger.warning("InstantlyCollector failed: %s", exc)
162
177
  return []
178
+ self.last_ok = True
163
179
 
164
180
  metrics: list[PerformanceMetric] = []
165
181
  for row in rows:
@@ -218,6 +234,7 @@ class SocialCollector:
218
234
  def __init__(self, state_db_path: Path):
219
235
  self.state_db_path = state_db_path
220
236
  self._schema_verified = False
237
+ self.last_ok = True
221
238
 
222
239
  def _verify_schema(self, conn: sqlite3.Connection) -> bool:
223
240
  """Confirm social_mentions has all required columns.
@@ -269,6 +286,7 @@ class SocialCollector:
269
286
  async def collect(self, period: Period) -> list[PerformanceMetric]:
270
287
  start, end = period
271
288
  if not self.state_db_path.is_file():
289
+ self.last_ok = False
272
290
  logger.info("SocialCollector: state.db not present, skipping")
273
291
  return []
274
292
 
@@ -278,7 +296,9 @@ class SocialCollector:
278
296
  end.isoformat(),
279
297
  )
280
298
  if rows is None:
299
+ self.last_ok = False
281
300
  return []
301
+ self.last_ok = True
282
302
 
283
303
  metrics: list[PerformanceMetric] = []
284
304
  for row in rows: