devcouncil 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +220 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +195 -54
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security
|
|
3
|
+
title: Application Security / Secure Coding Intake
|
|
4
|
+
description: Before writing security-sensitive code or assessing a vulnerability, retrieve current guidance for the relevant class (injection, authn/z, crypto, deserialization), the project's existing controls, and the right scanning/test commands — like a senior application-security engineer briefing themselves.
|
|
5
|
+
triggers:
|
|
6
|
+
keywords: [security, vulnerability, "secure coding", hardening, "security audit", pentest, "penetration test", owasp, xss, csrf, ssrf, "sql injection", sqli, rce, deserialization, "path traversal", cryptography, encryption, "threat model", sast, dast, cve, sandbox, "least privilege", "input validation"]
|
|
7
|
+
globs: [".semgrep.yml", ".semgrep.yaml", "bandit.yaml", ".bandit", "*.nuclei.yaml", "trivy.yaml", ".snyk"]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Application Security / Secure Coding Intake
|
|
11
|
+
|
|
12
|
+
Do this **before** writing security-sensitive code or judging a vulnerability. Don't rely on
|
|
13
|
+
training data — attack techniques and recommended mitigations evolve, and a plausible-looking
|
|
14
|
+
fix can be incomplete or introduce a new hole. Confirm against current guidance (OWASP, the
|
|
15
|
+
framework's security docs, the relevant CVE/advisory) and the project's existing controls.
|
|
16
|
+
|
|
17
|
+
## Establish current state first
|
|
18
|
+
|
|
19
|
+
1. **Trust boundaries & data flow** — where untrusted input enters and where it reaches a sink
|
|
20
|
+
(DB, shell, filesystem, deserializer, template, HTTP). Map the path this change touches.
|
|
21
|
+
2. **Vulnerability class & correct mitigation** — identify the class precisely and use the
|
|
22
|
+
*current* canonical defense: parameterized queries (not escaping) for SQLi; context-aware
|
|
23
|
+
output encoding for XSS; allow-lists + canonicalization for path/SSRF; safe deserializers;
|
|
24
|
+
constant-time comparison for secrets. Avoid blacklist/regex "sanitizers."
|
|
25
|
+
3. **AuthN / AuthZ** — every new entry point authenticates and authorizes (object-level too —
|
|
26
|
+
no IDOR); sessions/tokens follow the project's scheme; deny by default.
|
|
27
|
+
4. **Secrets & crypto** — secrets from a vault/env, never committed or logged; use vetted
|
|
28
|
+
libraries and current algorithms/parameters (no home-rolled crypto, no MD5/SHA1 for security).
|
|
29
|
+
5. **Dependencies & config** — check for known-vulnerable dependencies and insecure defaults
|
|
30
|
+
(CORS, headers, TLS, file permissions). Note anything in scope.
|
|
31
|
+
|
|
32
|
+
## Build & CLI tools
|
|
33
|
+
|
|
34
|
+
- Static/secret scanning: `semgrep`, `bandit` (Python), `gosec`, `npm audit`/`pip-audit`,
|
|
35
|
+
`gitleaks`/`trufflehog`, `trivy`/`grype` for images and deps.
|
|
36
|
+
- Dynamic/dependency: the project's DAST/`snyk`/`nuclei` flow where present.
|
|
37
|
+
- Add or update a test that *proves* the vulnerability is closed (a failing-then-passing case),
|
|
38
|
+
not just that the happy path still works.
|
|
39
|
+
|
|
40
|
+
## What to record before coding
|
|
41
|
+
|
|
42
|
+
- The vulnerability class, the trust boundary, and the exact sink involved.
|
|
43
|
+
- The current canonical mitigation chosen (and why a weaker one was rejected).
|
|
44
|
+
- The authz/secret/crypto requirements the change must satisfy.
|
|
45
|
+
- The scan/test commands and the regression test that prove the issue is fixed.
|
|
46
|
+
|
|
47
|
+
Stay surgical: fix the specific weakness without unrelated refactors, and never weaken an
|
|
48
|
+
existing control as a side effect (see core-engineering).
|
|
@@ -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,408 @@
|
|
|
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 functools
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from devcouncil.knowledge.frontmatter import build_frontmatter_markdown, split_frontmatter
|
|
21
|
+
|
|
22
|
+
LIBRARY_DIR = Path(__file__).resolve().parent / "library"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _keyword_in_text(keyword: str, text_lower: str) -> bool:
|
|
26
|
+
"""Whether a trigger keyword appears in already-lowercased goal text.
|
|
27
|
+
|
|
28
|
+
Plain alphanumeric keywords ("gin", "unity", "flutter") match on word
|
|
29
|
+
boundaries so a short framework name can't fire on an unrelated word it
|
|
30
|
+
happens to sit inside ("gin" in "engine", "echo" in "echoes", "go" in
|
|
31
|
+
"logo"). Keywords that contain spaces or punctuation ("react native",
|
|
32
|
+
".net", "c#", "c++", "ci/cd") are distinctive enough to match as substrings.
|
|
33
|
+
"""
|
|
34
|
+
kw = keyword.lower().strip()
|
|
35
|
+
if not kw:
|
|
36
|
+
return False
|
|
37
|
+
if kw.isalnum():
|
|
38
|
+
return re.search(rf"(?<![a-z0-9]){re.escape(kw)}(?![a-z0-9])", text_lower) is not None
|
|
39
|
+
return kw in text_lower
|
|
40
|
+
|
|
41
|
+
# Directories never worth walking when matching file-based triggers.
|
|
42
|
+
_PRUNE_DIRS = {
|
|
43
|
+
".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__",
|
|
44
|
+
".devcouncil", ".idea", ".gradle", "build", "dist", ".mypy_cache",
|
|
45
|
+
".pytest_cache", ".ruff_cache", "DerivedData", "Pods",
|
|
46
|
+
}
|
|
47
|
+
_MAX_WALK_FILES = 20_000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SkillTriggers(BaseModel):
|
|
51
|
+
keywords: list[str] = Field(default_factory=list)
|
|
52
|
+
globs: list[str] = Field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Skill(BaseModel):
|
|
56
|
+
name: str
|
|
57
|
+
title: str = ""
|
|
58
|
+
description: str = ""
|
|
59
|
+
always: bool = False
|
|
60
|
+
triggers: SkillTriggers = Field(default_factory=SkillTriggers)
|
|
61
|
+
body: str = ""
|
|
62
|
+
source_path: Path | None = None
|
|
63
|
+
|
|
64
|
+
def matches(self, goal: str, repo_files_present: "set[str] | None" = None) -> bool:
|
|
65
|
+
"""True if this skill applies to the given goal text / repo file basenames."""
|
|
66
|
+
if self.always:
|
|
67
|
+
return True
|
|
68
|
+
goal_lower = goal.lower()
|
|
69
|
+
if any(_keyword_in_text(keyword, goal_lower) for keyword in self.triggers.keywords):
|
|
70
|
+
return True
|
|
71
|
+
if repo_files_present:
|
|
72
|
+
for pattern in self.triggers.globs:
|
|
73
|
+
pat = pattern.lower()
|
|
74
|
+
if any(fnmatch.fnmatch(name, pat) for name in repo_files_present):
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def relevance_score(self, goal: str, repo_files_present: "set[str] | None" = None) -> int:
|
|
79
|
+
"""How strongly this skill applies — used to rank which skills ride inline before
|
|
80
|
+
the size budget truncates. Goal-text keyword hits weigh more than file-glob
|
|
81
|
+
presence; always-on skills sort first regardless."""
|
|
82
|
+
if self.always:
|
|
83
|
+
return 1_000_000
|
|
84
|
+
goal_lower = goal.lower()
|
|
85
|
+
score = 2 * sum(1 for keyword in self.triggers.keywords if _keyword_in_text(keyword, goal_lower))
|
|
86
|
+
if repo_files_present:
|
|
87
|
+
score += sum(
|
|
88
|
+
1 for pattern in self.triggers.globs
|
|
89
|
+
if any(fnmatch.fnmatch(name, pattern.lower()) for name in repo_files_present)
|
|
90
|
+
)
|
|
91
|
+
return score
|
|
92
|
+
|
|
93
|
+
def to_skill_md(self) -> str:
|
|
94
|
+
"""Render as a Claude-Code-style SKILL.md (name + description frontmatter + body)."""
|
|
95
|
+
return build_frontmatter_markdown(
|
|
96
|
+
{"name": self.name, "description": self.description},
|
|
97
|
+
self.body,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Frontmatter parsing lives in devcouncil.knowledge.frontmatter so skills and the OKF /
|
|
102
|
+
# design.md formats share one implementation; kept aliased here for existing callers.
|
|
103
|
+
_split_frontmatter = split_frontmatter
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _skill_from_meta(path: Path, meta: dict, body: str) -> Skill:
|
|
107
|
+
triggers = meta.get("triggers") or {}
|
|
108
|
+
return Skill(
|
|
109
|
+
name=str(meta.get("name") or path.stem),
|
|
110
|
+
title=str(meta.get("title") or ""),
|
|
111
|
+
description=str(meta.get("description") or ""),
|
|
112
|
+
always=bool(meta.get("always", False)),
|
|
113
|
+
triggers=SkillTriggers(
|
|
114
|
+
keywords=list(triggers.get("keywords") or []),
|
|
115
|
+
globs=list(triggers.get("globs") or []),
|
|
116
|
+
),
|
|
117
|
+
body=body.strip(),
|
|
118
|
+
source_path=path,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Repo-local skill locations, scanned in addition to the packaged library so users
|
|
123
|
+
# can drop their own skill markdown into a project and have it picked up.
|
|
124
|
+
REPO_SKILL_DIRS = (".claude/skills", ".devcouncil/skills")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _try_skill_from_file(path: Path) -> Skill | None:
|
|
128
|
+
"""Parse a markdown file into a Skill in a single read, or None if it isn't a skill.
|
|
129
|
+
|
|
130
|
+
A markdown file is a skill only if its frontmatter carries a ``name``; plain docs
|
|
131
|
+
(e.g. a contributor README) are ignored. Reads the file once — previously callers
|
|
132
|
+
read it twice (an ``_is_skill_file`` check followed by a separate parse)."""
|
|
133
|
+
try:
|
|
134
|
+
meta, body = _split_frontmatter(path.read_text(encoding="utf-8"))
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
if not meta.get("name"):
|
|
138
|
+
return None
|
|
139
|
+
return _skill_from_meta(path, meta, body)
|
|
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:
|
|
158
|
+
continue
|
|
159
|
+
skill = _try_skill_from_file(path)
|
|
160
|
+
if skill is None:
|
|
161
|
+
continue
|
|
162
|
+
seen.add(resolved)
|
|
163
|
+
found.append(skill)
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load_okf_skills(project_root: Path, directory: str = ".devcouncil/knowledge") -> list[Skill]:
|
|
168
|
+
"""Load skills from ingested OKF documents typed as engineering skills.
|
|
169
|
+
|
|
170
|
+
Reads every ``*.md`` under ``<project_root>/<directory>/okf/`` (recursively), parses
|
|
171
|
+
each as an :class:`~devcouncil.knowledge.okf.OKFDocument`, and keeps the ones whose
|
|
172
|
+
``type`` marks them as a skill (the OKF->Skill conversion returns ``None`` for any
|
|
173
|
+
other node type — BigQuery tables, tasks, ...). This is how an OKF bundle ingested
|
|
174
|
+
from another repo contributes its skills to selection alongside the packaged library.
|
|
175
|
+
|
|
176
|
+
``index.md`` files are skipped: a bundle index is navigation scaffolding, not a node.
|
|
177
|
+
The document's ``rel_path`` is set relative to the okf dir so its skill ``name`` derives
|
|
178
|
+
from the file stem (matching how the export side names ``skills/<name>.md``).
|
|
179
|
+
"""
|
|
180
|
+
# Lazy import to avoid a registry <-> skill_bridge import cycle (see that module);
|
|
181
|
+
# OKFDocument is cycle-safe (knowledge.okf doesn't import skills) but kept here too
|
|
182
|
+
# to keep the OKF-ingest dependency local to the one function that uses it.
|
|
183
|
+
from devcouncil.knowledge.okf import OKFDocument
|
|
184
|
+
from devcouncil.knowledge.skill_bridge import okf_document_to_skill
|
|
185
|
+
|
|
186
|
+
okf_dir = project_root / directory / "okf"
|
|
187
|
+
if not okf_dir.exists():
|
|
188
|
+
return []
|
|
189
|
+
skills: list[Skill] = []
|
|
190
|
+
for path in sorted(okf_dir.rglob("*.md")):
|
|
191
|
+
if path.name == "index.md":
|
|
192
|
+
continue
|
|
193
|
+
rel = path.relative_to(okf_dir).as_posix()
|
|
194
|
+
doc = OKFDocument.from_markdown(path.read_text(encoding="utf-8"), rel_path=rel)
|
|
195
|
+
skill = okf_document_to_skill(doc)
|
|
196
|
+
if skill is not None:
|
|
197
|
+
skills.append(skill)
|
|
198
|
+
return skills
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@functools.lru_cache(maxsize=32)
|
|
202
|
+
def load_skills(
|
|
203
|
+
library_dir: Path = LIBRARY_DIR,
|
|
204
|
+
project_root: Path | None = None,
|
|
205
|
+
include_okf: bool = True,
|
|
206
|
+
) -> list[Skill]:
|
|
207
|
+
"""Load skills: the packaged library plus, when ``project_root`` is given, the
|
|
208
|
+
repo's own skills and (when ``include_okf``) skills from ingested OKF documents.
|
|
209
|
+
Repo-local skills override packaged ones with the same name.
|
|
210
|
+
|
|
211
|
+
Result is cached per (library_dir, project_root, include_okf) for the lifetime of
|
|
212
|
+
the process, mirroring the repo-basename cache below: a ``dev e2e``/``repair-all``
|
|
213
|
+
run calls ``select_skills`` once per task, and re-reading+parsing the whole skill
|
|
214
|
+
tree (library glob, repo ``SKILL.md`` discovery, OKF markdown) every time is pure
|
|
215
|
+
waste since the on-disk skills don't change during a run. The returned list is
|
|
216
|
+
shared and must be treated read-only by callers (all current callers only iterate
|
|
217
|
+
it); ``clear_skill_caches()`` drops the cache when a refresh is needed.
|
|
218
|
+
|
|
219
|
+
OKF-derived skills are merged in last and only for names not already taken, so a
|
|
220
|
+
packaged library skill or a repo-local skill always wins a name conflict over an
|
|
221
|
+
ingested bundle node (the local definition is authoritative and carries richer
|
|
222
|
+
selection metadata like globs that OKF tags can't represent).
|
|
223
|
+
|
|
224
|
+
Always-on skills come first, then alphabetical. Markdown files without skill
|
|
225
|
+
frontmatter (e.g. a contributor README) are ignored.
|
|
226
|
+
"""
|
|
227
|
+
by_name: dict[str, Skill] = {}
|
|
228
|
+
if library_dir.exists():
|
|
229
|
+
for path in sorted(library_dir.glob("*.md")):
|
|
230
|
+
skill = _try_skill_from_file(path)
|
|
231
|
+
if skill is not None:
|
|
232
|
+
by_name[skill.name] = skill
|
|
233
|
+
if project_root is not None:
|
|
234
|
+
for skill in discover_repo_skills(project_root):
|
|
235
|
+
base = by_name.get(skill.name)
|
|
236
|
+
if base is not None:
|
|
237
|
+
# A repo-local copy of a library skill (commonly a scaffolded
|
|
238
|
+
# passthrough whose SKILL.md frontmatter is only name+description)
|
|
239
|
+
# overrides the body/description, but must INHERIT the library's
|
|
240
|
+
# selection metadata when it doesn't declare its own — otherwise
|
|
241
|
+
# scaffolding a skill silently strips its `always`/triggers and the
|
|
242
|
+
# skill stops being selected (selection would return nothing).
|
|
243
|
+
has_own_triggers = bool(skill.triggers.keywords or skill.triggers.globs)
|
|
244
|
+
skill = skill.model_copy(update={
|
|
245
|
+
"always": skill.always or base.always,
|
|
246
|
+
"triggers": skill.triggers if has_own_triggers else base.triggers,
|
|
247
|
+
})
|
|
248
|
+
by_name[skill.name] = skill # repo-local wins on name conflict
|
|
249
|
+
if include_okf:
|
|
250
|
+
# Honor a custom knowledge.directory: `dev okf ingest` and knowledge-source
|
|
251
|
+
# discovery both write/read under the configured dir, so the skill-ingest read
|
|
252
|
+
# path must too — otherwise ingested OKF skills land somewhere this never looks
|
|
253
|
+
# and silently never get selected. Best-effort; lazy import keeps app.config out
|
|
254
|
+
# of the skills package's module-load graph.
|
|
255
|
+
knowledge_dir = ".devcouncil/knowledge"
|
|
256
|
+
try:
|
|
257
|
+
from devcouncil.app.config import load_config
|
|
258
|
+
knowledge_dir = load_config(project_root).knowledge.directory
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
for skill in load_okf_skills(project_root, directory=knowledge_dir):
|
|
262
|
+
# Only fill gaps: library + repo-local skills win on name conflict.
|
|
263
|
+
by_name.setdefault(skill.name, skill)
|
|
264
|
+
skills = list(by_name.values())
|
|
265
|
+
skills.sort(key=lambda s: (not s.always, s.name))
|
|
266
|
+
return skills
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_skill(name: str, library_dir: Path = LIBRARY_DIR, project_root: Path | None = None) -> Skill | None:
|
|
270
|
+
for skill in load_skills(library_dir, project_root):
|
|
271
|
+
if skill.name == name:
|
|
272
|
+
return skill
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# Per-process cache of the repo file scan, keyed by (resolved path, root mtime).
|
|
277
|
+
# Selecting skills for every task in a `dev e2e`/`repair-all` run would otherwise walk
|
|
278
|
+
# the whole tree once per task. Keyed on the root dir's mtime so adding/removing a
|
|
279
|
+
# top-level marker file (package.json, build.gradle, go.mod, ...) invalidates it.
|
|
280
|
+
_basename_cache: dict[tuple[str, int], set[str]] = {}
|
|
281
|
+
_BASENAME_CACHE_MAX = 32
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def clear_skill_caches() -> None:
|
|
285
|
+
"""Drop the cached repo file scans and loaded-skill sets (useful in long-running
|
|
286
|
+
processes/tests). Fully resets module-level skill state so test isolation holds."""
|
|
287
|
+
_basename_cache.clear()
|
|
288
|
+
load_skills.cache_clear()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _walk_repo_basenames(project_root: Path) -> set[str]:
|
|
292
|
+
names: set[str] = set()
|
|
293
|
+
count = 0
|
|
294
|
+
for _dirpath, dirnames, filenames in os.walk(project_root):
|
|
295
|
+
dirnames[:] = [d for d in dirnames if d not in _PRUNE_DIRS]
|
|
296
|
+
for filename in filenames:
|
|
297
|
+
names.add(filename.lower())
|
|
298
|
+
count += 1
|
|
299
|
+
if count >= _MAX_WALK_FILES:
|
|
300
|
+
return names
|
|
301
|
+
return names
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _collect_repo_basenames(project_root: Path) -> set[str]:
|
|
305
|
+
"""Lowercased basenames of files in the repo, with heavy dirs pruned and a cap.
|
|
306
|
+
|
|
307
|
+
Result is cached per (resolved path, root mtime) so repeated selections within one
|
|
308
|
+
run (e.g. one prompt per task in an e2e flow) don't re-walk the tree each time.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
key: tuple[str, int] | None = (str(project_root.resolve()), project_root.stat().st_mtime_ns)
|
|
312
|
+
except OSError:
|
|
313
|
+
key = None
|
|
314
|
+
if key is not None:
|
|
315
|
+
cached = _basename_cache.get(key)
|
|
316
|
+
if cached is not None:
|
|
317
|
+
return cached
|
|
318
|
+
names = _walk_repo_basenames(project_root)
|
|
319
|
+
if key is not None:
|
|
320
|
+
if len(_basename_cache) >= _BASENAME_CACHE_MAX:
|
|
321
|
+
_basename_cache.clear()
|
|
322
|
+
_basename_cache[key] = names
|
|
323
|
+
return names
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def select_skills(
|
|
327
|
+
goal: str = "",
|
|
328
|
+
project_root: Path | None = None,
|
|
329
|
+
library_dir: Path = LIBRARY_DIR,
|
|
330
|
+
) -> list[Skill]:
|
|
331
|
+
"""Select the skills that apply to a goal and/or repository.
|
|
332
|
+
|
|
333
|
+
Includes repo-local skills (``.claude/skills/**``) when ``project_root`` is given.
|
|
334
|
+
"""
|
|
335
|
+
skills = load_skills(library_dir, project_root)
|
|
336
|
+
repo_files = _collect_repo_basenames(project_root) if project_root else set()
|
|
337
|
+
# Score each skill once and keep the ones that apply: for a Skill, matches() is exactly
|
|
338
|
+
# relevance_score() > 0 (always-on -> 1_000_000; otherwise a positive score requires a
|
|
339
|
+
# keyword/glob hit, which is what matches() tests), so a single pass replaces the old
|
|
340
|
+
# match-then-score double walk. (This equivalence is Skill-specific — do NOT copy it to
|
|
341
|
+
# KnowledgeSource, whose nonzero priority floor breaks it.)
|
|
342
|
+
# Rank by relevance so the most applicable domain skill survives the inline budget on a
|
|
343
|
+
# polyglot repo; always-on skills keep their leading position; ties break by name.
|
|
344
|
+
scored = [
|
|
345
|
+
(s, score) for s in skills if (score := s.relevance_score(goal, repo_files)) > 0
|
|
346
|
+
]
|
|
347
|
+
scored.sort(key=lambda item: (not item[0].always, -item[1], item[0].name))
|
|
348
|
+
return [skill for skill, _ in scored]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def render_preamble(skills: list[Skill]) -> str:
|
|
352
|
+
"""Concatenate skill bodies into a single prompt preamble block."""
|
|
353
|
+
if not skills:
|
|
354
|
+
return ""
|
|
355
|
+
sections = [skill.body.strip() for skill in skills if skill.body.strip()]
|
|
356
|
+
return "\n\n---\n\n".join(sections).strip()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def bound_skills(
|
|
360
|
+
skills: list[Skill],
|
|
361
|
+
max_skills: int = 5,
|
|
362
|
+
max_chars: int = 14000,
|
|
363
|
+
) -> "tuple[list[Skill], list[Skill]]":
|
|
364
|
+
"""Split selected skills into (inline, deferred) to bound prompt size.
|
|
365
|
+
|
|
366
|
+
Skills are kept in order (always-on first), so the core skill is always inline;
|
|
367
|
+
once the skill count or the cumulative body size would be exceeded, the rest are
|
|
368
|
+
deferred (their full text still lives in the scaffolded .claude/skills/ files).
|
|
369
|
+
"""
|
|
370
|
+
inline: list[Skill] = []
|
|
371
|
+
total = 0
|
|
372
|
+
for skill in skills:
|
|
373
|
+
body = skill.body.strip()
|
|
374
|
+
if not body:
|
|
375
|
+
continue
|
|
376
|
+
if len(inline) >= max_skills or (inline and total + len(body) > max_chars):
|
|
377
|
+
break
|
|
378
|
+
inline.append(skill)
|
|
379
|
+
total += len(body)
|
|
380
|
+
inline_set = {id(s) for s in inline}
|
|
381
|
+
deferred = [s for s in skills if id(s) not in inline_set and s.body.strip()]
|
|
382
|
+
return inline, deferred
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def scaffold_skills(project_root: Path, skills: list[Skill]) -> list[Path]:
|
|
386
|
+
"""Write the given skills into ``<project_root>/.claude/skills/<name>/SKILL.md``.
|
|
387
|
+
|
|
388
|
+
Only rewrites a file when its content changes, so re-running is a no-op.
|
|
389
|
+
"""
|
|
390
|
+
written: list[Path] = []
|
|
391
|
+
skills_root = project_root / ".claude" / "skills"
|
|
392
|
+
proot = project_root.resolve()
|
|
393
|
+
for skill in skills:
|
|
394
|
+
# Don't re-materialize a skill that already lives inside this repo.
|
|
395
|
+
if skill.source_path is not None:
|
|
396
|
+
try:
|
|
397
|
+
skill.source_path.resolve().relative_to(proot)
|
|
398
|
+
continue
|
|
399
|
+
except ValueError:
|
|
400
|
+
pass
|
|
401
|
+
target = skills_root / skill.name / "SKILL.md"
|
|
402
|
+
content = skill.to_skill_md()
|
|
403
|
+
if target.exists() and target.read_text(encoding="utf-8") == content:
|
|
404
|
+
continue
|
|
405
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
target.write_text(content, encoding="utf-8")
|
|
407
|
+
written.append(target)
|
|
408
|
+
return written
|