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.
- okl/__init__.py +12 -0
- okl/__main__.py +8 -0
- okl/bootstrap.py +83 -0
- okl/cli.py +484 -0
- okl/client.py +160 -0
- okl/core.py +223 -0
- okl/drift.py +119 -0
- okl/mcp_server.py +75 -0
- okl/scaffold/MANIFEST.md +59 -0
- okl/scaffold/ci/method-gates.yml +32 -0
- okl/scaffold/ci/okl-verify.yml +59 -0
- okl/scaffold/claude/agents/architecture-reviewer.md +41 -0
- okl/scaffold/claude/commands/check-rules.md +24 -0
- okl/scaffold/claude/commands/feature-spec.md +37 -0
- okl/scaffold/claude/rules/example-area.md +22 -0
- okl/scaffold/claude/skills/RECOMMENDED-COMPANIONS.md +40 -0
- okl/scaffold/claude/skills/encoding-loop/SKILL.md +48 -0
- okl/scaffold/claude/skills/verify-before-claiming/SKILL.md +56 -0
- okl/scaffold/evals/README.md +32 -0
- okl/scaffold/evals/cases.jsonl +1 -0
- okl/scaffold/evals/run_evals.py +109 -0
- okl/scaffold/gates/check-canon-size.sh +11 -0
- okl/scaffold/gates/check-doc-orphans.sh +19 -0
- okl/scaffold/gates/check-retractions.sh +22 -0
- okl/scaffold/gates/check-tombstones.sh +22 -0
- okl/scaffold/gates/run-gates.sh +31 -0
- okl/scaffold/hooks/hooks.json +16 -0
- okl/scaffold/hooks/stop-okl-encode.sh +78 -0
- okl/scaffold/hooks/userpromptsubmit-okl-check.sh +68 -0
- okl/scaffold/plugin/plugin.json +10 -0
- okl/scaffold/profiles/dotnet/README.md +12 -0
- okl/scaffold/profiles/dotnet/rules/architecture.md +55 -0
- okl/scaffold/profiles/dotnet/rules/messaging.md +31 -0
- okl/scaffold/profiles/dotnet/rules/performance-and-data.md +36 -0
- okl/scaffold/profiles/dotnet/rules/security.md +42 -0
- okl/scaffold/profiles/geospatial/README.md +6 -0
- okl/scaffold/profiles/geospatial/rules/geospatial-ml.md +38 -0
- okl/scaffold/profiles/python-rag/README.md +13 -0
- okl/scaffold/profiles/python-rag/rules/fastapi-backend.md +37 -0
- okl/scaffold/profiles/python-rag/rules/project-structure.md +28 -0
- okl/scaffold/profiles/python-rag/rules/rag-pipeline.md +73 -0
- okl/scaffold/profiles/react/README.md +18 -0
- okl/scaffold/profiles/react/rules/frontend.md +57 -0
- okl/scaffold/registries/RETRACTIONS.md +19 -0
- okl/scaffold/registries/tombstones.txt +7 -0
- okl/scaffold/root/CLAUDE.md +55 -0
- okl/scaffold/root/METHOD.md +64 -0
- okl/scaffold_cmd.py +110 -0
- okl/seed/dotnet-canon.json +489 -0
- okl/seed/dotnet-decisions.json +328 -0
- okl/seed/dotnet-defects.json +133 -0
- okl/seed/dotnet-review-surfaces.json +147 -0
- okl/seed/frontend-canon.json +116 -0
- okl/seed/geospatial-deeptime-defects.json +59 -0
- okl/seed/geospatial-defects.json +154 -0
- okl/seed/geospatial-enforcement-defects.json +121 -0
- okl/seed/geospatial-eval-defects.json +25 -0
- okl/seed/rag-defects.json +120 -0
- okl/seed/react-defects.json +45 -0
- okl/seed.py +55 -0
- okl/service.py +137 -0
- okl/store.py +432 -0
- org_knowledge_layer-0.1.0.dist-info/METADATA +475 -0
- org_knowledge_layer-0.1.0.dist-info/RECORD +67 -0
- org_knowledge_layer-0.1.0.dist-info/WHEEL +4 -0
- org_knowledge_layer-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|
okl/scaffold/MANIFEST.md
ADDED
|
@@ -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.
|