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.
Files changed (159) hide show
  1. package/README.md +201 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +348 -12
  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 +32 -5
  8. package/src/devcouncil/assets/__init__.py +1 -0
  9. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  10. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  11. package/src/devcouncil/cli/commands/agents.py +292 -0
  12. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  13. package/src/devcouncil/cli/commands/check.py +220 -0
  14. package/src/devcouncil/cli/commands/config.py +43 -4
  15. package/src/devcouncil/cli/commands/cost.py +57 -0
  16. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  17. package/src/devcouncil/cli/commands/design.py +186 -0
  18. package/src/devcouncil/cli/commands/doctor.py +379 -22
  19. package/src/devcouncil/cli/commands/evidence.py +48 -0
  20. package/src/devcouncil/cli/commands/go.py +532 -33
  21. package/src/devcouncil/cli/commands/handoff.py +69 -0
  22. package/src/devcouncil/cli/commands/hook.py +296 -15
  23. package/src/devcouncil/cli/commands/init.py +161 -20
  24. package/src/devcouncil/cli/commands/integrate.py +1371 -124
  25. package/src/devcouncil/cli/commands/logs.py +106 -0
  26. package/src/devcouncil/cli/commands/map.py +80 -10
  27. package/src/devcouncil/cli/commands/okf.py +245 -0
  28. package/src/devcouncil/cli/commands/plan.py +256 -55
  29. package/src/devcouncil/cli/commands/prompt.py +18 -7
  30. package/src/devcouncil/cli/commands/repair.py +50 -24
  31. package/src/devcouncil/cli/commands/report.py +8 -0
  32. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  33. package/src/devcouncil/cli/commands/rollback.py +27 -28
  34. package/src/devcouncil/cli/commands/run.py +195 -54
  35. package/src/devcouncil/cli/commands/runs.py +223 -0
  36. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  37. package/src/devcouncil/cli/commands/semantic.py +47 -0
  38. package/src/devcouncil/cli/commands/setup.py +145 -6
  39. package/src/devcouncil/cli/commands/shell.py +73 -0
  40. package/src/devcouncil/cli/commands/skills.py +267 -0
  41. package/src/devcouncil/cli/commands/status.py +30 -15
  42. package/src/devcouncil/cli/commands/trace.py +47 -3
  43. package/src/devcouncil/cli/commands/verify.py +144 -3
  44. package/src/devcouncil/cli/commands/watch.py +32 -12
  45. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  46. package/src/devcouncil/cli/main.py +91 -7
  47. package/src/devcouncil/domain/evidence.py +29 -2
  48. package/src/devcouncil/domain/gap.py +27 -1
  49. package/src/devcouncil/domain/task.py +31 -2
  50. package/src/devcouncil/execution/checkpoints.py +256 -0
  51. package/src/devcouncil/execution/context_builder.py +1 -1
  52. package/src/devcouncil/execution/fs_watcher.py +205 -0
  53. package/src/devcouncil/execution/handoff.py +102 -0
  54. package/src/devcouncil/execution/hook_policy.py +162 -74
  55. package/src/devcouncil/execution/patch.py +65 -10
  56. package/src/devcouncil/execution/permissions.py +24 -24
  57. package/src/devcouncil/execution/policy_engine.py +350 -0
  58. package/src/devcouncil/execution/prompt_builder.py +751 -23
  59. package/src/devcouncil/execution/shell_session.py +231 -0
  60. package/src/devcouncil/execution/task_runner.py +24 -9
  61. package/src/devcouncil/executors/agent_registry.py +596 -0
  62. package/src/devcouncil/executors/coding_cli.py +791 -39
  63. package/src/devcouncil/executors/mini_swe.py +6 -0
  64. package/src/devcouncil/executors/native/agent.py +135 -19
  65. package/src/devcouncil/executors/openhands.py +6 -0
  66. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  67. package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
  68. package/src/devcouncil/gating/policy.py +190 -11
  69. package/src/devcouncil/hardware.py +184 -0
  70. package/src/devcouncil/indexing/ast_matcher.py +17 -7
  71. package/src/devcouncil/indexing/lsp.py +45 -4
  72. package/src/devcouncil/indexing/repo_mapper.py +1284 -15
  73. package/src/devcouncil/indexing/semantic_index.py +221 -0
  74. package/src/devcouncil/integrations/actions.py +166 -0
  75. package/src/devcouncil/integrations/check.py +426 -0
  76. package/src/devcouncil/integrations/claude_assets.py +444 -0
  77. package/src/devcouncil/integrations/code_review_graph.py +13 -2
  78. package/src/devcouncil/integrations/github_intent.py +149 -0
  79. package/src/devcouncil/integrations/gitnexus.py +45 -2
  80. package/src/devcouncil/integrations/mcp/server.py +1944 -32
  81. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  82. package/src/devcouncil/integrations/pr_comments.py +9 -0
  83. package/src/devcouncil/knowledge/__init__.py +23 -0
  84. package/src/devcouncil/knowledge/design.py +374 -0
  85. package/src/devcouncil/knowledge/design_conformance.py +317 -0
  86. package/src/devcouncil/knowledge/fetch.py +223 -0
  87. package/src/devcouncil/knowledge/frontmatter.py +51 -0
  88. package/src/devcouncil/knowledge/okf.py +202 -0
  89. package/src/devcouncil/knowledge/skill_bridge.py +96 -0
  90. package/src/devcouncil/knowledge/sources.py +239 -0
  91. package/src/devcouncil/live/cards.py +181 -25
  92. package/src/devcouncil/live/repair_prompt.py +29 -6
  93. package/src/devcouncil/live/reviewer.py +72 -13
  94. package/src/devcouncil/live/signals.py +2 -2
  95. package/src/devcouncil/live/summary.py +18 -8
  96. package/src/devcouncil/live/transcripts.py +47 -11
  97. package/src/devcouncil/llm/cache.py +20 -8
  98. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  99. package/src/devcouncil/llm/provider.py +617 -49
  100. package/src/devcouncil/llm/router.py +337 -53
  101. package/src/devcouncil/optimization/__init__.py +1 -0
  102. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  103. package/src/devcouncil/optimization/skillopt.py +673 -0
  104. package/src/devcouncil/planning/arbiter_service.py +10 -2
  105. package/src/devcouncil/planning/correction_manifest.py +346 -0
  106. package/src/devcouncil/planning/critique_service.py +16 -4
  107. package/src/devcouncil/planning/plan_service.py +86 -6
  108. package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
  109. package/src/devcouncil/planning/repair_service.py +8 -2
  110. package/src/devcouncil/planning/spec_service.py +37 -3
  111. package/src/devcouncil/repo/ci_scaffold.py +165 -0
  112. package/src/devcouncil/repo/gitignore.py +123 -0
  113. package/src/devcouncil/repo/sca.py +384 -0
  114. package/src/devcouncil/reporting/json_report.py +22 -1
  115. package/src/devcouncil/reporting/markdown_report.py +29 -1
  116. package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
  117. package/src/devcouncil/reporting/okf_html.py +323 -0
  118. package/src/devcouncil/reporting/report_builder.py +18 -1
  119. package/src/devcouncil/skills/__init__.py +19 -0
  120. package/src/devcouncil/skills/library/README.md +46 -0
  121. package/src/devcouncil/skills/library/ai-training.md +50 -0
  122. package/src/devcouncil/skills/library/android.md +50 -0
  123. package/src/devcouncil/skills/library/backend.md +52 -0
  124. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  125. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  126. package/src/devcouncil/skills/library/desktop.md +46 -0
  127. package/src/devcouncil/skills/library/devops.md +48 -0
  128. package/src/devcouncil/skills/library/game-dev.md +46 -0
  129. package/src/devcouncil/skills/library/ios.md +48 -0
  130. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  131. package/src/devcouncil/skills/library/security.md +48 -0
  132. package/src/devcouncil/skills/library/systems.md +48 -0
  133. package/src/devcouncil/skills/library/web.md +47 -0
  134. package/src/devcouncil/skills/library/windows.md +47 -0
  135. package/src/devcouncil/skills/registry.py +408 -0
  136. package/src/devcouncil/storage/db.py +140 -3
  137. package/src/devcouncil/storage/models.py +125 -0
  138. package/src/devcouncil/storage/native.py +559 -0
  139. package/src/devcouncil/storage/repositories.py +157 -78
  140. package/src/devcouncil/telemetry/cost.py +123 -17
  141. package/src/devcouncil/telemetry/logging_setup.py +244 -0
  142. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  143. package/src/devcouncil/telemetry/pricing.py +28 -0
  144. package/src/devcouncil/telemetry/stages.py +141 -0
  145. package/src/devcouncil/telemetry/traces.py +62 -7
  146. package/src/devcouncil/telemetry/tracker.py +24 -10
  147. package/src/devcouncil/ui/dashboard.py +393 -28
  148. package/src/devcouncil/utils/redaction.py +9 -3
  149. package/src/devcouncil/utils/subprocess_env.py +69 -0
  150. package/src/devcouncil/verification/acceptance_compiler.py +253 -0
  151. package/src/devcouncil/verification/ad_hoc_check.py +135 -0
  152. package/src/devcouncil/verification/diff_coverage.py +353 -0
  153. package/src/devcouncil/verification/implementation_reviewer.py +11 -2
  154. package/src/devcouncil/verification/next_actions.py +189 -0
  155. package/src/devcouncil/verification/sandbox.py +181 -0
  156. package/src/devcouncil/verification/test_resolver.py +91 -0
  157. package/src/devcouncil/verification/verifier.py +1549 -143
  158. package/uv.lock +205 -64
  159. 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 (``![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
+ )
@@ -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()