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/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """okl — the Org Knowledge Layer (the sixth surface).
2
+
3
+ Install into any repo; read lessons curated by your other repos; contribute back.
4
+ See the design rationale in the-sixth-surface.md.
5
+ """
6
+ from . import core
7
+ from .client import Client, OKLUnreachable
8
+ from .store import EDGE_RELS, NODE_TYPES, Edge, Node, Store
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = ["Store", "Node", "Edge", "Client", "OKLUnreachable", "core",
12
+ "NODE_TYPES", "EDGE_RELS", "__version__"]
okl/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python3 -m okl` — the PATH-independent entry point.
2
+
3
+ Hooks run in whatever environment the agent harness spawns, which often lacks the
4
+ console-script directory on PATH; any python3 that can import okl can still run it.
5
+ """
6
+ from .cli import main
7
+
8
+ raise SystemExit(main())
okl/bootstrap.py ADDED
@@ -0,0 +1,83 @@
1
+ """Factory bootstrapping (Codified Context §5.1 — factory agents that generate each tier).
2
+
3
+ Their companion repo ships agents that generate the initial infrastructure for a new
4
+ project. OKL's analogue: instead of stamping only static templates, *read the repo's own
5
+ signals* — git history, existing docs, an existing `.claude/` — and propose a starter set
6
+ of okl nodes for a human to review and seed.
7
+
8
+ This is deliberately NOT an autonomous LLM interview at install time (the kit can't assume
9
+ network/model access when `okl init` runs). It emits a reviewable `okl-bootstrap.json` in
10
+ the same format `okl seed` consumes; the human edits it, then `okl seed okl-bootstrap.json`.
11
+ The point is to lower the empty-store cold-start, not to fabricate lessons.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ def _git(args: list[str], repo_dir: str) -> str:
21
+ try:
22
+ out = subprocess.run(["git", "-C", repo_dir, *args],
23
+ capture_output=True, text=True, timeout=20)
24
+ return out.stdout if out.returncode == 0 else ""
25
+ except (FileNotFoundError, subprocess.TimeoutExpired):
26
+ return ""
27
+
28
+
29
+ def propose_nodes(repo: str, repo_dir: str = ".") -> dict[str, Any]:
30
+ """Return a seed-format dict of PROPOSED starter nodes derived from repo signals.
31
+
32
+ Every proposed node is marked unverified and tagged in its body as a bootstrap
33
+ proposal, so a human must confirm it (and choose scope) before it becomes canon.
34
+ """
35
+ nodes: list[dict] = []
36
+
37
+ # 1) Fix/bug commits → candidate Defect nodes (the repo already told us what broke).
38
+ log = _git(["log", "--no-merges", "-n", "400", "--format=%h%x09%s"], repo_dir)
39
+ fix_kw = ("fix", "bug", "revert", "hotfix", "regression", "broke", "incorrect", "wrong")
40
+ seen = set()
41
+ for line in log.splitlines():
42
+ if "\t" not in line:
43
+ continue
44
+ sha, subj = line.split("\t", 1)
45
+ low = subj.lower()
46
+ if any(k in low for k in fix_kw):
47
+ key = subj.strip().lower()[:60]
48
+ if key in seen:
49
+ continue
50
+ seen.add(key)
51
+ nodes.append({
52
+ "key": f"boot_fix_{sha}",
53
+ "type": "Defect",
54
+ "title": subj.strip()[:120],
55
+ "scope": f"repo:{repo}",
56
+ "body": f"[BOOTSTRAP PROPOSAL from commit {sha}] Review: is this a recurring "
57
+ f"class worth encoding? If so, write the symptom/cause/fix and set scope.",
58
+ "found_by": "bootstrap: git fix-commit scan",
59
+ })
60
+ if len(nodes) >= 25:
61
+ break
62
+
63
+ # 2) Existing docs → candidate Rule/Deep-reference pointers (don't ingest text, point at it).
64
+ docs = []
65
+ for pat in ("docs", "doc"):
66
+ dd = Path(repo_dir) / pat
67
+ if dd.is_dir():
68
+ docs += [p for p in dd.rglob("*.md")]
69
+ for p in docs[:15]:
70
+ rel = p.relative_to(repo_dir)
71
+ nodes.append({
72
+ "key": f"boot_doc_{abs(hash(str(rel))) % 10**8}",
73
+ "type": "Rule",
74
+ "title": f"See {rel}",
75
+ "scope": f"repo:{repo}",
76
+ "body": "[BOOTSTRAP PROPOSAL] Existing doc — extract any durable rule and encode it, "
77
+ "or leave as a deep-reference pointer.",
78
+ "files": str(rel),
79
+ })
80
+
81
+ note = ("PROPOSED nodes from repo signals — NOT yet canon. Review titles/scope, add "
82
+ "symptom/cause/fix, delete noise, then `okl seed okl-bootstrap.json`.")
83
+ return {"_comment": note, "nodes": nodes, "edges": []}
okl/cli.py ADDED
@@ -0,0 +1,484 @@
1
+ """okl CLI — init / connect / check / record / search / seed / metric / serve.
2
+
3
+ Stdlib argparse only, so the package installs with zero required deps for the
4
+ local + client path. `serve` and `mcp` import their extras lazily.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import core
14
+ from .client import Client, OKLUnreachable, load_config, save_config
15
+
16
+
17
+ def _print_json(obj) -> None:
18
+ json.dump(obj, sys.stdout, indent=2)
19
+ sys.stdout.write("\n")
20
+
21
+
22
+ def _merge_hook_settings(claude: Path) -> bool:
23
+ """Register both okl hooks in .claude/settings.json. Idempotent: existing settings and
24
+ unrelated hooks are preserved; an already-registered okl hook is left alone. A hook
25
+ that is installed but unregistered is a surface nobody runs."""
26
+ settings_path = claude / "settings.json"
27
+ try:
28
+ settings = json.loads(settings_path.read_text()) if settings_path.exists() else {}
29
+ except json.JSONDecodeError:
30
+ print(f"! {settings_path} is not valid JSON — not touching it; register the hooks manually.")
31
+ return False
32
+ hooks_cfg = settings.setdefault("hooks", {})
33
+ changed = False
34
+ # UserPromptSubmit, not PreToolUse: only UserPromptSubmit/SessionStart stdout reaches the
35
+ # model's context. A PreToolUse briefing fires but is never read (found by E2E test).
36
+ wanted = [
37
+ ("UserPromptSubmit", None, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/userpromptsubmit-okl-check.sh'),
38
+ ("Stop", None, '"$CLAUDE_PROJECT_DIR"/.claude/hooks/stop-okl-encode.sh'),
39
+ ]
40
+ for event, matcher, command in wanted:
41
+ entries = hooks_cfg.setdefault(event, [])
42
+ if any(h.get("command", "").endswith(Path(command).name)
43
+ for e in entries for h in e.get("hooks", [])):
44
+ continue
45
+ entry: dict = {"hooks": [{"type": "command", "command": command}]}
46
+ if matcher:
47
+ entry["matcher"] = matcher
48
+ entries.append(entry)
49
+ changed = True
50
+ if changed:
51
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n")
52
+ return changed
53
+
54
+
55
+ def cmd_init(args) -> int:
56
+ """Wire the current repo so the loop runs without manual follow-up steps:
57
+ config, hooks (installed AND registered), CI verifier, MCP registration."""
58
+ import shutil
59
+ repo = args.repo or Path.cwd().name
60
+ cfg = load_config()
61
+ cfg["repo"] = repo
62
+ if args.service:
63
+ cfg["service_url"] = args.service
64
+ if args.interests:
65
+ cfg["interests"] = [t.strip().lower() for t in args.interests.split(",") if t.strip()]
66
+ # Pin how to invoke okl on THIS machine, for hooks running outside the dev shell
67
+ # (agent harnesses don't inherit venv/pipx PATH entries). Machine-local by design —
68
+ # .okl/ is gitignored; hooks fall back to PATH and `python3 -m okl` regardless.
69
+ cfg["okl_bin"] = shutil.which("okl") or f"{sys.executable} -m okl"
70
+ path = save_config(cfg)
71
+ print(f"✓ wrote {path} (repo={repo}, mode={'remote' if cfg.get('service_url') else 'local'}"
72
+ + (f", interests={','.join(cfg['interests'])}" if cfg.get("interests") else "") + ")")
73
+
74
+ claude = Path(".claude")
75
+ if claude.exists():
76
+ _install_claude_wiring(claude)
77
+ else:
78
+ print("• no .claude/ dir found — see README to wire the hook for your agent (AGENTS.md/.cursor).")
79
+ _install_ci_verifier()
80
+ return 0
81
+
82
+
83
+ def _install_claude_wiring(claude: Path) -> None:
84
+ """Install AND register the hooks: the PreToolUse check (the enforced read) and the
85
+ Stop encode reminder (the write-side catch). Scripts come from the packaged scaffold —
86
+ one canonical source, no drift. Also registers the MCP server when the extra exists."""
87
+ hooks = claude / "hooks"
88
+ hooks.mkdir(exist_ok=True)
89
+ scaffold_hooks = Path(__file__).parent / "scaffold" / "hooks"
90
+ for name, label in [("userpromptsubmit-okl-check.sh", "pre-task check hook (UserPromptSubmit)"),
91
+ ("stop-okl-encode.sh", "encode reminder (Stop hook)")]:
92
+ dst = hooks / name
93
+ dst.write_text((scaffold_hooks / name).read_text())
94
+ dst.chmod(0o755)
95
+ print(f"✓ installed {label} → {dst}")
96
+ if _merge_hook_settings(claude):
97
+ print("✓ registered both hooks in .claude/settings.json (UserPromptSubmit + Stop)")
98
+ else:
99
+ print("• hooks already registered in .claude/settings.json")
100
+ # MCP: register the okl server only if the extra is importable (a registration whose
101
+ # dependency is missing would be a broken tool, worse than none).
102
+ try:
103
+ import mcp # noqa: F401
104
+ except ImportError:
105
+ print("• MCP extra not installed — `pip install okl[mcp]` then re-run init to register the agent tools.")
106
+ return
107
+ mcp_path = Path(".mcp.json")
108
+ mcp_cfg = json.loads(mcp_path.read_text()) if mcp_path.exists() else {}
109
+ servers = mcp_cfg.setdefault("mcpServers", {})
110
+ if "okl" not in servers:
111
+ servers["okl"] = {"command": "okl", "args": ["mcp"]}
112
+ mcp_path.write_text(json.dumps(mcp_cfg, indent=2) + "\n")
113
+ print("✓ registered okl MCP server → .mcp.json (okl_check / okl_record / okl_search)")
114
+
115
+
116
+ def _install_ci_verifier() -> None:
117
+ """Install the CI verifier workflow instead of printing a copy instruction; warn
118
+ loudly when git is absent, because the drift layer is dead without history."""
119
+ if not Path(".git").exists():
120
+ print("⚠ not a git repository — the drift verifier (okl drift) and the CI gate are DISABLED")
121
+ print(" until `git init`: drift compares governed files against their last-verified commit.")
122
+ return
123
+ wf = Path(".github") / "workflows" / "okl-verify.yml"
124
+ if wf.exists():
125
+ print(f"• CI verifier already present → {wf}")
126
+ return
127
+ wf.parent.mkdir(parents=True, exist_ok=True)
128
+ src = Path(__file__).parent / "scaffold" / "ci" / "okl-verify.yml"
129
+ wf.write_text(src.read_text())
130
+ print(f"✓ installed CI verifier → {wf} (drift gate + repo gates on every PR)")
131
+
132
+
133
+ def cmd_connect(args) -> int:
134
+ cfg = load_config()
135
+ cfg["service_url"] = args.url
136
+ if args.token:
137
+ cfg["token"] = args.token
138
+ path = save_config(cfg)
139
+ print(f"✓ connected → {args.url} ({path})")
140
+ return 0
141
+
142
+
143
+ def cmd_check(args) -> int:
144
+ client = Client()
145
+ try:
146
+ result = client.check(args.task, repo=args.repo)
147
+ except OKLUnreachable as e:
148
+ # FAIL CLOSED — loud, non-zero, no reassuring empty result.
149
+ print(f"OKL UNREACHABLE — refusing to report a clean check.\n{e}", file=sys.stderr)
150
+ return 2
151
+ if args.format == "json":
152
+ _print_json(result)
153
+ else:
154
+ print(core.render_check_for_agent(result))
155
+ return 0
156
+
157
+
158
+ def cmd_record(args) -> int:
159
+ client = Client()
160
+ kwargs = dict(type=args.type, title=args.title, scope=args.scope,
161
+ body=args.body, status=args.status, found_by=args.found_by,
162
+ ttl_days=args.ttl_days, owner=args.owner, verified=args.verified,
163
+ files=args.files, symptom=args.symptom, fix=args.fix, tags=args.tags,
164
+ id=args.id)
165
+ if args.repo:
166
+ kwargs["repo"] = args.repo
167
+ node_id = client.record(**{k: v for k, v in kwargs.items() if v is not None})
168
+ print(node_id)
169
+ return 0
170
+
171
+
172
+ def cmd_link(args) -> int:
173
+ Client().link(args.src, args.rel, args.dst)
174
+ print(f"✓ {args.src} -[{args.rel}]-> {args.dst}")
175
+ return 0
176
+
177
+
178
+ def cmd_verify(args) -> int:
179
+ """Run the named check, and stamp the node verified ONLY on an observed pass.
180
+
181
+ The evidence trail (command, expect-match, timestamp) is stored on the node —
182
+ the store-side mechanization of verify-before-claiming: no run, no stamp."""
183
+ import subprocess
184
+ from datetime import datetime, timezone
185
+ r = subprocess.run(args.run, shell=True, capture_output=True, text=True,
186
+ timeout=args.timeout)
187
+ output = (r.stdout or "") + (r.stderr or "")
188
+ tail = "\n".join(output.strip().splitlines()[-5:])
189
+ if r.returncode != 0:
190
+ print(f"✗ check FAILED (exit {r.returncode}) — NOT stamping verification.\n{tail}",
191
+ file=sys.stderr)
192
+ return 1
193
+ if args.expect and args.expect not in output:
194
+ # exit 0 alone is a step grading itself — require the positive success signal
195
+ # when the caller names one (the exit-0-zero-files lesson).
196
+ print(f"✗ check exited 0 but expected signal {args.expect!r} NOT in output — NOT stamping.\n{tail}",
197
+ file=sys.stderr)
198
+ return 1
199
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")
200
+ evidence = f"`{args.run}` exit 0" + (f", matched {args.expect!r}" if args.expect else "") + f" @ {stamp}"
201
+ try:
202
+ node = Client().verify(args.node_id, evidence)
203
+ except OKLUnreachable as e:
204
+ print(f"OKL UNREACHABLE — check passed but the stamp was NOT recorded.\n{e}", file=sys.stderr)
205
+ return 2
206
+ print(f"✓ verified {node['id']} — {node['title']}\n evidence: {node['verified_by']}")
207
+ return 0
208
+
209
+
210
+ def cmd_search(args) -> int:
211
+ results = Client().search(args.query, scope=args.scope,
212
+ node_types=args.type, limit=args.limit)
213
+ if args.format == "json":
214
+ _print_json(results)
215
+ else:
216
+ for r in results:
217
+ tag = " (STALE)" if r.get("stale") else ""
218
+ print(f"[{r['type']:10}] {r['scope']:16} {r['title']}{tag}")
219
+ return 0
220
+
221
+
222
+ def cmd_metric(args) -> int:
223
+ """Recurrence-after-arming — the quantification the method says it lacks."""
224
+ try:
225
+ rows = Client().recurrence()
226
+ except OKLUnreachable as e:
227
+ print(f"OKL UNREACHABLE — cannot compute metric.\n{e}", file=sys.stderr)
228
+ return 2
229
+ if args.format == "json":
230
+ _print_json({"recurrence_after_arming": rows, "count": len(rows)})
231
+ else:
232
+ if not rows:
233
+ print("recurrence-after-arming: 0 — no known defect class has recurred where a gate should have armed. ✓")
234
+ else:
235
+ print(f"recurrence-after-arming: {len(rows)}")
236
+ for r in rows:
237
+ print(f" {r['defect_class']} recurred in {r['recurred_in']} (gate: {r['gate']})")
238
+ return 0
239
+
240
+
241
+ def cmd_drift(args) -> int:
242
+ """Source-vs-spec drift: rules whose governed code changed after last verification."""
243
+ from . import drift
244
+ client = Client()
245
+ try:
246
+ nodes = client.all_nodes()
247
+ except OKLUnreachable as e:
248
+ print(f"OKL UNREACHABLE — cannot check drift.\n{e}", file=sys.stderr)
249
+ return 2
250
+ repo = args.repo or client.repo
251
+ hits = drift.detect_drift(nodes, repo, repo_dir=args.repo_dir)
252
+ if args.format == "json":
253
+ _print_json({"drift": [h.as_dict() for h in hits], "count": len(hits)})
254
+ return 0
255
+ print(drift.render_drift(hits))
256
+ # Fail closed when asked to gate (CI): drift is a defect to surface, exit 1.
257
+ return 1 if (hits and args.gate) else 0
258
+
259
+
260
+ def cmd_coverage(args) -> int:
261
+ """Knowledge-to-code ratio — a health signal, not a target (Codified Context §4.2)."""
262
+ import subprocess
263
+ from pathlib import Path
264
+ client = Client()
265
+ try:
266
+ nodes = client.all_nodes()
267
+ except OKLUnreachable as e:
268
+ print(f"OKL UNREACHABLE — cannot compute coverage.\n{e}", file=sys.stderr)
269
+ return 2
270
+ repo = args.repo or client.repo
271
+ in_scope = [n for n in nodes if n.scope == "org" or n.scope == f"repo:{repo}"]
272
+ knowledge_lines = sum(len((n.body or "").splitlines()) + 1 for n in in_scope)
273
+ # code lines: git ls-files line count, or None if not a repo
274
+ code_lines = None
275
+ try:
276
+ files = subprocess.run(["git", "-C", args.repo_dir, "ls-files"],
277
+ capture_output=True, text=True, timeout=15)
278
+ if files.returncode == 0:
279
+ code_lines = 0
280
+ for f in files.stdout.splitlines():
281
+ fp = Path(args.repo_dir) / f
282
+ if fp.suffix.lower() in {".py",".cs",".ts",".tsx",".js",".jsx",".go",".rs",".java",".rb"}:
283
+ try:
284
+ code_lines += sum(1 for _ in fp.open("rb"))
285
+ except OSError:
286
+ pass
287
+ except (FileNotFoundError, subprocess.TimeoutExpired):
288
+ pass
289
+ ratio = (knowledge_lines / code_lines) if code_lines else None
290
+ out = {"nodes_in_scope": len(in_scope), "knowledge_lines": knowledge_lines,
291
+ "code_lines": code_lines,
292
+ "knowledge_to_code": round(ratio, 4) if ratio is not None else None}
293
+ if args.format == "json":
294
+ _print_json(out); return 0
295
+ print(f"OKL coverage for {repo}:")
296
+ print(f" encoded nodes in scope : {out['nodes_in_scope']}")
297
+ print(f" knowledge lines : {out['knowledge_lines']}")
298
+ print(f" code lines : {out['code_lines'] if out['code_lines'] is not None else '(not a git repo)'}")
299
+ if ratio is not None:
300
+ print(f" knowledge-to-code : {ratio:.1%} (health signal — a sudden spike in agent confusion "
301
+ "means a relevant node is missing or stale, not that this number is wrong)")
302
+ return 0
303
+
304
+
305
+ def cmd_bootstrap(args) -> int:
306
+ """Propose starter nodes from repo signals into a reviewable okl-bootstrap.json."""
307
+ from pathlib import Path
308
+
309
+ from . import bootstrap
310
+ repo = args.repo or Client().repo
311
+ proposal = bootstrap.propose_nodes(repo, repo_dir=args.repo_dir)
312
+ out = Path(args.out)
313
+ out.write_text(json.dumps(proposal, indent=1))
314
+ n = len(proposal["nodes"])
315
+ print(f"✓ proposed {n} starter node(s) → {out}")
316
+ print(" Review + edit (set scope, add symptom/cause/fix, delete noise), then:")
317
+ print(f" okl seed {out}")
318
+ return 0
319
+
320
+
321
+ def cmd_seed(args) -> int:
322
+ import glob
323
+ from pathlib import Path
324
+
325
+ from .seed import seed_from_file
326
+ path = args.path or str(Path(__file__).parent.parent.parent / "seed")
327
+ if path == str(Path(__file__).parent.parent.parent / "seed") and not Path(path).exists():
328
+ # installed package: seeds ship under the package dir
329
+ path = str(Path(__file__).parent / "seed")
330
+ targets = (sorted(glob.glob(str(Path(path) / "*-defects.json")))
331
+ if Path(path).is_dir() else [path])
332
+ if not targets:
333
+ print(f"no *-defects.json found under {path}")
334
+ return 1
335
+ client, total = Client(), 0
336
+ for t in targets:
337
+ n = seed_from_file(client, t)
338
+ total += n
339
+ print(f" ✓ {n} node(s) from {Path(t).name}")
340
+ print(f"✓ seeded {total} node(s) from {len(targets)} file(s)")
341
+ return 0
342
+
343
+
344
+ def cmd_scaffold(args) -> int:
345
+ """Stamp the portable method kit (canon, skills, agent, commands, gates, evals, hook) into a repo."""
346
+ from .scaffold_cmd import scaffold
347
+ res = scaffold(target=args.target, repo=args.repo, force=args.force, plugin=args.plugin,
348
+ profile=args.profile)
349
+ print(f"✓ scaffolded method kit into {res['root']} (repo={res['repo']}"
350
+ + (f", profiles={'+'.join(args.profile)}" if args.profile else "")
351
+ + (", as Claude Code plugin" if res['plugin'] else "") + ")")
352
+ print(f" {len(res['written'])} file(s) written, {len(res['skipped'])} skipped (already existed).")
353
+ if args.verbose:
354
+ for f in res["written"]:
355
+ print(f" + {f}")
356
+ if res["skipped"] and not args.force:
357
+ print(f" skipped (use --force to overwrite): {', '.join(res['skipped'][:8])}"
358
+ + (" …" if len(res['skipped']) > 8 else ""))
359
+ if res["fills"]:
360
+ print(f"\n {len(res['fills'])} <<FILL>> slot(s) to complete (stack-specific rules):")
361
+ for f in res["fills"]:
362
+ print(f" • {f}")
363
+ print(" grep -rn '<<FILL' . to find them all later.")
364
+ print("\nNext: `okl init` to wire the knowledge layer, then `/feature-spec` before your first change.")
365
+ return 0
366
+
367
+
368
+ def cmd_serve(args) -> int:
369
+ from .service import run
370
+ run(host=args.host, port=args.port)
371
+ return 0
372
+
373
+
374
+ def cmd_mcp(args) -> int:
375
+ from .mcp_server import run_stdio
376
+ run_stdio()
377
+ return 0
378
+
379
+
380
+ def build_parser() -> argparse.ArgumentParser:
381
+ p = argparse.ArgumentParser(prog="okl", description="Org Knowledge Layer — the sixth surface.")
382
+ sub = p.add_subparsers(dest="cmd", required=True)
383
+
384
+ pi = sub.add_parser("init", help="wire the current repo (config + hook + CI pointer)")
385
+ pi.add_argument("--repo"); pi.add_argument("--service")
386
+ pi.add_argument("--interests", help="comma-sep subject tags this repo cares about "
387
+ "(filters org-scope lessons in `check`; see store.KNOWN_TAGS)")
388
+ pi.set_defaults(func=cmd_init)
389
+
390
+ pc = sub.add_parser("connect", help="point this repo at a shared OKL service URL")
391
+ pc.add_argument("url"); pc.add_argument("--token")
392
+ pc.set_defaults(func=cmd_connect)
393
+
394
+ pk = sub.add_parser("check", help="pre-task read: relevant lessons for a task")
395
+ pk.add_argument("--task", required=True); pk.add_argument("--repo")
396
+ pk.add_argument("--format", choices=["agent", "json"], default="agent")
397
+ pk.set_defaults(func=cmd_check)
398
+
399
+ pr = sub.add_parser("record", help="record a node (defect/gate/claim/...)")
400
+ pr.add_argument("--type", required=True); pr.add_argument("--title", required=True)
401
+ pr.add_argument("--scope", required=True, help="'org' or 'repo:<name>' or 'repo'")
402
+ pr.add_argument("--repo"); pr.add_argument("--body"); pr.add_argument("--status")
403
+ pr.add_argument("--found-by", dest="found_by")
404
+ pr.add_argument("--ttl-days", dest="ttl_days", type=int); pr.add_argument("--owner")
405
+ pr.add_argument("--files", help="comma-sep path globs this node governs (enrolls it in drift detection)")
406
+ pr.add_argument("--symptom", help="Symptom→Cause→Fix: the observable symptom (cause goes in --body)")
407
+ pr.add_argument("--fix", help="Symptom→Cause→Fix: the fix to apply")
408
+ pr.add_argument("--tags", help="comma-sep subject tags from the controlled vocabulary "
409
+ "(store.KNOWN_TAGS), e.g. 'react,security'")
410
+ pr.add_argument("--id", help="explicit stable id (makes the write idempotent — re-records replace)")
411
+ pr.add_argument("--verified", action="store_true")
412
+ pr.set_defaults(func=cmd_record)
413
+
414
+ pl = sub.add_parser("link", help="add an edge between two nodes")
415
+ pl.add_argument("src"); pl.add_argument("rel"); pl.add_argument("dst")
416
+ pl.set_defaults(func=cmd_link)
417
+
418
+ pvf = sub.add_parser("verify", help="run a check and stamp a node verified only on an observed pass")
419
+ pvf.add_argument("node_id")
420
+ pvf.add_argument("--run", required=True, help="the check command; exit 0 required to stamp")
421
+ pvf.add_argument("--expect", help="substring that must appear in the output — a positive success "
422
+ "signal, so exit 0 alone can't self-certify (the exit-0-zero-files lesson)")
423
+ pvf.add_argument("--timeout", type=int, default=600, help="seconds before the check is killed (default 600)")
424
+ pvf.set_defaults(func=cmd_verify)
425
+
426
+ ps = sub.add_parser("search", help="full-text search over the encoded body")
427
+ ps.add_argument("query"); ps.add_argument("--scope")
428
+ ps.add_argument("--type", nargs="*"); ps.add_argument("--limit", type=int, default=25)
429
+ ps.add_argument("--format", choices=["text", "json"], default="text")
430
+ ps.set_defaults(func=cmd_search)
431
+
432
+ pm = sub.add_parser("metric", help="recurrence-after-arming metric")
433
+ pm.add_argument("--format", choices=["text", "json"], default="text")
434
+ pm.set_defaults(func=cmd_metric)
435
+
436
+ pdr = sub.add_parser("drift", help="source-vs-spec drift: rules whose governed code changed after verification")
437
+ pdr.add_argument("--repo"); pdr.add_argument("--repo-dir", dest="repo_dir", default=".")
438
+ pdr.add_argument("--gate", action="store_true", help="exit 1 if drift found (for CI)")
439
+ pdr.add_argument("--format", choices=["text", "json"], default="text")
440
+ pdr.set_defaults(func=cmd_drift)
441
+
442
+ pcv = sub.add_parser("coverage", help="knowledge-to-code ratio (health signal)")
443
+ pcv.add_argument("--repo"); pcv.add_argument("--repo-dir", dest="repo_dir", default=".")
444
+ pcv.add_argument("--format", choices=["text", "json"], default="text")
445
+ pcv.set_defaults(func=cmd_coverage)
446
+
447
+ pb = sub.add_parser("bootstrap", help="propose starter nodes from repo signals (git log, docs)")
448
+ pb.add_argument("--repo"); pb.add_argument("--repo-dir", dest="repo_dir", default=".")
449
+ pb.add_argument("--out", default="okl-bootstrap.json")
450
+ pb.set_defaults(func=cmd_bootstrap)
451
+
452
+ pd = sub.add_parser("seed", help="ingest seed file(s) as nodes (a *-defects.json, or a dir of them)")
453
+ pd.add_argument("path", nargs="?", default=None,
454
+ help="a seed JSON, or a directory of *-defects.json (default: the bundled seed dir — all three repos)")
455
+ pd.set_defaults(func=cmd_seed)
456
+
457
+ psc = sub.add_parser("scaffold", help="stamp the portable method kit into a repo")
458
+ psc.add_argument("target", nargs="?", default=".", help="target repo dir (default: cwd)")
459
+ psc.add_argument("--repo", help="repo name (default: dir name)")
460
+ psc.add_argument("--plugin", action="store_true", help="also write plugin.json (Claude Code plugin)")
461
+ from .scaffold_cmd import list_profiles
462
+ psc.add_argument("--profile", action="append", choices=list_profiles(), metavar="PROFILE",
463
+ help="drop a stack's verbatim canon into .claude/rules/; repeatable and composable, "
464
+ f"e.g. --profile dotnet --profile react (available: {', '.join(list_profiles())})")
465
+ psc.add_argument("--force", action="store_true", help="overwrite existing files")
466
+ psc.add_argument("--verbose", "-v", action="store_true")
467
+ psc.set_defaults(func=cmd_scaffold)
468
+
469
+ pv = sub.add_parser("serve", help="run the shared FastAPI service")
470
+ pv.add_argument("--host", default="0.0.0.0"); pv.add_argument("--port", type=int, default=8080)
471
+ pv.set_defaults(func=cmd_serve)
472
+
473
+ pmcp = sub.add_parser("mcp", help="run the MCP server (stdio) for agent tools")
474
+ pmcp.set_defaults(func=cmd_mcp)
475
+ return p
476
+
477
+
478
+ def main(argv: list[str] | None = None) -> int:
479
+ args = build_parser().parse_args(argv)
480
+ return args.func(args)
481
+
482
+
483
+ if __name__ == "__main__":
484
+ raise SystemExit(main())