data-olympus 0.3.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.
Files changed (73) hide show
  1. data_olympus/__init__.py +14 -0
  2. data_olympus/_bin/_kb_detect_workspace.sh +57 -0
  3. data_olympus/_bin/_kb_enforce.py +619 -0
  4. data_olympus/_bin/_kb_fallback.py +361 -0
  5. data_olympus/_bin/kb +957 -0
  6. data_olympus/_bin/kb-enforce-hook +337 -0
  7. data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
  8. data_olympus/audit_log.py +275 -0
  9. data_olympus/audit_trailers.py +42 -0
  10. data_olympus/auth.py +139 -0
  11. data_olympus/cli/__init__.py +1 -0
  12. data_olympus/cli/import_cmd.py +115 -0
  13. data_olympus/cli/indexgen.py +60 -0
  14. data_olympus/cli/main.py +151 -0
  15. data_olympus/cli/report_cmd.py +181 -0
  16. data_olympus/config.py +261 -0
  17. data_olympus/cooccurrence.py +393 -0
  18. data_olympus/dedup.py +57 -0
  19. data_olympus/durable.py +51 -0
  20. data_olympus/embeddings.py +317 -0
  21. data_olympus/enforce_policy.py +297 -0
  22. data_olympus/format/__init__.py +16 -0
  23. data_olympus/format/document.py +39 -0
  24. data_olympus/format/frontmatter.py +35 -0
  25. data_olympus/format/lint.py +92 -0
  26. data_olympus/format/validate.py +71 -0
  27. data_olympus/git_ops.py +397 -0
  28. data_olympus/health.py +114 -0
  29. data_olympus/importer/__init__.py +13 -0
  30. data_olympus/importer/adr.py +286 -0
  31. data_olympus/importer/flat.py +170 -0
  32. data_olympus/importer/model.py +106 -0
  33. data_olympus/importer/okf.py +192 -0
  34. data_olympus/importer/run.py +416 -0
  35. data_olympus/importer/stamp.py +227 -0
  36. data_olympus/index.py +1745 -0
  37. data_olympus/markdown_parse.py +103 -0
  38. data_olympus/models.py +480 -0
  39. data_olympus/onboarding.py +131 -0
  40. data_olympus/onboarding_inflight.py +137 -0
  41. data_olympus/onboarding_playbook.py +99 -0
  42. data_olympus/pending.py +533 -0
  43. data_olympus/principals.py +168 -0
  44. data_olympus/prompts.py +35 -0
  45. data_olympus/push_queue.py +261 -0
  46. data_olympus/query_expansion.py +200 -0
  47. data_olympus/rate_limit.py +81 -0
  48. data_olympus/refresh.py +329 -0
  49. data_olympus/report.py +133 -0
  50. data_olympus/rest_api.py +845 -0
  51. data_olympus/safe_id.py +36 -0
  52. data_olympus/search_gate.py +64 -0
  53. data_olympus/search_shortcut.py +146 -0
  54. data_olympus/server.py +1115 -0
  55. data_olympus/session_metrics.py +303 -0
  56. data_olympus/setup_wizard.py +751 -0
  57. data_olympus/thin_pointer.py +20 -0
  58. data_olympus/tools_audit.py +41 -0
  59. data_olympus/tools_enforce.py +198 -0
  60. data_olympus/tools_onboarding.py +585 -0
  61. data_olympus/tools_read.py +230 -0
  62. data_olympus/tools_write.py +878 -0
  63. data_olympus/trigram.py +126 -0
  64. data_olympus/viewer/__init__.py +1 -0
  65. data_olympus/viewer/generator.py +375 -0
  66. data_olympus/worktrees.py +147 -0
  67. data_olympus/write_gate.py +382 -0
  68. data_olympus-0.3.0.dist-info/METADATA +97 -0
  69. data_olympus-0.3.0.dist-info/RECORD +73 -0
  70. data_olympus-0.3.0.dist-info/WHEEL +4 -0
  71. data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
  72. data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. data_olympus-0.3.0.dist-info/licenses/NOTICE +8 -0
@@ -0,0 +1,275 @@
1
+ """Append-only JSONL audit log of all writes, with a tamper-evident hash chain.
2
+
3
+ Each appended event carries an ``event_id``, the ``prev_hash`` of the previous
4
+ chained event, and its own ``hash`` over the canonical event body (which includes
5
+ ``prev_hash``). Any later edit/deletion/reordering of an event breaks the chain
6
+ and is detected by :meth:`verify`. With ``KB_AUDIT_HMAC_KEY`` set the digest is a
7
+ keyed HMAC-SHA256, so an attacker who can write the log file still cannot forge a
8
+ valid chain without the key. Legacy lines without a ``hash`` are tolerated: the
9
+ chain validates the hashed lines in order and ignores unhashed ones, so enabling
10
+ the feature on an existing log does not retroactively flag it.
11
+
12
+ Rotation (WP3a). When the live file grows past ``max_bytes`` a fresh append
13
+ rotates it: the live file is renamed to ``<stem>-<UTC-timestamp><suffix>`` and a
14
+ new live file is started. The hash chain carries across the boundary because
15
+ ``_last_hash`` is retained in memory across the rename, so the first event of the
16
+ new file links to the last hash of the rotated one. :meth:`verify` replays every
17
+ rotated segment in chronological order followed by the live file, so an intact
18
+ chain validates across rotations and any break at the boundary is caught. Reads
19
+ (:meth:`iter_filtered`) default to the live file only (cheap, matches the pre-
20
+ rotation behaviour) but can walk rotated segments newest-first when
21
+ ``include_rotated=True`` so a ``since``-filtered query still sees history that has
22
+ rotated out of the live file.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import glob
27
+ import hashlib
28
+ import hmac
29
+ import json
30
+ import os
31
+ import threading
32
+ import time
33
+ import uuid
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Iterator
38
+
39
+ GENESIS = ""
40
+
41
+
42
+ class AuditLog:
43
+ def __init__(
44
+ self, *, log_path: str, hmac_key: str = "", max_bytes: int = 0,
45
+ ) -> None:
46
+ self._path = log_path
47
+ self._hmac_key = hmac_key or ""
48
+ # Size-based rotation threshold in bytes. 0 (the default) disables
49
+ # rotation entirely, so an operator who never sets KB_AUDIT_MAX_BYTES keeps
50
+ # the single-file behaviour and existing logs are untouched.
51
+ self._max_bytes = max_bytes if max_bytes and max_bytes > 0 else 0
52
+ # append() is a read-modify-write on _last_hash plus a file write. Once
53
+ # handlers are offloaded to the anyio threadpool, appends can run
54
+ # concurrently; without this lock two threads read the same _last_hash
55
+ # and produce sibling events that break the hash chain. Reads (verify /
56
+ # iter_filtered) take the same lock so they never observe a torn append.
57
+ self._lock = threading.Lock()
58
+ self._last_hash = self._load_last_hash()
59
+
60
+ # --- hashing helpers ---------------------------------------------------
61
+ def _digest(self, payload: str) -> str:
62
+ if self._hmac_key:
63
+ return hmac.new(
64
+ self._hmac_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
65
+ ).hexdigest()
66
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
67
+
68
+ @staticmethod
69
+ def _canonical(event: dict[str, Any]) -> str:
70
+ """Deterministic serialization of the event body excluding its own hash."""
71
+ body = {k: v for k, v in event.items() if k != "hash"}
72
+ return json.dumps(body, sort_keys=True, ensure_ascii=False)
73
+
74
+ def _load_last_hash(self) -> str:
75
+ """The last chained hash across ALL segments (rotated + live).
76
+
77
+ Reads rotated segments in chronological order then the live file so a
78
+ process that restarts after a rotation still chains its first append to
79
+ the true tail of the chain rather than to the (now-rotated-out) live
80
+ file's older content.
81
+ """
82
+ last = GENESIS
83
+ for path in [*self._rotated_paths(), self._path]:
84
+ if not os.path.exists(path):
85
+ continue
86
+ with open(path, encoding="utf-8") as f:
87
+ for line in f:
88
+ line = line.strip()
89
+ if not line:
90
+ continue
91
+ try:
92
+ ev = json.loads(line)
93
+ except json.JSONDecodeError:
94
+ continue
95
+ if isinstance(ev, dict) and ev.get("hash"):
96
+ last = ev["hash"]
97
+ return last
98
+
99
+ # --- rotation helpers --------------------------------------------------
100
+ def _rotated_paths(self) -> list[str]:
101
+ """Rotated segment paths, oldest-first.
102
+
103
+ Rotated files are named ``<stem>-<UTC-timestamp><suffix>`` next to the
104
+ live file. Sorting the glob lexically orders them chronologically because
105
+ the timestamp is fixed-width ``YYYYmmddTHHMMSSffffff``. The live file
106
+ itself is excluded (its name has no timestamp segment)."""
107
+ stem, suffix = os.path.splitext(self._path)
108
+ pattern = f"{glob.escape(stem)}-*{suffix}"
109
+ return sorted(p for p in glob.glob(pattern) if p != self._path)
110
+
111
+ def _rotated_path_for(self, when: float) -> str:
112
+ stem, suffix = os.path.splitext(self._path)
113
+ ts = time.strftime("%Y%m%dT%H%M%S", time.gmtime(when))
114
+ micros = int((when % 1) * 1_000_000)
115
+ candidate = f"{stem}-{ts}{micros:06d}{suffix}"
116
+ # Extremely unlikely, but never clobber an existing rotated segment.
117
+ n = 1
118
+ while os.path.exists(candidate):
119
+ candidate = f"{stem}-{ts}{micros:06d}-{n}{suffix}"
120
+ n += 1
121
+ return candidate
122
+
123
+ def _maybe_rotate_locked(self) -> None:
124
+ """Rotate the live file when it exceeds the size threshold. Caller holds
125
+ the lock. The in-memory ``_last_hash`` is deliberately NOT reset, so the
126
+ first event written to the fresh live file links to the last hash of the
127
+ rotated segment and the chain is continuous across the boundary."""
128
+ if self._max_bytes <= 0 or not os.path.exists(self._path):
129
+ return
130
+ try:
131
+ size = os.path.getsize(self._path)
132
+ except OSError: # pragma: no cover - defensive
133
+ return
134
+ if size < self._max_bytes:
135
+ return
136
+ os.rename(self._path, self._rotated_path_for(time.time()))
137
+
138
+ # --- public API --------------------------------------------------------
139
+ def append(self, event: dict[str, Any]) -> None:
140
+ """Append a single JSON object as one line, chained to the previous event.
141
+
142
+ The parent directory is created lazily on first append so that
143
+ constructing an AuditLog with an unwritable default path (e.g. macOS
144
+ tests that never trigger a write) does not crash startup. When rotation
145
+ is enabled and the live file has passed the size threshold, it is rotated
146
+ BEFORE this event is written so the new event starts the fresh segment.
147
+ """
148
+ os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
149
+ with self._lock:
150
+ self._maybe_rotate_locked()
151
+ chained = dict(event)
152
+ chained["event_id"] = uuid.uuid4().hex
153
+ chained["prev_hash"] = self._last_hash
154
+ chained["hash"] = self._digest(self._canonical(chained))
155
+ line = json.dumps(chained, ensure_ascii=False)
156
+ with open(self._path, "a", encoding="utf-8") as f:
157
+ f.write(line + "\n")
158
+ self._last_hash = chained["hash"]
159
+
160
+ def verify(self) -> tuple[bool, int]:
161
+ """Recompute the hash chain over the whole log (all rotated segments +
162
+ the live file, in chronological order).
163
+
164
+ Returns ``(True, -1)`` when intact (or empty / all-legacy), else
165
+ ``(False, line_index)`` for the 0-based index of the first offending line
166
+ counted across the concatenation of all segments (rotated oldest-first,
167
+ then live). So a single-file log verifies exactly as before, and a
168
+ rotated log verifies across the boundary: the first event of a new segment
169
+ must carry the last hash of the previous segment as its ``prev_hash``.
170
+
171
+ Unhashed legacy lines are tolerated ONLY as a prefix before the first
172
+ hashed event (the pre-chaining migration window). Once the chain has
173
+ started, any unhashed JSON line is treated as tampering and breaks
174
+ verification, so an attacker cannot append a legacy-shaped record to forge
175
+ an event while keeping verify green.
176
+ """
177
+ # Snapshot every segment under the lock (short hold), then verify in
178
+ # memory so a concurrent append cannot make us read a torn final line as
179
+ # tampering.
180
+ with self._lock:
181
+ segments: list[list[str]] = []
182
+ for path in [*self._rotated_paths(), self._path]:
183
+ if not os.path.exists(path):
184
+ continue
185
+ with open(path, encoding="utf-8") as f:
186
+ segments.append(f.readlines())
187
+ prev = GENESIS
188
+ seen_hashed = False
189
+ global_index = 0
190
+ for raw_lines in segments:
191
+ for raw in raw_lines:
192
+ i = global_index
193
+ global_index += 1
194
+ raw = raw.strip()
195
+ if not raw:
196
+ continue
197
+ try:
198
+ ev = json.loads(raw)
199
+ except json.JSONDecodeError:
200
+ return (False, i)
201
+ if not (isinstance(ev, dict) and ev.get("hash")):
202
+ if seen_hashed:
203
+ return (False, i) # unhashed line after the chain started
204
+ continue # legacy prefix, tolerated
205
+ if ev.get("prev_hash") != prev:
206
+ return (False, i)
207
+ if self._digest(self._canonical(ev)) != ev["hash"]:
208
+ return (False, i)
209
+ prev = ev["hash"]
210
+ seen_hashed = True
211
+ return (True, -1)
212
+
213
+ def iter_filtered(
214
+ self,
215
+ *,
216
+ since: float | None = None,
217
+ agent: str | None = None,
218
+ status: str | None = None,
219
+ include_rotated: bool = False,
220
+ max_scan_events: int | None = None,
221
+ ) -> Iterator[dict[str, Any]]:
222
+ """Yield events matching the filters, most-recent first.
223
+
224
+ By default only the live file is read (cheap; matches the pre-rotation
225
+ behaviour). When ``include_rotated`` is True the rotated segments are also
226
+ walked, newest-first, AFTER the live file so results stay strictly most-
227
+ recent-first across the rotation boundary. Callers that pass a ``since``
228
+ filter should set ``include_rotated=True`` to see history that has already
229
+ rotated out of the live file.
230
+
231
+ ``max_scan_events`` bounds the reverse scan: once that many raw events
232
+ have been examined (across all scanned segments) iteration stops even if
233
+ more match. This caps the memory/time cost of a query against a large
234
+ rotated history; callers that only need "the most recent N" pass their N
235
+ so the scan does not walk the entire archive.
236
+ """
237
+ scanned = 0
238
+ # Live file first (newest events live here), then rotated newest-first.
239
+ paths = [self._path]
240
+ if include_rotated:
241
+ paths.extend(reversed(self._rotated_paths()))
242
+ for path in paths:
243
+ if not os.path.exists(path):
244
+ continue
245
+ # Read under the lock so we never see a torn append, then yield from
246
+ # the in-memory snapshot (holding the lock across yields would block
247
+ # appends for as long as the caller iterates).
248
+ with self._lock, open(path, encoding="utf-8") as f:
249
+ lines = f.readlines()
250
+ for line in reversed(lines):
251
+ if max_scan_events is not None and scanned >= max_scan_events:
252
+ return
253
+ line = line.strip()
254
+ if not line:
255
+ continue
256
+ scanned += 1
257
+ try:
258
+ ev = json.loads(line)
259
+ except json.JSONDecodeError:
260
+ continue
261
+ if since is not None and float(ev.get("ts", 0)) < since:
262
+ # Events are appended in time order, so within a segment a
263
+ # reverse scan that reaches an older-than-``since`` event has
264
+ # no newer matches left below it; and every rotated segment is
265
+ # strictly older than the live file we scan first. So the first
266
+ # sub-floor event ends the whole scan. (A clock that ran
267
+ # backwards mid-run could in theory hide a straggler; the audit
268
+ # ts is server wall-clock and this is a best-effort recency
269
+ # query, so the bound is worth the rare miss.)
270
+ return
271
+ if agent is not None and ev.get("agent_identity") != agent:
272
+ continue
273
+ if status is not None and ev.get("status") != status:
274
+ continue
275
+ yield ev
@@ -0,0 +1,42 @@
1
+ """Build commit messages with 7 audit trailers."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Literal
5
+
6
+
7
+ def build_commit_message(
8
+ *,
9
+ subject: str,
10
+ source_session: str,
11
+ agent_identity: str,
12
+ confidence_original: float,
13
+ operator_confirmed: bool,
14
+ proposal_type: Literal["memory", "edit"],
15
+ target_tier: str,
16
+ target_path: str,
17
+ ) -> str:
18
+ """Return a commit message with subject + blank line + 7 trailers.
19
+
20
+ Raises ValueError if any trailer value contains a newline (which would
21
+ break git's trailer parsing).
22
+ """
23
+ for label, value in [
24
+ ("subject", subject),
25
+ ("source_session", source_session),
26
+ ("agent_identity", agent_identity),
27
+ ("target_tier", target_tier),
28
+ ("target_path", target_path),
29
+ ]:
30
+ if "\n" in value or "\r" in value:
31
+ raise ValueError(f"trailer value for {label!r} contains newline")
32
+
33
+ trailers = [
34
+ f"KB-Source-Session: {source_session}",
35
+ f"KB-Agent-Identity: {agent_identity}",
36
+ f"KB-Confidence: {confidence_original:.2f}",
37
+ f"KB-Operator-Confirmed: {'yes' if operator_confirmed else 'no'}",
38
+ f"KB-Proposal-Type: {proposal_type}",
39
+ f"KB-Target-Tier: {target_tier}",
40
+ f"KB-Target-Path: {target_path}",
41
+ ]
42
+ return subject + "\n\n" + "\n".join(trailers) + "\n"
data_olympus/auth.py ADDED
@@ -0,0 +1,139 @@
1
+ """Structural rule (always applies, not configurable) + policy blocklist
2
+ (operator-configurable, empty by default).
3
+
4
+ Rules:
5
+ - Structural rule rejects: empty, NUL, absolute, traversal, non-md, non-indexed,
6
+ structurally-excluded. Normalization happens BEFORE prefix match.
7
+ - Policy blocklist consists of tier names (e.g. {"T1"}) and fnmatch globs
8
+ (e.g. ["decisions/ADR-008-*.md"]).
9
+
10
+ By default both blocklists are empty (allow-by-default modulo the structural rule).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import fnmatch
15
+ import os
16
+ from pathlib import PurePosixPath
17
+
18
+ # Generic, deployment-neutral default. A deployment with extra top-level
19
+ # directories supplies its own writable set via KB_INDEXED_PREFIXES (a
20
+ # comma-separated list), which replaces this default wholesale.
21
+ DEFAULT_INDEXED_PREFIXES: tuple[str, ...] = (
22
+ "universal/", "tech-stacks/", "projects/",
23
+ "decisions/", "workflows/", "memory/",
24
+ "tooling/", "templates/",
25
+ )
26
+
27
+
28
+ def indexed_prefixes() -> tuple[str, ...]:
29
+ """Active writable prefixes: KB_INDEXED_PREFIXES if set, else default."""
30
+ raw = os.environ.get("KB_INDEXED_PREFIXES", "").strip()
31
+ if not raw:
32
+ return DEFAULT_INDEXED_PREFIXES
33
+ return tuple(s.strip() for s in raw.split(",") if s.strip())
34
+
35
+ STRUCTURALLY_EXCLUDED: frozenset[str] = frozenset({
36
+ ".git", ".worktrees", ".ruff_cache", "__pycache__",
37
+ "to-delete", "archive", "_archive", "tools",
38
+ ".pytest_cache", ".mypy_cache", "node_modules",
39
+ })
40
+
41
+
42
+ def normalize_target_path(raw: str) -> str | None:
43
+ """Return the canonical relative POSIX path, or None if structurally invalid.
44
+
45
+ The returned string is the SINGLE authoritative form of the path: backslashes
46
+ are folded to ``/`` and the segments are re-joined with POSIX separators.
47
+ Every downstream operation (classification, blocklist match, filesystem join,
48
+ ``git add``) MUST use this canonical value, never the raw input. If callers
49
+ validated with the canonical form but then joined/wrote/git-added the raw
50
+ string, a value like ``decisions\\x.md`` would pass validation as
51
+ ``decisions/x.md`` on Linux yet land a literal root-level file named
52
+ ``decisions\\x.md`` that is outside every indexed prefix and invisible to
53
+ ``KB_WRITE_BLOCK_PATHS`` globs. Returning the canonical form and using it
54
+ everywhere keeps the policy decision and the filesystem effect in lockstep.
55
+
56
+ Rejections: empty/whitespace-only, NUL or any other control character
57
+ (``\\n``, ``\\r``, ``\\t``, etc. would let a payload smuggle newlines into a
58
+ path), absolute paths, Windows drive letters, ``.``/``..`` traversal, and any
59
+ structurally-excluded segment.
60
+ """
61
+ if not raw or not raw.strip():
62
+ return None
63
+ # Reject control characters (NUL, newline, CR, tab, and the rest of the
64
+ # C0/C1 range) before any normalization: a newline in a path is never a
65
+ # legitimate target and would otherwise smuggle through classification and
66
+ # audit records.
67
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw):
68
+ return None
69
+ if raw.startswith("/") or (len(raw) >= 2 and raw[1] == ":"):
70
+ return None
71
+ parts = raw.replace("\\", "/").split("/")
72
+ if any(p == "" or p == "." or p == ".." for p in parts):
73
+ return None
74
+ if any(p in STRUCTURALLY_EXCLUDED for p in parts):
75
+ return None
76
+ return str(PurePosixPath(*parts))
77
+
78
+
79
+ # Back-compat alias for the previous private name; both point at the same guard.
80
+ _normalize_target_path = normalize_target_path
81
+
82
+
83
+ def is_writable_path(target_path: str) -> bool:
84
+ """Structural rule. Independent of policy blocklist.
85
+
86
+ Callers that go on to write MUST first resolve the canonical form with
87
+ :func:`normalize_target_path` and operate on that; this predicate only
88
+ answers whether the canonical form is a writable indexed ``.md`` path.
89
+ """
90
+ canonical = normalize_target_path(target_path)
91
+ if canonical is None:
92
+ return False
93
+ if not canonical.endswith(".md"):
94
+ return False
95
+ return any(canonical.startswith(p) for p in indexed_prefixes())
96
+
97
+
98
+ def safe_join_under_root(root: str, target_path: str) -> str | None:
99
+ """Join ``target_path`` under ``root`` and return the absolute path only when
100
+ it resolves to exactly that lexical location inside ``root``; else return None.
101
+
102
+ Two checks, both required:
103
+
104
+ 1. The realpath-resolved location is strictly inside ``root``. This rejects
105
+ traversal and symlinks that point *outside* the worktree (e.g. a malicious
106
+ ``memory/inbox`` symlinked to ``/etc``).
107
+ 2. The resolved location equals the lexical join of ``root`` and
108
+ ``target_path``. This additionally rejects a symlink component that
109
+ redirects to *another in-root path*: without it, an allowed lexical
110
+ ``target_path`` (which is what gets classified, blocklist-checked, audited,
111
+ and ``git add``-ed) could land its bytes on a different file, decoupling
112
+ the policy decision from the filesystem effect.
113
+
114
+ The returned value is the *unresolved* join so callers keep writing to the
115
+ in-tree path and ``git add`` the relative ``target_path`` exactly as before.
116
+ This is the single shared containment guard used by every write path (memory
117
+ propose, edit, resolve, bootstrap).
118
+ """
119
+ root_real = os.path.realpath(root)
120
+ full = os.path.join(root, target_path)
121
+ real = os.path.realpath(full)
122
+ expected = os.path.normpath(os.path.join(root_real, target_path))
123
+ inside = real != root_real and real.startswith(root_real + os.sep)
124
+ if inside and real == expected:
125
+ return full
126
+ return None
127
+
128
+
129
+ class PathBlocklist:
130
+ """Operator-configured per-tier + per-path blocklist. Both default empty."""
131
+
132
+ def __init__(self, tier_blocks: list[str], path_blocks: list[str]) -> None:
133
+ self._tier_blocks = {t.strip() for t in tier_blocks if t.strip()}
134
+ self._path_blocks = [p.strip() for p in path_blocks if p.strip()]
135
+
136
+ def blocks(self, target_path: str, target_tier: str) -> bool:
137
+ if target_tier in self._tier_blocks:
138
+ return True
139
+ return any(fnmatch.fnmatch(target_path, p) for p in self._path_blocks)
@@ -0,0 +1 @@
1
+ """data-olympus local-bundle CLI (lint, index, visualize)."""
@@ -0,0 +1,115 @@
1
+ """`data-olympus import`: migrate an existing rule corpus into a draft bundle.
2
+
3
+ Wires the CLI flags to ``data_olympus.importer.run_import`` and renders the
4
+ import report either human-readably or as JSON (``--json``). Exit codes:
5
+ - 0: import succeeded and the output is lint-clean.
6
+ - 1: import succeeded but the output has lint errors (should not happen on the
7
+ happy path; surfaces a stamping regression instead of silently passing).
8
+ - 2: bad input or a refused re-run (ImportError_).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from typing import TYPE_CHECKING
16
+
17
+ from data_olympus.importer import ImportError_, run_import
18
+
19
+ if TYPE_CHECKING:
20
+ import argparse
21
+
22
+ from data_olympus.importer.model import ImportReport
23
+
24
+
25
+ def _render_human(report: ImportReport) -> str:
26
+ lines: list[str] = []
27
+ lines.append(f"Imported {report.kind} from {report.source}")
28
+ lines.append(f" output: {report.out_dir}")
29
+ lines.append(f" created {len(report.created)} draft(s):")
30
+ for name in report.created:
31
+ lines.append(f" + {name}")
32
+ if report.skipped:
33
+ lines.append(f" skipped {len(report.skipped)} section(s) (too short):")
34
+ for s in report.skipped:
35
+ lines.append(f" - {s.heading}: {s.reason}")
36
+ if report.inferences:
37
+ lines.append(f" inferences ({len(report.inferences)}):")
38
+ for note in report.inferences:
39
+ lines.append(f" * {note}")
40
+ if report.needs_review:
41
+ lines.append(f" needs human review ({len(report.needs_review)}):")
42
+ for note in report.needs_review:
43
+ lines.append(f" ! {note}")
44
+ if report.lint:
45
+ lines.append(f" lint findings ({len(report.lint)}):")
46
+ for f in report.lint:
47
+ lines.append(f" [{f.severity}] {f.path}: {f.field}: {f.message}")
48
+ lines.append(f" lint clean: {report.lint_clean}")
49
+ else:
50
+ lines.append(" lint: clean (no findings)")
51
+ lines.append(" next steps:")
52
+ for step in report.next_steps:
53
+ lines.append(f" - {step}")
54
+ return "\n".join(lines)
55
+
56
+
57
+ def _cmd_import(args: argparse.Namespace) -> int:
58
+ try:
59
+ report = run_import(
60
+ source=args.source,
61
+ kind=args.kind,
62
+ tier=args.tier,
63
+ out=args.out,
64
+ category=args.category,
65
+ id_prefix=args.id_prefix,
66
+ force=args.force,
67
+ )
68
+ except ImportError_ as exc:
69
+ print(f"error: {exc}", file=sys.stderr)
70
+ return 2
71
+
72
+ if args.json:
73
+ print(json.dumps(report.to_dict()))
74
+ else:
75
+ print(_render_human(report))
76
+
77
+ return 0 if report.lint_clean else 1
78
+
79
+
80
+ def add_import_subparser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
81
+ """Register the ``import`` subcommand on the CLI's subparser action."""
82
+ p = sub.add_parser(
83
+ "import",
84
+ help="migrate an existing rule corpus (CLAUDE.md/AGENTS.md/.cursorrules/ADR/OKF) "
85
+ "into a governed draft bundle",
86
+ )
87
+ p.add_argument("source", help="path to the source file (flat rule file) or directory (ADR/OKF)")
88
+ p.add_argument(
89
+ "--kind",
90
+ required=True,
91
+ choices=["claude-md", "agents-md", "gemini-md", "cursorrules", "adr", "okf"],
92
+ help="source corpus kind",
93
+ )
94
+ p.add_argument(
95
+ "--tier",
96
+ required=True,
97
+ help="governance tier for the imported drafts (T1|T2|T3|T4|meta, or a bare digit)",
98
+ )
99
+ p.add_argument(
100
+ "-o", "--out", default=None,
101
+ help="output bundle directory (default: <source>/imported-<kind>). Refuses to write "
102
+ "into a dir that already holds governed files unless --force is given.",
103
+ )
104
+ p.add_argument("--category", default=None, help="optional category stamped on every draft")
105
+ p.add_argument(
106
+ "--id-prefix", default=None,
107
+ help="id prefix for generated ids (default: per-kind, e.g. CLAUDE/AGENTS/OKF; "
108
+ "ADR imports always use ADR-NNNN)",
109
+ )
110
+ p.add_argument(
111
+ "--force", action="store_true",
112
+ help="overwrite an output dir that was already used as an import target",
113
+ )
114
+ p.add_argument("--json", action="store_true", help="emit the import report as JSON")
115
+ p.set_defaults(func=_cmd_import)
@@ -0,0 +1,60 @@
1
+ """Regenerate index.md files for progressive disclosure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from data_olympus.format import Document
8
+ from data_olympus.format.validate import RESERVED as _RESERVED
9
+
10
+ _SKIP = {".git", "__pycache__", ".venv", ".pytest_cache", ".ruff_cache", "node_modules"}
11
+
12
+
13
+ def _dirs_with_markdown(root: Path) -> list[Path]:
14
+ dirs: set[Path] = set()
15
+ for md in root.rglob("*.md"):
16
+ if any(part in _SKIP for part in md.parts):
17
+ continue
18
+ if md.name in _RESERVED:
19
+ continue
20
+ dirs.add(md.parent)
21
+ return sorted(dirs)
22
+
23
+
24
+ def _entry(doc_path: Path) -> tuple[str, str, str]:
25
+ """Return (title, relative_url, description) for a concept file."""
26
+ doc = Document.load(doc_path)
27
+ title = doc.frontmatter.get("title") or doc_path.stem
28
+ desc = doc.frontmatter.get("description") or ""
29
+ return str(title), doc_path.name, str(desc)
30
+
31
+
32
+ def regenerate_indexes(root: Path) -> list[Path]:
33
+ """Write an index.md into every directory that holds concept docs.
34
+ Returns the list of index.md paths written."""
35
+ root = Path(root)
36
+ written: list[Path] = []
37
+ for d in _dirs_with_markdown(root):
38
+ concepts = sorted(p for p in d.glob("*.md") if p.name not in _RESERVED)
39
+ subdirs = sorted(
40
+ sub
41
+ for sub in d.iterdir()
42
+ if sub.is_dir() and sub.name not in _SKIP and any(sub.rglob("*.md"))
43
+ )
44
+ lines: list[str] = [f"# {d.name or root.name}", ""]
45
+ if concepts:
46
+ lines.append("# Concepts")
47
+ for c in concepts:
48
+ title, url, desc = _entry(c)
49
+ suffix = f" - {desc}" if desc else ""
50
+ lines.append(f"* [{title}]({url}){suffix}")
51
+ lines.append("")
52
+ if subdirs:
53
+ lines.append("# Subdirectories")
54
+ for sub in subdirs:
55
+ lines.append(f"* [{sub.name}/]({sub.name}/)")
56
+ lines.append("")
57
+ idx_path = d / "index.md"
58
+ idx_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
59
+ written.append(idx_path)
60
+ return written