devcouncil 0.2.0 → 0.3.1

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 (98) hide show
  1. package/README.md +12 -1
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/devcouncil/app/config.py +181 -7
  5. package/src/devcouncil/app/orchestrator.py +10 -6
  6. package/src/devcouncil/app/state_machine.py +4 -0
  7. package/src/devcouncil/artifacts/graph.py +9 -2
  8. package/src/devcouncil/cli/commands/check.py +12 -1
  9. package/src/devcouncil/cli/commands/design.py +186 -0
  10. package/src/devcouncil/cli/commands/doctor.py +160 -3
  11. package/src/devcouncil/cli/commands/go.py +96 -16
  12. package/src/devcouncil/cli/commands/hook.py +172 -0
  13. package/src/devcouncil/cli/commands/init.py +7 -2
  14. package/src/devcouncil/cli/commands/integrate.py +492 -34
  15. package/src/devcouncil/cli/commands/logs.py +106 -0
  16. package/src/devcouncil/cli/commands/okf.py +245 -0
  17. package/src/devcouncil/cli/commands/plan.py +54 -14
  18. package/src/devcouncil/cli/commands/repair.py +12 -3
  19. package/src/devcouncil/cli/commands/run.py +128 -7
  20. package/src/devcouncil/cli/commands/skills.py +180 -1
  21. package/src/devcouncil/cli/commands/status.py +7 -16
  22. package/src/devcouncil/cli/commands/verify.py +16 -10
  23. package/src/devcouncil/cli/commands/watch.py +24 -4
  24. package/src/devcouncil/cli/main.py +36 -1
  25. package/src/devcouncil/domain/evidence.py +7 -0
  26. package/src/devcouncil/execution/checkpoints.py +12 -2
  27. package/src/devcouncil/execution/fs_watcher.py +27 -2
  28. package/src/devcouncil/execution/handoff.py +1 -1
  29. package/src/devcouncil/execution/patch.py +6 -0
  30. package/src/devcouncil/execution/permissions.py +7 -0
  31. package/src/devcouncil/execution/policy_engine.py +12 -5
  32. package/src/devcouncil/execution/prompt_builder.py +126 -10
  33. package/src/devcouncil/execution/shell_session.py +6 -0
  34. package/src/devcouncil/execution/task_runner.py +18 -7
  35. package/src/devcouncil/executors/agent_registry.py +22 -1
  36. package/src/devcouncil/executors/coding_cli.py +133 -5
  37. package/src/devcouncil/executors/mini_swe.py +6 -0
  38. package/src/devcouncil/executors/native/agent.py +15 -0
  39. package/src/devcouncil/executors/openhands.py +6 -0
  40. package/src/devcouncil/gating/checks/secret_scan_check.py +7 -0
  41. package/src/devcouncil/gating/policy.py +38 -7
  42. package/src/devcouncil/indexing/ast_matcher.py +16 -6
  43. package/src/devcouncil/indexing/repo_mapper.py +30 -8
  44. package/src/devcouncil/indexing/semantic_index.py +42 -26
  45. package/src/devcouncil/integrations/actions.py +24 -4
  46. package/src/devcouncil/integrations/check.py +7 -4
  47. package/src/devcouncil/integrations/claude_assets.py +444 -0
  48. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  49. package/src/devcouncil/integrations/github_intent.py +8 -1
  50. package/src/devcouncil/integrations/gitnexus.py +10 -2
  51. package/src/devcouncil/integrations/mcp/server.py +404 -15
  52. package/src/devcouncil/integrations/pr_comments.py +9 -0
  53. package/src/devcouncil/knowledge/__init__.py +23 -0
  54. package/src/devcouncil/knowledge/design.py +374 -0
  55. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  56. package/src/devcouncil/knowledge/fetch.py +223 -0
  57. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  58. package/src/devcouncil/knowledge/okf.py +202 -0
  59. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  60. package/src/devcouncil/knowledge/sources.py +239 -0
  61. package/src/devcouncil/live/cards.py +20 -6
  62. package/src/devcouncil/live/repair_prompt.py +29 -6
  63. package/src/devcouncil/live/reviewer.py +72 -13
  64. package/src/devcouncil/live/summary.py +18 -8
  65. package/src/devcouncil/live/transcripts.py +38 -5
  66. package/src/devcouncil/llm/cache.py +14 -6
  67. package/src/devcouncil/llm/provider.py +179 -92
  68. package/src/devcouncil/llm/router.py +122 -23
  69. package/src/devcouncil/optimization/skillopt.py +673 -0
  70. package/src/devcouncil/planning/arbiter_service.py +10 -2
  71. package/src/devcouncil/planning/correction_manifest.py +47 -4
  72. package/src/devcouncil/planning/critique_service.py +9 -2
  73. package/src/devcouncil/planning/plan_service.py +69 -3
  74. package/src/devcouncil/planning/prompt_enhancer_service.py +124 -0
  75. package/src/devcouncil/planning/repair_service.py +8 -2
  76. package/src/devcouncil/planning/spec_service.py +10 -2
  77. package/src/devcouncil/repo/ci_scaffold.py +13 -5
  78. package/src/devcouncil/repo/sca.py +11 -1
  79. package/src/devcouncil/reporting/json_report.py +11 -0
  80. package/src/devcouncil/reporting/markdown_report.py +14 -1
  81. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  82. package/src/devcouncil/reporting/okf_html.py +323 -0
  83. package/src/devcouncil/reporting/report_builder.py +18 -1
  84. package/src/devcouncil/skills/registry.py +111 -33
  85. package/src/devcouncil/storage/db.py +58 -2
  86. package/src/devcouncil/storage/models.py +4 -0
  87. package/src/devcouncil/storage/native.py +20 -18
  88. package/src/devcouncil/storage/repositories.py +35 -18
  89. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  90. package/src/devcouncil/telemetry/stages.py +141 -0
  91. package/src/devcouncil/telemetry/tracker.py +12 -1
  92. package/src/devcouncil/ui/dashboard.py +69 -5
  93. package/src/devcouncil/verification/acceptance_compiler.py +147 -19
  94. package/src/devcouncil/verification/ad_hoc_check.py +6 -0
  95. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  96. package/src/devcouncil/verification/sandbox.py +7 -4
  97. package/src/devcouncil/verification/verifier.py +905 -517
  98. 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 (``![alt](x.png)``) 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
+ )