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/state.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Persistent state — what was the project's snapshot last time we ran?"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class TaskillState:
|
|
12
|
+
last_run_iso: str | None = None
|
|
13
|
+
last_commit_sha: str | None = None
|
|
14
|
+
last_coverage_pct: float | None = None
|
|
15
|
+
last_failed_tests: int | None = None
|
|
16
|
+
last_sumd_hash: str | None = None
|
|
17
|
+
file_mtimes: dict[str, float] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def last_run_dt(self) -> datetime | None:
|
|
21
|
+
if not self.last_run_iso:
|
|
22
|
+
return None
|
|
23
|
+
return datetime.fromisoformat(self.last_run_iso)
|
|
24
|
+
|
|
25
|
+
def stamp(self) -> None:
|
|
26
|
+
self.last_run_iso = datetime.now(timezone.utc).isoformat()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_state(path: Path) -> TaskillState:
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return TaskillState()
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
return TaskillState(**data)
|
|
35
|
+
except (json.JSONDecodeError, TypeError):
|
|
36
|
+
# corrupt state → start fresh, don't crash
|
|
37
|
+
return TaskillState()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_state(path: Path, state: TaskillState) -> None:
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
path.write_text(json.dumps(asdict(state), indent=2), encoding="utf-8")
|
taskill/triggers.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Decide whether thresholds in taskill.yaml are met → should we run?"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from taskill.config import Triggers
|
|
9
|
+
from taskill.git_state import ProjectSnapshot
|
|
10
|
+
from taskill.state import TaskillState
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TriggerEvaluation:
|
|
15
|
+
should_run: bool
|
|
16
|
+
reasons: list[str]
|
|
17
|
+
skipped: list[str]
|
|
18
|
+
|
|
19
|
+
def summary(self) -> str:
|
|
20
|
+
if self.should_run:
|
|
21
|
+
return "RUN: " + "; ".join(self.reasons)
|
|
22
|
+
return "SKIP: thresholds not met"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def evaluate(
|
|
26
|
+
triggers: Triggers,
|
|
27
|
+
state: TaskillState,
|
|
28
|
+
snapshot: ProjectSnapshot,
|
|
29
|
+
project_root: Path,
|
|
30
|
+
) -> TriggerEvaluation:
|
|
31
|
+
reasons: list[str] = []
|
|
32
|
+
skipped: list[str] = []
|
|
33
|
+
|
|
34
|
+
# 1. time since last run
|
|
35
|
+
if state.last_run_dt is None:
|
|
36
|
+
reasons.append("never run before")
|
|
37
|
+
else:
|
|
38
|
+
delta_h = (datetime.now(timezone.utc) - state.last_run_dt).total_seconds() / 3600
|
|
39
|
+
if delta_h >= triggers.min_hours_since_last_run:
|
|
40
|
+
reasons.append(f"{delta_h:.1f}h since last run (≥{triggers.min_hours_since_last_run}h)")
|
|
41
|
+
else:
|
|
42
|
+
skipped.append(f"only {delta_h:.1f}h since last run")
|
|
43
|
+
|
|
44
|
+
# 2. commits since last
|
|
45
|
+
n_commits = len(snapshot.commits_since_last_run)
|
|
46
|
+
if n_commits >= triggers.min_commits_since_last_run:
|
|
47
|
+
reasons.append(f"{n_commits} new commit(s)")
|
|
48
|
+
else:
|
|
49
|
+
skipped.append(f"only {n_commits} commits (<{triggers.min_commits_since_last_run})")
|
|
50
|
+
|
|
51
|
+
# 3. changed files
|
|
52
|
+
n_changed = len(snapshot.changed_files)
|
|
53
|
+
if n_changed >= triggers.changed_files_threshold:
|
|
54
|
+
reasons.append(f"{n_changed} changed file(s)")
|
|
55
|
+
else:
|
|
56
|
+
skipped.append(f"only {n_changed} files changed")
|
|
57
|
+
|
|
58
|
+
# 4. coverage delta
|
|
59
|
+
if triggers.coverage_change_pct is not None and snapshot.coverage_pct is not None and \
|
|
60
|
+
state.last_coverage_pct is not None:
|
|
61
|
+
delta = abs(snapshot.coverage_pct - state.last_coverage_pct)
|
|
62
|
+
if delta >= triggers.coverage_change_pct:
|
|
63
|
+
reasons.append(f"coverage moved {delta:.1f}pp")
|
|
64
|
+
else:
|
|
65
|
+
skipped.append(f"coverage Δ {delta:.1f}pp")
|
|
66
|
+
|
|
67
|
+
# 5. failed tests changed
|
|
68
|
+
if triggers.failed_tests_changed and snapshot.failed_tests is not None and \
|
|
69
|
+
state.last_failed_tests is not None and snapshot.failed_tests != state.last_failed_tests:
|
|
70
|
+
reasons.append(
|
|
71
|
+
f"failed tests {state.last_failed_tests} → {snapshot.failed_tests}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 6. SUMD changed
|
|
75
|
+
if snapshot.sumd_hash and snapshot.sumd_hash != state.last_sumd_hash:
|
|
76
|
+
reasons.append("SUMD.md changed")
|
|
77
|
+
|
|
78
|
+
# 7. watched files mtime
|
|
79
|
+
for rel in triggers.watch_files:
|
|
80
|
+
p = project_root / rel
|
|
81
|
+
if not p.exists():
|
|
82
|
+
continue
|
|
83
|
+
mtime = p.stat().st_mtime
|
|
84
|
+
prev = state.file_mtimes.get(rel)
|
|
85
|
+
if prev is None or mtime > prev:
|
|
86
|
+
reasons.append(f"{rel} touched")
|
|
87
|
+
|
|
88
|
+
if triggers.require_all:
|
|
89
|
+
# AND semantics: must have NO skipped reasons
|
|
90
|
+
should_run = bool(reasons) and not skipped
|
|
91
|
+
else:
|
|
92
|
+
should_run = bool(reasons)
|
|
93
|
+
|
|
94
|
+
return TriggerEvaluation(should_run=should_run, reasons=reasons, skipped=skipped)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Document updaters — apply GeneratedDocs to README/CHANGELOG/TODO."""
|
|
2
|
+
from taskill.updaters.changelog import update_changelog
|
|
3
|
+
from taskill.updaters.todo import update_todo
|
|
4
|
+
from taskill.updaters.readme import update_readme
|
|
5
|
+
|
|
6
|
+
__all__ = ["update_changelog", "update_todo", "update_readme"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Update CHANGELOG.md following the Keep a Changelog convention.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Keep the file's prelude (intro before first version heading).
|
|
5
|
+
- Find or create the `## [Unreleased]` section.
|
|
6
|
+
- Append new entries under it (deduplicated by exact line match).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
UNRELEASED_HEADING = "## [Unreleased]"
|
|
15
|
+
|
|
16
|
+
DEFAULT_HEADER = """# Changelog
|
|
17
|
+
|
|
18
|
+
All notable changes to this project will be documented in this file.
|
|
19
|
+
|
|
20
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
21
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def update_changelog(path: Path, entries: list[str]) -> bool:
|
|
27
|
+
"""Append entries under [Unreleased]. Returns True if the file changed."""
|
|
28
|
+
if not entries:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
if path.exists():
|
|
32
|
+
original = path.read_text(encoding="utf-8")
|
|
33
|
+
else:
|
|
34
|
+
original = DEFAULT_HEADER + UNRELEASED_HEADING + "\n\n"
|
|
35
|
+
|
|
36
|
+
# ensure [Unreleased] section exists
|
|
37
|
+
if UNRELEASED_HEADING not in original:
|
|
38
|
+
# insert it after the header (after first blank line following "# Changelog")
|
|
39
|
+
if original.startswith("# "):
|
|
40
|
+
head, _, tail = original.partition("\n\n")
|
|
41
|
+
original = f"{head}\n\n{UNRELEASED_HEADING}\n\n{tail}"
|
|
42
|
+
else:
|
|
43
|
+
original = UNRELEASED_HEADING + "\n\n" + original
|
|
44
|
+
|
|
45
|
+
# find the [Unreleased] block (until next "## [" or EOF)
|
|
46
|
+
pattern = re.compile(
|
|
47
|
+
rf"({re.escape(UNRELEASED_HEADING)}\n)(.*?)(?=^## \[|\Z)",
|
|
48
|
+
re.DOTALL | re.MULTILINE,
|
|
49
|
+
)
|
|
50
|
+
m = pattern.search(original)
|
|
51
|
+
if not m:
|
|
52
|
+
# shouldn't happen, but fail safely
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
block_body = m.group(2)
|
|
56
|
+
existing_lines = set(line.rstrip() for line in block_body.splitlines() if line.strip())
|
|
57
|
+
|
|
58
|
+
# add only genuinely new entries; preserve order from `entries`
|
|
59
|
+
new_lines: list[str] = []
|
|
60
|
+
for entry in entries:
|
|
61
|
+
clean = entry.rstrip()
|
|
62
|
+
if clean and clean not in existing_lines:
|
|
63
|
+
new_lines.append(clean)
|
|
64
|
+
existing_lines.add(clean)
|
|
65
|
+
|
|
66
|
+
if not new_lines:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# rebuild block: keep original body, append new bullets at the end of the
|
|
70
|
+
# appropriate subsection if a heading-of-headings exists; otherwise dump at end
|
|
71
|
+
new_block = block_body.rstrip() + "\n\n" + "\n".join(new_lines) + "\n\n"
|
|
72
|
+
updated = original[:m.start(2)] + new_block + original[m.end(2):]
|
|
73
|
+
|
|
74
|
+
path.write_text(updated, encoding="utf-8")
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def release_unreleased(path: Path, version: str, today: date | None = None) -> bool:
|
|
79
|
+
"""Move [Unreleased] block to a versioned [x.y.z] - YYYY-MM-DD heading.
|
|
80
|
+
|
|
81
|
+
Optional helper, not invoked by default. Useful for `taskill release`.
|
|
82
|
+
"""
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return False
|
|
85
|
+
today = today or date.today()
|
|
86
|
+
txt = path.read_text(encoding="utf-8")
|
|
87
|
+
if UNRELEASED_HEADING not in txt:
|
|
88
|
+
return False
|
|
89
|
+
new_heading = f"## [{version}] - {today.isoformat()}"
|
|
90
|
+
txt = txt.replace(UNRELEASED_HEADING, f"{UNRELEASED_HEADING}\n\n{new_heading}", 1)
|
|
91
|
+
path.write_text(txt, encoding="utf-8")
|
|
92
|
+
return True
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""README updater — refresh marked sections only.
|
|
2
|
+
|
|
3
|
+
Philosophy: never overwrite the whole README. We only touch sections wrapped in
|
|
4
|
+
HTML comment markers, so users can curate the rest by hand:
|
|
5
|
+
|
|
6
|
+
<!-- taskill:status:start -->
|
|
7
|
+
...auto-generated content...
|
|
8
|
+
<!-- taskill:status:end -->
|
|
9
|
+
|
|
10
|
+
If the markers don't exist, we append a status block at the bottom.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from taskill.git_state import ProjectSnapshot
|
|
19
|
+
|
|
20
|
+
START = "<!-- taskill:status:start -->"
|
|
21
|
+
END = "<!-- taskill:status:end -->"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_status_block(snapshot: ProjectSnapshot, summary: str) -> str:
|
|
25
|
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
26
|
+
coverage = f"{snapshot.coverage_pct:.1f}%" if snapshot.coverage_pct is not None else "—"
|
|
27
|
+
failed = (
|
|
28
|
+
str(snapshot.failed_tests) if snapshot.failed_tests is not None else "—"
|
|
29
|
+
)
|
|
30
|
+
head = (snapshot.head_sha or "")[:7] or "—"
|
|
31
|
+
|
|
32
|
+
lines = [
|
|
33
|
+
START,
|
|
34
|
+
"",
|
|
35
|
+
"## Status",
|
|
36
|
+
"",
|
|
37
|
+
f"_Last updated by [taskill](https://github.com/oqlos/taskill) at {now}_",
|
|
38
|
+
"",
|
|
39
|
+
"| Metric | Value |",
|
|
40
|
+
"|---|---|",
|
|
41
|
+
f"| HEAD | `{head}` |",
|
|
42
|
+
f"| Coverage | {coverage} |",
|
|
43
|
+
f"| Failing tests | {failed} |",
|
|
44
|
+
f"| Commits in last cycle | {len(snapshot.commits_since_last_run)} |",
|
|
45
|
+
]
|
|
46
|
+
if summary:
|
|
47
|
+
lines += ["", f"> {summary}"]
|
|
48
|
+
lines += ["", END]
|
|
49
|
+
return "\n".join(lines)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def update_readme(path: Path, snapshot: ProjectSnapshot, summary: str) -> bool:
|
|
53
|
+
block = render_status_block(snapshot, summary)
|
|
54
|
+
|
|
55
|
+
if not path.exists():
|
|
56
|
+
# don't invent a README — too presumptuous. Just write the block.
|
|
57
|
+
path.write_text(f"# {path.parent.name}\n\n{block}\n", encoding="utf-8")
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
original = path.read_text(encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
if START in original and END in original:
|
|
63
|
+
pattern = re.compile(
|
|
64
|
+
rf"{re.escape(START)}.*?{re.escape(END)}", re.DOTALL
|
|
65
|
+
)
|
|
66
|
+
updated = pattern.sub(block, original)
|
|
67
|
+
else:
|
|
68
|
+
sep = "\n\n" if not original.endswith("\n\n") else ""
|
|
69
|
+
updated = original.rstrip() + "\n\n" + block + "\n"
|
|
70
|
+
|
|
71
|
+
if updated == original:
|
|
72
|
+
return False
|
|
73
|
+
path.write_text(updated, encoding="utf-8")
|
|
74
|
+
return True
|
taskill/updaters/todo.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Update TODO.md.
|
|
2
|
+
|
|
3
|
+
Two operations:
|
|
4
|
+
1. Remove (or strike-through) lines that are now done.
|
|
5
|
+
2. Append newly-discovered TODOs.
|
|
6
|
+
|
|
7
|
+
Behavior is conservative — we never delete user-authored text we don't recognize.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULT_HEADER = "# TODO\n\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def update_todo(
|
|
17
|
+
path: Path,
|
|
18
|
+
completed_lines: list[str],
|
|
19
|
+
new_items: list[str],
|
|
20
|
+
*,
|
|
21
|
+
archive_completed: bool = True,
|
|
22
|
+
) -> bool:
|
|
23
|
+
"""Remove completed_lines from TODO and append new_items. Returns True on change."""
|
|
24
|
+
if not (completed_lines or new_items):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
if path.exists():
|
|
28
|
+
original = path.read_text(encoding="utf-8")
|
|
29
|
+
else:
|
|
30
|
+
original = DEFAULT_HEADER
|
|
31
|
+
|
|
32
|
+
lines = original.splitlines(keepends=False)
|
|
33
|
+
completed_set = {l.rstrip() for l in completed_lines if l.strip()}
|
|
34
|
+
|
|
35
|
+
kept: list[str] = []
|
|
36
|
+
archived: list[str] = []
|
|
37
|
+
for line in lines:
|
|
38
|
+
if line.rstrip() in completed_set and line.strip().startswith(("-", "*", "+")):
|
|
39
|
+
archived.append(line)
|
|
40
|
+
else:
|
|
41
|
+
kept.append(line)
|
|
42
|
+
|
|
43
|
+
# dedup new items: don't re-add what's already in TODO
|
|
44
|
+
existing = {l.strip() for l in kept if l.strip()}
|
|
45
|
+
fresh_new = [item for item in new_items if item.strip() not in existing]
|
|
46
|
+
|
|
47
|
+
# build output
|
|
48
|
+
out_lines = list(kept)
|
|
49
|
+
if fresh_new:
|
|
50
|
+
if out_lines and out_lines[-1].strip():
|
|
51
|
+
out_lines.append("")
|
|
52
|
+
# if no header, prepend one
|
|
53
|
+
if not any(l.startswith("# ") for l in out_lines):
|
|
54
|
+
out_lines = ["# TODO", ""] + out_lines
|
|
55
|
+
out_lines.append("## Discovered")
|
|
56
|
+
out_lines.append("")
|
|
57
|
+
out_lines.extend(fresh_new)
|
|
58
|
+
out_lines.append("")
|
|
59
|
+
|
|
60
|
+
if archive_completed and archived:
|
|
61
|
+
out_lines.append("")
|
|
62
|
+
out_lines.append("## Done (moved to CHANGELOG)")
|
|
63
|
+
out_lines.append("")
|
|
64
|
+
out_lines.extend(archived)
|
|
65
|
+
out_lines.append("")
|
|
66
|
+
|
|
67
|
+
new_content = "\n".join(out_lines).rstrip() + "\n"
|
|
68
|
+
if new_content == original:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
path.write_text(new_content, encoding="utf-8")
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def empty_todo(path: Path, header: str = DEFAULT_HEADER) -> None:
|
|
76
|
+
"""Reset TODO.md to a clean empty header. Used by `taskill clean-todo`."""
|
|
77
|
+
path.write_text(header, encoding="utf-8")
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taskill
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Daily project hygiene: keep README / CHANGELOG / TODO in sync with reality. LLM-first, algorithmic fallback.
|
|
5
|
+
Project-URL: Homepage, https://github.com/oqlos/taskill
|
|
6
|
+
Project-URL: Issues, https://github.com/oqlos/taskill/issues
|
|
7
|
+
Author: Tom Sapletta
|
|
8
|
+
Author-email: Tom Sapletta <tom@sapletta.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,changelog,ci-cd,llm,mcp,openrouter,readme,todo
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: click>=8.1
|
|
14
|
+
Requires-Dist: costs>=0.1.20
|
|
15
|
+
Requires-Dist: goal>=2.1.0
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: pfix>=0.1.60
|
|
18
|
+
Requires-Dist: python-dotenv>=1.0
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Dist: rich>=13.7
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: costs>=0.1.20; extra == 'dev'
|
|
23
|
+
Requires-Dist: goal>=2.1.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy>=1.5; extra == 'dev'
|
|
25
|
+
Requires-Dist: pfix>=0.1.60; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
29
|
+
Provides-Extra: mcp
|
|
30
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
31
|
+
Provides-Extra: schedule
|
|
32
|
+
Requires-Dist: apscheduler>=3.10; extra == 'schedule'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# taskill
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## AI Cost Tracking
|
|
39
|
+
|
|
40
|
+
   
|
|
41
|
+
  
|
|
42
|
+
|
|
43
|
+
- 🤖 **LLM usage:** $0.1500 (1 commits)
|
|
44
|
+
- 👤 **Human dev:** ~$100 (1.0h @ $100/h, 30min dedup)
|
|
45
|
+
|
|
46
|
+
Generated on 2026-04-25 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
> Daily project hygiene: keep `README.md` / `CHANGELOG.md` / `TODO.md` in sync with reality.
|
|
53
|
+
> LLM-first, algorithmic fallback. Works standalone, in CI, or via Ansible.
|
|
54
|
+
|
|
55
|
+
`taskill` is the small daemon you stop forgetting to run. Once a day (or whenever metrics drift), it reads your git log, your `SUMD.md` / `SUMR.md`, your coverage report, and updates the three documentation files everyone tells themselves they'll keep current and never do.
|
|
56
|
+
|
|
57
|
+
It uses an LLM where one is available — Windsurf MCP first (because you're probably already running it in JetBrains), OpenRouter second — and falls back to a deterministic Conventional Commits parser when no LLM is reachable. The fallback is always available and always runs offline.
|
|
58
|
+
|
|
59
|
+
`taskill` deliberately doesn't replace `pyqual`, `llx`, or `prefact`. It calls them as subprocesses when configured, picks up their reports, and stays out of the way otherwise.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install taskill # core
|
|
65
|
+
pip install "taskill[mcp]" # with Windsurf MCP support
|
|
66
|
+
pip install "taskill[mcp,schedule]" # with built-in scheduler
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Quickstart
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
cd your-project/
|
|
73
|
+
taskill init # writes taskill.yaml + .env.example
|
|
74
|
+
cp .env.example .env # add OPENROUTER_API_KEY
|
|
75
|
+
taskill status # preview without running
|
|
76
|
+
taskill run --dry-run # see what would change
|
|
77
|
+
taskill run # do it
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## What it does
|
|
81
|
+
|
|
82
|
+
Every run produces three (idempotent) edits:
|
|
83
|
+
|
|
84
|
+
1. **`CHANGELOG.md`** — appends new entries under `## [Unreleased]`, grouped by Conventional Commit type (`### Added`, `### Fixed`, `### Performance`, etc.). Uses [Keep a Changelog](https://keepachangelog.com/) layout. Existing entries are deduplicated.
|
|
85
|
+
2. **`TODO.md`** — moves completed items to a `## Done (moved to CHANGELOG)` section, and appends `TODO:` / `FIXME:` markers found in new commit bodies under `## Discovered`.
|
|
86
|
+
3. **`README.md`** — refreshes only the block between `<!-- taskill:status:start -->` and `<!-- taskill:status:end -->` markers (HEAD, coverage, failing tests, summary). Never touches the rest of the file.
|
|
87
|
+
|
|
88
|
+
## Provider chain
|
|
89
|
+
|
|
90
|
+
The chain runs top-to-bottom. First provider that's available *and* succeeds wins.
|
|
91
|
+
|
|
92
|
+
| Order | Provider | Used when |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| 1 | `windsurf_mcp` | `mcp` package installed and a Windsurf endpoint resolves |
|
|
95
|
+
| 2 | `openrouter` | `OPENROUTER_API_KEY` is set |
|
|
96
|
+
| 3 | `algorithmic` | always — pure git-log + Conventional Commits parser |
|
|
97
|
+
|
|
98
|
+
You can reorder, disable, or pass options via `taskill.yaml`:
|
|
99
|
+
|
|
100
|
+
```yaml
|
|
101
|
+
providers:
|
|
102
|
+
- name: openrouter # skip windsurf, go straight to OpenRouter
|
|
103
|
+
enabled: true
|
|
104
|
+
- name: algorithmic
|
|
105
|
+
enabled: true
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Triggers
|
|
109
|
+
|
|
110
|
+
`taskill run` is a no-op unless one of the configured thresholds is crossed. State lives in `.taskill/state.json` so cron, GitHub Actions, and Ansible all share the same delta logic.
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
triggers:
|
|
114
|
+
min_hours_since_last_run: 24
|
|
115
|
+
min_commits_since_last_run: 1
|
|
116
|
+
changed_files_threshold: 1
|
|
117
|
+
coverage_change_pct: 2.0 # absolute pp; null to disable
|
|
118
|
+
failed_tests_changed: true
|
|
119
|
+
watch_files: [SUMD.md, SUMR.md]
|
|
120
|
+
require_all: false # OR by default; set true for AND
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`taskill run --force` ignores triggers entirely.
|
|
124
|
+
|
|
125
|
+
## Running it
|
|
126
|
+
|
|
127
|
+
### Cron / systemd timer
|
|
128
|
+
|
|
129
|
+
```cron
|
|
130
|
+
0 6 * * * cd /path/to/project && /usr/local/bin/taskill run >> ~/.taskill.log 2>&1
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### GitHub Actions
|
|
134
|
+
|
|
135
|
+
See `examples/github-action.yml`. Triggers on `push` to main, runs `taskill run`, opens a PR if files changed.
|
|
136
|
+
|
|
137
|
+
### GitLab CI
|
|
138
|
+
|
|
139
|
+
See `examples/gitlab-ci.yml`. Same idea, with merge-request creation via the GitLab API.
|
|
140
|
+
|
|
141
|
+
### Ansible
|
|
142
|
+
|
|
143
|
+
See `examples/ansible-playbook.yml`. Useful for fleet-wide hygiene across many self-hosted repos.
|
|
144
|
+
|
|
145
|
+
## CLI
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
taskill init # generate taskill.yaml + .env.example
|
|
149
|
+
taskill status # show what would happen, no writes
|
|
150
|
+
taskill run # execute (respects triggers)
|
|
151
|
+
taskill run --force # ignore triggers
|
|
152
|
+
taskill run --dry-run # don't write files or state
|
|
153
|
+
taskill run --json # machine-readable output
|
|
154
|
+
taskill release X.Y.Z # promote [Unreleased] → versioned heading
|
|
155
|
+
taskill clean-todo # wipe TODO.md (after a release)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Configuration reference
|
|
159
|
+
|
|
160
|
+
See [`taskill.yaml`](./taskill.yaml) at the repo root for the annotated default config.
|
|
161
|
+
|
|
162
|
+
## Reusing existing tools
|
|
163
|
+
|
|
164
|
+
`taskill` doesn't try to absorb `pyqual` / `llx` / `prefact`. It calls them by `subprocess` when toggled in `reuse:` and feeds their JSON output to the LLM as extra context:
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
reuse:
|
|
168
|
+
pyqual: true # taskill will run `pyqual report --json`
|
|
169
|
+
llx: false # ...add llx context (planned for v0.2)
|
|
170
|
+
prefact: false # ...add prefact suggestions (planned for v0.2)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
If a tool isn't on `PATH`, `taskill` skips it silently — no hard dependency.
|
|
174
|
+
|
|
175
|
+
## How it relates to the wider stack
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
SUMD (description) ─┐
|
|
179
|
+
├─→ taskill ──→ README.md
|
|
180
|
+
git log ────────────┤ CHANGELOG.md
|
|
181
|
+
pyqual report ──────┤ TODO.md
|
|
182
|
+
SUMR (state) ───────┘
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`taskill` reads, never generates code. That's what `prefact` / `llx` / `pyqual` are for.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
Licensed under Apache-2.0.
|
|
190
|
+
## Status
|
|
191
|
+
|
|
192
|
+
<!-- taskill:status:start -->
|
|
193
|
+
_Bootstrapped — no live project status yet. Will populate after first `taskill run`._
|
|
194
|
+
<!-- taskill:status:end -->
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
taskill/__init__.py,sha256=ghzG0Zm7axLz8dgGLVma8qCNXD196BLIQQoec80WodI,812
|
|
2
|
+
taskill/cli.py,sha256=cChFaM96O8LSrYvvKEz14iPtEEp2lX5cQXAix4dnQRc,8731
|
|
3
|
+
taskill/config.py,sha256=Z32qtXgLTqIF5o8PhXBPT4MCt7Xo47rKnWTm2zDoagE,5660
|
|
4
|
+
taskill/core.py,sha256=VhzZ6Y8j8539RXWt3BjHsP5wncKAUSJHSMRc9x02YWc,8126
|
|
5
|
+
taskill/git_state.py,sha256=4GCs-9yp0xUyRgYGV8rIYzzj-YhG0kwWCfCYDMW55cQ,5055
|
|
6
|
+
taskill/state.py,sha256=pEhShu_tEus5LuL2qwXDu4xETyscMmtnchdJlPT5vQk,1327
|
|
7
|
+
taskill/triggers.py,sha256=6LIS7TrD0TXGUkbMWrnkVOTxuAYmm2a4W2BZjQibybE,3228
|
|
8
|
+
taskill/providers/__init__.py,sha256=NZARGAesk3QfNMDlFMXbC8IcA1NBhHTNtdgns7prBOE,1128
|
|
9
|
+
taskill/providers/algorithmic.py,sha256=a5BVG41NOFhwxIJoMJOiwYAIhJgs8HdSV6JZEoxI7eA,5670
|
|
10
|
+
taskill/providers/base.py,sha256=-zEqWn_ho2fKjsOmGNbUN5qPukze6He8G_koj5mxTNU,3497
|
|
11
|
+
taskill/providers/openrouter.py,sha256=p1J-EprSOPbSjJlZbLjChFwPqY-9DT4IAoR8gksdPUI,4000
|
|
12
|
+
taskill/providers/windsurf_mcp.py,sha256=KrafaByK-6OJkA80C5J86V-sKzX1x6i8EcTtE2_k5LE,5199
|
|
13
|
+
taskill/updaters/__init__.py,sha256=Ks0dfgA7KlnrmIvPTytMTcr8PPkwJsXEVd2hC-R0m4E,290
|
|
14
|
+
taskill/updaters/changelog.py,sha256=m_y1enqLDMNt3G4_U6QcbzwPelQwlu1IMyzXu_EpiFc,3174
|
|
15
|
+
taskill/updaters/readme.py,sha256=W_eOwcasYPvJtT4JwOUZFS77NJ3XnthkRHMYN76JO1g,2344
|
|
16
|
+
taskill/updaters/todo.py,sha256=7V9oGp9oI5tERj-w18La_IOHVLVIeWbRsoyL66-u64A,2301
|
|
17
|
+
taskill-0.1.1.dist-info/METADATA,sha256=W3PZEH9bqmXFlDpAZF00-i9mdgP8LIS8oZFYgNONr8A,7357
|
|
18
|
+
taskill-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
19
|
+
taskill-0.1.1.dist-info/entry_points.txt,sha256=LRZ0ou7wTswiRh3bMpi9E0O57tf4njpjv5jd8jX3_GE,45
|
|
20
|
+
taskill-0.1.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
21
|
+
taskill-0.1.1.dist-info/RECORD,,
|