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,106 @@
1
+ """`dev logs` — find and read DevCouncil's runtime logs.
2
+
3
+ Comprehensive logging only helps if the logs are easy to reach when something
4
+ breaks. This command surfaces the always-on shared log
5
+ (``.devcouncil/logs/devcouncil.log``) and the per-run logs
6
+ (``.devcouncil/runs/<run_id>/run.log``) without the user needing to remember
7
+ paths or hand-roll ``tail``/``grep``.
8
+ """
9
+
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from devcouncil.telemetry.logging_setup import LOG_RELATIVE_PATH
18
+
19
+ app = typer.Typer(help="View DevCouncil runtime logs (shared and per-run).")
20
+ console = Console()
21
+
22
+
23
+ def _shared_log(root: Path) -> Path:
24
+ return root / LOG_RELATIVE_PATH
25
+
26
+
27
+ def _runs_dir(root: Path) -> Path:
28
+ return root / ".devcouncil" / "runs"
29
+
30
+
31
+ def _print_tail(path: Path, limit: int, grep: Optional[str]) -> None:
32
+ if not path.exists():
33
+ console.print(f"[yellow]No log at {path}. Run a command first (or pass --project-root).[/yellow]")
34
+ raise typer.Exit(code=0)
35
+ # Read only the tail rather than the whole (potentially large, rotated) file.
36
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
37
+ if grep:
38
+ lines = [line for line in lines if grep.lower() in line.lower()]
39
+ for line in lines[-limit:]:
40
+ console.print(line, markup=False, highlight=False)
41
+
42
+
43
+ @app.command("tail")
44
+ def tail(
45
+ limit: int = typer.Option(50, "--limit", "-n", help="Number of trailing lines to show."),
46
+ follow: bool = typer.Option(False, "--follow", "-f", help="Keep printing new lines as they are written."),
47
+ grep: Optional[str] = typer.Option(None, "--grep", "-g", help="Only show lines containing this substring (case-insensitive)."),
48
+ run: Optional[str] = typer.Option(None, "--run", help="Show a specific run's log (.devcouncil/runs/<run>/run.log) instead of the shared log."),
49
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
50
+ ):
51
+ """Print the tail of the shared log (or a per-run log with --run)."""
52
+ root = project_root.expanduser().resolve()
53
+ path = (_runs_dir(root) / run / "run.log") if run else _shared_log(root)
54
+
55
+ _print_tail(path, limit, grep)
56
+ if not follow:
57
+ return
58
+
59
+ # Follow mode: poll for appended bytes (the file is append-only between rotations).
60
+ console.print(f"[dim]— following {path} (Ctrl-C to stop) —[/dim]")
61
+ try:
62
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
63
+ handle.seek(0, 2) # jump to EOF; we already printed the tail
64
+ while True:
65
+ line = handle.readline()
66
+ if line:
67
+ rendered = line.rstrip("\n")
68
+ if not grep or grep.lower() in rendered.lower():
69
+ console.print(rendered, markup=False, highlight=False)
70
+ else:
71
+ time.sleep(0.4)
72
+ except KeyboardInterrupt: # pragma: no cover - interactive
73
+ pass
74
+
75
+
76
+ @app.command("path")
77
+ def path(
78
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
79
+ ):
80
+ """Print the shared log file path (and whether it exists)."""
81
+ root = project_root.expanduser().resolve()
82
+ log = _shared_log(root)
83
+ marker = "" if log.exists() else " [yellow](not created yet)[/yellow]"
84
+ console.print(f"{log}{marker}")
85
+
86
+
87
+ @app.command("runs")
88
+ def runs(
89
+ limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of recent run logs to list."),
90
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
91
+ ):
92
+ """List per-run logs, newest first, with their paths."""
93
+ root = project_root.expanduser().resolve()
94
+ runs_dir = _runs_dir(root)
95
+ if not runs_dir.exists():
96
+ console.print("[yellow]No runs yet.[/yellow]")
97
+ return
98
+ run_logs = [d / "run.log" for d in runs_dir.iterdir() if (d / "run.log").exists()]
99
+ if not run_logs:
100
+ console.print("[yellow]No per-run logs yet (run.log appears once an executor runs).[/yellow]")
101
+ return
102
+ run_logs.sort(key=lambda p: p.stat().st_mtime, reverse=True)
103
+ for log in run_logs[:limit]:
104
+ size_kb = log.stat().st_size / 1024
105
+ console.print(f"[bold]{log.parent.name}[/bold] [dim]{log} ({size_kb:.1f} KB)[/dim]")
106
+ console.print("\n[dim]View one with:[/dim] dev logs tail --run <run-id>")
@@ -5,13 +5,85 @@ import typer
5
5
  from rich.console import Console
6
6
 
7
7
  from devcouncil.cli.commands.init import initialize_project
8
- from devcouncil.indexing.repo_mapper import RepoMapper
8
+ from devcouncil.indexing.repo_mapper import RepoMap, RepoMapper
9
9
  from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
10
10
  from devcouncil.storage.db import get_db
11
11
 
12
12
  console = Console()
13
13
  status_console = Console(stderr=True)
14
14
 
15
+ AGENT_GUIDE_MARKER = "<!-- Managed by dev map: keep this file in sync with .devcouncil/repo_map.json. -->"
16
+
17
+
18
+ def _important_surfaces(repo_map: RepoMap) -> list[str]:
19
+ """Derive the 'important surfaces' list from the computed map, so the guide points
20
+ at THIS repo's real subsystems instead of hardcoded DevCouncil paths."""
21
+ lines: list[str] = []
22
+ for index, subsystem in enumerate(repo_map.subsystems[:6], start=1):
23
+ lines.append(f"{index}. `{subsystem.area}/` — {subsystem.summary}")
24
+ if not lines:
25
+ for index, path in enumerate(repo_map.important_files[:6], start=1):
26
+ lines.append(f"{index}. `{path}`")
27
+ return lines or ["1. See `.devcouncil/repo_map.json` for the file index."]
28
+
29
+
30
+ def _agent_guide_text(repo_map_path: Path, repo_root: Path, repo_map: RepoMap) -> str:
31
+ return "\n".join(
32
+ [
33
+ AGENT_GUIDE_MARKER,
34
+ "",
35
+ "# Agent Workspace Guide",
36
+ "",
37
+ "Use `.devcouncil/repo_map.json` as the primary file index for this workspace.",
38
+ f"Repo map: `{repo_map_path.relative_to(repo_root).as_posix() if repo_map_path.is_relative_to(repo_root) else repo_map_path}`",
39
+ "",
40
+ "Workflow for agents:",
41
+ "1. Open `.devcouncil/repo_map.json` before guessing at file locations.",
42
+ "2. Use the `files` list to resolve module ownership and nearby siblings.",
43
+ "3. Use `subsystems` for subsystem-level navigation.",
44
+ "4. In `subsystems`, use `entry_points` + `critical_files` for entry points and starting context.",
45
+ "5. Use `role_files` in `subsystems` for subsystem role buckets (entry, runtime, policy, adapters, etc.).",
46
+ "6. Use `neighbors` and `handoff_paths` in `subsystems` to follow cross-subsystem flow.",
47
+ "7. Run `dev map` again after large refactors to refresh the map.",
48
+ "",
49
+ "Important surfaces:",
50
+ *_important_surfaces(repo_map),
51
+ "",
52
+ "If the map and source disagree, trust the source and regenerate the map.",
53
+ ]
54
+ )
55
+
56
+
57
+ def _write_agent_guides(repo_root: Path, repo_map_path: Path, repo_map: RepoMap) -> None:
58
+ for filename in ("AGENTS.md", "CLAUDE.md"):
59
+ path = repo_root / filename
60
+ if path.exists():
61
+ existing = path.read_text(encoding="utf-8")
62
+ if AGENT_GUIDE_MARKER not in existing:
63
+ continue
64
+ path.write_text(_agent_guide_text(repo_map_path, repo_root, repo_map) + "\n", encoding="utf-8")
65
+
66
+
67
+ def generate_map_artifacts(root: Path, output: Path, goal: str = "", *, scan_dependencies: bool = False) -> RepoMap:
68
+ """Build the repo map and write repo_map.json + agent guides (no LLM, no re-init).
69
+
70
+ Assumes ``.devcouncil/`` already exists. Shared by the ``dev map`` command and
71
+ by project initialization so a freshly set-up repo is immediately navigable.
72
+ ``scan_dependencies`` is opt-in (off for init and default mapping) because it can
73
+ shell out to dependency auditors.
74
+ """
75
+ repo_map = RepoMapper(root).map_repo(goal, scan_dependencies=scan_dependencies)
76
+ graph_context = CodeReviewGraphAdapter(root).get_context()
77
+ output = output if output.is_absolute() else root / output
78
+ output.parent.mkdir(parents=True, exist_ok=True)
79
+ output.write_text(repo_map.model_dump_json(indent=2), encoding="utf-8")
80
+ _write_agent_guides(root, output, repo_map)
81
+ if graph_context.available:
82
+ graph_output = output.with_name("code_review_graph_context.json")
83
+ graph_output.write_text(graph_context.model_dump_json(indent=2), encoding="utf-8")
84
+ status_console.print(f"[green]Wrote code-review-graph context to {graph_output}[/green]")
85
+ return repo_map
86
+
15
87
 
16
88
  def map_repo(
17
89
  goal: str = typer.Argument("", help="Goal text used for candidate-file ranking."),
@@ -22,21 +94,19 @@ def map_repo(
22
94
  help="Path to write repo_map.json.",
23
95
  ),
24
96
  project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
97
+ scan_deps: bool = typer.Option(
98
+ False,
99
+ "--scan-deps",
100
+ help="Run available dependency auditors (pip-audit/npm audit/osv-scanner) and record dependency_risks in the map. Off by default.",
101
+ ),
25
102
  ):
26
103
  """Build the deterministic repository map without calling an LLM."""
27
104
  root = project_root.expanduser().resolve()
28
- initialize_project(root, quiet=True)
105
+ initialize_project(root, quiet=True, with_map=False)
29
106
  if not get_db(root):
30
107
  raise typer.Exit(code=1)
31
108
 
32
- repo_map = RepoMapper(root).map_repo(goal)
33
- graph_context = CodeReviewGraphAdapter(root).get_context()
34
109
  output = output if output.is_absolute() else root / output
35
- output.parent.mkdir(parents=True, exist_ok=True)
36
- output.write_text(repo_map.model_dump_json(indent=2), encoding="utf-8")
37
- if graph_context.available:
38
- graph_output = output.with_name("code_review_graph_context.json")
39
- graph_output.write_text(graph_context.model_dump_json(indent=2), encoding="utf-8")
40
- status_console.print(f"[green]Wrote code-review-graph context to {graph_output}[/green]")
110
+ repo_map = generate_map_artifacts(root, output, goal, scan_dependencies=scan_deps)
41
111
  typer.echo(json.dumps(repo_map.model_dump(), indent=2))
42
112
  status_console.print(f"[green]Wrote repository map to {output}[/green]")
@@ -0,0 +1,245 @@
1
+ """`dev okf` — export, ingest, and validate Open Knowledge Format bundles.
2
+
3
+ * ``dev okf export`` renders DevCouncil's artifact graph as a portable OKF bundle.
4
+ * ``dev okf ingest`` imports an external OKF bundle as planning/coding context under
5
+ ``.devcouncil/knowledge/okf/`` (selected into prompts like a domain skill).
6
+ * ``dev okf validate`` checks a bundle for the OKF invariants (typed docs, resolved links).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from devcouncil.cli.commands.init import initialize_project
19
+ from devcouncil.knowledge.okf import read_bundle, validate_bundle
20
+ from devcouncil.storage.db import get_db
21
+ from devcouncil.storage.repositories import ArtifactGraphRepository
22
+
23
+ app = typer.Typer(help="Export, ingest, and validate Open Knowledge Format bundles.")
24
+ console = Console()
25
+
26
+
27
+ def _knowledge_okf_dir(root: Path) -> Path:
28
+ from devcouncil.app.config import load_config
29
+
30
+ directory = ".devcouncil/knowledge"
31
+ try:
32
+ directory = load_config(root).knowledge.directory
33
+ except Exception:
34
+ pass
35
+ return root / directory / "okf"
36
+
37
+
38
+ def _knowledge_design_md(root: Path) -> Path | None:
39
+ """Locate the project's design.md under the configured knowledge dir's design/ subdir.
40
+
41
+ Honors the same config dir resolution as :func:`_knowledge_okf_dir`, falling back to the
42
+ default ``.devcouncil/knowledge/design/design.md``. Returns ``None`` if no design.md exists.
43
+ """
44
+ from devcouncil.app.config import load_config
45
+
46
+ directory = ".devcouncil/knowledge"
47
+ try:
48
+ directory = load_config(root).knowledge.directory
49
+ except Exception:
50
+ pass
51
+ candidates = [
52
+ root / directory / "design" / "design.md",
53
+ root / ".devcouncil" / "knowledge" / "design" / "design.md",
54
+ ]
55
+ for candidate in candidates:
56
+ if candidate.is_file():
57
+ return candidate
58
+ return None
59
+
60
+
61
+ @app.command("export")
62
+ def export(
63
+ output: Path = typer.Option(Path("okf_bundle"), "--output", "-o", help="Directory to write the OKF bundle into."),
64
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
65
+ skills: bool = typer.Option(
66
+ True,
67
+ "--skills/--no-skills",
68
+ help="Include the engineering skills library as OKF documents in the bundle.",
69
+ ),
70
+ design: bool = typer.Option(
71
+ True,
72
+ "--design/--no-design",
73
+ help="Include the project's design.md (if present) as an OKF document in the bundle.",
74
+ ),
75
+ ):
76
+ """Export the DevCouncil artifact graph as an OKF bundle."""
77
+ root = project_root.expanduser().resolve()
78
+ initialize_project(root, quiet=True)
79
+ db = get_db(root)
80
+ if not db:
81
+ console.print("[red]DevCouncil state is unavailable in this directory.[/red]")
82
+ raise typer.Exit(code=1)
83
+
84
+ out_dir = output.expanduser().resolve()
85
+ with db.get_session() as session:
86
+ graph = ArtifactGraphRepository(session).load_graph()
87
+
88
+ project_name = root.name or "DevCouncil Project"
89
+ try:
90
+ from devcouncil.app.config import load_config
91
+
92
+ project_name = load_config(root).project.name or project_name
93
+ except Exception:
94
+ pass
95
+
96
+ # Load the FULL skill set (packaged library + this repo's own skills) so the export is
97
+ # complete; goal-driven selection would only emit the few skills that match a goal.
98
+ skill_list: list = []
99
+ if skills:
100
+ from devcouncil.skills.registry import load_skills
101
+
102
+ skill_list = load_skills(project_root=root)
103
+
104
+ # Look for the project's design.md under the configured knowledge dir; silently skip if
105
+ # absent so export stays useful for projects without a design system.
106
+ design_obj = None
107
+ if design:
108
+ design_md = _knowledge_design_md(root)
109
+ if design_md is not None:
110
+ from devcouncil.knowledge.design import parse_design_md
111
+
112
+ try:
113
+ design_obj = parse_design_md(design_md)
114
+ except Exception:
115
+ design_obj = None
116
+
117
+ timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
118
+ from devcouncil.reporting.okf_bundle_writer import OKFBundleWriter
119
+
120
+ written = OKFBundleWriter.generate(
121
+ graph,
122
+ out_dir,
123
+ project_name=project_name,
124
+ timestamp=timestamp,
125
+ include_skills=bool(skill_list),
126
+ skills=skill_list,
127
+ include_design=design_obj is not None,
128
+ design=design_obj,
129
+ )
130
+ console.print(
131
+ f"[green]Exported {len(written)} OKF documents to[/green] {out_dir} "
132
+ f"([cyan]{out_dir / 'index.md'}[/cyan])."
133
+ )
134
+ if skill_list:
135
+ console.print(f"[green]Included {len(skill_list)} engineering skill document(s).[/green]")
136
+ if design_obj is not None:
137
+ console.print("[green]Included 1 design system document(s).[/green]")
138
+
139
+
140
+ @app.command("ingest")
141
+ def ingest(
142
+ bundle: str = typer.Argument(
143
+ ...,
144
+ help="OKF bundle source: a local directory, a .tar.gz/.tgz/.zip archive, or a git URL.",
145
+ ),
146
+ name: str = typer.Option("", "--name", help="Subfolder name under knowledge/okf (defaults to the bundle name)."),
147
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Repository root containing .devcouncil/."),
148
+ ):
149
+ """Ingest an external OKF bundle as durable planning/coding context.
150
+
151
+ The bundle may be a local directory, a local archive (``.tar.gz``/``.tgz``/``.zip``,
152
+ extracted behind a path-traversal guard), or a git URL (shallow-cloned). After the
153
+ source is materialized to a local directory, the existing read/validate/copy logic runs
154
+ unchanged; any temp directory is removed afterwards.
155
+ """
156
+ root = project_root.expanduser().resolve()
157
+
158
+ from devcouncil.knowledge.fetch import fetch_bundle
159
+
160
+ try:
161
+ fetched = fetch_bundle(bundle)
162
+ except Exception as exc:
163
+ console.print(f"[red]Could not fetch bundle[/red] {bundle!r}: {exc}")
164
+ raise typer.Exit(code=1)
165
+
166
+ try:
167
+ src = fetched.directory
168
+ if not src.is_dir():
169
+ console.print(f"[red]Not a directory:[/red] {src}")
170
+ raise typer.Exit(code=1)
171
+
172
+ initialize_project(root, quiet=True)
173
+ parsed = read_bundle(src)
174
+ if not parsed.documents:
175
+ console.print(f"[yellow]No OKF documents (*.md) found in[/yellow] {bundle}")
176
+ raise typer.Exit(code=1)
177
+
178
+ problems = validate_bundle(parsed)
179
+ if problems:
180
+ console.print(f"[yellow]Ingesting a bundle with {len(problems)} validation issue(s):[/yellow]")
181
+ for p in problems[:10]:
182
+ console.print(f" - {p}")
183
+
184
+ dest = _knowledge_okf_dir(root) / (name or fetched.suggested_name or "bundle")
185
+ dest.mkdir(parents=True, exist_ok=True)
186
+ count = 0
187
+ for md in src.rglob("*.md"):
188
+ rel = md.relative_to(src)
189
+ target = dest / rel
190
+ target.parent.mkdir(parents=True, exist_ok=True)
191
+ shutil.copyfile(md, target)
192
+ count += 1
193
+ console.print(
194
+ f"[green]Ingested {count} OKF document(s) into[/green] {dest}. "
195
+ "They are now available as planning/coding context."
196
+ )
197
+ finally:
198
+ fetched.cleanup()
199
+
200
+
201
+ @app.command("validate")
202
+ def validate(
203
+ bundle: Path = typer.Argument(..., help="Path to an OKF bundle directory to validate."),
204
+ ):
205
+ """Validate an OKF bundle (every doc typed; every intra-bundle link resolves)."""
206
+ src = bundle.expanduser().resolve()
207
+ if not src.is_dir():
208
+ console.print(f"[red]Not a directory:[/red] {src}")
209
+ raise typer.Exit(code=1)
210
+
211
+ parsed = read_bundle(src)
212
+ problems = validate_bundle(parsed)
213
+ if not problems:
214
+ console.print(f"[green]✓ Valid OKF bundle[/green] — {len(parsed.documents)} document(s), all links resolve.")
215
+ return
216
+ console.print(f"[red]✗ {len(problems)} problem(s) in[/red] {src}:")
217
+ for p in problems:
218
+ console.print(f" - {p}")
219
+ raise typer.Exit(code=1)
220
+
221
+
222
+ @app.command("html")
223
+ def html(
224
+ bundle: Path = typer.Argument(..., help="Path to an OKF bundle directory to render."),
225
+ output: Path = typer.Option(Path("okf_site"), "--output", "-o", help="Directory to write the static HTML site into."),
226
+ ):
227
+ """Render an OKF bundle as a browsable, self-contained static HTML site."""
228
+ src = bundle.expanduser().resolve()
229
+ if not src.is_dir():
230
+ console.print(f"[red]Not a directory:[/red] {src}")
231
+ raise typer.Exit(code=1)
232
+
233
+ parsed = read_bundle(src)
234
+ if not parsed.documents:
235
+ console.print(f"[yellow]No OKF documents (*.md) found in[/yellow] {src}")
236
+ raise typer.Exit(code=1)
237
+
238
+ from devcouncil.reporting.okf_html import write_bundle_html
239
+
240
+ out_dir = output.expanduser().resolve()
241
+ written = write_bundle_html(parsed, out_dir)
242
+ console.print(
243
+ f"[green]Rendered {len(written)} page(s) to[/green] {out_dir} "
244
+ f"([cyan]{out_dir / 'index.html'}[/cyan])."
245
+ )