devcouncil 0.2.0 → 0.3.0
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.
- package/README.md +12 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/devcouncil/app/config.py +181 -7
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +9 -2
- package/src/devcouncil/cli/commands/check.py +12 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +160 -3
- package/src/devcouncil/cli/commands/go.py +96 -16
- package/src/devcouncil/cli/commands/hook.py +172 -0
- package/src/devcouncil/cli/commands/init.py +7 -2
- package/src/devcouncil/cli/commands/integrate.py +492 -34
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +54 -14
- package/src/devcouncil/cli/commands/repair.py +12 -3
- package/src/devcouncil/cli/commands/run.py +128 -7
- package/src/devcouncil/cli/commands/skills.py +180 -1
- package/src/devcouncil/cli/commands/status.py +7 -16
- package/src/devcouncil/cli/commands/verify.py +16 -10
- package/src/devcouncil/cli/commands/watch.py +24 -4
- package/src/devcouncil/cli/main.py +36 -1
- package/src/devcouncil/domain/evidence.py +7 -0
- package/src/devcouncil/execution/checkpoints.py +12 -2
- package/src/devcouncil/execution/fs_watcher.py +27 -2
- package/src/devcouncil/execution/handoff.py +1 -1
- package/src/devcouncil/execution/patch.py +6 -0
- package/src/devcouncil/execution/permissions.py +7 -0
- package/src/devcouncil/execution/policy_engine.py +12 -5
- package/src/devcouncil/execution/prompt_builder.py +126 -10
- package/src/devcouncil/execution/shell_session.py +6 -0
- package/src/devcouncil/execution/task_runner.py +18 -7
- package/src/devcouncil/executors/agent_registry.py +22 -1
- package/src/devcouncil/executors/coding_cli.py +133 -5
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +15 -0
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
- package/src/devcouncil/gating/policy.py +38 -7
- package/src/devcouncil/indexing/ast_matcher.py +16 -6
- package/src/devcouncil/indexing/repo_mapper.py +30 -8
- package/src/devcouncil/indexing/semantic_index.py +42 -26
- package/src/devcouncil/integrations/actions.py +24 -4
- package/src/devcouncil/integrations/check.py +7 -4
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +8 -1
- package/src/devcouncil/integrations/gitnexus.py +10 -2
- package/src/devcouncil/integrations/mcp/server.py +404 -15
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +20 -6
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +38 -5
- package/src/devcouncil/llm/cache.py +14 -6
- package/src/devcouncil/llm/provider.py +179 -92
- package/src/devcouncil/llm/router.py +122 -23
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +47 -4
- package/src/devcouncil/planning/critique_service.py +9 -2
- package/src/devcouncil/planning/plan_service.py +69 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +10 -2
- package/src/devcouncil/repo/ci_scaffold.py +13 -5
- package/src/devcouncil/repo/sca.py +11 -1
- package/src/devcouncil/reporting/json_report.py +11 -0
- package/src/devcouncil/reporting/markdown_report.py +14 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/registry.py +111 -33
- package/src/devcouncil/storage/db.py +58 -2
- package/src/devcouncil/storage/models.py +4 -0
- package/src/devcouncil/storage/native.py +20 -18
- package/src/devcouncil/storage/repositories.py +35 -18
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/tracker.py +12 -1
- package/src/devcouncil/ui/dashboard.py +69 -5
- package/src/devcouncil/verification/acceptance_compiler.py +147 -19
- package/src/devcouncil/verification/ad_hoc_check.py +6 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/sandbox.py +7 -4
- package/src/devcouncil/verification/verifier.py +905 -517
- package/uv.lock +1 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Materialize an OKF bundle *source* into a local directory for ingest.
|
|
2
|
+
|
|
3
|
+
``dev okf ingest`` historically accepted only a local bundle directory. Bundles travel,
|
|
4
|
+
though — as ``.tar.gz``/``.zip`` archives or behind a git URL — so this module resolves
|
|
5
|
+
any of those forms to a concrete on-disk directory the existing read/validate/copy logic
|
|
6
|
+
can consume unchanged:
|
|
7
|
+
|
|
8
|
+
* an existing local directory → returned as-is (no temp dir, nothing to clean up);
|
|
9
|
+
* a local archive (``.tar.gz``/``.tgz``/``.zip``) → extracted into a temp dir, with a
|
|
10
|
+
**path-traversal guard** that rejects entries (or link targets) escaping the target;
|
|
11
|
+
* a git URL (``http(s)://``, ``git@``, ``ssh://``, or ``*.git``) → ``git clone --depth 1``
|
|
12
|
+
into a temp dir (best-effort; a clear error is raised if git is missing or the clone
|
|
13
|
+
fails).
|
|
14
|
+
|
|
15
|
+
Callers are responsible for invoking :meth:`FetchedBundle.cleanup` (in a ``finally``) to
|
|
16
|
+
remove any temp dir once the bundle has been read/copied.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import tarfile
|
|
24
|
+
import tempfile
|
|
25
|
+
import zipfile
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_ARCHIVE_SUFFIXES = (".tar.gz", ".tgz", ".zip")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UnsafeArchiveError(ValueError):
|
|
33
|
+
"""An archive entry (or link target) resolves outside the extraction directory.
|
|
34
|
+
|
|
35
|
+
Subclasses :class:`ValueError` so callers can catch either; raised by the extraction
|
|
36
|
+
guard before any unsafe member is written to disk (a path-traversal / Zip-Slip block).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class FetchedBundle:
|
|
42
|
+
"""The resolved local bundle directory plus any temp dir that must be cleaned up.
|
|
43
|
+
|
|
44
|
+
``directory`` is the bundle root to read. ``cleanup_dir`` is the temp directory created
|
|
45
|
+
for archives/git (``None`` for a pre-existing local directory, which is returned as-is
|
|
46
|
+
and must NOT be deleted). ``suggested_name`` is a sensible default ingest subfolder name
|
|
47
|
+
derived from the original source (its temp dir name would otherwise be random).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
directory: Path
|
|
51
|
+
cleanup_dir: Path | None
|
|
52
|
+
suggested_name: str = ""
|
|
53
|
+
|
|
54
|
+
def cleanup(self) -> None:
|
|
55
|
+
"""Remove the temp dir if one was created; safe to call when there is none."""
|
|
56
|
+
if self.cleanup_dir is not None:
|
|
57
|
+
shutil.rmtree(self.cleanup_dir, ignore_errors=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_git_url(source: str) -> bool:
|
|
61
|
+
"""Whether ``source`` looks like a git remote we should ``git clone``."""
|
|
62
|
+
s = source.strip()
|
|
63
|
+
return (
|
|
64
|
+
s.startswith("http://")
|
|
65
|
+
or s.startswith("https://")
|
|
66
|
+
or s.startswith("git@")
|
|
67
|
+
or s.startswith("ssh://")
|
|
68
|
+
or s.endswith(".git")
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _archive_stem(name: str) -> str:
|
|
73
|
+
"""The base name of an archive file with its (possibly two-part) suffix removed."""
|
|
74
|
+
lower = name.lower()
|
|
75
|
+
for suffix in _ARCHIVE_SUFFIXES:
|
|
76
|
+
if lower.endswith(suffix):
|
|
77
|
+
return name[: -len(suffix)] or "bundle"
|
|
78
|
+
return Path(name).stem or "bundle"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _git_repo_name(url: str) -> str:
|
|
82
|
+
tail = url.rstrip("/").split("/")[-1].split(":")[-1]
|
|
83
|
+
if tail.endswith(".git"):
|
|
84
|
+
tail = tail[:-4]
|
|
85
|
+
return tail or "bundle"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _within(base: Path, target: Path) -> bool:
|
|
89
|
+
"""Whether resolved ``target`` is ``base`` itself or lives beneath it."""
|
|
90
|
+
try:
|
|
91
|
+
target.relative_to(base)
|
|
92
|
+
return True
|
|
93
|
+
except ValueError:
|
|
94
|
+
# relative_to only raises when target is neither base nor beneath it, so an
|
|
95
|
+
# escaping path is unambiguously outside the extraction root.
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _safe_extract_tar(archive: Path, dest: Path) -> None:
|
|
100
|
+
"""Extract a tar archive into ``dest``, rejecting any path-escaping member/link."""
|
|
101
|
+
dest_resolved = dest.resolve()
|
|
102
|
+
with tarfile.open(archive, "r:*") as tf:
|
|
103
|
+
members = tf.getmembers()
|
|
104
|
+
for member in members:
|
|
105
|
+
target = (dest / member.name).resolve()
|
|
106
|
+
if not _within(dest_resolved, target):
|
|
107
|
+
raise UnsafeArchiveError(
|
|
108
|
+
f"unsafe archive entry {member.name!r} escapes the extraction directory"
|
|
109
|
+
)
|
|
110
|
+
# A symlink/hardlink could still point outside even if its own path is safe.
|
|
111
|
+
# Symlink ``linkname`` is relative to the link's own directory; hardlink
|
|
112
|
+
# ``linkname`` is relative to the archive root — resolve each against the
|
|
113
|
+
# correct base, else a hardlink escaping via the root is mis-validated.
|
|
114
|
+
if member.issym() or member.islnk():
|
|
115
|
+
base = target.parent if member.issym() else dest
|
|
116
|
+
link_target = (base / member.linkname).resolve()
|
|
117
|
+
if not _within(dest_resolved, link_target):
|
|
118
|
+
raise UnsafeArchiveError(
|
|
119
|
+
f"unsafe link target {member.linkname!r} in entry {member.name!r}"
|
|
120
|
+
)
|
|
121
|
+
# ``filter="data"`` is the safe extraction default on Python 3.12+ (defense in depth
|
|
122
|
+
# alongside the explicit guard above); fall back gracefully on older interpreters.
|
|
123
|
+
try:
|
|
124
|
+
tf.extractall(dest, members=members, filter="data")
|
|
125
|
+
except TypeError: # pragma: no cover - Python < 3.12 has no filter kwarg
|
|
126
|
+
tf.extractall(dest, members=members)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _safe_extract_zip(archive: Path, dest: Path) -> None:
|
|
130
|
+
"""Extract a zip archive into ``dest``, rejecting any path-escaping member (Zip-Slip)."""
|
|
131
|
+
dest_resolved = dest.resolve()
|
|
132
|
+
with zipfile.ZipFile(archive) as zf:
|
|
133
|
+
for name in zf.namelist():
|
|
134
|
+
target = (dest / name).resolve()
|
|
135
|
+
if not _within(dest_resolved, target):
|
|
136
|
+
raise UnsafeArchiveError(
|
|
137
|
+
f"unsafe archive entry {name!r} escapes the extraction directory"
|
|
138
|
+
)
|
|
139
|
+
zf.extractall(dest)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _resolve_bundle_root(extracted: Path) -> Path:
|
|
143
|
+
"""Descend into a lone top-level directory.
|
|
144
|
+
|
|
145
|
+
Archives produced with ``tar czf x.tgz somedir`` (or a git repo whose bundle lives in
|
|
146
|
+
a subdir) nest everything under one directory; collapsing it makes the returned path the
|
|
147
|
+
actual bundle root. If the markdown already sits at the top level, the dir is used as-is.
|
|
148
|
+
Purely best-effort: :func:`read_bundle` recurses anyway, so a wrong guess is harmless.
|
|
149
|
+
"""
|
|
150
|
+
entries = [p for p in extracted.iterdir() if not p.name.startswith(".")]
|
|
151
|
+
if any(p.is_file() and p.suffix == ".md" for p in entries):
|
|
152
|
+
return extracted
|
|
153
|
+
if len(entries) == 1 and entries[0].is_dir():
|
|
154
|
+
return entries[0]
|
|
155
|
+
return extracted
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def fetch_bundle(source: str) -> FetchedBundle:
|
|
159
|
+
"""Resolve ``source`` (local dir, local archive, or git URL) to a local bundle dir.
|
|
160
|
+
|
|
161
|
+
Raises :class:`UnsafeArchiveError` for a path-escaping archive entry, :class:`FileNotFoundError`
|
|
162
|
+
for a non-existent local path that isn't a git URL, and :class:`RuntimeError` if a git
|
|
163
|
+
clone is required but git is missing or the clone fails.
|
|
164
|
+
"""
|
|
165
|
+
raw = source.strip()
|
|
166
|
+
local = Path(raw).expanduser()
|
|
167
|
+
|
|
168
|
+
# (a) existing local directory — use it directly; nothing to clean up.
|
|
169
|
+
if local.is_dir():
|
|
170
|
+
resolved = local.resolve()
|
|
171
|
+
return FetchedBundle(directory=resolved, cleanup_dir=None, suggested_name=resolved.name)
|
|
172
|
+
|
|
173
|
+
# (b) local archive — extract into a temp dir behind the traversal guard.
|
|
174
|
+
if local.is_file() and local.name.lower().endswith(_ARCHIVE_SUFFIXES):
|
|
175
|
+
tmp = Path(tempfile.mkdtemp(prefix="okf-archive-"))
|
|
176
|
+
try:
|
|
177
|
+
if local.name.lower().endswith(".zip"):
|
|
178
|
+
_safe_extract_zip(local, tmp)
|
|
179
|
+
else:
|
|
180
|
+
_safe_extract_tar(local, tmp)
|
|
181
|
+
except BaseException:
|
|
182
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
183
|
+
raise
|
|
184
|
+
return FetchedBundle(
|
|
185
|
+
directory=_resolve_bundle_root(tmp),
|
|
186
|
+
cleanup_dir=tmp,
|
|
187
|
+
suggested_name=_archive_stem(local.name),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# (c) git URL — shallow clone into a temp dir.
|
|
191
|
+
if is_git_url(raw):
|
|
192
|
+
return _clone_git(raw)
|
|
193
|
+
|
|
194
|
+
raise FileNotFoundError(
|
|
195
|
+
f"bundle source not found: {source!r} (expected a directory, a .tar.gz/.tgz/.zip "
|
|
196
|
+
"archive, or a git URL)"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _clone_git(url: str) -> FetchedBundle:
|
|
201
|
+
"""``git clone --depth 1`` ``url`` into a fresh temp dir (best-effort)."""
|
|
202
|
+
if shutil.which("git") is None:
|
|
203
|
+
raise RuntimeError("git is not installed; cannot clone bundle from a git URL")
|
|
204
|
+
parent = Path(tempfile.mkdtemp(prefix="okf-git-"))
|
|
205
|
+
target = parent / "clone"
|
|
206
|
+
try:
|
|
207
|
+
result = subprocess.run(
|
|
208
|
+
["git", "clone", "--depth", "1", url, str(target)],
|
|
209
|
+
capture_output=True,
|
|
210
|
+
text=True,
|
|
211
|
+
)
|
|
212
|
+
except OSError as exc: # pragma: no cover - git present but unexecutable
|
|
213
|
+
shutil.rmtree(parent, ignore_errors=True)
|
|
214
|
+
raise RuntimeError(f"git clone failed to start: {exc}") from exc
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
shutil.rmtree(parent, ignore_errors=True)
|
|
217
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
218
|
+
raise RuntimeError(f"git clone of {url!r} failed: {detail}")
|
|
219
|
+
return FetchedBundle(
|
|
220
|
+
directory=_resolve_bundle_root(target),
|
|
221
|
+
cleanup_dir=parent,
|
|
222
|
+
suggested_name=_git_repo_name(url),
|
|
223
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Markdown + YAML frontmatter: the single split/build implementation.
|
|
2
|
+
|
|
3
|
+
Both the skills library (:mod:`devcouncil.skills.registry`) and the knowledge formats
|
|
4
|
+
(OKF, design.md) store structured metadata in a leading ``---`` YAML block followed by a
|
|
5
|
+
markdown body. Keeping one parser/serializer here means a fix to frontmatter handling
|
|
6
|
+
applies everywhere rather than drifting between copies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
17
|
+
"""Split ``text`` into (frontmatter dict, body).
|
|
18
|
+
|
|
19
|
+
Returns ``({}, text)`` when there is no leading ``---`` block or the block does not
|
|
20
|
+
parse to a mapping. Mirrors the historical behavior of
|
|
21
|
+
``skills.registry._split_frontmatter`` so existing skill files keep parsing.
|
|
22
|
+
"""
|
|
23
|
+
if text.startswith("---"):
|
|
24
|
+
parts = text.split("---", 2)
|
|
25
|
+
if len(parts) == 3:
|
|
26
|
+
try:
|
|
27
|
+
meta = yaml.safe_load(parts[1]) or {}
|
|
28
|
+
except yaml.YAMLError:
|
|
29
|
+
meta = {}
|
|
30
|
+
return (meta if isinstance(meta, dict) else {}), parts[2].lstrip("\r\n")
|
|
31
|
+
return {}, text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_frontmatter_markdown(meta: dict[str, Any], body: str) -> str:
|
|
35
|
+
"""Render a ``---`` YAML frontmatter block above ``body``.
|
|
36
|
+
|
|
37
|
+
Empty/``None`` values are dropped so the frontmatter stays minimal (OKF and design.md
|
|
38
|
+
both favor only-what-you-have metadata). Key order is preserved as given by the caller
|
|
39
|
+
(``sort_keys=False``); Unicode is kept literal rather than escaped.
|
|
40
|
+
"""
|
|
41
|
+
clean = {k: v for k, v in meta.items() if v not in (None, "", [], {})}
|
|
42
|
+
front = yaml.safe_dump(
|
|
43
|
+
clean,
|
|
44
|
+
sort_keys=False,
|
|
45
|
+
default_flow_style=False,
|
|
46
|
+
allow_unicode=True,
|
|
47
|
+
).strip()
|
|
48
|
+
body = body.strip()
|
|
49
|
+
if not front:
|
|
50
|
+
return f"{body}\n" if body else ""
|
|
51
|
+
return f"---\n{front}\n---\n\n{body}\n" if body else f"---\n{front}\n---\n"
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Open Knowledge Format (OKF) v0.1 — model, bundle I/O, and validation.
|
|
2
|
+
|
|
3
|
+
OKF (Google Cloud) formalizes the "LLM-wiki" pattern: a directory of markdown files,
|
|
4
|
+
each carrying a small YAML frontmatter header, cross-linked with plain markdown links to
|
|
5
|
+
form a portable, vendor-neutral knowledge graph. The only required frontmatter field is
|
|
6
|
+
``type``; everything else is producer-defined.
|
|
7
|
+
|
|
8
|
+
DevCouncil uses this module in both directions:
|
|
9
|
+
|
|
10
|
+
* **Export** — :mod:`devcouncil.reporting.okf_bundle_writer` builds an :class:`OKFBundle`
|
|
11
|
+
from the artifact graph and calls :func:`write_bundle`.
|
|
12
|
+
* **Ingest** — :func:`read_bundle` parses an external bundle so it can be surfaced as
|
|
13
|
+
planning context (:mod:`devcouncil.knowledge.sources`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from devcouncil.knowledge.frontmatter import build_frontmatter_markdown, split_frontmatter
|
|
25
|
+
|
|
26
|
+
# Markdown inline links: [text](target). We only resolve relative, non-anchor, non-URL
|
|
27
|
+
# targets into intra-bundle edges; external resources live in the `resource` field.
|
|
28
|
+
_LINK_RE = re.compile(r"\[(?P<text>[^\]]+)\]\((?P<target>[^)]+)\)")
|
|
29
|
+
_URL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OKFDocument(BaseModel):
|
|
33
|
+
"""A single OKF document: YAML frontmatter header + markdown body.
|
|
34
|
+
|
|
35
|
+
``rel_path`` is the document's POSIX path relative to the bundle root (e.g.
|
|
36
|
+
``tasks/TASK-001.md``); it is the node identity used when resolving links. ``links``
|
|
37
|
+
are resolved intra-bundle edges (relative link targets normalized to bundle-relative
|
|
38
|
+
POSIX paths), computed by :func:`read_bundle`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
type: str
|
|
42
|
+
title: str = ""
|
|
43
|
+
description: str = ""
|
|
44
|
+
resource: str = ""
|
|
45
|
+
tags: list[str] = Field(default_factory=list)
|
|
46
|
+
timestamp: str = ""
|
|
47
|
+
body: str = ""
|
|
48
|
+
rel_path: str = ""
|
|
49
|
+
links: list[str] = Field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def to_markdown(self) -> str:
|
|
52
|
+
"""Render this document as OKF markdown (frontmatter + body)."""
|
|
53
|
+
meta: dict[str, Any] = {
|
|
54
|
+
"type": self.type,
|
|
55
|
+
"title": self.title,
|
|
56
|
+
"description": self.description,
|
|
57
|
+
"resource": self.resource,
|
|
58
|
+
"tags": self.tags,
|
|
59
|
+
"timestamp": self.timestamp,
|
|
60
|
+
}
|
|
61
|
+
return build_frontmatter_markdown(meta, self.body)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_markdown(cls, text: str, rel_path: str = "") -> "OKFDocument":
|
|
65
|
+
"""Parse OKF markdown into a document (links are resolved by :func:`read_bundle`)."""
|
|
66
|
+
meta, body = split_frontmatter(text)
|
|
67
|
+
tags = meta.get("tags") or []
|
|
68
|
+
if isinstance(tags, str):
|
|
69
|
+
tags = [tags]
|
|
70
|
+
return cls(
|
|
71
|
+
type=str(meta.get("type") or ""),
|
|
72
|
+
title=str(meta.get("title") or ""),
|
|
73
|
+
description=str(meta.get("description") or ""),
|
|
74
|
+
resource=str(meta.get("resource") or ""),
|
|
75
|
+
tags=[str(t) for t in tags],
|
|
76
|
+
timestamp=str(meta.get("timestamp") or ""),
|
|
77
|
+
body=body.strip(),
|
|
78
|
+
rel_path=rel_path,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OKFBundle(BaseModel):
|
|
83
|
+
"""A collection of OKF documents keyed by bundle-relative path."""
|
|
84
|
+
|
|
85
|
+
documents: list[OKFDocument] = Field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def by_path(self) -> dict[str, OKFDocument]:
|
|
88
|
+
return {doc.rel_path: doc for doc in self.documents if doc.rel_path}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_link(source_rel_path: str, target: str) -> str | None:
|
|
92
|
+
"""Resolve a markdown link target found in ``source_rel_path`` to a bundle-relative
|
|
93
|
+
POSIX path, or ``None`` if it is external (URL), an in-page anchor, a non-document
|
|
94
|
+
asset, or escapes root.
|
|
95
|
+
|
|
96
|
+
Only ``.md`` targets are treated as intra-bundle document edges: a bundle's document
|
|
97
|
+
set is markdown-only, so links to images (````) or other assets must not
|
|
98
|
+
be recorded as edges — otherwise ``validate_bundle`` would flag every such link as a
|
|
99
|
+
broken intra-bundle reference.
|
|
100
|
+
"""
|
|
101
|
+
target = target.strip()
|
|
102
|
+
if not target or target.startswith("#") or _URL_RE.match(target) or target.startswith("mailto:"):
|
|
103
|
+
return None
|
|
104
|
+
target = target.split("#", 1)[0].strip() # drop any anchor fragment
|
|
105
|
+
if not target or not target.endswith(".md"):
|
|
106
|
+
return None
|
|
107
|
+
source_dir = PurePosix(source_rel_path).parent
|
|
108
|
+
try:
|
|
109
|
+
resolved = (source_dir / target).resolve_relative()
|
|
110
|
+
except ValueError:
|
|
111
|
+
return None
|
|
112
|
+
return resolved
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PurePosix:
|
|
116
|
+
"""Tiny relative-POSIX-path helper.
|
|
117
|
+
|
|
118
|
+
``pathlib.PurePosixPath`` does not collapse ``..`` segments (it has no filesystem to
|
|
119
|
+
resolve against), so this resolves ``a/b/../c`` → ``a/c`` purely lexically and rejects
|
|
120
|
+
paths that escape the bundle root. Kept local to avoid pulling in os.path semantics
|
|
121
|
+
that differ on Windows.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, raw: str) -> None:
|
|
125
|
+
self.parts = [p for p in raw.replace("\\", "/").split("/") if p not in ("", ".")]
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def parent(self) -> "PurePosix":
|
|
129
|
+
p = PurePosix("")
|
|
130
|
+
p.parts = self.parts[:-1]
|
|
131
|
+
return p
|
|
132
|
+
|
|
133
|
+
def __truediv__(self, other: str) -> "PurePosix":
|
|
134
|
+
p = PurePosix("")
|
|
135
|
+
p.parts = self.parts + [seg for seg in other.replace("\\", "/").split("/") if seg not in ("", ".")]
|
|
136
|
+
return p
|
|
137
|
+
|
|
138
|
+
def resolve_relative(self) -> str:
|
|
139
|
+
out: list[str] = []
|
|
140
|
+
for seg in self.parts:
|
|
141
|
+
if seg == "..":
|
|
142
|
+
if not out:
|
|
143
|
+
raise ValueError("path escapes bundle root")
|
|
144
|
+
out.pop()
|
|
145
|
+
else:
|
|
146
|
+
out.append(seg)
|
|
147
|
+
return "/".join(out)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_bundle(bundle_dir: Path) -> OKFBundle:
|
|
151
|
+
"""Read an OKF bundle from ``bundle_dir``: parse every ``*.md`` file and resolve
|
|
152
|
+
intra-bundle markdown links into :attr:`OKFDocument.links` edges."""
|
|
153
|
+
bundle_dir = bundle_dir.expanduser().resolve()
|
|
154
|
+
docs: list[OKFDocument] = []
|
|
155
|
+
for path in sorted(bundle_dir.rglob("*.md")):
|
|
156
|
+
rel = path.relative_to(bundle_dir).as_posix()
|
|
157
|
+
doc = OKFDocument.from_markdown(path.read_text(encoding="utf-8"), rel_path=rel)
|
|
158
|
+
links: list[str] = []
|
|
159
|
+
seen_links: set[str] = set()
|
|
160
|
+
for match in _LINK_RE.finditer(doc.body):
|
|
161
|
+
resolved = _resolve_link(rel, match.group("target"))
|
|
162
|
+
if resolved and resolved not in seen_links:
|
|
163
|
+
seen_links.add(resolved)
|
|
164
|
+
links.append(resolved)
|
|
165
|
+
doc.links = links
|
|
166
|
+
docs.append(doc)
|
|
167
|
+
return OKFBundle(documents=docs)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_bundle(bundle: OKFBundle, bundle_dir: Path) -> list[Path]:
|
|
171
|
+
"""Write every document in ``bundle`` to ``bundle_dir`` at its ``rel_path``.
|
|
172
|
+
|
|
173
|
+
Returns the list of written file paths. Documents without a ``rel_path`` are skipped.
|
|
174
|
+
"""
|
|
175
|
+
bundle_dir = bundle_dir.expanduser().resolve()
|
|
176
|
+
written: list[Path] = []
|
|
177
|
+
for doc in bundle.documents:
|
|
178
|
+
if not doc.rel_path:
|
|
179
|
+
continue
|
|
180
|
+
target = bundle_dir / doc.rel_path
|
|
181
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
target.write_text(doc.to_markdown(), encoding="utf-8")
|
|
183
|
+
written.append(target)
|
|
184
|
+
return written
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def validate_bundle(bundle: OKFBundle) -> list[str]:
|
|
188
|
+
"""Return human-readable validation problems for ``bundle`` (empty list == valid).
|
|
189
|
+
|
|
190
|
+
Checks the OKF invariants DevCouncil relies on: every document declares a ``type``,
|
|
191
|
+
and every intra-bundle link resolves to a document actually present in the bundle.
|
|
192
|
+
"""
|
|
193
|
+
problems: list[str] = []
|
|
194
|
+
present = set(bundle.by_path().keys())
|
|
195
|
+
for doc in bundle.documents:
|
|
196
|
+
where = doc.rel_path or doc.title or "<unknown>"
|
|
197
|
+
if not doc.type.strip():
|
|
198
|
+
problems.append(f"{where}: missing required 'type' frontmatter field")
|
|
199
|
+
for link in doc.links:
|
|
200
|
+
if link not in present:
|
|
201
|
+
problems.append(f"{where}: broken link to '{link}' (no such document in bundle)")
|
|
202
|
+
return problems
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Single source of truth for Skill <-> OKF document interconversion.
|
|
2
|
+
|
|
3
|
+
DevCouncil skills (:class:`devcouncil.skills.registry.Skill`) and the Open Knowledge
|
|
4
|
+
Format (:class:`devcouncil.knowledge.okf.OKFDocument`) describe the same kind of
|
|
5
|
+
artifact from two angles: a skill is "guidance that fires on triggers", an OKF document
|
|
6
|
+
is "a typed, portable markdown node". This module is the one place that maps between
|
|
7
|
+
them, so exporting skills into an OKF bundle and ingesting an OKF bundle back into skills
|
|
8
|
+
stay symmetric and don't drift apart across the codebase.
|
|
9
|
+
|
|
10
|
+
Skill documents are marked with the OKF ``type`` value :data:`SKILL_OKF_TYPE`; that type
|
|
11
|
+
tag is what lets :func:`okf_document_to_skill` tell skill nodes apart from other OKF nodes
|
|
12
|
+
(BigQuery tables, tasks, requirements, ...) in a mixed bundle.
|
|
13
|
+
|
|
14
|
+
Import-cycle note: :mod:`devcouncil.skills.registry` imports this module, so ``Skill`` /
|
|
15
|
+
``SkillTriggers`` are imported *lazily* inside :func:`okf_document_to_skill` rather than at
|
|
16
|
+
module top. ``OKFDocument`` is safe to import at top because ``knowledge.okf`` does not
|
|
17
|
+
import the skills package.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from devcouncil.knowledge.okf import OKFDocument
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from devcouncil.skills.registry import Skill
|
|
30
|
+
|
|
31
|
+
# The OKF `type` frontmatter value carried by every skill document. Used both when
|
|
32
|
+
# emitting skills (export) and when filtering a mixed bundle back into skills (ingest).
|
|
33
|
+
SKILL_OKF_TYPE = "Engineering Skill"
|
|
34
|
+
|
|
35
|
+
# Fallback name derivation when a document has no rel_path to take a stem from.
|
|
36
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _slug(text: str) -> str:
|
|
40
|
+
"""Lowercase, hyphen-joined slug of ``text`` (used to name a skill that lacks a path)."""
|
|
41
|
+
return _SLUG_RE.sub("-", text.strip().lower()).strip("-") or "skill"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def skill_to_okf_document(skill: "Skill", rel_dir: str = "skills") -> OKFDocument:
|
|
45
|
+
"""Render a :class:`Skill` as an OKF document for inclusion in a bundle.
|
|
46
|
+
|
|
47
|
+
Keyword triggers become OKF ``tags`` (sorted + deduped for stable, diff-friendly
|
|
48
|
+
output); ``timestamp`` is left empty because a skill is library content, not a
|
|
49
|
+
timestamped artifact. The document lands at ``<rel_dir>/<skill.name>.md``.
|
|
50
|
+
"""
|
|
51
|
+
return OKFDocument(
|
|
52
|
+
type=SKILL_OKF_TYPE,
|
|
53
|
+
title=skill.title or skill.name,
|
|
54
|
+
description=skill.description,
|
|
55
|
+
tags=sorted(set(skill.triggers.keywords)),
|
|
56
|
+
timestamp="",
|
|
57
|
+
body=skill.body,
|
|
58
|
+
rel_path=f"{rel_dir.rstrip('/')}/{skill.name}.md",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_skill_document(doc: OKFDocument) -> bool:
|
|
63
|
+
"""Whether ``doc`` is a skill node, i.e. its OKF ``type`` is :data:`SKILL_OKF_TYPE`.
|
|
64
|
+
|
|
65
|
+
Comparison is case-insensitive and whitespace-trimmed so hand-edited bundles still
|
|
66
|
+
round-trip.
|
|
67
|
+
"""
|
|
68
|
+
return doc.type.strip().lower() == SKILL_OKF_TYPE.lower()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def okf_document_to_skill(doc: OKFDocument) -> "Skill | None":
|
|
72
|
+
"""Reconstruct a :class:`Skill` from an OKF document, or ``None`` if it isn't a skill.
|
|
73
|
+
|
|
74
|
+
Non-skill-typed nodes (BigQuery tables, tasks, ...) return ``None`` so callers can
|
|
75
|
+
map over a mixed bundle and keep only the skill nodes. The skill ``name`` comes from
|
|
76
|
+
the document's ``rel_path`` stem when present, else a slug of its title. The stem is
|
|
77
|
+
slugged too, so a foreign bundle whose file is ``skills/Foo Bar.md`` yields the skill
|
|
78
|
+
name ``foo-bar`` (a clean identifier that scaffolds to a sane ``.claude/skills`` dir),
|
|
79
|
+
not ``Foo Bar``. ``always`` is ``False`` and ``globs`` empty because OKF tags only carry
|
|
80
|
+
keyword triggers; ``source_path`` is ``None`` since the skill originates from a bundle.
|
|
81
|
+
"""
|
|
82
|
+
if not is_skill_document(doc):
|
|
83
|
+
return None
|
|
84
|
+
# Lazy import to avoid a registry <-> skill_bridge import cycle (see module docstring).
|
|
85
|
+
from devcouncil.skills.registry import Skill, SkillTriggers
|
|
86
|
+
|
|
87
|
+
name = _slug(Path(doc.rel_path).stem) if doc.rel_path else _slug(doc.title)
|
|
88
|
+
return Skill(
|
|
89
|
+
name=name,
|
|
90
|
+
title=doc.title,
|
|
91
|
+
description=doc.description,
|
|
92
|
+
always=False,
|
|
93
|
+
triggers=SkillTriggers(keywords=list(doc.tags), globs=[]),
|
|
94
|
+
body=doc.body,
|
|
95
|
+
source_path=None,
|
|
96
|
+
)
|