devcouncil 0.2.0 → 0.2.2
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 +144 -308
- package/bin/devcouncil.js +130 -32
- package/package.json +22 -19
- package/pyproject.toml +0 -66
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +0 -4
- package/src/devcouncil/app/__init__.py +0 -28
- package/src/devcouncil/app/config.py +0 -320
- package/src/devcouncil/app/errors.py +0 -23
- package/src/devcouncil/app/events.py +0 -44
- package/src/devcouncil/app/orchestrator.py +0 -92
- package/src/devcouncil/app/project_status.py +0 -29
- package/src/devcouncil/app/run_context.py +0 -39
- package/src/devcouncil/app/state_machine.py +0 -108
- package/src/devcouncil/artifacts/__init__.py +0 -1
- package/src/devcouncil/artifacts/coverage.py +0 -96
- package/src/devcouncil/artifacts/graph.py +0 -163
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/artifacts/validators.py +0 -27
- package/src/devcouncil/assets/__init__.py +0 -1
- package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/agents.py +0 -292
- package/src/devcouncil/cli/commands/artifacts.py +0 -54
- package/src/devcouncil/cli/commands/ast.py +0 -22
- package/src/devcouncil/cli/commands/baseline.py +0 -35
- package/src/devcouncil/cli/commands/check.py +0 -209
- package/src/devcouncil/cli/commands/config.py +0 -115
- package/src/devcouncil/cli/commands/cost.py +0 -57
- package/src/devcouncil/cli/commands/dashboard.py +0 -31
- package/src/devcouncil/cli/commands/doctor.py +0 -340
- package/src/devcouncil/cli/commands/evidence.py +0 -48
- package/src/devcouncil/cli/commands/go.py +0 -656
- package/src/devcouncil/cli/commands/handoff.py +0 -69
- package/src/devcouncil/cli/commands/hook.py +0 -237
- package/src/devcouncil/cli/commands/init.py +0 -289
- package/src/devcouncil/cli/commands/integrate.py +0 -1515
- package/src/devcouncil/cli/commands/lsp.py +0 -20
- package/src/devcouncil/cli/commands/map.py +0 -112
- package/src/devcouncil/cli/commands/mcp_server.py +0 -18
- package/src/devcouncil/cli/commands/plan.py +0 -488
- package/src/devcouncil/cli/commands/prompt.py +0 -61
- package/src/devcouncil/cli/commands/repair.py +0 -89
- package/src/devcouncil/cli/commands/report.py +0 -137
- package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
- package/src/devcouncil/cli/commands/rollback.py +0 -59
- package/src/devcouncil/cli/commands/run.py +0 -289
- package/src/devcouncil/cli/commands/runs.py +0 -223
- package/src/devcouncil/cli/commands/scaffold.py +0 -32
- package/src/devcouncil/cli/commands/semantic.py +0 -47
- package/src/devcouncil/cli/commands/setup.py +0 -362
- package/src/devcouncil/cli/commands/shell.py +0 -73
- package/src/devcouncil/cli/commands/show.py +0 -76
- package/src/devcouncil/cli/commands/skills.py +0 -88
- package/src/devcouncil/cli/commands/status.py +0 -141
- package/src/devcouncil/cli/commands/tasks.py +0 -55
- package/src/devcouncil/cli/commands/trace.py +0 -88
- package/src/devcouncil/cli/commands/verify.py +0 -328
- package/src/devcouncil/cli/commands/version.py +0 -20
- package/src/devcouncil/cli/commands/watch.py +0 -574
- package/src/devcouncil/cli/commands/watch_fs.py +0 -40
- package/src/devcouncil/cli/main.py +0 -137
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +0 -19
- package/src/devcouncil/council/prompts/critic_a.md +0 -10
- package/src/devcouncil/council/prompts/critic_b.md +0 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
- package/src/devcouncil/council/prompts/planner_a.md +0 -16
- package/src/devcouncil/council/prompts/planner_b.md +0 -16
- package/src/devcouncil/council/prompts/rebuttal.md +0 -10
- package/src/devcouncil/council/prompts/spec_writer.md +0 -12
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +0 -17
- package/src/devcouncil/domain/critique.py +0 -32
- package/src/devcouncil/domain/evidence.py +0 -47
- package/src/devcouncil/domain/gap.py +0 -52
- package/src/devcouncil/domain/requirement.py +0 -22
- package/src/devcouncil/domain/task.py +0 -55
- package/src/devcouncil/execution/__init__.py +0 -1
- package/src/devcouncil/execution/checkpoints.py +0 -246
- package/src/devcouncil/execution/context_builder.py +0 -60
- package/src/devcouncil/execution/executor.py +0 -15
- package/src/devcouncil/execution/fs_watcher.py +0 -180
- package/src/devcouncil/execution/handoff.py +0 -102
- package/src/devcouncil/execution/hook_policy.py +0 -253
- package/src/devcouncil/execution/patch.py +0 -77
- package/src/devcouncil/execution/paths.py +0 -14
- package/src/devcouncil/execution/permissions.py +0 -85
- package/src/devcouncil/execution/policy_engine.py +0 -343
- package/src/devcouncil/execution/prompt_builder.py +0 -671
- package/src/devcouncil/execution/shell_session.py +0 -225
- package/src/devcouncil/execution/task_runner.py +0 -170
- package/src/devcouncil/executors/__init__.py +0 -1
- package/src/devcouncil/executors/agent_registry.py +0 -575
- package/src/devcouncil/executors/coding_cli.py +0 -736
- package/src/devcouncil/executors/mini_swe.py +0 -73
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +0 -208
- package/src/devcouncil/executors/openhands.py +0 -71
- package/src/devcouncil/gating/__init__.py +0 -1
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +0 -52
- package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
- package/src/devcouncil/gating/policy.py +0 -338
- package/src/devcouncil/hardware.py +0 -184
- package/src/devcouncil/indexing/__init__.py +0 -1
- package/src/devcouncil/indexing/ast_matcher.py +0 -168
- package/src/devcouncil/indexing/graph_index.py +0 -48
- package/src/devcouncil/indexing/lsp.py +0 -161
- package/src/devcouncil/indexing/repo_mapper.py +0 -1455
- package/src/devcouncil/indexing/semantic_index.py +0 -205
- package/src/devcouncil/integrations/actions.py +0 -146
- package/src/devcouncil/integrations/check.py +0 -423
- package/src/devcouncil/integrations/code_review_graph.py +0 -163
- package/src/devcouncil/integrations/github.py +0 -39
- package/src/devcouncil/integrations/github_intent.py +0 -142
- package/src/devcouncil/integrations/gitnexus.py +0 -62
- package/src/devcouncil/integrations/graphify.py +0 -34
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +0 -2122
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
- package/src/devcouncil/integrations/pr_comments.py +0 -62
- package/src/devcouncil/live/__init__.py +0 -2
- package/src/devcouncil/live/cards.py +0 -349
- package/src/devcouncil/live/models.py +0 -63
- package/src/devcouncil/live/repair_prompt.py +0 -83
- package/src/devcouncil/live/reviewer.py +0 -70
- package/src/devcouncil/live/signals.py +0 -135
- package/src/devcouncil/live/summary.py +0 -34
- package/src/devcouncil/live/tasks.py +0 -18
- package/src/devcouncil/live/transcripts.py +0 -141
- package/src/devcouncil/llm/__init__.py +0 -1
- package/src/devcouncil/llm/cache.py +0 -42
- package/src/devcouncil/llm/model_defaults.yaml +0 -44
- package/src/devcouncil/llm/provider.py +0 -627
- package/src/devcouncil/llm/router.py +0 -310
- package/src/devcouncil/optimization/__init__.py +0 -1
- package/src/devcouncil/optimization/gepa_agent.py +0 -318
- package/src/devcouncil/planning/__init__.py +0 -1
- package/src/devcouncil/planning/arbiter_service.py +0 -57
- package/src/devcouncil/planning/correction_manifest.py +0 -303
- package/src/devcouncil/planning/critique_service.py +0 -71
- package/src/devcouncil/planning/plan_service.py +0 -60
- package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
- package/src/devcouncil/planning/repair_service.py +0 -39
- package/src/devcouncil/planning/spec_service.py +0 -70
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/repo/ci_scaffold.py +0 -157
- package/src/devcouncil/repo/gitignore.py +0 -123
- package/src/devcouncil/repo/sca.py +0 -374
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +0 -32
- package/src/devcouncil/reporting/json_report.py +0 -30
- package/src/devcouncil/reporting/markdown_report.py +0 -83
- package/src/devcouncil/reporting/report_builder.py +0 -14
- package/src/devcouncil/skills/__init__.py +0 -19
- package/src/devcouncil/skills/library/README.md +0 -46
- package/src/devcouncil/skills/library/ai-training.md +0 -50
- package/src/devcouncil/skills/library/android.md +0 -50
- package/src/devcouncil/skills/library/backend.md +0 -52
- package/src/devcouncil/skills/library/core-engineering.md +0 -95
- package/src/devcouncil/skills/library/data-engineering.md +0 -47
- package/src/devcouncil/skills/library/desktop.md +0 -46
- package/src/devcouncil/skills/library/devops.md +0 -48
- package/src/devcouncil/skills/library/game-dev.md +0 -46
- package/src/devcouncil/skills/library/ios.md +0 -48
- package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
- package/src/devcouncil/skills/library/security.md +0 -48
- package/src/devcouncil/skills/library/systems.md +0 -48
- package/src/devcouncil/skills/library/web.md +0 -47
- package/src/devcouncil/skills/library/windows.md +0 -47
- package/src/devcouncil/skills/registry.py +0 -330
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +0 -147
- package/src/devcouncil/storage/models.py +0 -204
- package/src/devcouncil/storage/native.py +0 -557
- package/src/devcouncil/storage/repositories.py +0 -485
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +0 -140
- package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
- package/src/devcouncil/telemetry/pricing.py +0 -28
- package/src/devcouncil/telemetry/traces.py +0 -146
- package/src/devcouncil/telemetry/tracker.py +0 -52
- package/src/devcouncil/ui/__init__.py +0 -1
- package/src/devcouncil/ui/dashboard.py +0 -423
- package/src/devcouncil/utils/__init__.py +0 -1
- package/src/devcouncil/utils/redaction.py +0 -147
- package/src/devcouncil/utils/subprocess_env.py +0 -69
- package/src/devcouncil/verification/__init__.py +0 -1
- package/src/devcouncil/verification/acceptance_compiler.py +0 -125
- package/src/devcouncil/verification/ad_hoc_check.py +0 -129
- package/src/devcouncil/verification/diff_coverage.py +0 -353
- package/src/devcouncil/verification/implementation_reviewer.py +0 -55
- package/src/devcouncil/verification/next_actions.py +0 -189
- package/src/devcouncil/verification/sandbox.py +0 -178
- package/src/devcouncil/verification/test_resolver.py +0 -91
- package/src/devcouncil/verification/verifier.py +0 -1548
- package/uv.lock +0 -1226
|
@@ -1,47 +0,0 @@
|
|
|
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.
|
|
@@ -1,330 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
from collections import defaultdict
|
|
2
|
-
from contextlib import contextmanager
|
|
3
|
-
from datetime import datetime, timezone
|
|
4
|
-
|
|
5
|
-
from sqlmodel import SQLModel, create_engine, Session
|
|
6
|
-
from sqlalchemy import inspect, text
|
|
7
|
-
from sqlalchemy.exc import OperationalError
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
10
|
-
|
|
11
|
-
from devcouncil.storage.models import SchemaVersionModel
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# v3: per-task indexes on the task/audit tables (task_id, lease status).
|
|
15
|
-
# v4: machine-routable gap columns (file, line, suggested_command,
|
|
16
|
-
# acceptance_criterion_id) so the repair contract survives a reload.
|
|
17
|
-
# v5: partial unique index enforcing one ACTIVE lease per task (single-writer).
|
|
18
|
-
SCHEMA_VERSION = 5
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class Database:
|
|
22
|
-
def __init__(self, db_path: Path):
|
|
23
|
-
self.db_path = db_path
|
|
24
|
-
self.engine = create_engine(f"sqlite:///{db_path}")
|
|
25
|
-
|
|
26
|
-
def create_db_and_tables(self):
|
|
27
|
-
self._create_tables()
|
|
28
|
-
self.ensure_schema_version()
|
|
29
|
-
|
|
30
|
-
def ensure_schema_version(self):
|
|
31
|
-
self._create_tables()
|
|
32
|
-
with Session(self.engine) as session:
|
|
33
|
-
current = session.get(SchemaVersionModel, "singleton")
|
|
34
|
-
if current is None:
|
|
35
|
-
session.add(SchemaVersionModel(id="singleton", version=SCHEMA_VERSION))
|
|
36
|
-
session.commit()
|
|
37
|
-
return
|
|
38
|
-
if current.version > SCHEMA_VERSION:
|
|
39
|
-
raise RuntimeError(
|
|
40
|
-
f"Unsupported DevCouncil schema version {current.version}; "
|
|
41
|
-
f"expected {SCHEMA_VERSION}."
|
|
42
|
-
)
|
|
43
|
-
if current.version < SCHEMA_VERSION:
|
|
44
|
-
self._create_tables()
|
|
45
|
-
current.version = SCHEMA_VERSION
|
|
46
|
-
session.add(current)
|
|
47
|
-
session.commit()
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
def _create_tables(self):
|
|
51
|
-
try:
|
|
52
|
-
SQLModel.metadata.create_all(self.engine)
|
|
53
|
-
except OperationalError as exc:
|
|
54
|
-
if "already exists" not in str(exc):
|
|
55
|
-
raise
|
|
56
|
-
self._create_missing_columns()
|
|
57
|
-
self._dedup_active_leases()
|
|
58
|
-
self._create_missing_indexes()
|
|
59
|
-
|
|
60
|
-
def _dedup_active_leases(self):
|
|
61
|
-
# The partial unique index (ux_task_leases_active) can only be created if no task
|
|
62
|
-
# already has two ACTIVE leases. Older databases predate the constraint, so
|
|
63
|
-
# collapse any duplicates first — keep the newest active lease per task, mark the
|
|
64
|
-
# rest stale — making index creation succeed and restoring single-writer state.
|
|
65
|
-
inspector = inspect(self.engine)
|
|
66
|
-
if "task_leases" not in inspector.get_table_names():
|
|
67
|
-
return
|
|
68
|
-
with self.engine.begin() as conn:
|
|
69
|
-
rows = conn.execute(
|
|
70
|
-
text("SELECT id, task_id, created_at FROM task_leases WHERE status = 'active'")
|
|
71
|
-
).fetchall()
|
|
72
|
-
by_task = defaultdict(list)
|
|
73
|
-
for row in rows:
|
|
74
|
-
by_task[row.task_id].append(((row.created_at or ""), row.id))
|
|
75
|
-
stale_ids = []
|
|
76
|
-
for items in by_task.values():
|
|
77
|
-
if len(items) <= 1:
|
|
78
|
-
continue
|
|
79
|
-
items.sort() # ascending by (created_at, id); keep the last = newest
|
|
80
|
-
stale_ids.extend(item[1] for item in items[:-1])
|
|
81
|
-
if stale_ids:
|
|
82
|
-
now = datetime.now(timezone.utc).isoformat()
|
|
83
|
-
for lease_id in stale_ids:
|
|
84
|
-
conn.execute(
|
|
85
|
-
text("UPDATE task_leases SET status = 'stale', released_at = :ts WHERE id = :id"),
|
|
86
|
-
{"ts": now, "id": lease_id},
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
def _create_missing_columns(self):
|
|
90
|
-
# create_all never alters an existing table, so columns added to a model
|
|
91
|
-
# later (e.g. the v4 gap routing columns) are missing on databases created by
|
|
92
|
-
# an older version — and a SELECT of the model would then fail. Add any
|
|
93
|
-
# missing column as a nullable ADD COLUMN (the only shape SQLite can add in
|
|
94
|
-
# place without a table rewrite); non-nullable additions are intentionally
|
|
95
|
-
# skipped because existing rows could not satisfy them.
|
|
96
|
-
inspector = inspect(self.engine)
|
|
97
|
-
existing_tables = set(inspector.get_table_names())
|
|
98
|
-
for table in SQLModel.metadata.sorted_tables:
|
|
99
|
-
if table.name not in existing_tables:
|
|
100
|
-
continue
|
|
101
|
-
existing_cols = {col["name"] for col in inspector.get_columns(table.name)}
|
|
102
|
-
for column in table.columns:
|
|
103
|
-
if column.name in existing_cols or not column.nullable:
|
|
104
|
-
continue
|
|
105
|
-
ddl_type = column.type.compile(self.engine.dialect)
|
|
106
|
-
stmt = text(f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {ddl_type}')
|
|
107
|
-
try:
|
|
108
|
-
with self.engine.begin() as conn:
|
|
109
|
-
conn.execute(stmt)
|
|
110
|
-
except OperationalError as exc:
|
|
111
|
-
if "duplicate column name" not in str(exc).lower():
|
|
112
|
-
raise
|
|
113
|
-
|
|
114
|
-
def _create_missing_indexes(self):
|
|
115
|
-
# create_all skips tables that already exist, so indexes added to the
|
|
116
|
-
# model definitions later never materialize on existing databases.
|
|
117
|
-
for table in SQLModel.metadata.sorted_tables:
|
|
118
|
-
for index in table.indexes:
|
|
119
|
-
try:
|
|
120
|
-
index.create(self.engine, checkfirst=True)
|
|
121
|
-
except OperationalError as exc:
|
|
122
|
-
if "already exists" not in str(exc):
|
|
123
|
-
raise
|
|
124
|
-
|
|
125
|
-
@contextmanager
|
|
126
|
-
def get_session(self):
|
|
127
|
-
"""Yield a session with automatic commit/rollback/close."""
|
|
128
|
-
session = Session(self.engine)
|
|
129
|
-
try:
|
|
130
|
-
yield session
|
|
131
|
-
session.commit()
|
|
132
|
-
except Exception:
|
|
133
|
-
session.rollback()
|
|
134
|
-
raise
|
|
135
|
-
finally:
|
|
136
|
-
session.close()
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def get_db(project_root: Path = Path(".")) -> Optional[Database]:
|
|
140
|
-
dev_dir = project_root / ".devcouncil"
|
|
141
|
-
if not dev_dir.exists():
|
|
142
|
-
return None
|
|
143
|
-
|
|
144
|
-
db_path = dev_dir / "state.sqlite"
|
|
145
|
-
db = Database(db_path)
|
|
146
|
-
db.ensure_schema_version()
|
|
147
|
-
return db
|