org-knowledge-layer 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.
Files changed (67) hide show
  1. okl/__init__.py +12 -0
  2. okl/__main__.py +8 -0
  3. okl/bootstrap.py +83 -0
  4. okl/cli.py +484 -0
  5. okl/client.py +160 -0
  6. okl/core.py +223 -0
  7. okl/drift.py +119 -0
  8. okl/mcp_server.py +75 -0
  9. okl/scaffold/MANIFEST.md +59 -0
  10. okl/scaffold/ci/method-gates.yml +32 -0
  11. okl/scaffold/ci/okl-verify.yml +59 -0
  12. okl/scaffold/claude/agents/architecture-reviewer.md +41 -0
  13. okl/scaffold/claude/commands/check-rules.md +24 -0
  14. okl/scaffold/claude/commands/feature-spec.md +37 -0
  15. okl/scaffold/claude/rules/example-area.md +22 -0
  16. okl/scaffold/claude/skills/RECOMMENDED-COMPANIONS.md +40 -0
  17. okl/scaffold/claude/skills/encoding-loop/SKILL.md +48 -0
  18. okl/scaffold/claude/skills/verify-before-claiming/SKILL.md +56 -0
  19. okl/scaffold/evals/README.md +32 -0
  20. okl/scaffold/evals/cases.jsonl +1 -0
  21. okl/scaffold/evals/run_evals.py +109 -0
  22. okl/scaffold/gates/check-canon-size.sh +11 -0
  23. okl/scaffold/gates/check-doc-orphans.sh +19 -0
  24. okl/scaffold/gates/check-retractions.sh +22 -0
  25. okl/scaffold/gates/check-tombstones.sh +22 -0
  26. okl/scaffold/gates/run-gates.sh +31 -0
  27. okl/scaffold/hooks/hooks.json +16 -0
  28. okl/scaffold/hooks/stop-okl-encode.sh +78 -0
  29. okl/scaffold/hooks/userpromptsubmit-okl-check.sh +68 -0
  30. okl/scaffold/plugin/plugin.json +10 -0
  31. okl/scaffold/profiles/dotnet/README.md +12 -0
  32. okl/scaffold/profiles/dotnet/rules/architecture.md +55 -0
  33. okl/scaffold/profiles/dotnet/rules/messaging.md +31 -0
  34. okl/scaffold/profiles/dotnet/rules/performance-and-data.md +36 -0
  35. okl/scaffold/profiles/dotnet/rules/security.md +42 -0
  36. okl/scaffold/profiles/geospatial/README.md +6 -0
  37. okl/scaffold/profiles/geospatial/rules/geospatial-ml.md +38 -0
  38. okl/scaffold/profiles/python-rag/README.md +13 -0
  39. okl/scaffold/profiles/python-rag/rules/fastapi-backend.md +37 -0
  40. okl/scaffold/profiles/python-rag/rules/project-structure.md +28 -0
  41. okl/scaffold/profiles/python-rag/rules/rag-pipeline.md +73 -0
  42. okl/scaffold/profiles/react/README.md +18 -0
  43. okl/scaffold/profiles/react/rules/frontend.md +57 -0
  44. okl/scaffold/registries/RETRACTIONS.md +19 -0
  45. okl/scaffold/registries/tombstones.txt +7 -0
  46. okl/scaffold/root/CLAUDE.md +55 -0
  47. okl/scaffold/root/METHOD.md +64 -0
  48. okl/scaffold_cmd.py +110 -0
  49. okl/seed/dotnet-canon.json +489 -0
  50. okl/seed/dotnet-decisions.json +328 -0
  51. okl/seed/dotnet-defects.json +133 -0
  52. okl/seed/dotnet-review-surfaces.json +147 -0
  53. okl/seed/frontend-canon.json +116 -0
  54. okl/seed/geospatial-deeptime-defects.json +59 -0
  55. okl/seed/geospatial-defects.json +154 -0
  56. okl/seed/geospatial-enforcement-defects.json +121 -0
  57. okl/seed/geospatial-eval-defects.json +25 -0
  58. okl/seed/rag-defects.json +120 -0
  59. okl/seed/react-defects.json +45 -0
  60. okl/seed.py +55 -0
  61. okl/service.py +137 -0
  62. okl/store.py +432 -0
  63. org_knowledge_layer-0.1.0.dist-info/METADATA +475 -0
  64. org_knowledge_layer-0.1.0.dist-info/RECORD +67 -0
  65. org_knowledge_layer-0.1.0.dist-info/WHEEL +4 -0
  66. org_knowledge_layer-0.1.0.dist-info/entry_points.txt +2 -0
  67. org_knowledge_layer-0.1.0.dist-info/licenses/LICENSE +21 -0
okl/client.py ADDED
@@ -0,0 +1,160 @@
1
+ """Client resolution: talk to a REMOTE service if configured, else a LOCAL store.
2
+
3
+ `okl connect <url>` writes the service URL into .okl/config.json. When a remote
4
+ is configured every operation is an HTTP call; otherwise it falls back to a
5
+ local store file (single-machine mode). This is what lets the same CLI work
6
+ before you've deployed the shared service and after.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib import request as _req
15
+ from urllib.error import HTTPError, URLError
16
+
17
+ from . import core
18
+ from .store import Store
19
+
20
+ CONFIG_DIR = ".okl"
21
+ CONFIG_FILE = "config.json"
22
+
23
+
24
+ def _find_config(start: Path | None = None) -> Path | None:
25
+ p = (start or Path.cwd()).resolve()
26
+ for d in [p, *p.parents]:
27
+ cfg = d / CONFIG_DIR / CONFIG_FILE
28
+ if cfg.exists():
29
+ return cfg
30
+ return None
31
+
32
+
33
+ def load_config() -> dict[str, Any]:
34
+ cfg = _find_config()
35
+ if cfg:
36
+ return json.loads(cfg.read_text())
37
+ return {}
38
+
39
+
40
+ def save_config(data: dict[str, Any], root: Path | None = None) -> Path:
41
+ d = (root or Path.cwd()) / CONFIG_DIR
42
+ d.mkdir(parents=True, exist_ok=True)
43
+ path = d / CONFIG_FILE
44
+ path.write_text(json.dumps(data, indent=2) + "\n")
45
+ return path
46
+
47
+
48
+ class Client:
49
+ """Uniform surface over local-store and remote-service modes."""
50
+
51
+ def __init__(self, config: dict[str, Any] | None = None):
52
+ self.config = config if config is not None else load_config()
53
+ self.service_url = os.environ.get("OKL_SERVICE_URL") or self.config.get("service_url")
54
+ self.repo = self.config.get("repo") or Path.cwd().name
55
+ self.interests = self.config.get("interests") or []
56
+ self._store: Store | None = None
57
+
58
+ @property
59
+ def mode(self) -> str:
60
+ return "remote" if self.service_url else "local"
61
+
62
+ def _local_store(self) -> Store:
63
+ if self._store is None:
64
+ # local store lives next to the config, or ./okl.db
65
+ cfg = _find_config()
66
+ db = (cfg.parent / "okl.db") if cfg else Path("okl.db")
67
+ self._store = Store(f"sqlite:///{db}")
68
+ return self._store
69
+
70
+ # -- HTTP helper (stdlib only; fails CLOSED — raises, never returns empty) --
71
+ def _post(self, path: str, payload: dict) -> dict:
72
+ url = self.service_url.rstrip("/") + path
73
+ data = json.dumps(payload).encode()
74
+ req = _req.Request(url, data=data, headers={"Content-Type": "application/json"})
75
+ token = os.environ.get("OKL_TOKEN") or self.config.get("token")
76
+ if token:
77
+ req.add_header("Authorization", f"Bearer {token}")
78
+ try:
79
+ with _req.urlopen(req, timeout=10) as resp:
80
+ return json.loads(resp.read())
81
+ except HTTPError as e:
82
+ # The service answered — so this is NOT "unreachable". A 4xx is the caller's
83
+ # error and must surface its detail (found by E2E: an unknown-tag 400 was
84
+ # reported to the agent as an outage).
85
+ try:
86
+ detail = json.loads(e.read()).get("detail", "")
87
+ except Exception: # noqa: BLE001
88
+ detail = ""
89
+ if 400 <= e.code < 500:
90
+ raise ValueError(f"OKL service rejected the request ({e.code}): {detail or e.reason}") from e
91
+ raise OKLUnreachable(f"OKL service error at {url}: {e.code} {detail or e.reason}") from e
92
+ except URLError as e:
93
+ raise OKLUnreachable(f"OKL service unreachable at {url}: {e}") from e
94
+
95
+ # -- operations ---------------------------------------------------------
96
+ def check(self, task: str, repo: str | None = None) -> dict:
97
+ repo = repo or self.repo
98
+ if self.mode == "remote":
99
+ return self._post("/check", {"repo": repo, "task": task,
100
+ "interests": self.interests or None})
101
+ return core.check(self._local_store(), repo, task, interests=self.interests)
102
+
103
+ def record(self, **kwargs) -> str:
104
+ # Default the repo in BOTH modes: `--scope repo` needs it to become repo:<name>,
105
+ # and the remote path used to skip this (found by E2E: 400 on every repo-scoped record).
106
+ kwargs.setdefault("repo", self.repo)
107
+ if self.mode == "remote":
108
+ return self._post("/record", kwargs)["id"]
109
+ return core.record(self._local_store(), **kwargs)
110
+
111
+ def search(self, query: str, scope: str | None = None,
112
+ node_types: list[str] | None = None, limit: int = 25) -> list[dict]:
113
+ if self.mode == "remote":
114
+ return self._post("/search", {"query": query, "scope": scope,
115
+ "node_types": node_types, "limit": limit})["results"]
116
+ return core.search(self._local_store(), query, scope, node_types, limit)
117
+
118
+ def link(self, src: str, rel: str, dst: str) -> None:
119
+ if self.mode == "remote":
120
+ self._post("/link", {"src": src, "rel": rel, "dst": dst})
121
+ return
122
+ core.link(self._local_store(), src, rel, dst)
123
+
124
+ def verify(self, node_id: str, evidence: str) -> dict:
125
+ if self.mode == "remote":
126
+ return self._post("/verify", {"id": node_id, "evidence": evidence})
127
+ return core.verify(self._local_store(), node_id, evidence)
128
+
129
+ def recurrence(self) -> list[dict]:
130
+ if self.mode == "remote":
131
+ return self._get("/metric/recurrence")["recurrence_after_arming"]
132
+ return self._local_store().recurrence_after_arming()
133
+
134
+ def all_nodes(self):
135
+ """Return all in-scope Node objects (local store, or /nodes on a remote service).
136
+
137
+ Used by the drift detector, which needs the node set locally but runs its
138
+ git lookups against the working tree.
139
+ """
140
+ from .store import Node
141
+ if self.mode == "remote":
142
+ rows = self._get("/nodes")["nodes"]
143
+ return [Node(**{k: v for k, v in r.items()
144
+ if k in Node.__dataclass_fields__}) for r in rows]
145
+ return self._local_store().all_nodes()
146
+
147
+ def _get(self, path: str) -> dict:
148
+ url = self.service_url.rstrip("/") + path
149
+ try:
150
+ with _req.urlopen(url, timeout=10) as resp:
151
+ return json.loads(resp.read())
152
+ except URLError as e:
153
+ raise OKLUnreachable(f"OKL service unreachable at {url}: {e}") from e
154
+
155
+
156
+ class OKLUnreachable(RuntimeError):
157
+ """Raised when a configured remote service can't be reached. Callers that
158
+ gate work on OKL (the pre-task hook) must FAIL CLOSED on this — the
159
+ merge-gate lesson: a check that silently returns 'nothing' is worse than
160
+ no check."""
okl/core.py ADDED
@@ -0,0 +1,223 @@
1
+ """The three operations, independent of transport (CLI / HTTP / MCP all call these).
2
+
3
+ check(repo, task) -> the pre-task neighborhood: armed gates, live retractions,
4
+ in-scope tombstones, THREAT prior-art, vocabulary, stale warnings.
5
+ This is the load-bearing read (design doc §3.4). Fails CLOSED
6
+ at the transport layer, never here.
7
+ record(node, scope) -> the org-scope arm of the encoding response (§3.3).
8
+ search(query, ...) -> targeted retrieval (progressive disclosure).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict
13
+ from typing import Any
14
+
15
+ from .store import Edge, Node, Store, _now_ms, split_tags
16
+
17
+
18
+ def _node_public(n: Node) -> dict[str, Any]:
19
+ d = asdict(n)
20
+ d["stale"] = n.is_stale()
21
+ return d
22
+
23
+
24
+ def _in_scope(n: Node, repo_scope: str, interests: list[str] | None) -> bool:
25
+ """Scope answers "who may see this"; tags answer "what is this about".
26
+ The repo's own nodes and untagged nodes always pass — declared interests
27
+ only drop org nodes tagged entirely outside them."""
28
+ if n.scope == repo_scope:
29
+ return True
30
+ if n.scope != "org":
31
+ return False
32
+ if not interests:
33
+ return True
34
+ tags = split_tags(n.tags)
35
+ return not tags or bool(tags & {t.strip().lower() for t in interests if t.strip()})
36
+
37
+
38
+ def check(store: Store, repo: str, task: str, limit: int = 12,
39
+ interests: list[str] | None = None) -> dict[str, Any]:
40
+ """Return the slice of the encoded body relevant to starting `task` in `repo`.
41
+
42
+ Matches on the task text across all node types, then buckets by type so the
43
+ agent gets an actionable briefing rather than a flat list. Org-scope nodes
44
+ and this repo's own nodes are both in scope; other repos' repo-scope nodes
45
+ are not (that is the curation boundary, §"repo vs org scope").
46
+
47
+ `interests` is the repo's declared subject-tag list (.okl/config.json). When
48
+ set, org-scope nodes tagged entirely OUTSIDE it are dropped: scope answers
49
+ "who may see this", tags answer "what is this about". Untagged nodes and the
50
+ repo's own nodes always pass — declaring interests must never hide them.
51
+ """
52
+ repo_scope = f"repo:{repo}"
53
+ hits = [n for n in store.search(task, limit=limit * 3)
54
+ if _in_scope(n, repo_scope, interests)]
55
+
56
+ buckets: dict[str, list[dict]] = {
57
+ "armed_gates": [], "relevant_defects": [], "live_retractions": [],
58
+ "in_scope_tombstones": [], "threat_prior_art": [], "rules": [],
59
+ "vocabulary": [], "stale_warnings": [],
60
+ }
61
+ for n in hits:
62
+ pub = _node_public(n)
63
+ if n.is_stale():
64
+ buckets["stale_warnings"].append(pub)
65
+ if n.type == "Gate":
66
+ buckets["armed_gates"].append(pub)
67
+ elif n.type == "Defect":
68
+ buckets["relevant_defects"].append(pub)
69
+ elif n.type == "Retraction" or (n.type == "Claim" and n.status == "retracted"):
70
+ buckets["live_retractions"].append(pub)
71
+ elif n.type == "Tombstone":
72
+ buckets["in_scope_tombstones"].append(pub)
73
+ elif n.type == "PriorArt" and (n.status == "live" or (n.body and "THREAT" in n.body)):
74
+ buckets["threat_prior_art"].append(pub)
75
+ elif n.type == "Rule":
76
+ buckets["rules"].append(pub)
77
+ elif n.type == "Vocabulary":
78
+ buckets["vocabulary"].append(pub)
79
+
80
+ # For each armed gate, pull the defect it CATCHES so the briefing says *why*.
81
+ for g in buckets["armed_gates"]:
82
+ why = {n.title for (e, n) in store.neighbors(g["id"], rels=["CATCHES"])
83
+ if n.type == "Defect"}
84
+ g["catches"] = sorted(why)
85
+
86
+ # Router (Codified Context §3.1.1 `suggest_agent`): turn the matched nodes into an
87
+ # explicit, ordered action list so the agent gets "do this" not just "here's context".
88
+ actions: list[dict] = []
89
+ for g in buckets["armed_gates"]:
90
+ actions.append({"kind": "arm_gate", "target": g["title"],
91
+ "why": g.get("catches") or None,
92
+ "how": g.get("fix") or "run this gate before you finish the task"})
93
+ for d in buckets["relevant_defects"] + buckets["rules"]:
94
+ if d.get("fix"):
95
+ actions.append({"kind": "apply_fix", "target": d["title"],
96
+ "symptom": d.get("symptom"), "how": d["fix"]})
97
+ for r in buckets["live_retractions"]:
98
+ actions.append({"kind": "avoid_retracted", "target": r["title"],
99
+ "how": "do not restate this as fact; it was retracted"})
100
+ for t in buckets["in_scope_tombstones"]:
101
+ actions.append({"kind": "avoid_identifier", "target": t["title"],
102
+ "how": "do not reintroduce this retired identifier"})
103
+
104
+ total = sum(len(v) for k, v in buckets.items() if k != "stale_warnings")
105
+ return {"repo": repo, "task": task, "match_count": total,
106
+ "next_actions": actions, **buckets}
107
+
108
+
109
+ def record(store: Store, *, type: str, title: str, scope: str, repo: str | None = None,
110
+ body: str | None = None, status: str | None = None, found_by: str | None = None,
111
+ ttl_days: int | None = None, owner: str | None = None,
112
+ files: str | None = None, symptom: str | None = None, fix: str | None = None,
113
+ tags: str | None = None, verified: bool = False, id: str | None = None) -> str:
114
+ """Create a node. `scope` is 'org' (propagates to all repos) or 'repo:<name>'.
115
+
116
+ The scope decision is the curation gate: only world-facts (prior art, API
117
+ contracts, data-source gotchas, vocabulary) should be 'org'. Repo-specific
118
+ quirks stay 'repo:<name>' and never leak into another repo's `check`.
119
+
120
+ `files` is a comma-separated list of path globs the node governs; setting it
121
+ enrolls the node in the source-vs-spec drift detector (see drift.py).
122
+ `symptom`/`fix` populate the Symptom→Cause→Fix schema (cause lives in `body`)
123
+ so `check` can surface "if you see X, it's Y, do Z" instead of prose.
124
+ `tags` is a comma-separated subject list from the controlled vocabulary
125
+ (store.KNOWN_TAGS); repos declare interest tags so `check` can filter by subject.
126
+ """
127
+ if scope == "repo" and repo:
128
+ scope = f"repo:{repo}"
129
+ kw = dict(
130
+ type=type, title=title, scope=scope, repo=repo, body=body, status=status,
131
+ found_by=found_by, ttl_days=ttl_days, owner=owner,
132
+ files=files, symptom=symptom, fix=fix, tags=tags,
133
+ verified_at=_now_ms() if verified else None,
134
+ )
135
+ # An explicit id makes the write idempotent (upsert replaces the same row);
136
+ # omit it and the store mints a fresh random id (a genuinely new node).
137
+ if id is not None:
138
+ kw["id"] = id
139
+ n = Node(**kw)
140
+ return store.add_node(n)
141
+
142
+
143
+ def link(store: Store, src: str, rel: str, dst: str) -> None:
144
+ store.add_edge(Edge(src=src, rel=rel, dst=dst))
145
+
146
+
147
+ def verify(store: Store, node_id: str, evidence: str) -> dict[str, Any]:
148
+ """Stamp a node verified from an OBSERVED check — never from assertion.
149
+
150
+ `evidence` names the check that passed (the command + when). This is the
151
+ store-side half of the verify-before-claiming rule: callers (the CLI, CI)
152
+ must actually run the check first; this function just refuses to stamp
153
+ without an evidence string and records it as the audit trail.
154
+ """
155
+ if not evidence or not evidence.strip():
156
+ raise ValueError("refusing to stamp verification without evidence — run a check and pass it")
157
+ n = store.get_node(node_id)
158
+ if n is None:
159
+ raise ValueError(f"no node with id {node_id!r}")
160
+ n.verified_at = _now_ms()
161
+ n.verified_by = evidence.strip()
162
+ store.add_node(n)
163
+ return _node_public(n)
164
+
165
+
166
+ def search(store: Store, query: str, scope: str | None = None,
167
+ node_types: list[str] | None = None, limit: int = 25) -> list[dict]:
168
+ return [_node_public(n) for n in store.search(query, scope, node_types, limit)]
169
+
170
+
171
+ def render_check_for_agent(result: dict[str, Any]) -> str:
172
+ """Format a check() result as compact markdown for injection into agent context."""
173
+ lines = [f"## OKL briefing — {result['repo']} · task: {result['task']}",
174
+ f"_{result['match_count']} relevant node(s) from the org's encoded body._", ""]
175
+
176
+ # Router first: the ordered "do this" list, so the agent acts, not just reads.
177
+ actions = result.get("next_actions") or []
178
+ if actions:
179
+ lines.append("### ✅ Do this (routed actions)")
180
+ verb = {"arm_gate": "ARM", "apply_fix": "FIX", "avoid_retracted": "AVOID",
181
+ "avoid_identifier": "AVOID"}
182
+ for a in actions:
183
+ v = verb.get(a["kind"], "DO")
184
+ sym = f" — when you see: {a['symptom']}" if a.get("symptom") else ""
185
+ lines.append(f"- **{v}: {a['target']}**{sym}")
186
+ lines.append(f" → {a['how']}")
187
+ lines.append("")
188
+
189
+ order = [
190
+ ("armed_gates", "🔒 Armed gates — adopt before you start"),
191
+ ("relevant_defects", "⚠️ Past defects in this area"),
192
+ ("live_retractions", "🚫 Live retractions — do not restate as fact"),
193
+ ("in_scope_tombstones", "⛔ Retired identifiers — do not resurrect"),
194
+ ("threat_prior_art", "📄 Prior art (THREAT) — novelty already claimed"),
195
+ ("rules", "📐 Encoded rules"),
196
+ ("vocabulary", "📖 Vocabulary"),
197
+ ]
198
+ any_hit = False
199
+ for key, header in order:
200
+ items = result.get(key) or []
201
+ if not items:
202
+ continue
203
+ any_hit = True
204
+ lines.append(f"### {header}")
205
+ for it in items:
206
+ suffix = ""
207
+ if key == "armed_gates" and it.get("catches"):
208
+ suffix = f" ← catches: {', '.join(it['catches'])}"
209
+ tag = " *(STALE — re-verify)*" if it.get("stale") else ""
210
+ lines.append(f"- **{it['title']}**{tag}{suffix}")
211
+ if it.get("symptom"):
212
+ lines.append(f" symptom: {it['symptom'][:160]}")
213
+ if it.get("body"):
214
+ lines.append(f" cause: {it['body'][:200]}" if it.get("symptom") else f" {it['body'][:200]}")
215
+ if it.get("fix"):
216
+ lines.append(f" fix: {it['fix'][:200]}")
217
+ lines.append("")
218
+ if result.get("stale_warnings"):
219
+ lines.append(f"> {len(result['stale_warnings'])} node(s) are past TTL and shown demoted — re-verify before trusting.")
220
+ if not any_hit:
221
+ lines.append("_No encoded lessons matched this task. Proceeding with a clean slate — "
222
+ "record anything you learn with `okl record`._")
223
+ return "\n".join(lines)
okl/drift.py ADDED
@@ -0,0 +1,119 @@
1
+ """Source-vs-spec drift detection.
2
+
3
+ The problem (Codified Context, arXiv 2602.20478 §5.2 — their *primary* failure mode):
4
+ an encoded rule points at code, the code changes, the rule is never revisited, and the
5
+ agent is now primed with a stale mental model. OKL already catches two drift classes —
6
+ doc-orphans (a doc nobody links) and resurrected tombstones (a retired id reused) — but
7
+ nothing catches "the code this rule governs moved after the rule was last verified."
8
+
9
+ This closes that gap. A node may declare `files` (comma-separated path globs it governs).
10
+ For each such node we ask git for the last commit that touched any matching path; if that
11
+ commit is newer than the node's `verified_at` (or the node was never verified), the node
12
+ has *drifted* — its source changed under it and a human should re-verify it.
13
+
14
+ Unlike the passive TTL clock (store.Node.is_stale), this is event-driven: it fires exactly
15
+ when the governed code moves, not on a fixed schedule.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import subprocess
20
+ from collections.abc import Iterable
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ from .store import Node
25
+
26
+
27
+ @dataclass
28
+ class DriftHit:
29
+ node_id: str
30
+ title: str
31
+ scope: str
32
+ files: str
33
+ last_change_ms: int # newest governed-file commit, epoch ms
34
+ verified_at: int | None # node's last verification, epoch ms (None = never)
35
+ reason: str
36
+
37
+ def as_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "node_id": self.node_id, "title": self.title, "scope": self.scope,
40
+ "files": self.files, "last_change_ms": self.last_change_ms,
41
+ "verified_at": self.verified_at, "reason": self.reason,
42
+ }
43
+
44
+
45
+ def _git_last_change_ms(globs: list[str], repo_dir: str) -> int | None:
46
+ """Epoch-ms of the most recent commit touching any path matching `globs`.
47
+
48
+ Uses `git log -1 --format=%ct -- <pathspec...>`. Returns None if git is
49
+ unavailable, the dir isn't a repo, or no commit ever touched those paths.
50
+ """
51
+ pathspecs = [g.strip() for g in globs if g.strip()]
52
+ if not pathspecs:
53
+ return None
54
+ try:
55
+ out = subprocess.run(
56
+ ["git", "-C", repo_dir, "log", "-1", "--format=%ct", "--", *pathspecs],
57
+ capture_output=True, text=True, timeout=15,
58
+ )
59
+ except (FileNotFoundError, subprocess.TimeoutExpired):
60
+ return None
61
+ if out.returncode != 0:
62
+ return None
63
+ ts = out.stdout.strip()
64
+ if not ts:
65
+ return None
66
+ try:
67
+ return int(ts) * 1000 # git %ct is epoch *seconds*
68
+ except ValueError:
69
+ return None
70
+
71
+
72
+ def detect_drift(nodes: Iterable[Node], repo: str, repo_dir: str = ".") -> list[DriftHit]:
73
+ """Return nodes whose governed source changed after they were last verified.
74
+
75
+ `nodes` is any iterable of Node (Client.all_nodes() in local OR remote mode) —
76
+ drift is mode-agnostic on the node side; only the git lookup is local to `repo_dir`.
77
+ In scope: org nodes and this repo's own nodes (the same curation boundary as check()).
78
+ Only nodes with a non-empty `files` glob list participate.
79
+ """
80
+ repo_scope = f"repo:{repo}"
81
+ hits: list[DriftHit] = []
82
+ for n in nodes:
83
+ if not (n.scope == "org" or n.scope == repo_scope):
84
+ continue
85
+ if not n.files:
86
+ continue
87
+ globs = [g for g in n.files.split(",") if g.strip()]
88
+ last = _git_last_change_ms(globs, repo_dir)
89
+ if last is None:
90
+ continue # git couldn't attribute a change — not evidence of drift
91
+ base = n.verified_at
92
+ if base is None:
93
+ hits.append(DriftHit(
94
+ n.id, n.title, n.scope, n.files, last, None,
95
+ "governed source has commits but the rule was never verified",
96
+ ))
97
+ elif last > base:
98
+ hits.append(DriftHit(
99
+ n.id, n.title, n.scope, n.files, last, base,
100
+ "governed source changed after the rule was last verified",
101
+ ))
102
+ return hits
103
+
104
+
105
+ def render_drift(hits: list[DriftHit]) -> str:
106
+ if not hits:
107
+ return "OKL drift: OK — no encoded rule's governed source changed after its last verification."
108
+ lines = [f"OKL drift: {len(hits)} rule(s) may be stale (source changed after verification):", ""]
109
+ import datetime as _dt
110
+ for h in hits:
111
+ when = _dt.datetime.utcfromtimestamp(h.last_change_ms / 1000).strftime("%Y-%m-%d")
112
+ ver = ("never verified" if h.verified_at is None
113
+ else "verified " + _dt.datetime.utcfromtimestamp(h.verified_at / 1000).strftime("%Y-%m-%d"))
114
+ lines.append(f" • [{h.node_id}] {h.title}")
115
+ lines.append(f" files: {h.files}")
116
+ lines.append(f" last source change: {when} · {ver} → {h.reason}")
117
+ lines.append(" fix: re-verify against current source, then `okl record --verified` to reset, "
118
+ "or update the rule.")
119
+ return "\n".join(lines)
okl/mcp_server.py ADDED
@@ -0,0 +1,75 @@
1
+ """MCP server exposing okl.check / okl.record / okl.search to coding agents.
2
+
3
+ This is how Claude Code / Cursor / Copilot call the layer as first-class tools.
4
+ It resolves through the same Client, so it works in local OR remote mode. The
5
+ check tool fails CLOSED (raises) if a configured remote is unreachable.
6
+
7
+ Requires the `mcp` package: install okl[mcp]. Run: `okl mcp` (stdio transport).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from . import core
12
+ from .client import Client, OKLUnreachable
13
+
14
+
15
+ def _build():
16
+ try:
17
+ from mcp.server.fastmcp import FastMCP
18
+ except ImportError as e: # pragma: no cover
19
+ raise RuntimeError("The MCP server needs the 'mcp' package — install okl[mcp]") from e
20
+
21
+ mcp = FastMCP("okl")
22
+ client = Client()
23
+
24
+ @mcp.tool()
25
+ def okl_check(task: str, repo: str | None = None) -> str:
26
+ """Read the org's relevant encoded lessons BEFORE starting a task.
27
+
28
+ Returns armed gates (with the defect each catches), past defects in this
29
+ area, live retractions, retired identifiers, THREAT prior-art, rules, and
30
+ vocabulary — as markdown to read before writing any code. Call this first.
31
+ """
32
+ try:
33
+ result = client.check(task, repo=repo)
34
+ except OKLUnreachable as e:
35
+ return (f"⚠️ OKL UNREACHABLE — cannot confirm a clean check ({e}). "
36
+ "Treat as: lessons may exist that you cannot see. Proceed with caution "
37
+ "and re-run once connectivity is restored.")
38
+ return core.render_check_for_agent(result)
39
+
40
+ @mcp.tool()
41
+ def okl_record(type: str, title: str, scope: str, body: str | None = None,
42
+ status: str | None = None, found_by: str | None = None,
43
+ ttl_days: int | None = None, repo: str | None = None,
44
+ symptom: str | None = None, fix: str | None = None,
45
+ files: str | None = None, tags: str | None = None) -> str:
46
+ """Record a lesson so other repos inherit it.
47
+
48
+ scope='org' for facts about the world (prior art, API contracts, data
49
+ gotchas, vocabulary) that should propagate to every repo; scope='repo'
50
+ for a quirk true only of this codebase. type is one of: Defect, Gate,
51
+ Rule, Claim, Retraction, Tombstone, Decision, PriorArt, Vocabulary, Entity.
52
+ symptom/fix make the lesson actionable ("when you see X → do Z"; cause
53
+ goes in body). files (comma-sep globs) enrolls it in drift detection.
54
+ tags (comma-sep, controlled vocabulary — e.g. react, security,
55
+ eval-integrity) categorize the subject so `check` can filter by interest.
56
+ """
57
+ node_id = client.record(type=type, title=title, scope=scope, body=body,
58
+ status=status, found_by=found_by, ttl_days=ttl_days, repo=repo,
59
+ symptom=symptom, fix=fix, files=files, tags=tags)
60
+ return f"recorded {node_id} ({type}, {scope})"
61
+
62
+ @mcp.tool()
63
+ def okl_search(query: str, scope: str | None = None, limit: int = 15) -> str:
64
+ """Search the org's encoded body for anything matching `query`."""
65
+ rows = client.search(query, scope=scope, limit=limit)
66
+ if not rows:
67
+ return "no matches."
68
+ return "\n".join(f"[{r['type']}] {r['scope']} — {r['title']}"
69
+ + (" (STALE)" if r.get("stale") else "") for r in rows)
70
+
71
+ return mcp
72
+
73
+
74
+ def run_stdio() -> None:
75
+ _build().run()
@@ -0,0 +1,59 @@
1
+ # Method kit — what's portable, what you fill
2
+
3
+ `okl scaffold` stamps this tree into a repo. Everything here is **portable skeleton** — it works in
4
+ any language/stack. The parts you complete per repo are marked inline with `<<FILL: ...>>`.
5
+
6
+ ## What gets installed where
7
+
8
+ | Template (in package) | Installed to | Portable? |
9
+ |---|---|---|
10
+ | `root/CLAUDE.md` | `CLAUDE.md` | skeleton + FILL slots for stack rules |
11
+ | `root/METHOD.md` | `METHOD.md` | fully portable (the seven earned rules) |
12
+ | `claude/skills/encoding-loop/` | `.claude/skills/encoding-loop/` | fully portable |
13
+ | `claude/agents/architecture-reviewer.md` | `.claude/agents/` | skeleton + FILL for stack checks |
14
+ | `claude/commands/feature-spec.md`, `check-rules.md` | `.claude/commands/` | portable |
15
+ | `claude/rules/example-area.md` | `.claude/rules/` | template — copy per area, set `paths:` |
16
+ | `gates/*.sh` | `gates/` | fully portable (retractions/tombstones/orphans/canon-size) |
17
+ | `registries/*` | `registries/` | portable format; FILL entries as earned |
18
+ | `evals/*` | `evals/` | portable harness; FILL `evaluate_one()` + `cases.jsonl` |
19
+ | `ci/method-gates.yml` | `.github/workflows/` | portable |
20
+ | `hooks/*` | `.claude/hooks/` (+ plugin) | portable (fail-closed pre-task check) |
21
+ | `plugin/plugin.json` | repo root (if `--plugin`) | portable — makes the whole thing a Claude Code plugin |
22
+
23
+ ## The portable / stack-specific boundary (the load-bearing decision)
24
+
25
+ - **Portable (ships as-is):** the encoding loop, the six surfaces, the seven earned rules, the
26
+ drift-gate *scripts*, the eval *invariants* (failure-count-first, no self-grading judge, cross-tab),
27
+ the fail-closed pre-task hook, the registries *format*.
28
+ - **Stack-specific (you fill):** the actual coding rules that make an agent code *your* way — framework
29
+ choices, domain constraints, pipeline conventions. These go in `.claude/rules/<area>.md` (with a
30
+ `paths:` glob) and the `<<FILL>>` slots, and are recorded to okl at `--scope repo` so they never
31
+ leak into another repo's `okl check`.
32
+
33
+ Grep for `<<FILL` after scaffolding to find every slot you still need to complete:
34
+ `grep -rn '<<FILL' .`
35
+
36
+ ## Stack profiles — real canon, not FILL slots
37
+
38
+ The `<<FILL>>` slots above are the empty path. If your repo's stack matches one the kit already knows,
39
+ skip the FILL work and stamp a **profile** — verbatim canon lifted from a real repo, dropped straight
40
+ into `.claude/rules/` as path-scoped rule files:
41
+
42
+ | `--profile` | Source repo | Rule files | Path scope |
43
+ |---|---|---|---|
44
+ | `dotnet` | the .NET platform | architecture, security, performance-and-data, messaging | `**/*.cs`, endpoints, features |
45
+ | `geospatial` | the geospatial pipeline | geospatial-ml | `**/*.py`, `**/*.yaml` |
46
+ | `python-rag` | the RAG service | rag-pipeline, fastapi-backend, project-structure | `**/*.py`, `**/main.py` |
47
+ | `react` | the .NET platform storefront | frontend | `frontend/**`, `**/frontend/**` |
48
+
49
+ **Profiles compose** — `react` is backend-agnostic, so stack it onto any backend:
50
+
51
+ ```
52
+ okl scaffold --profile dotnet --profile react # .NET + React storefront
53
+ okl scaffold --profile python-rag --profile react # FastAPI backend + React SPA
54
+ okl scaffold --profile geospatial # rslearn/OlmoEarth pipeline
55
+ ```
56
+
57
+ Each profile also writes a `_PROFILE_<name>.md` into `.claude/rules/` documenting what it installed.
58
+ Profile rules are the *static* half; the *cross-repo* half (defects, gates, retractions that move
59
+ between repos) lives in okl and surfaces via `okl check`.
@@ -0,0 +1,32 @@
1
+ # Copy into .github/workflows/method-gates.yml
2
+ # Surface 5 in CI: the mechanical drift-gates + the eval tier. Fail-closed — a red gate blocks merge.
3
+ name: method-gates
4
+
5
+ on:
6
+ pull_request:
7
+ push:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ gates:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Run drift-gates (retractions / tombstones / doc-orphans / canon-size)
16
+ run: bash gates/run-gates.sh
17
+
18
+ evals:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.13"
25
+ - name: Run eval tier (leads with its own failure count; blocks if RESULTS NOT USABLE)
26
+ env:
27
+ GENERATOR_MODEL: ${{ vars.GENERATOR_MODEL }}
28
+ JUDGE_MODEL: ${{ vars.JUDGE_MODEL }} # must differ from GENERATOR_MODEL
29
+ run: python evals/run_evals.py --fail-rate 0.20
30
+
31
+ # Optional: emit VERIFIED_ON receipts to the org knowledge layer when gates pass.
32
+ # See okl README + ci/okl-verify.yml.