devcouncil 0.1.1 → 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 +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- 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 +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- 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 +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -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/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Knowledge sources: discover and select OKF / design.md context for prompts.
|
|
2
|
+
|
|
3
|
+
A :class:`KnowledgeSource` is the prompt-facing view of an ingested knowledge file. It
|
|
4
|
+
mirrors :class:`devcouncil.skills.registry.Skill` — same frontmatter contract, same
|
|
5
|
+
trigger-based selection and relevance ranking — so OKF bundles and a project design system
|
|
6
|
+
flow into planning/council/task prompts through the existing budget-aware machinery.
|
|
7
|
+
|
|
8
|
+
On-disk layout (under the project root)::
|
|
9
|
+
|
|
10
|
+
.devcouncil/knowledge/
|
|
11
|
+
design/design.md # one design system, always selected
|
|
12
|
+
okf/*.md # ingested OKF documents, selected by trigger/keyword
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from devcouncil.knowledge.frontmatter import split_frontmatter
|
|
23
|
+
from devcouncil.skills.registry import SkillTriggers, _keyword_in_text
|
|
24
|
+
|
|
25
|
+
KNOWLEDGE_DIR = ".devcouncil/knowledge"
|
|
26
|
+
|
|
27
|
+
Kind = Literal["okf", "design"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KnowledgeSource(BaseModel):
|
|
31
|
+
name: str
|
|
32
|
+
kind: Kind
|
|
33
|
+
description: str = ""
|
|
34
|
+
always: bool = False
|
|
35
|
+
triggers: SkillTriggers = Field(default_factory=SkillTriggers)
|
|
36
|
+
body: str = ""
|
|
37
|
+
priority: int = 50
|
|
38
|
+
source_path: Path | None = None
|
|
39
|
+
|
|
40
|
+
def _match_and_score(self, goal_lower: str) -> tuple[bool, int]:
|
|
41
|
+
"""Whether this source applies to ``goal_lower`` and its relevance rank, in one
|
|
42
|
+
keyword scan. Unlike a Skill, ``matches`` is NOT equivalent to ``score > 0`` here:
|
|
43
|
+
OKF sources have a nonzero ``priority`` floor, so the two must be returned together."""
|
|
44
|
+
if self.always:
|
|
45
|
+
return True, 1_000_000 + self.priority
|
|
46
|
+
hits = sum(1 for kw in self.triggers.keywords if _keyword_in_text(kw, goal_lower))
|
|
47
|
+
return hits > 0, self.priority + 5 * hits
|
|
48
|
+
|
|
49
|
+
def matches(self, goal: str) -> bool:
|
|
50
|
+
"""True if this source applies to the given goal text.
|
|
51
|
+
|
|
52
|
+
Design systems are always-on (a coding agent should always honor them); OKF
|
|
53
|
+
knowledge is matched on goal keywords like a domain skill.
|
|
54
|
+
"""
|
|
55
|
+
return self._match_and_score(goal.lower())[0]
|
|
56
|
+
|
|
57
|
+
def relevance_score(self, goal: str) -> int:
|
|
58
|
+
return self._match_and_score(goal.lower())[1]
|
|
59
|
+
|
|
60
|
+
def render(self) -> str:
|
|
61
|
+
"""A titled markdown block for inclusion in a prompt preamble."""
|
|
62
|
+
header = f"## {self.description or self.name}".rstrip()
|
|
63
|
+
return f"{header}\n\n{self.body.strip()}".strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Parsed-source cache keyed on (resolved path, mtime_ns, kind, always, priority). Knowledge
|
|
67
|
+
# discovery runs once per task during planning (and repeatedly via MCP), so without this an
|
|
68
|
+
# N-task plan re-reads + re-parses every knowledge file N times. Keyed on mtime so an edited
|
|
69
|
+
# or freshly-ingested file is re-parsed; the args are in the key because they shape the
|
|
70
|
+
# resulting source. Cached sources are read-only (callers only match/score/render them).
|
|
71
|
+
_source_cache: dict[tuple[str, int, str, bool, int], KnowledgeSource] = {}
|
|
72
|
+
_SOURCE_CACHE_MAX = 512
|
|
73
|
+
|
|
74
|
+
# Directory-scan cache for discover_knowledge_sources(). The glob/rglob filesystem walk
|
|
75
|
+
# repeats 5-30x per planning session (once per task, plus MCP calls) over a directory tree
|
|
76
|
+
# that almost never changes mid-session, while per-file PARSING is already memoized by
|
|
77
|
+
# _source_cache. This caches the discovered source *list* keyed on the call's shaping args.
|
|
78
|
+
# Each entry also stores a signature: the sorted set of (resolved-path, mtime_ns) for every
|
|
79
|
+
# scanned file. On the next call we recompute that signature (a cheap stat per file vs. the
|
|
80
|
+
# directory walk + frontmatter parse) and invalidate if it differs, so a freshly written,
|
|
81
|
+
# edited, added or removed file is picked up. Keying on the path set (not just mtimes) means
|
|
82
|
+
# add/remove is detected even if the filesystem's mtime granularity is too coarse to notice
|
|
83
|
+
# an in-place rewrite within the same tick. Cached lists are read-only to callers.
|
|
84
|
+
_DiscoverKey = tuple[str, str, bool, int, int]
|
|
85
|
+
_DiscoverSig = tuple[tuple[str, int], ...]
|
|
86
|
+
_discover_cache: dict[_DiscoverKey, tuple[_DiscoverSig, list[KnowledgeSource]]] = {}
|
|
87
|
+
_DISCOVER_CACHE_MAX = 256
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def clear_knowledge_caches() -> None:
|
|
91
|
+
"""Drop the cached parsed knowledge sources (useful in long-running processes/tests)."""
|
|
92
|
+
_source_cache.clear()
|
|
93
|
+
_discover_cache.clear()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _source_from_file(path: Path, kind: Kind, always: bool, priority: int) -> KnowledgeSource:
|
|
97
|
+
try:
|
|
98
|
+
key: tuple[str, int, str, bool, int] | None = (
|
|
99
|
+
str(path.resolve()), path.stat().st_mtime_ns, kind, always, priority,
|
|
100
|
+
)
|
|
101
|
+
except OSError:
|
|
102
|
+
key = None
|
|
103
|
+
if key is not None:
|
|
104
|
+
cached = _source_cache.get(key)
|
|
105
|
+
if cached is not None:
|
|
106
|
+
return cached
|
|
107
|
+
source = _parse_source_file(path, kind, always, priority)
|
|
108
|
+
if key is not None:
|
|
109
|
+
if len(_source_cache) >= _SOURCE_CACHE_MAX:
|
|
110
|
+
_source_cache.clear()
|
|
111
|
+
_source_cache[key] = source
|
|
112
|
+
return source
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_source_file(path: Path, kind: Kind, always: bool, priority: int) -> KnowledgeSource:
|
|
116
|
+
meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
|
|
117
|
+
triggers = meta.get("triggers") or {}
|
|
118
|
+
# Derive keywords from explicit triggers and, for OKF, the document's tags — so an
|
|
119
|
+
# OKF doc tagged [sales, revenue] fires on goals mentioning those domains for free.
|
|
120
|
+
keywords = list(triggers.get("keywords") or [])
|
|
121
|
+
if kind == "okf":
|
|
122
|
+
tags = meta.get("tags") or []
|
|
123
|
+
if isinstance(tags, str):
|
|
124
|
+
tags = [tags]
|
|
125
|
+
keywords.extend(str(t) for t in tags)
|
|
126
|
+
description = str(meta.get("description") or meta.get("title") or meta.get("type") or path.stem)
|
|
127
|
+
return KnowledgeSource(
|
|
128
|
+
name=str(meta.get("name") or path.stem),
|
|
129
|
+
kind=kind,
|
|
130
|
+
description=description,
|
|
131
|
+
always=always,
|
|
132
|
+
triggers=SkillTriggers(keywords=keywords, globs=list(triggers.get("globs") or [])),
|
|
133
|
+
body=body.strip(),
|
|
134
|
+
priority=priority,
|
|
135
|
+
source_path=path,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def discover_knowledge_sources(
|
|
140
|
+
project_root: Path,
|
|
141
|
+
directory: str = KNOWLEDGE_DIR,
|
|
142
|
+
design_always: bool = True,
|
|
143
|
+
design_priority: int = 80,
|
|
144
|
+
okf_priority: int = 50,
|
|
145
|
+
) -> list[KnowledgeSource]:
|
|
146
|
+
"""Find ingested knowledge under ``<project_root>/<directory>/{design,okf}``."""
|
|
147
|
+
base = project_root / directory
|
|
148
|
+
# Collect the files to ingest (the directory walk we want to cache), as
|
|
149
|
+
# (path, kind, always, priority) in stable order, before parsing any of them.
|
|
150
|
+
scanned: list[tuple[Path, Kind, bool, int]] = []
|
|
151
|
+
design_dir = base / "design"
|
|
152
|
+
if design_dir.exists():
|
|
153
|
+
for path in sorted(design_dir.glob("*.md")):
|
|
154
|
+
scanned.append((path, "design", design_always, design_priority))
|
|
155
|
+
okf_dir = base / "okf"
|
|
156
|
+
if okf_dir.exists():
|
|
157
|
+
for path in sorted(okf_dir.rglob("*.md")):
|
|
158
|
+
# Skip OKF index files: they are navigation, not knowledge worth injecting.
|
|
159
|
+
if path.name.lower() == "index.md":
|
|
160
|
+
continue
|
|
161
|
+
scanned.append((path, "okf", False, okf_priority))
|
|
162
|
+
|
|
163
|
+
# Signature of the current scan: (resolved path, mtime_ns) for each file. A miss here
|
|
164
|
+
# (file added/removed/edited) invalidates the cached list for this key.
|
|
165
|
+
sig_parts: list[tuple[str, int]] = []
|
|
166
|
+
for path, _kind, _always, _priority in scanned:
|
|
167
|
+
try:
|
|
168
|
+
sig_parts.append((str(path.resolve()), path.stat().st_mtime_ns))
|
|
169
|
+
except OSError:
|
|
170
|
+
# Vanished between glob and stat: skip from the signature; parsing handles it.
|
|
171
|
+
continue
|
|
172
|
+
signature: _DiscoverSig = tuple(sig_parts)
|
|
173
|
+
|
|
174
|
+
key: _DiscoverKey = (
|
|
175
|
+
str(project_root.resolve()), directory, design_always, design_priority, okf_priority,
|
|
176
|
+
)
|
|
177
|
+
cached = _discover_cache.get(key)
|
|
178
|
+
if cached is not None and cached[0] == signature:
|
|
179
|
+
return cached[1]
|
|
180
|
+
|
|
181
|
+
sources = [
|
|
182
|
+
_source_from_file(path, kind, always, priority)
|
|
183
|
+
for path, kind, always, priority in scanned
|
|
184
|
+
]
|
|
185
|
+
if len(_discover_cache) >= _DISCOVER_CACHE_MAX:
|
|
186
|
+
_discover_cache.clear()
|
|
187
|
+
_discover_cache[key] = (signature, sources)
|
|
188
|
+
return sources
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def select_knowledge_sources(
|
|
192
|
+
goal: str = "",
|
|
193
|
+
project_root: Path | None = None,
|
|
194
|
+
directory: str = KNOWLEDGE_DIR,
|
|
195
|
+
design_always: bool = True,
|
|
196
|
+
) -> list[KnowledgeSource]:
|
|
197
|
+
"""Select and rank the knowledge sources that apply to ``goal``.
|
|
198
|
+
|
|
199
|
+
Always-on design sources sort first; OKF sources follow, ranked by relevance. Returns
|
|
200
|
+
an empty list when nothing is ingested.
|
|
201
|
+
"""
|
|
202
|
+
if project_root is None:
|
|
203
|
+
return []
|
|
204
|
+
sources = discover_knowledge_sources(project_root, directory, design_always=design_always)
|
|
205
|
+
# One goal.lower() + one keyword scan per source (match and rank computed together).
|
|
206
|
+
goal_lower = goal.lower()
|
|
207
|
+
scored: list[tuple[KnowledgeSource, int]] = []
|
|
208
|
+
for source in sources:
|
|
209
|
+
ok, score = source._match_and_score(goal_lower)
|
|
210
|
+
if ok:
|
|
211
|
+
scored.append((source, score))
|
|
212
|
+
scored.sort(key=lambda item: (not item[0].always, -item[1], item[0].name))
|
|
213
|
+
return [source for source, _ in scored]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def render_knowledge_preamble(
|
|
217
|
+
sources: list[KnowledgeSource],
|
|
218
|
+
max_chars: int = 6000,
|
|
219
|
+
kind: Kind | None = None,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Concatenate source bodies (optionally filtered to one ``kind``) into a single
|
|
222
|
+
preamble, bounded to ``max_chars`` total. Sources are taken in the order given (so the
|
|
223
|
+
most relevant survive the budget); the first source is always included."""
|
|
224
|
+
separator = "\n\n---\n\n"
|
|
225
|
+
chosen = [s for s in sources if kind is None or s.kind == kind]
|
|
226
|
+
blocks: list[str] = []
|
|
227
|
+
total = 0
|
|
228
|
+
for source in chosen:
|
|
229
|
+
block = source.render()
|
|
230
|
+
if not block:
|
|
231
|
+
continue
|
|
232
|
+
# Count the separator that will join this block to the previous one, so the budget
|
|
233
|
+
# bounds the *rendered* preamble length, not just the sum of block bodies.
|
|
234
|
+
extra = len(separator) if blocks else 0
|
|
235
|
+
if blocks and total + extra + len(block) > max_chars:
|
|
236
|
+
break
|
|
237
|
+
blocks.append(block)
|
|
238
|
+
total += extra + len(block)
|
|
239
|
+
return separator.join(blocks).strip()
|