ctx-lessons 0.1.0__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.
ctx/__init__.py ADDED
File without changes
ctx/cli/__init__.py ADDED
File without changes
ctx/cli/app.py ADDED
@@ -0,0 +1,66 @@
1
+ """Typer chassis. Feature units register their subcommands here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+
7
+ import typer
8
+
9
+ app = typer.Typer(
10
+ name="ctx",
11
+ help="Curated knowledge CLI for AI coding agents.",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+
16
+ def _version_callback(value: bool) -> None:
17
+ if value:
18
+ # Distribution name is `ctx-lessons`; the command is `ctx`.
19
+ try:
20
+ version = importlib.metadata.version("ctx-lessons")
21
+ except importlib.metadata.PackageNotFoundError:
22
+ version = "unknown (not installed)"
23
+ typer.echo(version)
24
+ raise typer.Exit()
25
+
26
+
27
+ @app.callback()
28
+ def _root(
29
+ version: bool = typer.Option(
30
+ False,
31
+ "--version",
32
+ callback=_version_callback,
33
+ is_eager=True,
34
+ help="Show version and exit.",
35
+ ),
36
+ verbose: bool = typer.Option(
37
+ False,
38
+ "--verbose",
39
+ help="Verbose progress output (stderr).",
40
+ ),
41
+ ) -> None:
42
+ """Curated knowledge CLI for AI coding agents."""
43
+
44
+
45
+ def main() -> None:
46
+ """Entry point for `ctx` script."""
47
+ app()
48
+
49
+
50
+ def _register_subcommands() -> None:
51
+ from ctx.cli.init import register as register_init
52
+ from ctx.ingest.commands import register as register_ingest
53
+ from ctx.lesson.commands import register as register_lesson
54
+ from ctx.promote.commands import register as register_promote
55
+ from ctx.query.commands import register as register_query
56
+ from ctx.sync.commands import register as register_sync
57
+
58
+ register_lesson(app)
59
+ register_init(app)
60
+ register_query(app)
61
+ register_ingest(app)
62
+ register_sync(app)
63
+ register_promote(app)
64
+
65
+
66
+ _register_subcommands()
ctx/cli/config.py ADDED
@@ -0,0 +1,226 @@
1
+ """Config discovery + loading per ADR-0005.
2
+
3
+ Two separate config surfaces:
4
+ - **Repo** (`<repo>/.ctx.toml` or ``[ctx]`` table in pyproject.toml /
5
+ package.json): team-shared, defines scope identity for the repo.
6
+ - **Home** (``~/.ctx/config.toml``): per-developer, holds extractor
7
+ provider, model, and bot-author allowlist. Never overridden by repo.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ import tomllib
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ from ctx.lesson.models import SCOPE_RE
21
+
22
+ _DEFAULT_BOT_AUTHORS = [
23
+ "dependabot[bot]",
24
+ "renovate[bot]",
25
+ "codecov[bot]",
26
+ "github-actions[bot]",
27
+ ]
28
+
29
+ _MAX_CONFIG_FILE_BYTES = 1 * 1024 * 1024 # 1 MiB cap; defends against memory DoS
30
+ _MAX_WALK_DEPTH = 32
31
+
32
+ _CTX_HEADING_RE = re.compile(r"^## ctx\s*$", re.MULTILINE)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RepoConfig:
37
+ scope: str
38
+ team: str | None = None
39
+ product: str | None = None
40
+ include_products: list[str] = field(default_factory=list)
41
+ applies_to_default: str | None = None
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class HomeConfig:
46
+ extractor_provider: str = "anthropic"
47
+ extractor_model: str | None = None
48
+ extractor_base_url: str | None = None
49
+ excluded_authors: list[str] = field(default_factory=lambda: list(_DEFAULT_BOT_AUTHORS))
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ActiveScope:
54
+ """The scope set used as the default for queries.
55
+
56
+ Per ADR-0005 + ADR-0003: when no explicit ``--scope`` is given,
57
+ queries run against this set; ranking favours the most specific match.
58
+ """
59
+
60
+ scope_set: set[str]
61
+
62
+ @classmethod
63
+ def from_repo_config(cls, cfg: RepoConfig | None) -> ActiveScope:
64
+ if cfg is None:
65
+ return cls(scope_set={"org"})
66
+ scopes = {cfg.scope, "org"}
67
+ if cfg.team:
68
+ scopes.add(f"team:{cfg.team}")
69
+ if cfg.product:
70
+ scopes.add(f"product:{cfg.product}")
71
+ # C5: .ctx.toml `include_products` opts in to additional product scopes
72
+ # (e.g. predecessor product for legacy lesson access).
73
+ for product in cfg.include_products:
74
+ scopes.add(f"product:{product}")
75
+ return cls(scope_set=scopes)
76
+
77
+
78
+ def discover_repo_config(start: Path) -> RepoConfig | None:
79
+ """Walk from ``start`` upward looking for a ctx config.
80
+
81
+ Stops at the first match, the directory containing ``.git``, the user's
82
+ home directory, the filesystem root, or after ``_MAX_WALK_DEPTH`` steps —
83
+ whichever comes first.
84
+
85
+ To prevent symlink-based bypass of the home-boundary check, the walk
86
+ operates on logical (non-resolved) paths.
87
+ """
88
+ home = Path(os.environ.get("HOME", str(Path.home())))
89
+ start_path = Path(start)
90
+
91
+ # If start is not under home, only check start itself — do not walk into
92
+ # ancestor directories that may be attacker-plantable.
93
+ if not _is_under(start_path, home):
94
+ return _try_loaders_in(start_path)
95
+
96
+ cur = start_path
97
+ for _ in range(_MAX_WALK_DEPTH):
98
+ cfg = _try_loaders_in(cur)
99
+ if cfg is not None:
100
+ return cfg
101
+ if (cur / ".git").exists():
102
+ return None
103
+ if cur == home or cur.parent == cur:
104
+ return None
105
+ cur = cur.parent
106
+ return None
107
+
108
+
109
+ def _is_under(path: Path, ancestor: Path) -> bool:
110
+ """True if `path` is `ancestor` or below, comparing logical components."""
111
+ try:
112
+ path.relative_to(ancestor)
113
+ return True
114
+ except ValueError:
115
+ return False
116
+
117
+
118
+ def _try_loaders_in(directory: Path) -> RepoConfig | None:
119
+ if cfg := _load_ctx_toml(directory / ".ctx.toml"):
120
+ return cfg
121
+ if cfg := _load_table_from_toml(directory / "pyproject.toml"):
122
+ return cfg
123
+ if cfg := _load_table_from_package_json(directory / "package.json"):
124
+ return cfg
125
+ return None
126
+
127
+
128
+ def _safe_read_text(path: Path) -> str | None:
129
+ """Read a config file, refusing oversized files. Returns None if absent."""
130
+ try:
131
+ size = path.stat().st_size
132
+ except FileNotFoundError:
133
+ return None
134
+ if size > _MAX_CONFIG_FILE_BYTES:
135
+ return None
136
+ return path.read_text(encoding="utf-8")
137
+
138
+
139
+ def _load_ctx_toml(path: Path) -> RepoConfig | None:
140
+ text = _safe_read_text(path)
141
+ if text is None:
142
+ return None
143
+ data = tomllib.loads(text)
144
+ return _repo_config_from_dict(data, source=str(path))
145
+
146
+
147
+ def _load_table_from_toml(path: Path) -> RepoConfig | None:
148
+ text = _safe_read_text(path)
149
+ if text is None:
150
+ return None
151
+ data = tomllib.loads(text)
152
+ ctx_table = data.get("ctx")
153
+ if not isinstance(ctx_table, dict):
154
+ return None
155
+ return _repo_config_from_dict(ctx_table, source=str(path))
156
+
157
+
158
+ def _load_table_from_package_json(path: Path) -> RepoConfig | None:
159
+ text = _safe_read_text(path)
160
+ if text is None:
161
+ return None
162
+ try:
163
+ data = json.loads(text)
164
+ except json.JSONDecodeError:
165
+ return None
166
+ ctx_table = data.get("ctx")
167
+ if not isinstance(ctx_table, dict):
168
+ return None
169
+ return _repo_config_from_dict(ctx_table, source=str(path))
170
+
171
+
172
+ def _repo_config_from_dict(data: dict, *, source: str) -> RepoConfig:
173
+ if "scope" not in data:
174
+ raise ValueError(f"{source}: missing required field 'scope'")
175
+ scope = data["scope"]
176
+ if not isinstance(scope, str) or not SCOPE_RE.match(scope):
177
+ raise ValueError(f"{source}: invalid scope: {scope!r}")
178
+ return RepoConfig(
179
+ scope=scope,
180
+ team=data.get("team"),
181
+ product=data.get("product"),
182
+ include_products=list(data.get("include_products", [])),
183
+ applies_to_default=data.get("applies_to_default"),
184
+ )
185
+
186
+
187
+ def load_home_config() -> HomeConfig:
188
+ home = Path(os.environ.get("HOME", str(Path.home())))
189
+ path = home / ".ctx" / "config.toml"
190
+ text = _safe_read_text(path)
191
+ if text is None:
192
+ return HomeConfig()
193
+ data = tomllib.loads(text)
194
+ extractor = data.get("extractor", {})
195
+ return HomeConfig(
196
+ extractor_provider=extractor.get("provider", "anthropic"),
197
+ extractor_model=extractor.get("model"),
198
+ extractor_base_url=extractor.get("base_url"),
199
+ excluded_authors=list(data.get("excluded_authors", _DEFAULT_BOT_AUTHORS)),
200
+ )
201
+
202
+
203
+ # --- warn-once-per-session ----------------------------------------------------
204
+
205
+
206
+ def warn_if_no_repo_config(cfg: RepoConfig | None) -> None:
207
+ """Emit a one-shot stderr warning when falling back to org-only.
208
+
209
+ Per ADR-0005: at most one warning per shell session, gated by a touch
210
+ file under ``~/.ctx/`` keyed on the session id (see ``ctx.session``).
211
+ """
212
+ from ctx.session import session_hash
213
+
214
+ if cfg is not None:
215
+ return
216
+ home = Path(os.environ.get("HOME", str(Path.home())))
217
+ marker_dir = home / ".ctx"
218
+ marker_dir.mkdir(parents=True, exist_ok=True)
219
+ marker = marker_dir / f".warned-{session_hash()}"
220
+ if marker.exists():
221
+ return
222
+ marker.touch()
223
+ print(
224
+ "warning: no .ctx.toml found walking up to .git; falling back to org-only retrieval.",
225
+ file=sys.stderr,
226
+ )
ctx/cli/init.py ADDED
@@ -0,0 +1,123 @@
1
+ """`ctx init` subcommand: scaffold .ctx.toml + AGENTS.md (+ optional GHA workflow)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from ctx.lesson.models import SCOPE_RE
12
+
13
+ _AGENTS_SECTION_TEMPLATE = """## ctx
14
+
15
+ This repo's curated knowledge corpus is queryable via the `ctx` CLI.
16
+ Default scope: `{scope}`.
17
+
18
+ - `ctx search "<query>"` — search the corpus
19
+ - `ctx show <lesson-id>` — full lesson by id
20
+ - `ctx draft new` — capture a personal-layer lesson
21
+ - `ctx deprecate <id> [--superseded-by <id>]` — mark a lesson outdated
22
+ - `ctx validate <path>` — frontmatter + dead-glob CI gate
23
+ - `ctx sync` — pull team-knowledge updates and reindex
24
+
25
+ `ctx --help` lists every subcommand. Snippet output is capped at ~30
26
+ lines per result; pass `--json` for parseable agent consumption.
27
+ """
28
+
29
+ _CTX_TOML_TEMPLATE = """scope = "{scope}"
30
+ """
31
+
32
+ _GHA_WORKFLOW = """name: ctx validate
33
+
34
+ on:
35
+ pull_request:
36
+ paths:
37
+ - "**/*.md"
38
+
39
+ jobs:
40
+ validate:
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: astral-sh/setup-uv@v6
45
+ - run: uv tool install ctx
46
+ - run: ctx validate "**/*.md"
47
+ """
48
+
49
+ _CTX_HEADING_RE = re.compile(r"^## ctx\s*$", re.MULTILINE)
50
+
51
+
52
+ def register(app: typer.Typer) -> None:
53
+ @app.command("init")
54
+ def init(
55
+ scope: str = typer.Option(..., "--scope"),
56
+ team_knowledge: bool = typer.Option(
57
+ False,
58
+ "--team-knowledge",
59
+ help="Also write a starter GitHub Actions workflow that runs ctx validate.",
60
+ ),
61
+ ) -> None:
62
+ """Scaffold .ctx.toml and an AGENTS.md ctx section in the current directory."""
63
+ if not SCOPE_RE.match(scope):
64
+ typer.echo(f"invalid scope: {scope!r}", err=True)
65
+ raise typer.Exit(code=2)
66
+
67
+ cwd = Path.cwd()
68
+
69
+ ctx_toml = cwd / ".ctx.toml"
70
+ if ctx_toml.is_symlink():
71
+ typer.echo(f"refusing to write through symlink: {ctx_toml}", err=True)
72
+ raise typer.Exit(code=2)
73
+ if not ctx_toml.exists():
74
+ _write_no_follow(ctx_toml, _CTX_TOML_TEMPLATE.format(scope=scope))
75
+ typer.echo(f"wrote {ctx_toml}")
76
+ else:
77
+ typer.echo(f"{ctx_toml} already exists (skipped)")
78
+
79
+ _write_or_append_agents_md(cwd / "AGENTS.md", scope=scope)
80
+
81
+ if team_knowledge:
82
+ wf = cwd / ".github" / "workflows" / "ctx-validate.yml"
83
+ if wf.is_symlink():
84
+ typer.echo(f"refusing to write through symlink: {wf}", err=True)
85
+ raise typer.Exit(code=2)
86
+ wf.parent.mkdir(parents=True, exist_ok=True)
87
+ _write_no_follow(wf, _GHA_WORKFLOW)
88
+ typer.echo(f"wrote {wf}")
89
+
90
+
91
+ def _write_no_follow(path: Path, content: str) -> None:
92
+ """Create-or-truncate `path` without following symlinks.
93
+
94
+ Refuses if `path` exists as a symlink. Atomic-enough for v1: an attacker
95
+ racing to plant a symlink between this and the open() call would still
96
+ be blocked by O_NOFOLLOW.
97
+ """
98
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW
99
+ fd = os.open(path, flags, 0o644)
100
+ try:
101
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
102
+ f.write(content)
103
+ except BaseException:
104
+ os.close(fd)
105
+ raise
106
+
107
+
108
+ def _write_or_append_agents_md(path: Path, *, scope: str) -> None:
109
+ section = _AGENTS_SECTION_TEMPLATE.format(scope=scope)
110
+ if path.is_symlink():
111
+ typer.echo(f"refusing to write through symlink: {path}", err=True)
112
+ raise typer.Exit(code=2)
113
+ if not path.exists():
114
+ _write_no_follow(path, section)
115
+ typer.echo(f"wrote {path}")
116
+ return
117
+ existing = path.read_text(encoding="utf-8")
118
+ if _CTX_HEADING_RE.search(existing):
119
+ typer.echo(f"{path} already has a ## ctx section (skipped)")
120
+ return
121
+ sep = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n")
122
+ _write_no_follow(path, existing + sep + section)
123
+ typer.echo(f"updated {path}")
ctx/db/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """SQLite + FTS5 storage for the lesson corpus.
2
+
3
+ Public surface per ADR-0001. SQL strings never cross this package boundary.
4
+ """
5
+
6
+ from ctx.db.migrations import CURRENT_VERSION, apply_v2_reserved, migrate
7
+ from ctx.db.queries import (
8
+ LessonRecord,
9
+ SearchResult,
10
+ add_lesson,
11
+ connect,
12
+ count_lessons,
13
+ deprecated_lessons,
14
+ get,
15
+ list_by_scope,
16
+ mark_deprecated,
17
+ search,
18
+ set_superseded_by,
19
+ stale_lessons,
20
+ )
21
+
22
+ __all__ = [
23
+ "CURRENT_VERSION",
24
+ "LessonRecord",
25
+ "SearchResult",
26
+ "add_lesson",
27
+ "apply_v2_reserved",
28
+ "connect",
29
+ "count_lessons",
30
+ "deprecated_lessons",
31
+ "get",
32
+ "list_by_scope",
33
+ "mark_deprecated",
34
+ "migrate",
35
+ "search",
36
+ "set_superseded_by",
37
+ "stale_lessons",
38
+ ]
ctx/db/migrations.py ADDED
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+
5
+ from ctx.db.schema import V1_SCHEMA, V2_RESERVED
6
+
7
+ CURRENT_VERSION = 1
8
+
9
+
10
+ def migrate(conn: sqlite3.Connection) -> None:
11
+ """Apply forward migrations from the stored version to CURRENT_VERSION."""
12
+ current = conn.execute("PRAGMA user_version").fetchone()[0]
13
+ if current < 1:
14
+ conn.executescript(V1_SCHEMA)
15
+ conn.execute(f"PRAGMA user_version = {1}")
16
+ # Future: if current < 2: apply v2 here. v2 is NOT auto-applied in v1.
17
+ conn.commit()
18
+
19
+
20
+ def apply_v2_reserved(conn: sqlite3.Connection) -> None:
21
+ """Apply the reserved v2 embeddings-column migration.
22
+
23
+ Used by ADR-0002 forward-compat tests. NOT called by ``migrate()`` in v1.
24
+ """
25
+ conn.executescript(V2_RESERVED)
26
+ conn.execute("PRAGMA user_version = 2")
27
+ conn.commit()
ctx/db/queries.py ADDED
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from dataclasses import dataclass
5
+ from datetime import date, timedelta
6
+ from pathlib import Path
7
+
8
+ from ctx.lesson.frontmatter import dump_yaml, parse_yaml
9
+ from ctx.lesson.models import Lesson
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class LessonRecord:
14
+ lesson: Lesson
15
+ body: str
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SearchResult:
20
+ lesson: Lesson
21
+ score: float
22
+ snippet: str
23
+
24
+
25
+ def connect(path: Path) -> sqlite3.Connection:
26
+ conn = sqlite3.connect(str(path))
27
+ conn.execute("PRAGMA foreign_keys = ON")
28
+ return conn
29
+
30
+
31
+ def add_lesson(conn: sqlite3.Connection, lesson: Lesson, *, body: str) -> None:
32
+ """Upsert a lesson and rebuild its side-table rows. Atomic."""
33
+ fm = dump_yaml(lesson)
34
+ with conn:
35
+ conn.execute(
36
+ """
37
+ INSERT INTO lessons (id, scope, category, status, created, last_reviewed,
38
+ superseded_by, body, frontmatter)
39
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
40
+ ON CONFLICT(id) DO UPDATE SET
41
+ scope=excluded.scope,
42
+ category=excluded.category,
43
+ status=excluded.status,
44
+ created=excluded.created,
45
+ last_reviewed=excluded.last_reviewed,
46
+ superseded_by=excluded.superseded_by,
47
+ body=excluded.body,
48
+ frontmatter=excluded.frontmatter
49
+ """,
50
+ (
51
+ lesson.id,
52
+ lesson.scope,
53
+ lesson.category,
54
+ lesson.status,
55
+ lesson.created.isoformat(),
56
+ lesson.last_reviewed.isoformat(),
57
+ lesson.superseded_by,
58
+ body,
59
+ fm,
60
+ ),
61
+ )
62
+ conn.execute("DELETE FROM lesson_authors WHERE lesson_id = ?", (lesson.id,))
63
+ conn.execute("DELETE FROM lesson_tags WHERE lesson_id = ?", (lesson.id,))
64
+ conn.execute("DELETE FROM lesson_applies_to_files WHERE lesson_id = ?", (lesson.id,))
65
+ conn.executemany(
66
+ "INSERT INTO lesson_authors (lesson_id, author) VALUES (?, ?)",
67
+ [(lesson.id, a) for a in lesson.authors],
68
+ )
69
+ conn.executemany(
70
+ "INSERT INTO lesson_tags (lesson_id, tag) VALUES (?, ?)",
71
+ [(lesson.id, t) for t in lesson.tags],
72
+ )
73
+ conn.executemany(
74
+ "INSERT INTO lesson_applies_to_files (lesson_id, glob) VALUES (?, ?)",
75
+ [(lesson.id, g) for g in lesson.applies_to_files],
76
+ )
77
+
78
+
79
+ def get(conn: sqlite3.Connection, lesson_id: str) -> LessonRecord | None:
80
+ row = conn.execute(
81
+ "SELECT frontmatter, body FROM lessons WHERE id = ?", (lesson_id,)
82
+ ).fetchone()
83
+ if row is None:
84
+ return None
85
+ return LessonRecord(lesson=parse_yaml(row[0]), body=row[1])
86
+
87
+
88
+ def list_by_scope(conn: sqlite3.Connection, scope: str) -> list[Lesson]:
89
+ rows = conn.execute(
90
+ "SELECT frontmatter FROM lessons WHERE scope = ? ORDER BY id", (scope,)
91
+ ).fetchall()
92
+ return [parse_yaml(r[0]) for r in rows]
93
+
94
+
95
+ def _build_fts_query(query: str) -> str | None:
96
+ """Build a safe FTS5 MATCH expression from raw CLI input.
97
+
98
+ Each whitespace-separated term is wrapped as its own quoted phrase and the
99
+ terms are OR-ed together. Quoting every term defuses the FTS5 operator
100
+ grammar (NEAR, AND, column:term, parens, ``c++``, unbalanced quotes) — the
101
+ same injection-safety the old single-phrase wrapper gave us — while OR over
102
+ individual terms restores recall: a multi-word query matches lessons that
103
+ contain *any* of the terms, and bm25 ranks lessons matching more of them
104
+ higher. We deliberately do not treat the whole input as one contiguous
105
+ phrase, which required every word to appear adjacently and in order.
106
+
107
+ Returns ``None`` when the query has no usable terms (caller short-circuits
108
+ to an empty result set rather than issuing an empty MATCH, which errors).
109
+ """
110
+ terms = [t for t in query.split() if t]
111
+ if not terms:
112
+ return None
113
+ return " OR ".join(f'"{t.replace(chr(34), chr(34) * 2)}"' for t in terms)
114
+
115
+
116
+ def search(
117
+ conn: sqlite3.Connection,
118
+ query: str,
119
+ *,
120
+ scope_set: set[str] | None = None,
121
+ include_deprecated: bool = False,
122
+ limit: int = 5,
123
+ ) -> list[SearchResult]:
124
+ match_expr = _build_fts_query(query)
125
+ if match_expr is None:
126
+ return []
127
+ sql = """
128
+ SELECT l.frontmatter,
129
+ bm25(lessons_fts) AS rank,
130
+ snippet(lessons_fts, 0, '[', ']', '…', 16) AS snip
131
+ FROM lessons_fts
132
+ JOIN lessons l ON l.rowid = lessons_fts.rowid
133
+ WHERE lessons_fts MATCH ?
134
+ """
135
+ params: list[object] = [match_expr]
136
+ if not include_deprecated:
137
+ sql += " AND l.status != 'deprecated'"
138
+ if scope_set is not None:
139
+ placeholders = ",".join("?" for _ in scope_set)
140
+ sql += f" AND l.scope IN ({placeholders})"
141
+ params.extend(scope_set)
142
+ sql += " ORDER BY rank LIMIT ?"
143
+ params.append(limit)
144
+
145
+ rows = conn.execute(sql, params).fetchall()
146
+ return [
147
+ SearchResult(lesson=parse_yaml(fm), score=-rank, snippet=snip or "")
148
+ for fm, rank, snip in rows
149
+ ]
150
+
151
+
152
+ def count_lessons(
153
+ conn: sqlite3.Connection,
154
+ *,
155
+ scope_set: set[str] | None = None,
156
+ include_deprecated: bool = False,
157
+ ) -> int:
158
+ """Count lessons, optionally restricted to ``scope_set``.
159
+
160
+ Used by the search command to detect the silent empty-scope trap: an active
161
+ scope (e.g. the org-only fallback) that contains zero lessons while the
162
+ corpus as a whole is non-empty.
163
+ """
164
+ sql = "SELECT count(*) FROM lessons WHERE 1=1"
165
+ params: list[object] = []
166
+ if not include_deprecated:
167
+ sql += " AND status != 'deprecated'"
168
+ if scope_set is not None:
169
+ if not scope_set:
170
+ return 0
171
+ placeholders = ",".join("?" for _ in scope_set)
172
+ sql += f" AND scope IN ({placeholders})"
173
+ params.extend(scope_set)
174
+ return int(conn.execute(sql, params).fetchone()[0])
175
+
176
+
177
+ def mark_deprecated(
178
+ conn: sqlite3.Connection, lesson_id: str, superseded_by: str | None = None
179
+ ) -> None:
180
+ row = conn.execute("SELECT frontmatter FROM lessons WHERE id = ?", (lesson_id,)).fetchone()
181
+ if row is None:
182
+ raise KeyError(lesson_id)
183
+ updated = parse_yaml(row[0]).model_copy(
184
+ update={"status": "deprecated", "superseded_by": superseded_by}
185
+ )
186
+ with conn:
187
+ conn.execute(
188
+ "UPDATE lessons SET status=?, superseded_by=?, frontmatter=? WHERE id=?",
189
+ ("deprecated", superseded_by, dump_yaml(updated), lesson_id),
190
+ )
191
+
192
+
193
+ def set_superseded_by(conn: sqlite3.Connection, lesson_id: str, replacement_id: str) -> None:
194
+ row = conn.execute("SELECT frontmatter FROM lessons WHERE id = ?", (lesson_id,)).fetchone()
195
+ if row is None:
196
+ raise KeyError(lesson_id)
197
+ updated = parse_yaml(row[0]).model_copy(update={"superseded_by": replacement_id})
198
+ with conn:
199
+ conn.execute(
200
+ "UPDATE lessons SET superseded_by=?, frontmatter=? WHERE id=?",
201
+ (replacement_id, dump_yaml(updated), lesson_id),
202
+ )
203
+
204
+
205
+ def deprecated_lessons(conn: sqlite3.Connection) -> list[Lesson]:
206
+ rows = conn.execute(
207
+ "SELECT frontmatter FROM lessons WHERE status = 'deprecated' ORDER BY id"
208
+ ).fetchall()
209
+ return [parse_yaml(r[0]) for r in rows]
210
+
211
+
212
+ def stale_lessons(conn: sqlite3.Connection, months: int) -> list[Lesson]:
213
+ # 30-day months — good enough for "stale" semantics.
214
+ threshold = (date.today() - timedelta(days=months * 30)).isoformat()
215
+ rows = conn.execute(
216
+ "SELECT frontmatter FROM lessons WHERE last_reviewed <= ? ORDER BY last_reviewed",
217
+ (threshold,),
218
+ ).fetchall()
219
+ return [parse_yaml(r[0]) for r in rows]