devrel-origin 0.2.14__py3-none-any.whl

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 (98) hide show
  1. devrel_origin/__init__.py +15 -0
  2. devrel_origin/cli/__init__.py +92 -0
  3. devrel_origin/cli/_common.py +243 -0
  4. devrel_origin/cli/analytics.py +28 -0
  5. devrel_origin/cli/argus.py +497 -0
  6. devrel_origin/cli/auth.py +227 -0
  7. devrel_origin/cli/config.py +108 -0
  8. devrel_origin/cli/content.py +259 -0
  9. devrel_origin/cli/cost.py +108 -0
  10. devrel_origin/cli/cro.py +298 -0
  11. devrel_origin/cli/deliverables.py +65 -0
  12. devrel_origin/cli/docs.py +91 -0
  13. devrel_origin/cli/doctor.py +178 -0
  14. devrel_origin/cli/experiment.py +29 -0
  15. devrel_origin/cli/growth.py +97 -0
  16. devrel_origin/cli/init.py +472 -0
  17. devrel_origin/cli/intel.py +27 -0
  18. devrel_origin/cli/kb.py +96 -0
  19. devrel_origin/cli/listen.py +31 -0
  20. devrel_origin/cli/marketing.py +66 -0
  21. devrel_origin/cli/migrate.py +45 -0
  22. devrel_origin/cli/run.py +46 -0
  23. devrel_origin/cli/sales.py +57 -0
  24. devrel_origin/cli/schedule.py +62 -0
  25. devrel_origin/cli/synthesize.py +28 -0
  26. devrel_origin/cli/triage.py +29 -0
  27. devrel_origin/cli/video.py +35 -0
  28. devrel_origin/core/__init__.py +58 -0
  29. devrel_origin/core/agent_config.py +75 -0
  30. devrel_origin/core/argus.py +964 -0
  31. devrel_origin/core/atlas.py +1450 -0
  32. devrel_origin/core/base.py +372 -0
  33. devrel_origin/core/cyra.py +563 -0
  34. devrel_origin/core/dex.py +708 -0
  35. devrel_origin/core/echo.py +614 -0
  36. devrel_origin/core/growth/__init__.py +27 -0
  37. devrel_origin/core/growth/recommendations.py +219 -0
  38. devrel_origin/core/growth/target_kinds.py +51 -0
  39. devrel_origin/core/iris.py +513 -0
  40. devrel_origin/core/kai.py +1367 -0
  41. devrel_origin/core/llm.py +542 -0
  42. devrel_origin/core/llm_backends.py +274 -0
  43. devrel_origin/core/mox.py +514 -0
  44. devrel_origin/core/nova.py +349 -0
  45. devrel_origin/core/pax.py +1205 -0
  46. devrel_origin/core/rex.py +532 -0
  47. devrel_origin/core/sage.py +486 -0
  48. devrel_origin/core/sentinel.py +385 -0
  49. devrel_origin/core/types.py +98 -0
  50. devrel_origin/core/video/__init__.py +22 -0
  51. devrel_origin/core/video/assembler.py +131 -0
  52. devrel_origin/core/video/browser_recorder.py +118 -0
  53. devrel_origin/core/video/desktop_recorder.py +254 -0
  54. devrel_origin/core/video/overlay_renderer.py +143 -0
  55. devrel_origin/core/video/script_parser.py +147 -0
  56. devrel_origin/core/video/tts_engine.py +82 -0
  57. devrel_origin/core/vox.py +268 -0
  58. devrel_origin/core/watchdog.py +321 -0
  59. devrel_origin/project/__init__.py +1 -0
  60. devrel_origin/project/config.py +75 -0
  61. devrel_origin/project/cost_sink.py +61 -0
  62. devrel_origin/project/init.py +104 -0
  63. devrel_origin/project/paths.py +75 -0
  64. devrel_origin/project/state.py +241 -0
  65. devrel_origin/project/templates/__init__.py +4 -0
  66. devrel_origin/project/templates/config.toml +24 -0
  67. devrel_origin/project/templates/devrel.gitignore +10 -0
  68. devrel_origin/project/templates/slop-blocklist.md +45 -0
  69. devrel_origin/project/templates/style.md +24 -0
  70. devrel_origin/project/templates/voice.md +29 -0
  71. devrel_origin/quality/__init__.py +66 -0
  72. devrel_origin/quality/editorial.py +357 -0
  73. devrel_origin/quality/persona.py +84 -0
  74. devrel_origin/quality/readability.py +148 -0
  75. devrel_origin/quality/slop.py +167 -0
  76. devrel_origin/quality/style.py +110 -0
  77. devrel_origin/quality/voice.py +15 -0
  78. devrel_origin/tools/__init__.py +9 -0
  79. devrel_origin/tools/analytics.py +304 -0
  80. devrel_origin/tools/api_client.py +393 -0
  81. devrel_origin/tools/apollo_client.py +305 -0
  82. devrel_origin/tools/code_validator.py +428 -0
  83. devrel_origin/tools/github_tools.py +297 -0
  84. devrel_origin/tools/instantly_client.py +412 -0
  85. devrel_origin/tools/kb_harvester.py +340 -0
  86. devrel_origin/tools/mcp_server.py +578 -0
  87. devrel_origin/tools/notifications.py +245 -0
  88. devrel_origin/tools/run_report.py +193 -0
  89. devrel_origin/tools/scheduler.py +231 -0
  90. devrel_origin/tools/search_tools.py +321 -0
  91. devrel_origin/tools/self_improve.py +168 -0
  92. devrel_origin/tools/sheets.py +236 -0
  93. devrel_origin-0.2.14.dist-info/METADATA +354 -0
  94. devrel_origin-0.2.14.dist-info/RECORD +98 -0
  95. devrel_origin-0.2.14.dist-info/WHEEL +5 -0
  96. devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
  97. devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
  98. devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,75 @@
1
+ """Load .devrel/config.toml into a typed ProjectConfig.
2
+
3
+ The schema is intentionally narrow: project identity (required), model
4
+ selection (optional with sensible defaults), and budget guardrails
5
+ (optional). Future phases extend this with additional sections; the loader
6
+ is permissive about unknown top-level keys.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tomllib
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+
16
+ class ConfigError(Exception):
17
+ """Raised when config.toml is malformed or missing required fields."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ProjectIdentity:
22
+ name: str
23
+ url: str = ""
24
+ github_repo: str | None = None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ModelConfig:
29
+ default: str = "claude-sonnet-4-6"
30
+ cheap: str = "claude-haiku-4-5-20251001"
31
+ opus_opt_in: bool = True
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class BudgetConfig:
36
+ monthly_usd: float = 100.0
37
+ warn_at_pct: int = 80
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ProjectConfig:
42
+ project: ProjectIdentity
43
+ model: ModelConfig = field(default_factory=ModelConfig)
44
+ budget: BudgetConfig = field(default_factory=BudgetConfig)
45
+
46
+ @classmethod
47
+ def load(cls, config_file: Path) -> "ProjectConfig":
48
+ if not config_file.is_file():
49
+ raise ConfigError(f"config.toml not found at {config_file}")
50
+ with config_file.open("rb") as f:
51
+ raw = tomllib.load(f)
52
+ if "project" not in raw:
53
+ raise ConfigError("config.toml missing required [project] section")
54
+ proj = raw["project"]
55
+ if "name" not in proj or not proj["name"]:
56
+ raise ConfigError("config.toml missing required project.name")
57
+ identity = ProjectIdentity(
58
+ name=str(proj["name"]),
59
+ url=str(proj.get("url", "")),
60
+ github_repo=proj.get("github_repo"),
61
+ )
62
+ model_raw = raw.get("model") or {}
63
+ defaults = ModelConfig()
64
+ model = ModelConfig(
65
+ default=str(model_raw.get("default", defaults.default)),
66
+ cheap=str(model_raw.get("cheap", defaults.cheap)),
67
+ opus_opt_in=bool(model_raw.get("opus_opt_in", defaults.opus_opt_in)),
68
+ )
69
+ budget_raw = raw.get("budget") or {}
70
+ bdefaults = BudgetConfig()
71
+ budget = BudgetConfig(
72
+ monthly_usd=float(budget_raw.get("monthly_usd", bdefaults.monthly_usd)),
73
+ warn_at_pct=int(budget_raw.get("warn_at_pct", bdefaults.warn_at_pct)),
74
+ )
75
+ return cls(project=identity, model=model, budget=budget)
@@ -0,0 +1,61 @@
1
+ """Build a cost-sink callable that inserts rows into the project state DB.
2
+
3
+ Used by Atlas to wire LLMClient cost events into `.devrel/state.db`'s
4
+ `costs` table. The pricing table lives in core/llm.py — we read it
5
+ indirectly via the model names we receive.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from devrel_origin.core.llm import MODEL_COSTS
15
+
16
+
17
+ def _compute_cost_usd(model: str, usage: dict[str, Any]) -> float:
18
+ pricing = MODEL_COSTS.get(model)
19
+ if pricing is None:
20
+ return 0.0
21
+ input_per_1m = pricing["input"]
22
+ output_per_1m = pricing["output"]
23
+ input_tokens = usage.get("input_tokens", 0)
24
+ output_tokens = usage.get("output_tokens", 0)
25
+ cache_read = usage.get("cache_read_input_tokens", 0)
26
+ cache_write = usage.get("cache_creation_input_tokens", 0)
27
+ # Cache pricing: read at 0.1×, write at 1.25× of input rate (Anthropic standard)
28
+ cost = (
29
+ (input_tokens / 1_000_000) * input_per_1m
30
+ + (output_tokens / 1_000_000) * output_per_1m
31
+ + (cache_read / 1_000_000) * input_per_1m * 0.1
32
+ + (cache_write / 1_000_000) * input_per_1m * 1.25
33
+ )
34
+ return round(cost, 6)
35
+
36
+
37
+ def make_sqlite_sink(db_path: Path):
38
+ """Return an async ``(agent, model, usage) -> None`` callback that inserts
39
+ a row into the `costs` table at `db_path`."""
40
+
41
+ async def _sink(agent: str, model: str, usage: dict[str, Any]) -> None:
42
+ cost_usd = _compute_cost_usd(model, usage)
43
+ # SQLite is sync; we accept the brief blocking write inline.
44
+ with sqlite3.connect(db_path) as conn:
45
+ conn.execute(
46
+ "INSERT INTO costs (agent, model, input_tokens, output_tokens, "
47
+ "cache_read_tokens, cache_write_tokens, cost_usd) "
48
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
49
+ (
50
+ agent,
51
+ model,
52
+ usage.get("input_tokens", 0),
53
+ usage.get("output_tokens", 0),
54
+ usage.get("cache_read_input_tokens", 0),
55
+ usage.get("cache_creation_input_tokens", 0),
56
+ cost_usd,
57
+ ),
58
+ )
59
+ conn.commit()
60
+
61
+ return _sink
@@ -0,0 +1,104 @@
1
+ """Idempotent .devrel/ scaffolder.
2
+
3
+ `init_project(root, opts)` writes the .devrel/ directory tree, copies the
4
+ template files, substitutes config placeholders, and initializes the state
5
+ DB. Re-running on an existing project preserves user edits to committed
6
+ files (config.toml, voice.md, style.md, slop-blocklist.md, .gitignore) —
7
+ those are listed in `result.skipped`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from importlib.resources import files
14
+ from pathlib import Path
15
+
16
+ from devrel_origin.project.paths import ProjectPaths
17
+ from devrel_origin.project.state import init_db
18
+
19
+ _TEMPLATE_PKG = "devrel_origin.project.templates"
20
+
21
+ # Files that are committed and must NEVER be overwritten on re-init.
22
+ _COMMITTED_FILES = ("config.toml", "voice.md", "style.md", "slop-blocklist.md", ".gitignore")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class InitOptions:
27
+ name: str
28
+ url: str = ""
29
+ github_repo: str | None = None
30
+ dry_run: bool = False
31
+
32
+
33
+ @dataclass
34
+ class InitResult:
35
+ created: list[str] = field(default_factory=list)
36
+ skipped: list[str] = field(default_factory=list)
37
+ would_create: list[str] = field(default_factory=list)
38
+ dry_run: bool = False
39
+
40
+
41
+ def _read_template(name: str) -> str:
42
+ return (files(_TEMPLATE_PKG) / name).read_text(encoding="utf-8")
43
+
44
+
45
+ def _render_config_toml(opts: InitOptions) -> str:
46
+ body = _read_template("config.toml")
47
+ body = body.replace("PROJECT_NAME", opts.name)
48
+ body = body.replace("PROJECT_URL", opts.url)
49
+ if opts.github_repo:
50
+ body = body.replace('github_repo = "OWNER/REPO"', f'github_repo = "{opts.github_repo}"')
51
+ else:
52
+ body = body.replace(
53
+ 'github_repo = "OWNER/REPO"',
54
+ "# github_repo = # set if this product has a public repo",
55
+ )
56
+ return body
57
+
58
+
59
+ def init_project(root: Path, opts: InitOptions) -> InitResult:
60
+ """Scaffold .devrel/ under `root`. Idempotent: preserves committed files
61
+ on re-run."""
62
+ paths = ProjectPaths.from_root(root)
63
+ result = InitResult(dry_run=opts.dry_run)
64
+
65
+ # The directory and subdirectories.
66
+ dirs = [paths.devrel_dir, paths.kb_dir, paths.deliverables_dir, paths.context_dir]
67
+ for d in dirs:
68
+ if d.is_dir():
69
+ result.skipped.append(d.name + "/")
70
+ else:
71
+ if opts.dry_run:
72
+ result.would_create.append(d.name + "/")
73
+ else:
74
+ d.mkdir(parents=True, exist_ok=True)
75
+ result.created.append(d.name + "/")
76
+
77
+ # File payloads keyed by destination path.
78
+ payloads: dict[Path, str] = {
79
+ paths.config_file: _render_config_toml(opts),
80
+ paths.voice_file: _read_template("voice.md"),
81
+ paths.style_file: _read_template("style.md"),
82
+ paths.slop_file: _read_template("slop-blocklist.md"),
83
+ paths.gitignore: _read_template("devrel.gitignore"),
84
+ }
85
+ for dest, body in payloads.items():
86
+ if dest.is_file():
87
+ result.skipped.append(dest.name)
88
+ continue
89
+ if opts.dry_run:
90
+ result.would_create.append(dest.name)
91
+ else:
92
+ dest.write_text(body, encoding="utf-8")
93
+ result.created.append(dest.name)
94
+
95
+ # State DB: idempotent (init_db preserves rows).
96
+ if opts.dry_run:
97
+ if not paths.state_db.is_file():
98
+ result.would_create.append("state.db")
99
+ else:
100
+ already = paths.state_db.is_file()
101
+ init_db(paths.state_db)
102
+ (result.skipped if already else result.created).append("state.db")
103
+
104
+ return result
@@ -0,0 +1,75 @@
1
+ """Project path discovery and structure.
2
+
3
+ `find_devrel_root` walks up from a starting directory looking for the nearest
4
+ ancestor containing a `.devrel/config.toml`. `ProjectPaths` is a frozen
5
+ dataclass holding every derived path under `.devrel/`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ DEVREL_DIR_NAME = ".devrel"
14
+ CONFIG_FILE_NAME = "config.toml"
15
+
16
+
17
+ class ProjectNotFoundError(Exception):
18
+ """Raised when no .devrel/config.toml is found in cwd or any ancestor."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ProjectPaths:
23
+ """All derived paths for a devrel-origin project."""
24
+
25
+ root: Path
26
+ devrel_dir: Path
27
+ config_file: Path
28
+ voice_file: Path
29
+ style_file: Path
30
+ slop_file: Path
31
+ kb_dir: Path
32
+ deliverables_dir: Path
33
+ context_dir: Path
34
+ state_db: Path
35
+ env_file: Path
36
+ gitignore: Path
37
+
38
+ @classmethod
39
+ def from_root(cls, root: Path) -> "ProjectPaths":
40
+ d = root / DEVREL_DIR_NAME
41
+ return cls(
42
+ root=root,
43
+ devrel_dir=d,
44
+ config_file=d / CONFIG_FILE_NAME,
45
+ voice_file=d / "voice.md",
46
+ style_file=d / "style.md",
47
+ slop_file=d / "slop-blocklist.md",
48
+ kb_dir=d / "kb",
49
+ deliverables_dir=d / "deliverables",
50
+ context_dir=d / "context",
51
+ state_db=d / "state.db",
52
+ env_file=d / ".env",
53
+ gitignore=d / ".gitignore",
54
+ )
55
+
56
+
57
+ def find_devrel_root(start: Path | None = None) -> Path:
58
+ """Walk up from `start` (default: cwd) until a `.devrel/config.toml` is
59
+ found. Returns the project root (parent of `.devrel/`), resolved to an
60
+ absolute path.
61
+
62
+ Raises ProjectNotFoundError if no `.devrel/config.toml` is found before
63
+ the filesystem root.
64
+ """
65
+ cur = (start if start is not None else Path.cwd()).resolve()
66
+ while True:
67
+ candidate = cur / DEVREL_DIR_NAME / CONFIG_FILE_NAME
68
+ if candidate.is_file():
69
+ return cur
70
+ if cur.parent == cur:
71
+ raise ProjectNotFoundError(
72
+ "No .devrel/config.toml found in cwd or any ancestor. "
73
+ "Run `devrel init` from the project root."
74
+ )
75
+ cur = cur.parent
@@ -0,0 +1,241 @@
1
+ """Project state DB: SQLite at .devrel/state.db.
2
+
3
+ Stores: jobs (kind, status, started/finished timestamps), costs (per-call
4
+ token + USD ledger consumed by BudgetGate), checkpoints (per-agent context
5
+ snapshots, one per (agent, week_of) pair).
6
+
7
+ In Phase 2 the DB is initialized empty by `devrel init`. Agents start
8
+ writing to it in Phase 3 (quality pipeline cost recording) and beyond.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sqlite3
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Iterator
17
+
18
+ SCHEMA_VERSION = 5
19
+
20
+ SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS schema_meta (
22
+ version INTEGER PRIMARY KEY,
23
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS jobs (
27
+ id TEXT PRIMARY KEY,
28
+ kind TEXT NOT NULL,
29
+ status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'completed', 'failed')),
30
+ started_at TEXT,
31
+ finished_at TEXT,
32
+ error TEXT
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS costs (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ job_id TEXT,
38
+ agent TEXT NOT NULL,
39
+ model TEXT NOT NULL,
40
+ input_tokens INTEGER NOT NULL DEFAULT 0,
41
+ output_tokens INTEGER NOT NULL DEFAULT 0,
42
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
43
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
44
+ cost_usd REAL NOT NULL,
45
+ recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
46
+ FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS checkpoints (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ agent TEXT NOT NULL,
52
+ week_of TEXT NOT NULL,
53
+ payload_json TEXT NOT NULL,
54
+ saved_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ UNIQUE (agent, week_of)
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS analytics_reports (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ period_start TEXT NOT NULL,
61
+ period_end TEXT NOT NULL,
62
+ report_json TEXT NOT NULL,
63
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
64
+ );
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_analytics_reports_period
67
+ ON analytics_reports(period_end);
68
+
69
+ CREATE TABLE IF NOT EXISTS metric_history (
70
+ content_id TEXT NOT NULL,
71
+ period_end TEXT NOT NULL,
72
+ primary_metric REAL NOT NULL,
73
+ metric_name TEXT NOT NULL,
74
+ content_type TEXT NOT NULL,
75
+ PRIMARY KEY (content_id, period_end)
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_metric_history_content_period
79
+ ON metric_history(content_id, period_end DESC);
80
+
81
+ CREATE TABLE IF NOT EXISTS analytics_recommendations (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ report_id INTEGER NOT NULL,
84
+ period_end TEXT NOT NULL,
85
+ action TEXT NOT NULL,
86
+ target TEXT NOT NULL,
87
+ target_type TEXT NOT NULL,
88
+ rationale TEXT NOT NULL,
89
+ confidence REAL NOT NULL,
90
+ source_ids_json TEXT NOT NULL DEFAULT '[]',
91
+ evidence_json TEXT NOT NULL DEFAULT '[]',
92
+ first_seen_period TEXT NOT NULL,
93
+ applied_at TEXT,
94
+ FOREIGN KEY (report_id) REFERENCES analytics_reports(id) ON DELETE CASCADE
95
+ );
96
+
97
+ CREATE INDEX IF NOT EXISTS idx_analytics_recommendations_period
98
+ ON analytics_recommendations(period_end);
99
+
100
+ CREATE INDEX IF NOT EXISTS idx_analytics_recommendations_action_open
101
+ ON analytics_recommendations(action, applied_at);
102
+
103
+ CREATE TABLE IF NOT EXISTS seo_keyword_metrics (
104
+ keyword TEXT NOT NULL,
105
+ page_url TEXT NOT NULL,
106
+ period_end TEXT NOT NULL,
107
+ position REAL,
108
+ ctr REAL,
109
+ impressions INTEGER,
110
+ clicks INTEGER,
111
+ PRIMARY KEY (keyword, page_url, period_end)
112
+ );
113
+
114
+ CREATE INDEX IF NOT EXISTS idx_seo_keyword_metrics_period
115
+ ON seo_keyword_metrics(period_end DESC);
116
+
117
+ CREATE TABLE IF NOT EXISTS seo_page_profiles (
118
+ page_url TEXT NOT NULL,
119
+ period_end TEXT NOT NULL,
120
+ title_len INTEGER,
121
+ meta_len INTEGER,
122
+ h1_count INTEGER,
123
+ word_count INTEGER,
124
+ has_schema INTEGER,
125
+ schema_types_json TEXT,
126
+ internal_links INTEGER,
127
+ inp_ms INTEGER,
128
+ lcp_ms INTEGER,
129
+ redirect_chain_len INTEGER,
130
+ crawled_at TEXT NOT NULL,
131
+ PRIMARY KEY (page_url, period_end)
132
+ );
133
+
134
+ CREATE TABLE IF NOT EXISTS geo_visibility (
135
+ prompt_id TEXT NOT NULL,
136
+ engine TEXT NOT NULL,
137
+ period_end TEXT NOT NULL,
138
+ is_mentioned INTEGER,
139
+ mention_type TEXT,
140
+ position_score INTEGER,
141
+ citation_share REAL,
142
+ quality_score INTEGER,
143
+ response_path TEXT,
144
+ PRIMARY KEY (prompt_id, engine, period_end)
145
+ );
146
+
147
+ CREATE INDEX IF NOT EXISTS idx_geo_visibility_engine_period
148
+ ON geo_visibility(engine, period_end DESC);
149
+
150
+ CREATE TABLE IF NOT EXISTS cro_funnel_metrics (
151
+ funnel_id TEXT NOT NULL,
152
+ step_index INTEGER NOT NULL,
153
+ period_end TEXT NOT NULL,
154
+ conversion_rate REAL,
155
+ sample_size INTEGER,
156
+ segment_breakdown_json TEXT,
157
+ PRIMARY KEY (funnel_id, step_index, period_end)
158
+ );
159
+
160
+ CREATE INDEX IF NOT EXISTS idx_cro_funnel_period
161
+ ON cro_funnel_metrics(funnel_id, period_end DESC);
162
+ """
163
+
164
+
165
+ def _migrate_to_v5(conn: sqlite3.Connection) -> None:
166
+ """Add pillar + target_kind columns to analytics_recommendations if absent.
167
+
168
+ SQLite's ALTER TABLE ADD COLUMN is non-idempotent: running twice raises
169
+ OperationalError. We probe PRAGMA table_info first.
170
+ """
171
+ cur = conn.execute("PRAGMA table_info(analytics_recommendations)")
172
+ cols = {row[1] for row in cur.fetchall()}
173
+ if "pillar" not in cols:
174
+ conn.execute(
175
+ "ALTER TABLE analytics_recommendations ADD COLUMN pillar TEXT NOT NULL DEFAULT 'argus'"
176
+ )
177
+ if "target_kind" not in cols:
178
+ conn.execute(
179
+ "ALTER TABLE analytics_recommendations "
180
+ "ADD COLUMN target_kind TEXT NOT NULL DEFAULT 'content_id'"
181
+ )
182
+ # Backfill any rows that pre-date these columns. DEFAULT covers fresh
183
+ # inserts but old rows from a partial v4 db benefit from explicit values.
184
+ conn.execute(
185
+ "UPDATE analytics_recommendations "
186
+ "SET pillar = COALESCE(NULLIF(pillar, ''), 'argus'), "
187
+ " target_kind = COALESCE(NULLIF(target_kind, ''), 'content_id') "
188
+ "WHERE pillar IS NULL OR pillar = '' "
189
+ " OR target_kind IS NULL OR target_kind = ''"
190
+ )
191
+ # Indexes that reference the newly-added columns. Created here (not in
192
+ # SCHEMA) because SCHEMA's executescript runs before the ALTER TABLEs above.
193
+ conn.execute(
194
+ "CREATE INDEX IF NOT EXISTS idx_recs_pillar_period "
195
+ "ON analytics_recommendations(pillar, first_seen_period DESC)"
196
+ )
197
+ conn.execute(
198
+ "CREATE INDEX IF NOT EXISTS idx_recs_target "
199
+ "ON analytics_recommendations(target_kind, target)"
200
+ )
201
+
202
+
203
+ def init_db(db_path: Path) -> None:
204
+ """Create the DB file and apply the schema. Idempotent: preserves
205
+ existing data and bumps schema_meta to the current SCHEMA_VERSION."""
206
+ db_path.parent.mkdir(parents=True, exist_ok=True)
207
+ with sqlite3.connect(db_path) as conn:
208
+ conn.executescript(SCHEMA)
209
+ _migrate_to_v5(conn)
210
+ conn.execute(
211
+ "INSERT OR REPLACE INTO schema_meta (version, applied_at) VALUES (?, datetime('now'))",
212
+ (SCHEMA_VERSION,),
213
+ )
214
+ conn.commit()
215
+
216
+
217
+ def get_schema_version(db_path: Path) -> int | None:
218
+ """Return the current schema version, or None if the DB is missing /
219
+ has no schema_meta table."""
220
+ if not db_path.is_file():
221
+ return None
222
+ with sqlite3.connect(db_path) as conn:
223
+ try:
224
+ cur = conn.execute("SELECT MAX(version) FROM schema_meta")
225
+ except sqlite3.OperationalError:
226
+ return None
227
+ row = cur.fetchone()
228
+ return row[0] if row else None
229
+
230
+
231
+ @contextmanager
232
+ def open_db(db_path: Path) -> Iterator[sqlite3.Connection]:
233
+ """Context manager yielding a connection with row_factory set and
234
+ foreign-key enforcement enabled."""
235
+ conn = sqlite3.connect(db_path)
236
+ conn.row_factory = sqlite3.Row
237
+ conn.execute("PRAGMA foreign_keys = ON")
238
+ try:
239
+ yield conn
240
+ finally:
241
+ conn.close()
@@ -0,0 +1,4 @@
1
+ """Static template files copied into .devrel/ on `devrel init`.
2
+
3
+ Access via `importlib.resources.files("devrel_origin.project.templates")`.
4
+ """
@@ -0,0 +1,24 @@
1
+ # devrel-origin project config. Commit this file — it encodes editorial
2
+ # contract along with voice.md / style.md / slop-blocklist.md.
3
+
4
+ # Project identity. Replace placeholders with real values; the github_repo
5
+ # is optional (set to a comment-out or null if there isn't one).
6
+ [project]
7
+ name = "PROJECT_NAME"
8
+ url = "PROJECT_URL"
9
+ github_repo = "OWNER/REPO"
10
+
11
+ # Model selection. Sonnet for primary work; Haiku for cheap quality stages
12
+ # (slop lint, persona scoring, readability scoring); Opus opt-in via
13
+ # `--model opus` on individual commands.
14
+ [model]
15
+ default = "claude-sonnet-4-6"
16
+ cheap = "claude-haiku-4-5-20251001"
17
+ opus_opt_in = true
18
+
19
+ # Budget guardrails. monthly_usd is the cap; warn_at_pct is the threshold
20
+ # at which `devrel doctor` and `devrel run` print a warning. Set
21
+ # monthly_usd to 0 to disable the cap entirely.
22
+ [budget]
23
+ monthly_usd = 100.0
24
+ warn_at_pct = 80
@@ -0,0 +1,10 @@
1
+ # Auto-managed by `devrel init`. Edit only if you know what you're doing.
2
+ # Generated outputs and runtime state are gitignored; the editorial
3
+ # contract files (config.toml, voice.md, style.md, slop-blocklist.md) are
4
+ # intended to be committed.
5
+
6
+ kb/
7
+ deliverables/
8
+ context/
9
+ state.db
10
+ .env
@@ -0,0 +1,45 @@
1
+ # Anti-slop blocklist
2
+
3
+ Words, phrases, and patterns that mark text as AI-written. The quality pipeline rewrites any content that contains a hit; on second failure it aborts loud with a report listing offenders.
4
+
5
+ One entry per line. Lines starting with `#` are comments and ignored. Matching is case-insensitive against word boundaries.
6
+
7
+ ## Hedge words and filler
8
+ perhaps
9
+ furthermore
10
+ moreover
11
+ in conclusion
12
+ in today's
13
+ in this fast-paced world
14
+
15
+ ## AI tells
16
+ delve
17
+ delves
18
+ tapestry
19
+ seamless
20
+ seamlessly
21
+ unleash
22
+ unleashing
23
+ revolutionize
24
+ revolutionary
25
+ empower
26
+ empowering
27
+ groundbreaking
28
+
29
+ ## Generic CTAs
30
+ learn more
31
+ discover more
32
+ get started today
33
+ contact us today
34
+
35
+ ## Listicle filler
36
+ in this article we will
37
+ this article will explore
38
+ in this post
39
+
40
+ ## Empty intensifiers
41
+ truly
42
+ incredibly
43
+ extremely
44
+ very
45
+ really
@@ -0,0 +1,24 @@
1
+ # House style
2
+
3
+ Structural and per-content-type rules. Short rules; expand only where the rule isn't obvious from the rule itself.
4
+
5
+ ## Structural rules
6
+
7
+ - Sentence-case headings (not Title Case).
8
+ - One H1 per document (the title).
9
+ - Code blocks always have language tags: ```python, ```bash, etc.
10
+ - No trailing whitespace.
11
+ - Reference-style links only when the same URL repeats.
12
+ - No emojis in headings; sparingly in body.
13
+
14
+ ## Per-content-type targets
15
+
16
+ | Content type | Flesch-Kincaid | Mean sentence length | Jargon density |
17
+ |---|---|---|---|
18
+ | Tutorial | 50-65 | 12-18 words | medium |
19
+ | Blog post | 55-70 | 12-20 words | low-medium |
20
+ | Landing page | 60-75 | 10-15 words | low |
21
+ | Cold email | 65-80 | 10-14 words | low |
22
+ | Battle card | 45-60 | 12-18 words | medium-high |
23
+
24
+ Targets are guidance, not pass/fail gates. The readability check in the quality pipeline flags drift greater than ±10 points from the Flesch-Kincaid target.