taskill 0.1.1__py3-none-any.whl
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.
- taskill/__init__.py +20 -0
- taskill/cli.py +253 -0
- taskill/config.py +170 -0
- taskill/core.py +211 -0
- taskill/git_state.py +147 -0
- taskill/providers/__init__.py +37 -0
- taskill/providers/algorithmic.py +155 -0
- taskill/providers/base.py +105 -0
- taskill/providers/openrouter.py +114 -0
- taskill/providers/windsurf_mcp.py +135 -0
- taskill/state.py +42 -0
- taskill/triggers.py +94 -0
- taskill/updaters/__init__.py +6 -0
- taskill/updaters/changelog.py +92 -0
- taskill/updaters/readme.py +74 -0
- taskill/updaters/todo.py +77 -0
- taskill-0.1.1.dist-info/METADATA +194 -0
- taskill-0.1.1.dist-info/RECORD +21 -0
- taskill-0.1.1.dist-info/WHEEL +4 -0
- taskill-0.1.1.dist-info/entry_points.txt +2 -0
- taskill-0.1.1.dist-info/licenses/LICENSE +201 -0
taskill/git_state.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Read project state from git, filesystem, and (optionally) pyqual reports."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Commit:
|
|
14
|
+
sha: str
|
|
15
|
+
short_sha: str
|
|
16
|
+
author: str
|
|
17
|
+
date: str
|
|
18
|
+
subject: str
|
|
19
|
+
body: str = ""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def conventional_type(self) -> str | None:
|
|
23
|
+
"""Parse 'feat(scope): xxx' → 'feat'. Returns None if not conventional."""
|
|
24
|
+
m = re.match(r"^(feat|fix|docs|chore|refactor|test|perf|ci|build|style|revert)(\([^)]+\))?!?:\s",
|
|
25
|
+
self.subject)
|
|
26
|
+
return m.group(1) if m else None
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_breaking(self) -> bool:
|
|
30
|
+
return "!" in self.subject.split(":")[0] or "BREAKING CHANGE" in self.body
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ProjectSnapshot:
|
|
35
|
+
head_sha: str | None
|
|
36
|
+
commits_since_last_run: list[Commit] = field(default_factory=list)
|
|
37
|
+
changed_files: list[str] = field(default_factory=list)
|
|
38
|
+
coverage_pct: float | None = None
|
|
39
|
+
failed_tests: int | None = None
|
|
40
|
+
sumd_hash: str | None = None
|
|
41
|
+
sumd_text: str | None = None
|
|
42
|
+
sumr_text: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run(cmd: list[str], cwd: Path) -> str:
|
|
46
|
+
try:
|
|
47
|
+
out = subprocess.run(
|
|
48
|
+
cmd, cwd=cwd, capture_output=True, text=True, check=False, timeout=30
|
|
49
|
+
)
|
|
50
|
+
return out.stdout.strip()
|
|
51
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def head_sha(project_root: Path) -> str | None:
|
|
56
|
+
sha = _run(["git", "rev-parse", "HEAD"], project_root)
|
|
57
|
+
return sha or None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def commits_since(project_root: Path, since_sha: str | None) -> list[Commit]:
|
|
61
|
+
"""Get commits since a SHA (exclusive). If since_sha is None, return last 50."""
|
|
62
|
+
rng = f"{since_sha}..HEAD" if since_sha else "-50"
|
|
63
|
+
sep = "\x1e" # record sep
|
|
64
|
+
fmt = f"%H{sep}%h{sep}%an{sep}%aI{sep}%s{sep}%b\x1f"
|
|
65
|
+
raw = _run(["git", "log", rng, f"--pretty=format:{fmt}"], project_root)
|
|
66
|
+
if not raw:
|
|
67
|
+
return []
|
|
68
|
+
commits: list[Commit] = []
|
|
69
|
+
for record in raw.split("\x1f"):
|
|
70
|
+
record = record.strip("\n")
|
|
71
|
+
if not record:
|
|
72
|
+
continue
|
|
73
|
+
parts = record.split(sep)
|
|
74
|
+
if len(parts) < 5:
|
|
75
|
+
continue
|
|
76
|
+
sha, short, author, date, subject = parts[:5]
|
|
77
|
+
body = parts[5] if len(parts) > 5 else ""
|
|
78
|
+
commits.append(Commit(sha=sha, short_sha=short, author=author,
|
|
79
|
+
date=date, subject=subject, body=body))
|
|
80
|
+
return commits
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def changed_files_since(project_root: Path, since_sha: str | None) -> list[str]:
|
|
84
|
+
if not since_sha:
|
|
85
|
+
return []
|
|
86
|
+
out = _run(["git", "diff", "--name-only", f"{since_sha}..HEAD"], project_root)
|
|
87
|
+
return [line for line in out.splitlines() if line]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_coverage(project_root: Path) -> float | None:
|
|
91
|
+
"""Look for common coverage report locations."""
|
|
92
|
+
for candidate in ("coverage.json", ".coverage.json", "htmlcov/coverage.json"):
|
|
93
|
+
p = project_root / candidate
|
|
94
|
+
if p.exists():
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
97
|
+
pct = data.get("totals", {}).get("percent_covered")
|
|
98
|
+
if pct is not None:
|
|
99
|
+
return float(pct)
|
|
100
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
101
|
+
pass
|
|
102
|
+
# coverage.xml — minimal extraction
|
|
103
|
+
xml = project_root / "coverage.xml"
|
|
104
|
+
if xml.exists():
|
|
105
|
+
m = re.search(r'line-rate="([0-9.]+)"', xml.read_text(encoding="utf-8"))
|
|
106
|
+
if m:
|
|
107
|
+
return float(m.group(1)) * 100
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def read_failed_tests(project_root: Path) -> int | None:
|
|
112
|
+
"""Look for pytest junit xml or pyqual report."""
|
|
113
|
+
for candidate in ("junit.xml", "test-results.xml", "reports/junit.xml"):
|
|
114
|
+
p = project_root / candidate
|
|
115
|
+
if p.exists():
|
|
116
|
+
xml = p.read_text(encoding="utf-8")
|
|
117
|
+
m = re.search(r'failures="(\d+)"', xml)
|
|
118
|
+
e = re.search(r'errors="(\d+)"', xml)
|
|
119
|
+
if m:
|
|
120
|
+
return int(m.group(1)) + (int(e.group(1)) if e else 0)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def file_hash(path: Path) -> str | None:
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return None
|
|
127
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def collect_snapshot(
|
|
131
|
+
project_root: Path,
|
|
132
|
+
files: dict[str, str],
|
|
133
|
+
last_commit_sha: str | None,
|
|
134
|
+
) -> ProjectSnapshot:
|
|
135
|
+
sumd_path = project_root / files.get("sumd", "SUMD.md")
|
|
136
|
+
sumr_path = project_root / files.get("sumr", "SUMR.md")
|
|
137
|
+
|
|
138
|
+
return ProjectSnapshot(
|
|
139
|
+
head_sha=head_sha(project_root),
|
|
140
|
+
commits_since_last_run=commits_since(project_root, last_commit_sha),
|
|
141
|
+
changed_files=changed_files_since(project_root, last_commit_sha),
|
|
142
|
+
coverage_pct=read_coverage(project_root),
|
|
143
|
+
failed_tests=read_failed_tests(project_root),
|
|
144
|
+
sumd_hash=file_hash(sumd_path),
|
|
145
|
+
sumd_text=sumd_path.read_text(encoding="utf-8") if sumd_path.exists() else None,
|
|
146
|
+
sumr_text=sumr_path.read_text(encoding="utf-8") if sumr_path.exists() else None,
|
|
147
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Provider chain. First successful provider wins.
|
|
2
|
+
|
|
3
|
+
Order is determined by config (taskill.yaml: providers: [...]).
|
|
4
|
+
Default order: windsurf_mcp → openrouter → algorithmic.
|
|
5
|
+
"""
|
|
6
|
+
from taskill.providers.base import Provider, ProviderError, GeneratedDocs
|
|
7
|
+
from taskill.providers.algorithmic import AlgorithmicProvider
|
|
8
|
+
from taskill.providers.openrouter import OpenRouterProvider
|
|
9
|
+
from taskill.providers.windsurf_mcp import WindsurfMcpProvider
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Provider",
|
|
13
|
+
"ProviderError",
|
|
14
|
+
"GeneratedDocs",
|
|
15
|
+
"AlgorithmicProvider",
|
|
16
|
+
"OpenRouterProvider",
|
|
17
|
+
"WindsurfMcpProvider",
|
|
18
|
+
"build_chain",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_chain(provider_configs):
|
|
23
|
+
"""Materialize provider config list into provider instances (only enabled)."""
|
|
24
|
+
registry = {
|
|
25
|
+
"windsurf_mcp": WindsurfMcpProvider,
|
|
26
|
+
"openrouter": OpenRouterProvider,
|
|
27
|
+
"algorithmic": AlgorithmicProvider,
|
|
28
|
+
}
|
|
29
|
+
chain = []
|
|
30
|
+
for cfg in provider_configs:
|
|
31
|
+
if not cfg.enabled:
|
|
32
|
+
continue
|
|
33
|
+
cls = registry.get(cfg.name)
|
|
34
|
+
if cls is None:
|
|
35
|
+
continue
|
|
36
|
+
chain.append(cls(options=cfg.options))
|
|
37
|
+
return chain
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Algorithmic fallback — no LLM required.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Categorize commits via Conventional Commits prefixes.
|
|
5
|
+
- Move TODO lines that match commit subjects (fuzzy) to changelog.
|
|
6
|
+
- Detect '[x]' checkboxes in TODO and treat as completed.
|
|
7
|
+
- For new TODO items: scan commits for 'TODO:' / 'FIXME:' markers in body.
|
|
8
|
+
|
|
9
|
+
This is the safety net. Always available, always deterministic, ~zero deps.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from taskill.providers.base import GeneratedDocs, Provider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CATEGORY_HEADINGS = {
|
|
21
|
+
"feat": "### Added",
|
|
22
|
+
"fix": "### Fixed",
|
|
23
|
+
"perf": "### Performance",
|
|
24
|
+
"refactor": "### Changed",
|
|
25
|
+
"docs": "### Documentation",
|
|
26
|
+
"test": "### Tests",
|
|
27
|
+
"build": "### Build",
|
|
28
|
+
"ci": "### CI",
|
|
29
|
+
"chore": "### Chore",
|
|
30
|
+
"style": "### Style",
|
|
31
|
+
"revert": "### Reverted",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AlgorithmicProvider(Provider):
|
|
36
|
+
name = "algorithmic"
|
|
37
|
+
|
|
38
|
+
def is_available(self) -> bool:
|
|
39
|
+
return True # always
|
|
40
|
+
|
|
41
|
+
def generate(self, context: dict[str, Any]) -> GeneratedDocs:
|
|
42
|
+
snap = context["snapshot"]
|
|
43
|
+
existing_todo = context.get("existing_todo") or ""
|
|
44
|
+
|
|
45
|
+
# 1. group commits by conventional type
|
|
46
|
+
by_type: dict[str, list[str]] = defaultdict(list)
|
|
47
|
+
breaking: list[str] = []
|
|
48
|
+
uncategorized: list[str] = []
|
|
49
|
+
for c in snap.commits_since_last_run:
|
|
50
|
+
ctype = c.conventional_type
|
|
51
|
+
line = self._format_commit(c)
|
|
52
|
+
if c.is_breaking:
|
|
53
|
+
breaking.append(line)
|
|
54
|
+
if ctype:
|
|
55
|
+
by_type[ctype].append(line)
|
|
56
|
+
else:
|
|
57
|
+
uncategorized.append(line)
|
|
58
|
+
|
|
59
|
+
# 2. build changelog entries grouped by category
|
|
60
|
+
entries: list[str] = []
|
|
61
|
+
if breaking:
|
|
62
|
+
entries.append("### ⚠ BREAKING CHANGES")
|
|
63
|
+
entries.extend(breaking)
|
|
64
|
+
for ctype in ["feat", "fix", "perf", "refactor", "docs", "test",
|
|
65
|
+
"build", "ci", "chore", "style", "revert"]:
|
|
66
|
+
if by_type.get(ctype):
|
|
67
|
+
entries.append(CATEGORY_HEADINGS[ctype])
|
|
68
|
+
entries.extend(by_type[ctype])
|
|
69
|
+
if uncategorized:
|
|
70
|
+
entries.append("### Other")
|
|
71
|
+
entries.extend(uncategorized)
|
|
72
|
+
|
|
73
|
+
# 3. detect completed TODOs
|
|
74
|
+
todo_completed = self._find_completed_todos(existing_todo, snap.commits_since_last_run)
|
|
75
|
+
|
|
76
|
+
# 4. extract TODO/FIXME from commit bodies
|
|
77
|
+
todo_new = self._extract_new_todos(snap.commits_since_last_run)
|
|
78
|
+
|
|
79
|
+
n_total = len(snap.commits_since_last_run)
|
|
80
|
+
summary = (
|
|
81
|
+
f"{n_total} commit(s); "
|
|
82
|
+
f"{len(by_type.get('feat', []))} features, "
|
|
83
|
+
f"{len(by_type.get('fix', []))} fixes."
|
|
84
|
+
) if n_total else "No new commits — refreshing docs only."
|
|
85
|
+
|
|
86
|
+
return GeneratedDocs(
|
|
87
|
+
changelog_entries=entries,
|
|
88
|
+
todo_completed=todo_completed,
|
|
89
|
+
todo_new=todo_new,
|
|
90
|
+
summary=summary,
|
|
91
|
+
provider_name=self.name,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _format_commit(commit) -> str: # type: ignore[no-untyped-def]
|
|
96
|
+
# strip the conventional prefix from the bullet text — heading already conveys it
|
|
97
|
+
subject = re.sub(
|
|
98
|
+
r"^(feat|fix|docs|chore|refactor|test|perf|ci|build|style|revert)(\([^)]+\))?!?:\s*",
|
|
99
|
+
"",
|
|
100
|
+
commit.subject,
|
|
101
|
+
)
|
|
102
|
+
return f"- {subject} ({commit.short_sha})"
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _find_completed_todos(todo_text: str, commits) -> list[str]: # type: ignore[no-untyped-def]
|
|
106
|
+
"""Return TODO lines that look completed.
|
|
107
|
+
|
|
108
|
+
Two signals:
|
|
109
|
+
1. Line starts with `- [x]` or `* [x]` → explicitly checked off.
|
|
110
|
+
2. Line content has high token overlap with a commit subject.
|
|
111
|
+
"""
|
|
112
|
+
completed: list[str] = []
|
|
113
|
+
for raw_line in todo_text.splitlines():
|
|
114
|
+
line = raw_line.rstrip()
|
|
115
|
+
if not line.strip():
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# explicit checkbox
|
|
119
|
+
if re.match(r"^\s*[-*+]\s*\[[xX]\]", line):
|
|
120
|
+
completed.append(line)
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# only check unchecked todo bullets
|
|
124
|
+
m = re.match(r"^\s*[-*+]\s*(\[\s\]\s*)?(.+)$", line)
|
|
125
|
+
if not m:
|
|
126
|
+
continue
|
|
127
|
+
text = m.group(2).lower()
|
|
128
|
+
text_tokens = set(re.findall(r"\w{4,}", text))
|
|
129
|
+
if len(text_tokens) < 2:
|
|
130
|
+
continue
|
|
131
|
+
for c in commits:
|
|
132
|
+
commit_tokens = set(re.findall(r"\w{4,}", c.subject.lower()))
|
|
133
|
+
if not commit_tokens:
|
|
134
|
+
continue
|
|
135
|
+
overlap = text_tokens & commit_tokens
|
|
136
|
+
# need ≥2 shared significant tokens AND ≥40% of TODO tokens covered.
|
|
137
|
+
# 40% (not 50%) accommodates plurals/stems without proper stemming —
|
|
138
|
+
# e.g. "refresh tokens" in TODO vs "refresh token" in commit.
|
|
139
|
+
if len(overlap) >= 2 and len(overlap) / len(text_tokens) >= 0.4:
|
|
140
|
+
completed.append(line)
|
|
141
|
+
break
|
|
142
|
+
return completed
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _extract_new_todos(commits) -> list[str]: # type: ignore[no-untyped-def]
|
|
146
|
+
new: list[str] = []
|
|
147
|
+
pat = re.compile(r"\b(TODO|FIXME|XXX)[:\s]+([^\n]+)", re.IGNORECASE)
|
|
148
|
+
seen: set[str] = set()
|
|
149
|
+
for c in commits:
|
|
150
|
+
for m in pat.finditer(c.body or ""):
|
|
151
|
+
text = m.group(2).strip().rstrip(".")[:160]
|
|
152
|
+
if text and text.lower() not in seen:
|
|
153
|
+
seen.add(text.lower())
|
|
154
|
+
new.append(f"- [ ] {text}")
|
|
155
|
+
return new
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Provider abstraction + shared types."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProviderError(Exception):
|
|
10
|
+
"""Provider failed — chain falls through to next provider."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GeneratedDocs:
|
|
15
|
+
"""What every provider returns."""
|
|
16
|
+
readme: str | None = None
|
|
17
|
+
changelog_entries: list[str] = None # type: ignore[assignment] # markdown bullets
|
|
18
|
+
todo_completed: list[str] = None # type: ignore[assignment] # items to remove from TODO
|
|
19
|
+
todo_new: list[str] = None # type: ignore[assignment] # new items detected
|
|
20
|
+
summary: str = ""
|
|
21
|
+
provider_name: str = "unknown"
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.changelog_entries is None:
|
|
25
|
+
self.changelog_entries = []
|
|
26
|
+
if self.todo_completed is None:
|
|
27
|
+
self.todo_completed = []
|
|
28
|
+
if self.todo_new is None:
|
|
29
|
+
self.todo_new = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Provider(ABC):
|
|
33
|
+
"""Abstract provider — produces docs from a project snapshot."""
|
|
34
|
+
|
|
35
|
+
name: str = "abstract"
|
|
36
|
+
|
|
37
|
+
def __init__(self, options: dict[str, Any] | None = None) -> None:
|
|
38
|
+
self.options = options or {}
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def is_available(self) -> bool:
|
|
42
|
+
"""Quick self-check. Should NOT raise — just return False."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def generate(self, context: dict[str, Any]) -> GeneratedDocs:
|
|
46
|
+
"""Generate docs.
|
|
47
|
+
|
|
48
|
+
`context` keys (see core.build_context):
|
|
49
|
+
- snapshot: ProjectSnapshot
|
|
50
|
+
- existing_readme, existing_changelog, existing_todo: str
|
|
51
|
+
- sumd, sumr: str | None
|
|
52
|
+
- project_name: str
|
|
53
|
+
- pyqual_report: dict | None
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ─── shared prompt template (used by LLM providers) ──────────────────────
|
|
58
|
+
|
|
59
|
+
SYSTEM_PROMPT = """You are taskill, a release-notes / docs janitor.
|
|
60
|
+
Given a project snapshot (commits, changed files, coverage, SUMD), output STRICT JSON:
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
"summary": "1-2 sentence high-level description of what changed",
|
|
64
|
+
"changelog_entries": ["- feat: thing one", "- fix: thing two"],
|
|
65
|
+
"todo_completed": ["- exact text of TODO line that is now done"],
|
|
66
|
+
"todo_new": ["- new thing discovered from commits"],
|
|
67
|
+
"readme_patches": null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Rules:
|
|
71
|
+
- changelog_entries: markdown bullets, conventional-commits style if possible
|
|
72
|
+
- todo_completed: copy line text VERBATIM from the existing TODO; don't paraphrase
|
|
73
|
+
- Be conservative — when unsure, omit. False positives are worse than misses.
|
|
74
|
+
- Output ONLY JSON. No prose. No code fences.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_user_prompt(context: dict[str, Any]) -> str:
|
|
79
|
+
snap = context["snapshot"]
|
|
80
|
+
commits = "\n".join(
|
|
81
|
+
f" {c.short_sha} {c.subject}" for c in snap.commits_since_last_run[:50]
|
|
82
|
+
) or " (no new commits)"
|
|
83
|
+
changed = "\n".join(f" {f}" for f in snap.changed_files[:50]) or " (none)"
|
|
84
|
+
todo = context.get("existing_todo") or "(empty)"
|
|
85
|
+
sumd = context.get("sumd") or "(no SUMD.md)"
|
|
86
|
+
coverage = f"{snap.coverage_pct:.1f}%" if snap.coverage_pct else "n/a"
|
|
87
|
+
failed = snap.failed_tests if snap.failed_tests is not None else "n/a"
|
|
88
|
+
|
|
89
|
+
return f"""Project: {context.get('project_name', 'unknown')}
|
|
90
|
+
|
|
91
|
+
# Commits since last taskill run
|
|
92
|
+
{commits}
|
|
93
|
+
|
|
94
|
+
# Changed files
|
|
95
|
+
{changed}
|
|
96
|
+
|
|
97
|
+
# Coverage: {coverage} Failed tests: {failed}
|
|
98
|
+
|
|
99
|
+
# Current TODO.md (truncated)
|
|
100
|
+
{todo[:4000]}
|
|
101
|
+
|
|
102
|
+
# SUMD.md (project structure summary, truncated)
|
|
103
|
+
{sumd[:3000]}
|
|
104
|
+
|
|
105
|
+
Now produce the JSON described in the system prompt."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""OpenRouter provider.
|
|
2
|
+
|
|
3
|
+
Reads OPENROUTER_API_KEY and LLM_MODEL from env (loaded via .env in config).
|
|
4
|
+
Uses HTTP directly to keep deps minimal — no openai SDK needed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from taskill.providers.base import (
|
|
16
|
+
GeneratedDocs,
|
|
17
|
+
Provider,
|
|
18
|
+
ProviderError,
|
|
19
|
+
SYSTEM_PROMPT,
|
|
20
|
+
build_user_prompt,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# OpenRouter model strings often arrive as "openrouter/qwen/qwen3-coder-next" — the leading
|
|
25
|
+
# "openrouter/" segment is litellm-style. The OpenRouter REST API itself wants just
|
|
26
|
+
# "qwen/qwen3-coder-next". We strip the prefix automatically.
|
|
27
|
+
def _normalize_model(model: str) -> str:
|
|
28
|
+
if model.startswith("openrouter/"):
|
|
29
|
+
return model[len("openrouter/"):]
|
|
30
|
+
return model
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OpenRouterProvider(Provider):
|
|
34
|
+
name = "openrouter"
|
|
35
|
+
|
|
36
|
+
def is_available(self) -> bool:
|
|
37
|
+
return bool(os.getenv("OPENROUTER_API_KEY"))
|
|
38
|
+
|
|
39
|
+
def generate(self, context: dict[str, Any]) -> GeneratedDocs:
|
|
40
|
+
api_key = os.getenv("OPENROUTER_API_KEY")
|
|
41
|
+
if not api_key:
|
|
42
|
+
raise ProviderError("OPENROUTER_API_KEY not set")
|
|
43
|
+
|
|
44
|
+
model = _normalize_model(os.getenv("LLM_MODEL", "qwen/qwen3-coder-next"))
|
|
45
|
+
base_url = self.options.get("base_url", "https://openrouter.ai/api/v1")
|
|
46
|
+
timeout = self.options.get("timeout", 120)
|
|
47
|
+
|
|
48
|
+
payload = {
|
|
49
|
+
"model": model,
|
|
50
|
+
"temperature": self.options.get("temperature", 0.2),
|
|
51
|
+
"max_tokens": self.options.get("max_tokens", 4096),
|
|
52
|
+
"messages": [
|
|
53
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
54
|
+
{"role": "user", "content": build_user_prompt(context)},
|
|
55
|
+
],
|
|
56
|
+
# JSON mode if model supports it; harmless if it doesn't
|
|
57
|
+
"response_format": {"type": "json_object"},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
headers = {
|
|
61
|
+
"Authorization": f"Bearer {api_key}",
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
# OpenRouter recommends these for analytics / better routing
|
|
64
|
+
"HTTP-Referer": self.options.get("referer", "https://github.com/oqlos/taskill"),
|
|
65
|
+
"X-Title": "taskill",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with httpx.Client(timeout=timeout) as client:
|
|
70
|
+
resp = client.post(f"{base_url}/chat/completions", json=payload, headers=headers)
|
|
71
|
+
except httpx.HTTPError as e:
|
|
72
|
+
raise ProviderError(f"OpenRouter request failed: {e}") from e
|
|
73
|
+
|
|
74
|
+
if resp.status_code != 200:
|
|
75
|
+
raise ProviderError(
|
|
76
|
+
f"OpenRouter returned {resp.status_code}: {resp.text[:300]}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
data = resp.json()
|
|
81
|
+
content = data["choices"][0]["message"]["content"]
|
|
82
|
+
except (KeyError, IndexError, json.JSONDecodeError) as e:
|
|
83
|
+
raise ProviderError(f"Malformed OpenRouter response: {e}") from e
|
|
84
|
+
|
|
85
|
+
parsed = self._parse_json_loosely(content)
|
|
86
|
+
if parsed is None:
|
|
87
|
+
raise ProviderError("Model did not return valid JSON")
|
|
88
|
+
|
|
89
|
+
return GeneratedDocs(
|
|
90
|
+
changelog_entries=list(parsed.get("changelog_entries", [])),
|
|
91
|
+
todo_completed=list(parsed.get("todo_completed", [])),
|
|
92
|
+
todo_new=list(parsed.get("todo_new", [])),
|
|
93
|
+
summary=str(parsed.get("summary", "")),
|
|
94
|
+
provider_name=f"{self.name}:{model}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _parse_json_loosely(text: str) -> dict[str, Any] | None:
|
|
99
|
+
"""Be forgiving: strip ```json fences, extract first {...} block."""
|
|
100
|
+
text = text.strip()
|
|
101
|
+
# strip fences
|
|
102
|
+
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.MULTILINE)
|
|
103
|
+
try:
|
|
104
|
+
return json.loads(text)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
pass
|
|
107
|
+
# find first balanced { ... }
|
|
108
|
+
m = re.search(r"\{.*\}", text, re.DOTALL)
|
|
109
|
+
if not m:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return json.loads(m.group(0))
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
return None
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Windsurf MCP provider — first in the chain.
|
|
2
|
+
|
|
3
|
+
Why first: the user runs the Windsurf plugin in JetBrains, which exposes
|
|
4
|
+
an MCP server with rich workspace context (open files, diagnostics, etc.).
|
|
5
|
+
If reachable, it produces better results than a cold OpenRouter call.
|
|
6
|
+
|
|
7
|
+
Discovery (in order):
|
|
8
|
+
1. options.endpoint == "stdio" → spawn `windsurf mcp` (or path from options.command)
|
|
9
|
+
2. options.endpoint starts with "ws://" or "http://" → connect remotely
|
|
10
|
+
3. WINDSURF_MCP_ENDPOINT env var
|
|
11
|
+
4. Probe known JetBrains plugin sockets at ~/.codeium/windsurf/mcp.sock
|
|
12
|
+
and ~/.windsurf/mcp.sock
|
|
13
|
+
|
|
14
|
+
If the optional `mcp` package is not installed, this provider is unavailable
|
|
15
|
+
(returns False from is_available) and the chain falls through cleanly to
|
|
16
|
+
OpenRouter. This is intentional — taskill should not require MCP to work.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from taskill.providers.base import (
|
|
26
|
+
GeneratedDocs,
|
|
27
|
+
Provider,
|
|
28
|
+
ProviderError,
|
|
29
|
+
SYSTEM_PROMPT,
|
|
30
|
+
build_user_prompt,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _mcp_lib_present() -> bool:
|
|
35
|
+
try:
|
|
36
|
+
import mcp # noqa: F401
|
|
37
|
+
return True
|
|
38
|
+
except ImportError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _candidate_endpoints(options: dict[str, Any]) -> list[str]:
|
|
43
|
+
cands: list[str] = []
|
|
44
|
+
ep = options.get("endpoint")
|
|
45
|
+
if ep:
|
|
46
|
+
cands.append(ep)
|
|
47
|
+
env_ep = os.getenv("WINDSURF_MCP_ENDPOINT")
|
|
48
|
+
if env_ep:
|
|
49
|
+
cands.append(env_ep)
|
|
50
|
+
home = Path.home()
|
|
51
|
+
for sock in (home / ".codeium" / "windsurf" / "mcp.sock",
|
|
52
|
+
home / ".windsurf" / "mcp.sock"):
|
|
53
|
+
if sock.exists():
|
|
54
|
+
cands.append(f"unix://{sock}")
|
|
55
|
+
return cands
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class WindsurfMcpProvider(Provider):
|
|
59
|
+
name = "windsurf_mcp"
|
|
60
|
+
|
|
61
|
+
def is_available(self) -> bool:
|
|
62
|
+
if not _mcp_lib_present():
|
|
63
|
+
return False
|
|
64
|
+
# consider available if at least one candidate endpoint resolves
|
|
65
|
+
return bool(_candidate_endpoints(self.options))
|
|
66
|
+
|
|
67
|
+
def generate(self, context: dict[str, Any]) -> GeneratedDocs:
|
|
68
|
+
if not _mcp_lib_present():
|
|
69
|
+
raise ProviderError(
|
|
70
|
+
"MCP library not installed (pip install 'taskill[mcp]')"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Lazy import — keep MCP truly optional
|
|
74
|
+
try:
|
|
75
|
+
from mcp import ClientSession # type: ignore
|
|
76
|
+
from mcp.client.stdio import stdio_client, StdioServerParameters # type: ignore
|
|
77
|
+
except ImportError as e:
|
|
78
|
+
raise ProviderError(f"MCP import failed: {e}") from e
|
|
79
|
+
|
|
80
|
+
endpoints = _candidate_endpoints(self.options)
|
|
81
|
+
if not endpoints:
|
|
82
|
+
raise ProviderError("No Windsurf MCP endpoint configured or discovered")
|
|
83
|
+
|
|
84
|
+
# NOTE: full MCP plumbing is async; for the working version we delegate
|
|
85
|
+
# to a sync helper that picks the simplest viable path (stdio command).
|
|
86
|
+
# Remote ws/unix transports land in the refactor (see ROADMAP.md).
|
|
87
|
+
cmd = self.options.get("command")
|
|
88
|
+
if not cmd:
|
|
89
|
+
raise ProviderError(
|
|
90
|
+
"Windsurf MCP stdio requires options.command (e.g. 'windsurf mcp serve')"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
import asyncio
|
|
95
|
+
|
|
96
|
+
async def _run() -> str:
|
|
97
|
+
params = StdioServerParameters(
|
|
98
|
+
command=cmd[0] if isinstance(cmd, list) else cmd,
|
|
99
|
+
args=cmd[1:] if isinstance(cmd, list) else [],
|
|
100
|
+
)
|
|
101
|
+
async with stdio_client(params) as (read, write):
|
|
102
|
+
async with ClientSession(read, write) as session:
|
|
103
|
+
await session.initialize()
|
|
104
|
+
# Use a generic 'chat' or 'complete' tool exposed by the server.
|
|
105
|
+
tool_name = self.options.get("tool_name", "complete")
|
|
106
|
+
result = await session.call_tool(
|
|
107
|
+
tool_name,
|
|
108
|
+
arguments={
|
|
109
|
+
"system": SYSTEM_PROMPT,
|
|
110
|
+
"prompt": build_user_prompt(context),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
# MCP tool result content blocks
|
|
114
|
+
for block in result.content:
|
|
115
|
+
if hasattr(block, "text"):
|
|
116
|
+
return block.text # type: ignore[no-any-return]
|
|
117
|
+
raise ProviderError("Empty response from MCP tool")
|
|
118
|
+
|
|
119
|
+
content = asyncio.run(_run())
|
|
120
|
+
except Exception as e: # broad — MCP can fail many ways; surface cleanly
|
|
121
|
+
raise ProviderError(f"Windsurf MCP call failed: {e}") from e
|
|
122
|
+
|
|
123
|
+
# parse same JSON contract as OpenRouter
|
|
124
|
+
from taskill.providers.openrouter import OpenRouterProvider
|
|
125
|
+
parsed = OpenRouterProvider._parse_json_loosely(content)
|
|
126
|
+
if parsed is None:
|
|
127
|
+
raise ProviderError("Windsurf MCP did not return valid JSON")
|
|
128
|
+
|
|
129
|
+
return GeneratedDocs(
|
|
130
|
+
changelog_entries=list(parsed.get("changelog_entries", [])),
|
|
131
|
+
todo_completed=list(parsed.get("todo_completed", [])),
|
|
132
|
+
todo_new=list(parsed.get("todo_new", [])),
|
|
133
|
+
summary=str(parsed.get("summary", "")),
|
|
134
|
+
provider_name=self.name,
|
|
135
|
+
)
|