devcouncil 0.1.0 → 0.2.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 (190) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +197 -494
  3. package/package.json +9 -2
  4. package/pyproject.toml +62 -27
  5. package/src/devcouncil/__main__.py +4 -4
  6. package/src/devcouncil/app/__init__.py +28 -28
  7. package/src/devcouncil/app/config.py +297 -108
  8. package/src/devcouncil/app/errors.py +23 -23
  9. package/src/devcouncil/app/events.py +44 -44
  10. package/src/devcouncil/app/orchestrator.py +67 -67
  11. package/src/devcouncil/app/project_status.py +29 -0
  12. package/src/devcouncil/app/run_context.py +39 -39
  13. package/src/devcouncil/app/state_machine.py +108 -108
  14. package/src/devcouncil/artifacts/__init__.py +1 -1
  15. package/src/devcouncil/artifacts/coverage.py +96 -96
  16. package/src/devcouncil/artifacts/graph.py +163 -143
  17. package/src/devcouncil/artifacts/migrations.py +20 -20
  18. package/src/devcouncil/artifacts/schemas.py +23 -23
  19. package/src/devcouncil/artifacts/serializer.py +21 -21
  20. package/src/devcouncil/artifacts/validators.py +27 -27
  21. package/src/devcouncil/assets/__init__.py +1 -0
  22. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  23. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  24. package/src/devcouncil/cli/commands/agents.py +292 -0
  25. package/src/devcouncil/cli/commands/artifacts.py +54 -48
  26. package/src/devcouncil/cli/commands/ast.py +22 -0
  27. package/src/devcouncil/cli/commands/baseline.py +35 -32
  28. package/src/devcouncil/cli/commands/check.py +209 -0
  29. package/src/devcouncil/cli/commands/config.py +115 -54
  30. package/src/devcouncil/cli/commands/cost.py +57 -0
  31. package/src/devcouncil/cli/commands/dashboard.py +31 -0
  32. package/src/devcouncil/cli/commands/doctor.py +291 -47
  33. package/src/devcouncil/cli/commands/evidence.py +48 -0
  34. package/src/devcouncil/cli/commands/go.py +656 -0
  35. package/src/devcouncil/cli/commands/handoff.py +69 -0
  36. package/src/devcouncil/cli/commands/hook.py +209 -33
  37. package/src/devcouncil/cli/commands/init.py +204 -57
  38. package/src/devcouncil/cli/commands/integrate.py +1171 -76
  39. package/src/devcouncil/cli/commands/lsp.py +20 -0
  40. package/src/devcouncil/cli/commands/map.py +96 -22
  41. package/src/devcouncil/cli/commands/plan.py +422 -210
  42. package/src/devcouncil/cli/commands/prompt.py +48 -34
  43. package/src/devcouncil/cli/commands/repair.py +89 -69
  44. package/src/devcouncil/cli/commands/report.py +120 -54
  45. package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
  46. package/src/devcouncil/cli/commands/rollback.py +55 -54
  47. package/src/devcouncil/cli/commands/run.py +285 -220
  48. package/src/devcouncil/cli/commands/runs.py +223 -0
  49. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  50. package/src/devcouncil/cli/commands/semantic.py +47 -0
  51. package/src/devcouncil/cli/commands/setup.py +300 -20
  52. package/src/devcouncil/cli/commands/shell.py +73 -0
  53. package/src/devcouncil/cli/commands/show.py +76 -57
  54. package/src/devcouncil/cli/commands/skills.py +88 -0
  55. package/src/devcouncil/cli/commands/status.py +141 -105
  56. package/src/devcouncil/cli/commands/tasks.py +55 -41
  57. package/src/devcouncil/cli/commands/trace.py +49 -4
  58. package/src/devcouncil/cli/commands/verify.py +293 -128
  59. package/src/devcouncil/cli/commands/version.py +20 -20
  60. package/src/devcouncil/cli/commands/watch.py +574 -0
  61. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  62. package/src/devcouncil/cli/main.py +92 -25
  63. package/src/devcouncil/council/prompts/arbiter.md +19 -19
  64. package/src/devcouncil/council/prompts/critic_a.md +10 -10
  65. package/src/devcouncil/council/prompts/critic_b.md +10 -10
  66. package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
  67. package/src/devcouncil/council/prompts/planner_a.md +16 -16
  68. package/src/devcouncil/council/prompts/planner_b.md +16 -16
  69. package/src/devcouncil/council/prompts/rebuttal.md +10 -10
  70. package/src/devcouncil/council/prompts/spec_writer.md +12 -12
  71. package/src/devcouncil/domain/assumption.py +17 -17
  72. package/src/devcouncil/domain/critique.py +32 -32
  73. package/src/devcouncil/domain/evidence.py +47 -27
  74. package/src/devcouncil/domain/gap.py +52 -26
  75. package/src/devcouncil/domain/requirement.py +22 -22
  76. package/src/devcouncil/domain/task.py +55 -26
  77. package/src/devcouncil/execution/__init__.py +1 -1
  78. package/src/devcouncil/execution/checkpoints.py +246 -0
  79. package/src/devcouncil/execution/context_builder.py +54 -54
  80. package/src/devcouncil/execution/executor.py +15 -15
  81. package/src/devcouncil/execution/fs_watcher.py +180 -0
  82. package/src/devcouncil/execution/handoff.py +102 -0
  83. package/src/devcouncil/execution/hook_policy.py +186 -77
  84. package/src/devcouncil/execution/patch.py +77 -28
  85. package/src/devcouncil/execution/permissions.py +52 -59
  86. package/src/devcouncil/execution/policy_engine.py +343 -0
  87. package/src/devcouncil/execution/prompt_builder.py +650 -38
  88. package/src/devcouncil/execution/shell_session.py +225 -0
  89. package/src/devcouncil/execution/task_runner.py +68 -64
  90. package/src/devcouncil/executors/__init__.py +1 -1
  91. package/src/devcouncil/executors/agent_registry.py +575 -0
  92. package/src/devcouncil/executors/coding_cli.py +736 -0
  93. package/src/devcouncil/executors/mini_swe.py +63 -63
  94. package/src/devcouncil/executors/native/agent.py +186 -85
  95. package/src/devcouncil/executors/openhands.py +56 -56
  96. package/src/devcouncil/gating/__init__.py +1 -1
  97. package/src/devcouncil/gating/checks/clean_git.py +52 -45
  98. package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
  99. package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
  100. package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
  101. package/src/devcouncil/gating/policy.py +315 -167
  102. package/src/devcouncil/hardware.py +184 -0
  103. package/src/devcouncil/indexing/__init__.py +1 -1
  104. package/src/devcouncil/indexing/ast_matcher.py +168 -0
  105. package/src/devcouncil/indexing/graph_index.py +48 -48
  106. package/src/devcouncil/indexing/lsp.py +161 -0
  107. package/src/devcouncil/indexing/repo_mapper.py +1455 -204
  108. package/src/devcouncil/indexing/semantic_index.py +205 -0
  109. package/src/devcouncil/integrations/actions.py +146 -0
  110. package/src/devcouncil/integrations/check.py +423 -0
  111. package/src/devcouncil/integrations/github.py +35 -35
  112. package/src/devcouncil/integrations/github_intent.py +142 -0
  113. package/src/devcouncil/integrations/gitnexus.py +62 -27
  114. package/src/devcouncil/integrations/graphify.py +34 -34
  115. package/src/devcouncil/integrations/mcp/server.py +2072 -96
  116. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  117. package/src/devcouncil/integrations/pr_comments.py +62 -0
  118. package/src/devcouncil/live/__init__.py +2 -0
  119. package/src/devcouncil/live/cards.py +349 -0
  120. package/src/devcouncil/live/models.py +63 -0
  121. package/src/devcouncil/live/repair_prompt.py +83 -0
  122. package/src/devcouncil/live/reviewer.py +70 -0
  123. package/src/devcouncil/live/signals.py +135 -0
  124. package/src/devcouncil/live/summary.py +34 -0
  125. package/src/devcouncil/live/tasks.py +18 -0
  126. package/src/devcouncil/live/transcripts.py +141 -0
  127. package/src/devcouncil/llm/__init__.py +1 -1
  128. package/src/devcouncil/llm/cache.py +42 -38
  129. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  130. package/src/devcouncil/llm/provider.py +627 -125
  131. package/src/devcouncil/llm/router.py +303 -118
  132. package/src/devcouncil/optimization/__init__.py +1 -0
  133. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  134. package/src/devcouncil/planning/__init__.py +1 -1
  135. package/src/devcouncil/planning/arbiter_service.py +57 -57
  136. package/src/devcouncil/planning/correction_manifest.py +303 -0
  137. package/src/devcouncil/planning/critique_service.py +71 -66
  138. package/src/devcouncil/planning/plan_service.py +60 -46
  139. package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
  140. package/src/devcouncil/planning/repair_service.py +39 -39
  141. package/src/devcouncil/planning/spec_service.py +70 -44
  142. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  143. package/src/devcouncil/repo/gitignore.py +123 -0
  144. package/src/devcouncil/repo/sca.py +374 -0
  145. package/src/devcouncil/reporting/github_check.py +32 -32
  146. package/src/devcouncil/reporting/json_report.py +30 -17
  147. package/src/devcouncil/reporting/markdown_report.py +83 -46
  148. package/src/devcouncil/reporting/report_builder.py +14 -14
  149. package/src/devcouncil/skills/__init__.py +19 -0
  150. package/src/devcouncil/skills/library/README.md +46 -0
  151. package/src/devcouncil/skills/library/ai-training.md +50 -0
  152. package/src/devcouncil/skills/library/android.md +50 -0
  153. package/src/devcouncil/skills/library/backend.md +52 -0
  154. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  155. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  156. package/src/devcouncil/skills/library/desktop.md +46 -0
  157. package/src/devcouncil/skills/library/devops.md +48 -0
  158. package/src/devcouncil/skills/library/game-dev.md +46 -0
  159. package/src/devcouncil/skills/library/ios.md +48 -0
  160. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  161. package/src/devcouncil/skills/library/security.md +48 -0
  162. package/src/devcouncil/skills/library/systems.md +48 -0
  163. package/src/devcouncil/skills/library/web.md +47 -0
  164. package/src/devcouncil/skills/library/windows.md +47 -0
  165. package/src/devcouncil/skills/registry.py +330 -0
  166. package/src/devcouncil/storage/db.py +147 -66
  167. package/src/devcouncil/storage/models.py +204 -83
  168. package/src/devcouncil/storage/native.py +557 -0
  169. package/src/devcouncil/storage/repositories.py +388 -249
  170. package/src/devcouncil/telemetry/cost.py +140 -34
  171. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  172. package/src/devcouncil/telemetry/pricing.py +28 -0
  173. package/src/devcouncil/telemetry/traces.py +62 -7
  174. package/src/devcouncil/telemetry/tracker.py +52 -49
  175. package/src/devcouncil/ui/__init__.py +1 -0
  176. package/src/devcouncil/ui/dashboard.py +423 -0
  177. package/src/devcouncil/utils/__init__.py +1 -1
  178. package/src/devcouncil/utils/redaction.py +147 -141
  179. package/src/devcouncil/utils/subprocess_env.py +69 -0
  180. package/src/devcouncil/verification/__init__.py +1 -1
  181. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  182. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  183. package/src/devcouncil/verification/diff_coverage.py +353 -0
  184. package/src/devcouncil/verification/implementation_reviewer.py +55 -55
  185. package/src/devcouncil/verification/next_actions.py +189 -0
  186. package/src/devcouncil/verification/sandbox.py +178 -0
  187. package/src/devcouncil/verification/test_resolver.py +91 -0
  188. package/src/devcouncil/verification/verifier.py +1342 -307
  189. package/uv.lock +205 -64
  190. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: systems
3
+ title: Systems / Embedded / Native Intake
4
+ description: Before writing native, systems, or embedded code, retrieve current toolchain/standard versions, memory and concurrency rules, undefined-behavior and safety guidance, and the right build/flash/test commands — like a senior systems engineer briefing themselves.
5
+ triggers:
6
+ keywords: [embedded, firmware, rtos, freertos, zephyr, microcontroller, "bare metal", "bare-metal", no_std, kernel, "device driver", "systems programming", cmake, "c++", cpp, stm32, esp32, arduino, "memory safety", "undefined behavior", simd, mmap, syscall]
7
+ globs: ["CMakeLists.txt", "*.cpp", "*.cc", "*.hpp", "*.ino", "platformio.ini", "*.ld", "Kconfig", "prj.conf", "sdkconfig", "*.dts"]
8
+ ---
9
+
10
+ # Systems / Embedded / Native Intake
11
+
12
+ Do this **before** writing or changing native/systems/embedded code. Don't rely on training
13
+ data — toolchains, language standards, and platform constraints change, and a memory or
14
+ concurrency bug here is often silent until it corrupts state or crashes in the field. Confirm
15
+ against the toolchain/standard docs and the project's own build config.
16
+
17
+ ## Establish current state first
18
+
19
+ 1. **Toolchain & standard in use** — read the build config (`CMakeLists.txt`, `Cargo.toml`,
20
+ `platformio.ini`, `Makefile`): compiler + version, language standard (C11/C++20/Rust edition),
21
+ target triple/MCU, and `no_std`/freestanding vs hosted. Match what's already there.
22
+ 2. **Memory & ownership** — allocation strategy (heap vs static/stack, arenas, no-alloc on
23
+ embedded), ownership/lifetime rules, and buffer-bounds discipline. Avoid undefined behavior:
24
+ no use-after-free, no data races, no signed overflow, no aliasing violations.
25
+ 3. **Concurrency & interrupts** — what runs in ISR vs task context, shared state and its locking
26
+ (or lock-free/atomics), `volatile` for MMIO, and memory-ordering requirements.
27
+ 4. **Platform constraints** — flash/RAM budget, alignment and endianness, real-time deadlines,
28
+ and the ABI/calling convention if crossing language or FFI boundaries.
29
+ 5. **Safety tooling** — what's available and expected: sanitizers (ASan/UBSan/TSan), static
30
+ analysis (clang-tidy, cppcheck), `cargo clippy`/`miri`, and valgrind on hosted targets.
31
+
32
+ ## Build & CLI tools
33
+
34
+ - Build: `cmake --build`, `make`, `cargo build --target ...`, `west build`, `idf.py`,
35
+ `platformio run`. Use the project's presets/wrapper.
36
+ - Test/verify: `ctest`, `cargo test`/`clippy`/`miri`, unit tests on host, and on-target/HIL or
37
+ an emulator (QEMU/Renode) when hardware isn't available.
38
+ - Flash/debug: the project's `openocd`/`gdb`/`probe-rs`/`idf.py flash` flow.
39
+
40
+ ## What to record before coding
41
+
42
+ - The toolchain/standard/target and the exact build config you will use.
43
+ - The memory/ownership and concurrency model for the code you touch, and the UB you must avoid.
44
+ - The platform budget/constraints relevant to the change.
45
+ - The build/sanitizer/test commands (and on-target or emulator run) that prove correctness.
46
+
47
+ Don't broaden the change beyond the task — no incidental toolchain bumps or refactors across
48
+ unrelated modules (see the surgical-changes rule in core-engineering).
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: web
3
+ title: Web / Frontend Development Intake
4
+ description: Before writing web code, retrieve current framework versions, runtime/build tooling, deprecations, and recommended patterns — like a senior web engineer briefing themselves on the stack.
5
+ triggers:
6
+ keywords: [web, website, frontend, react, next, nextjs, vue, svelte, angular, typescript, javascript, vite, tailwind, node]
7
+ globs: ["package.json", "tsconfig.json", "*.tsx", "*.jsx", "*.vue", "*.svelte", "next.config.*", "vite.config.*", "tailwind.config.*"]
8
+ ---
9
+
10
+ # Web / Frontend Development Intake
11
+
12
+ Do this **before** writing or changing web code. The JS/TS ecosystem moves quickly
13
+ and major versions change defaults and APIs. Confirm against the framework's official
14
+ docs and the project's `package.json` — not from memory.
15
+
16
+ ## Establish current state first
17
+
18
+ 1. **Framework & versions** — read `package.json` (and lockfile): the framework
19
+ (React/Next, Vue/Nuxt, Svelte/SvelteKit, Angular), its major version, the build
20
+ tool (Vite, Next, Webpack), the package manager (npm/pnpm/yarn/bun), and the Node
21
+ version (`engines`, `.nvmrc`). Match what's already in use.
22
+ 2. **Latest stable & major-version shifts** — current stable major and any defaults
23
+ that changed (e.g. React Server Components / the App Router, Vue 3 Composition API,
24
+ Svelte 5 runes, ESM-only packages). Note what gates this task.
25
+ 3. **Deprecations** — APIs/patterns deprecated in the project's major version (e.g.
26
+ legacy lifecycle methods, `getInitialProps`, options API where composition is
27
+ preferred). List the ones this change touches and their replacements.
28
+ 4. **Recommended patterns** — TypeScript strictness, data-fetching/caching model,
29
+ state management, styling approach (CSS modules, Tailwind, CSS-in-JS), and
30
+ accessibility (semantic HTML, ARIA only where needed, keyboard support).
31
+ 5. **Guidelines** — performance budgets (Core Web Vitals), accessibility (WCAG), and
32
+ SSR/CSR/SSG choice relevant to the change.
33
+
34
+ ## Build & CLI tools
35
+
36
+ - Package manager scripts (`npm run build`/`test`/`lint`, or pnpm/yarn/bun equivalents).
37
+ - The framework CLI (`next`, `vite`, `ng`, `svelte-kit`) for dev/build.
38
+ - `eslint`/`prettier`/`tsc --noEmit` and the test runner (Vitest/Jest/Playwright) if configured.
39
+
40
+ ## What to record before coding
41
+
42
+ - Framework + major version, build tool, package manager, and Node version.
43
+ - Deprecated patterns to avoid and their modern replacements.
44
+ - The build/test/lint commands you will run so the change is verifiable.
45
+
46
+ Don't introduce a second styling system or state library when one is already in use,
47
+ and don't bump a major framework version as a side effect of an unrelated task.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: windows
3
+ title: Windows App Development Intake
4
+ description: Before writing Windows desktop code, retrieve current .NET/WinUI/WPF versions, supported targets, deprecations, recommended frameworks, and the right CLI/build tools — like a senior Windows engineer.
5
+ triggers:
6
+ keywords: [windows, wpf, winui, winforms, uwp, win32, dotnet, ".net", csharp, "c#", xaml, maui, msix]
7
+ globs: ["*.csproj", "*.sln", "*.xaml", "*.cs", "Directory.Build.props", "global.json", "*.vcxproj"]
8
+ ---
9
+
10
+ # Windows App Development Intake
11
+
12
+ Do this **before** writing or changing Windows desktop code. Confirm against
13
+ Microsoft Learn, the .NET release notes, and the project files — the Windows app
14
+ stack has several overlapping UI frameworks and the right choice depends on targets.
15
+
16
+ ## Establish current state first
17
+
18
+ 1. **Toolchain & targets** — read `*.csproj` / `global.json` / `Directory.Build.props`:
19
+ `TargetFramework(s)` (e.g. `net8.0-windows`), .NET SDK version, and the UI stack in
20
+ use (WPF, WinUI 3 / Windows App SDK, WinForms, UWP, or .NET MAUI). Identify which one
21
+ this file belongs to and stay in it.
22
+ 2. **Latest .NET & runtime** — current LTS/STS .NET release and whether the project
23
+ should target it; note Windows version / Windows App SDK minimums.
24
+ 3. **Deprecations & migrations** — UWP is in maintenance; new desktop work generally
25
+ targets WinUI 3 (Windows App SDK) or WPF on modern .NET. `.NET Framework` (4.x) is
26
+ legacy — don't introduce it for new code. Note any deprecated APIs the change touches.
27
+ 4. **Recommended frameworks** — packaging via MSIX; MVVM (e.g. CommunityToolkit.Mvvm);
28
+ dependency injection via `Microsoft.Extensions.DependencyInjection`; async/await over
29
+ blocking calls. Confirm current recommended packages and versions on NuGet.
30
+ 5. **Guidelines** — Fluent design, accessibility (UI Automation), and packaging/signing
31
+ requirements relevant to the change.
32
+
33
+ ## Build & CLI tools
34
+
35
+ - `dotnet build` / `dotnet test` / `dotnet publish`; `msbuild` for full solutions.
36
+ - `winget` for tooling; `nuget`/`dotnet add package` for dependencies.
37
+ - Visual Studio diagnostics for profiling.
38
+
39
+ ## What to record before coding
40
+
41
+ - Target framework(s), .NET SDK version, and which UI stack the change belongs to.
42
+ - Deprecated APIs/frameworks to avoid and their modern replacements.
43
+ - The build/test commands you will run (`dotnet test`, `dotnet build -c Release`) so
44
+ the change is verifiable.
45
+
46
+ Don't migrate a project between UI frameworks (e.g. WinForms → WinUI) as a side
47
+ effect of an unrelated task.
@@ -0,0 +1,330 @@
1
+ """DevCouncil skills library: load, select, and scaffold reusable agent skills.
2
+
3
+ A *skill* is a markdown file with YAML frontmatter describing when it applies. The
4
+ ``core-engineering`` skill is always selected; domain skills (android, ios, windows,
5
+ web, ai-training, ...) are selected when the goal text or the repository's files match
6
+ their triggers. Selected skills can be rendered into an agent prompt preamble or
7
+ scaffolded into a target repo's ``.claude/skills/`` directory.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import os
14
+ import re
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+ from pydantic import BaseModel, Field
19
+
20
+ LIBRARY_DIR = Path(__file__).resolve().parent / "library"
21
+
22
+
23
+ def _keyword_in_text(keyword: str, text_lower: str) -> bool:
24
+ """Whether a trigger keyword appears in already-lowercased goal text.
25
+
26
+ Plain alphanumeric keywords ("gin", "unity", "flutter") match on word
27
+ boundaries so a short framework name can't fire on an unrelated word it
28
+ happens to sit inside ("gin" in "engine", "echo" in "echoes", "go" in
29
+ "logo"). Keywords that contain spaces or punctuation ("react native",
30
+ ".net", "c#", "c++", "ci/cd") are distinctive enough to match as substrings.
31
+ """
32
+ kw = keyword.lower().strip()
33
+ if not kw:
34
+ return False
35
+ if kw.isalnum():
36
+ return re.search(rf"(?<![a-z0-9]){re.escape(kw)}(?![a-z0-9])", text_lower) is not None
37
+ return kw in text_lower
38
+
39
+ # Directories never worth walking when matching file-based triggers.
40
+ _PRUNE_DIRS = {
41
+ ".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__",
42
+ ".devcouncil", ".idea", ".gradle", "build", "dist", ".mypy_cache",
43
+ ".pytest_cache", ".ruff_cache", "DerivedData", "Pods",
44
+ }
45
+ _MAX_WALK_FILES = 20_000
46
+
47
+
48
+ class SkillTriggers(BaseModel):
49
+ keywords: list[str] = Field(default_factory=list)
50
+ globs: list[str] = Field(default_factory=list)
51
+
52
+
53
+ class Skill(BaseModel):
54
+ name: str
55
+ title: str = ""
56
+ description: str = ""
57
+ always: bool = False
58
+ triggers: SkillTriggers = Field(default_factory=SkillTriggers)
59
+ body: str = ""
60
+ source_path: Path | None = None
61
+
62
+ def matches(self, goal: str, repo_files_present: "set[str] | None" = None) -> bool:
63
+ """True if this skill applies to the given goal text / repo file basenames."""
64
+ if self.always:
65
+ return True
66
+ goal_lower = goal.lower()
67
+ if any(_keyword_in_text(keyword, goal_lower) for keyword in self.triggers.keywords):
68
+ return True
69
+ if repo_files_present:
70
+ for pattern in self.triggers.globs:
71
+ pat = pattern.lower()
72
+ if any(fnmatch.fnmatch(name, pat) for name in repo_files_present):
73
+ return True
74
+ return False
75
+
76
+ def relevance_score(self, goal: str, repo_files_present: "set[str] | None" = None) -> int:
77
+ """How strongly this skill applies — used to rank which skills ride inline before
78
+ the size budget truncates. Goal-text keyword hits weigh more than file-glob
79
+ presence; always-on skills sort first regardless."""
80
+ if self.always:
81
+ return 1_000_000
82
+ goal_lower = goal.lower()
83
+ score = 2 * sum(1 for keyword in self.triggers.keywords if _keyword_in_text(keyword, goal_lower))
84
+ if repo_files_present:
85
+ score += sum(
86
+ 1 for pattern in self.triggers.globs
87
+ if any(fnmatch.fnmatch(name, pattern.lower()) for name in repo_files_present)
88
+ )
89
+ return score
90
+
91
+ def to_skill_md(self) -> str:
92
+ """Render as a Claude-Code-style SKILL.md (name + description frontmatter + body)."""
93
+ front = yaml.safe_dump(
94
+ {"name": self.name, "description": self.description},
95
+ sort_keys=False,
96
+ default_flow_style=False,
97
+ allow_unicode=True,
98
+ ).strip()
99
+ return f"---\n{front}\n---\n\n{self.body.strip()}\n"
100
+
101
+
102
+ def _split_frontmatter(text: str) -> tuple[dict, str]:
103
+ if text.startswith("---"):
104
+ parts = text.split("---", 2)
105
+ if len(parts) == 3:
106
+ meta = yaml.safe_load(parts[1]) or {}
107
+ return (meta if isinstance(meta, dict) else {}), parts[2].lstrip("\n")
108
+ return {}, text
109
+
110
+
111
+ def _skill_from_file(path: Path) -> Skill:
112
+ meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
113
+ triggers = meta.get("triggers") or {}
114
+ return Skill(
115
+ name=str(meta.get("name") or path.stem),
116
+ title=str(meta.get("title") or ""),
117
+ description=str(meta.get("description") or ""),
118
+ always=bool(meta.get("always", False)),
119
+ triggers=SkillTriggers(
120
+ keywords=list(triggers.get("keywords") or []),
121
+ globs=list(triggers.get("globs") or []),
122
+ ),
123
+ body=body.strip(),
124
+ source_path=path,
125
+ )
126
+
127
+
128
+ # Repo-local skill locations, scanned in addition to the packaged library so users
129
+ # can drop their own skill markdown into a project and have it picked up.
130
+ REPO_SKILL_DIRS = (".claude/skills", ".devcouncil/skills")
131
+
132
+
133
+ def _is_skill_file(path: Path) -> bool:
134
+ """A markdown file is a skill only if it has frontmatter with a name."""
135
+ try:
136
+ meta, _ = _split_frontmatter(path.read_text(encoding="utf-8"))
137
+ except OSError:
138
+ return False
139
+ return bool(meta.get("name"))
140
+
141
+
142
+ def discover_repo_skills(project_root: Path) -> list[Skill]:
143
+ """Find user-authored skills in a repo (``.claude/skills/**/SKILL.md`` etc.).
144
+
145
+ Honors the same frontmatter contract as the packaged library; files without a
146
+ ``name`` (e.g. plain docs) are ignored.
147
+ """
148
+ found: list[Skill] = []
149
+ seen: set[Path] = set()
150
+ for rel in REPO_SKILL_DIRS:
151
+ base = project_root / rel
152
+ if not base.exists():
153
+ continue
154
+ candidates = sorted(base.rglob("SKILL.md")) + sorted(base.glob("*.md"))
155
+ for path in candidates:
156
+ resolved = path.resolve()
157
+ if resolved in seen or not _is_skill_file(path):
158
+ continue
159
+ seen.add(resolved)
160
+ found.append(_skill_from_file(path))
161
+ return found
162
+
163
+
164
+ def load_skills(library_dir: Path = LIBRARY_DIR, project_root: Path | None = None) -> list[Skill]:
165
+ """Load skills: the packaged library plus, when ``project_root`` is given, the
166
+ repo's own skills. Repo-local skills override packaged ones with the same name.
167
+
168
+ Always-on skills come first, then alphabetical. Markdown files without skill
169
+ frontmatter (e.g. a contributor README) are ignored.
170
+ """
171
+ by_name: dict[str, Skill] = {}
172
+ if library_dir.exists():
173
+ for path in sorted(library_dir.glob("*.md")):
174
+ if _is_skill_file(path):
175
+ skill = _skill_from_file(path)
176
+ by_name[skill.name] = skill
177
+ if project_root is not None:
178
+ for skill in discover_repo_skills(project_root):
179
+ base = by_name.get(skill.name)
180
+ if base is not None:
181
+ # A repo-local copy of a library skill (commonly a scaffolded
182
+ # passthrough whose SKILL.md frontmatter is only name+description)
183
+ # overrides the body/description, but must INHERIT the library's
184
+ # selection metadata when it doesn't declare its own — otherwise
185
+ # scaffolding a skill silently strips its `always`/triggers and the
186
+ # skill stops being selected (selection would return nothing).
187
+ has_own_triggers = bool(skill.triggers.keywords or skill.triggers.globs)
188
+ skill = skill.model_copy(update={
189
+ "always": skill.always or base.always,
190
+ "triggers": skill.triggers if has_own_triggers else base.triggers,
191
+ })
192
+ by_name[skill.name] = skill # repo-local wins on name conflict
193
+ skills = list(by_name.values())
194
+ skills.sort(key=lambda s: (not s.always, s.name))
195
+ return skills
196
+
197
+
198
+ def get_skill(name: str, library_dir: Path = LIBRARY_DIR, project_root: Path | None = None) -> Skill | None:
199
+ for skill in load_skills(library_dir, project_root):
200
+ if skill.name == name:
201
+ return skill
202
+ return None
203
+
204
+
205
+ # Per-process cache of the repo file scan, keyed by (resolved path, root mtime).
206
+ # Selecting skills for every task in a `dev e2e`/`repair-all` run would otherwise walk
207
+ # the whole tree once per task. Keyed on the root dir's mtime so adding/removing a
208
+ # top-level marker file (package.json, build.gradle, go.mod, ...) invalidates it.
209
+ _basename_cache: dict[tuple[str, int], set[str]] = {}
210
+ _BASENAME_CACHE_MAX = 32
211
+
212
+
213
+ def clear_skill_caches() -> None:
214
+ """Drop the cached repo file scans (useful in long-running processes/tests)."""
215
+ _basename_cache.clear()
216
+
217
+
218
+ def _walk_repo_basenames(project_root: Path) -> set[str]:
219
+ names: set[str] = set()
220
+ count = 0
221
+ for _dirpath, dirnames, filenames in os.walk(project_root):
222
+ dirnames[:] = [d for d in dirnames if d not in _PRUNE_DIRS]
223
+ for filename in filenames:
224
+ names.add(filename.lower())
225
+ count += 1
226
+ if count >= _MAX_WALK_FILES:
227
+ return names
228
+ return names
229
+
230
+
231
+ def _collect_repo_basenames(project_root: Path) -> set[str]:
232
+ """Lowercased basenames of files in the repo, with heavy dirs pruned and a cap.
233
+
234
+ Result is cached per (resolved path, root mtime) so repeated selections within one
235
+ run (e.g. one prompt per task in an e2e flow) don't re-walk the tree each time.
236
+ """
237
+ try:
238
+ key: tuple[str, int] | None = (str(project_root.resolve()), project_root.stat().st_mtime_ns)
239
+ except OSError:
240
+ key = None
241
+ if key is not None:
242
+ cached = _basename_cache.get(key)
243
+ if cached is not None:
244
+ return cached
245
+ names = _walk_repo_basenames(project_root)
246
+ if key is not None:
247
+ if len(_basename_cache) >= _BASENAME_CACHE_MAX:
248
+ _basename_cache.clear()
249
+ _basename_cache[key] = names
250
+ return names
251
+
252
+
253
+ def select_skills(
254
+ goal: str = "",
255
+ project_root: Path | None = None,
256
+ library_dir: Path = LIBRARY_DIR,
257
+ ) -> list[Skill]:
258
+ """Select the skills that apply to a goal and/or repository.
259
+
260
+ Includes repo-local skills (``.claude/skills/**``) when ``project_root`` is given.
261
+ """
262
+ skills = load_skills(library_dir, project_root)
263
+ repo_files = _collect_repo_basenames(project_root) if project_root else set()
264
+ matched = [s for s in skills if s.matches(goal, repo_files)]
265
+ # Rank by relevance so the most applicable domain skill survives the inline budget
266
+ # on a polyglot repo, instead of whichever happened to load first / sort alphabetically.
267
+ # always-on skills keep their leading position (highest score); ties break by name.
268
+ scored = [(s, s.relevance_score(goal, repo_files)) for s in matched]
269
+ scored.sort(key=lambda item: (not item[0].always, -item[1], item[0].name))
270
+ return [skill for skill, _ in scored]
271
+
272
+
273
+ def render_preamble(skills: list[Skill]) -> str:
274
+ """Concatenate skill bodies into a single prompt preamble block."""
275
+ if not skills:
276
+ return ""
277
+ sections = [skill.body.strip() for skill in skills if skill.body.strip()]
278
+ return "\n\n---\n\n".join(sections).strip()
279
+
280
+
281
+ def bound_skills(
282
+ skills: list[Skill],
283
+ max_skills: int = 5,
284
+ max_chars: int = 14000,
285
+ ) -> "tuple[list[Skill], list[Skill]]":
286
+ """Split selected skills into (inline, deferred) to bound prompt size.
287
+
288
+ Skills are kept in order (always-on first), so the core skill is always inline;
289
+ once the skill count or the cumulative body size would be exceeded, the rest are
290
+ deferred (their full text still lives in the scaffolded .claude/skills/ files).
291
+ """
292
+ inline: list[Skill] = []
293
+ total = 0
294
+ for skill in skills:
295
+ body = skill.body.strip()
296
+ if not body:
297
+ continue
298
+ if len(inline) >= max_skills or (inline and total + len(body) > max_chars):
299
+ break
300
+ inline.append(skill)
301
+ total += len(body)
302
+ inline_set = {id(s) for s in inline}
303
+ deferred = [s for s in skills if id(s) not in inline_set and s.body.strip()]
304
+ return inline, deferred
305
+
306
+
307
+ def scaffold_skills(project_root: Path, skills: list[Skill]) -> list[Path]:
308
+ """Write the given skills into ``<project_root>/.claude/skills/<name>/SKILL.md``.
309
+
310
+ Only rewrites a file when its content changes, so re-running is a no-op.
311
+ """
312
+ written: list[Path] = []
313
+ skills_root = project_root / ".claude" / "skills"
314
+ proot = project_root.resolve()
315
+ for skill in skills:
316
+ # Don't re-materialize a skill that already lives inside this repo.
317
+ if skill.source_path is not None:
318
+ try:
319
+ skill.source_path.resolve().relative_to(proot)
320
+ continue
321
+ except ValueError:
322
+ pass
323
+ target = skills_root / skill.name / "SKILL.md"
324
+ content = skill.to_skill_md()
325
+ if target.exists() and target.read_text(encoding="utf-8") == content:
326
+ continue
327
+ target.parent.mkdir(parents=True, exist_ok=True)
328
+ target.write_text(content, encoding="utf-8")
329
+ written.append(target)
330
+ return written