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 ADDED
@@ -0,0 +1,20 @@
1
+ """taskill — daily project hygiene automation.
2
+
3
+ Keeps README.md / CHANGELOG.md / TODO.md in sync with reality:
4
+ - reads git log, SUMD.md, SUMR.md, test results, coverage
5
+ - moves completed TODO items to CHANGELOG
6
+ - regenerates README sections that are out of date
7
+ - runs daily (or per metric thresholds) standalone, in CI, or via Ansible
8
+
9
+ Provider chain (first match wins):
10
+ 1. Windsurf MCP (when configured — uses your existing JetBrains plugin context)
11
+ 2. OpenRouter (LLM via OPENROUTER_API_KEY + LLM_MODEL)
12
+ 3. Algorithmic (deterministic fallback: conventional commits + heuristics)
13
+ """
14
+
15
+ __version__ = "0.1.1"
16
+
17
+ from taskill.config import TaskillConfig, load_config
18
+ from taskill.core import Taskill, TaskillResult
19
+
20
+ __all__ = ["TaskillConfig", "load_config", "Taskill", "TaskillResult", "__version__"]
taskill/cli.py ADDED
@@ -0,0 +1,253 @@
1
+ """Command-line interface for taskill.
2
+
3
+ Commands:
4
+ taskill run — execute the pipeline (respects triggers; --force overrides)
5
+ taskill status — show what would happen without running
6
+ taskill init — write a starter taskill.yaml + .env.example
7
+ taskill release — promote [Unreleased] CHANGELOG block to a versioned heading
8
+ taskill clean-todo — wipe TODO.md (use after manually moving items elsewhere)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from taskill import __version__
22
+ from taskill.config import load_config
23
+ from taskill.core import Taskill
24
+ from taskill.updaters.changelog import release_unreleased
25
+ from taskill.updaters.todo import empty_todo
26
+
27
+ console = Console()
28
+
29
+
30
+ def _setup_logging(verbose: bool) -> None:
31
+ level = logging.DEBUG if verbose else logging.INFO
32
+ logging.basicConfig(
33
+ level=level,
34
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
35
+ datefmt="%H:%M:%S",
36
+ )
37
+
38
+
39
+ @click.group()
40
+ @click.version_option(__version__, prog_name="taskill")
41
+ @click.option("--verbose", "-v", is_flag=True, help="Verbose logging")
42
+ @click.option(
43
+ "--config", "-c", default="taskill.yaml", show_default=True,
44
+ help="Path to taskill.yaml",
45
+ )
46
+ @click.pass_context
47
+ def main(ctx: click.Context, verbose: bool, config: str) -> None:
48
+ """taskill — keep README/CHANGELOG/TODO honest."""
49
+ _setup_logging(verbose)
50
+ ctx.ensure_object(dict)
51
+ ctx.obj["config_path"] = config
52
+
53
+
54
+ @main.command()
55
+ @click.option("--force", is_flag=True, help="Run even if triggers say no")
56
+ @click.option("--dry-run", is_flag=True, help="Don't write files or persist state")
57
+ @click.option("--json", "as_json", is_flag=True, help="Output result as JSON")
58
+ @click.pass_context
59
+ def run(ctx: click.Context, force: bool, dry_run: bool, as_json: bool) -> None:
60
+ """Execute the update pipeline."""
61
+ cfg = load_config(ctx.obj["config_path"])
62
+ if dry_run:
63
+ cfg.dry_run = True
64
+ tk = Taskill(config=cfg)
65
+ result = tk.run(force=force)
66
+
67
+ if as_json:
68
+ click.echo(json.dumps(result.as_dict(), indent=2))
69
+ sys.exit(0 if result.ran or not result.errors else 1)
70
+
71
+ if not result.ran:
72
+ console.print(f"[yellow]✗ Did not run.[/yellow] {result.trigger_eval.summary()}")
73
+ if result.errors:
74
+ for e in result.errors:
75
+ console.print(f" [red]error:[/red] {e}")
76
+ sys.exit(0)
77
+
78
+ console.print(
79
+ f"[green]✓ Ran via [bold]{result.provider_used}[/bold][/green]"
80
+ )
81
+ if result.docs and result.docs.summary:
82
+ console.print(f" [dim]{result.docs.summary}[/dim]")
83
+
84
+ table = Table(show_header=False, box=None, padding=(0, 1))
85
+ table.add_row("Changelog entries", str(len(result.docs.changelog_entries) if result.docs else 0))
86
+ table.add_row("TODO completed", str(len(result.docs.todo_completed) if result.docs else 0))
87
+ table.add_row("TODO new", str(len(result.docs.todo_new) if result.docs else 0))
88
+ table.add_row("Files changed", ", ".join(result.files_changed) or "(none)")
89
+ console.print(table)
90
+
91
+ if result.errors:
92
+ console.print("[dim]Provider fallthrough:[/dim]")
93
+ for e in result.errors:
94
+ console.print(f" [dim]- {e}[/dim]")
95
+
96
+
97
+ @main.command()
98
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
99
+ @click.pass_context
100
+ def status(ctx: click.Context, as_json: bool) -> None:
101
+ """Show what taskill would do without running it."""
102
+ cfg = load_config(ctx.obj["config_path"])
103
+ tk = Taskill(config=cfg)
104
+ info = tk.status()
105
+
106
+ if as_json:
107
+ click.echo(json.dumps(info, indent=2, default=str))
108
+ return
109
+
110
+ table = Table(title="taskill status", show_header=True, header_style="bold cyan")
111
+ table.add_column("Field"); table.add_column("Value")
112
+ color = "green" if info["would_run"] else "yellow"
113
+ table.add_row("Would run?", f"[{color}]{info['would_run']}[/{color}]")
114
+ table.add_row("HEAD", str(info["head"] or "—")[:12])
115
+ table.add_row("Pending commits", str(info["commits_pending"]))
116
+ table.add_row("Coverage", f"{info['coverage']:.1f}%" if info["coverage"] else "—")
117
+ table.add_row("Failed tests", str(info["failed_tests"]) if info["failed_tests"] is not None else "—")
118
+ table.add_row("Last run", str(info["last_run"] or "never"))
119
+ console.print(table)
120
+
121
+ if info["reasons"]:
122
+ console.print("[green]Reasons to run:[/green]")
123
+ for r in info["reasons"]:
124
+ console.print(f" • {r}")
125
+ if info["skipped_because"]:
126
+ console.print("[yellow]Why not running:[/yellow]")
127
+ for r in info["skipped_because"]:
128
+ console.print(f" • {r}")
129
+
130
+
131
+ @main.command()
132
+ @click.option("--force", is_flag=True, help="Overwrite existing taskill.yaml")
133
+ def init(force: bool) -> None:
134
+ """Generate a starter taskill.yaml + .env.example in the current directory."""
135
+ yml = Path("taskill.yaml")
136
+ env = Path(".env.example")
137
+
138
+ if yml.exists() and not force:
139
+ console.print(f"[yellow]{yml} already exists — use --force to overwrite[/yellow]")
140
+ sys.exit(1)
141
+
142
+ yml.write_text(STARTER_YAML, encoding="utf-8")
143
+ if not env.exists():
144
+ env.write_text(STARTER_ENV, encoding="utf-8")
145
+
146
+ console.print(f"[green]✓[/green] Wrote {yml} and {env}")
147
+ console.print("Next: copy [bold].env.example[/bold] → [bold].env[/bold] and add your OpenRouter key,")
148
+ console.print("then run [cyan]taskill status[/cyan] to inspect, [cyan]taskill run[/cyan] to execute.")
149
+
150
+
151
+ @main.command()
152
+ @click.argument("version")
153
+ @click.option(
154
+ "--changelog", default="CHANGELOG.md", show_default=True,
155
+ help="Path to CHANGELOG.md",
156
+ )
157
+ def release(version: str, changelog: str) -> None:
158
+ """Promote [Unreleased] section to a versioned [VERSION] heading."""
159
+ p = Path(changelog)
160
+ if release_unreleased(p, version):
161
+ console.print(f"[green]✓[/green] Released v{version} in {p}")
162
+ else:
163
+ console.print(f"[yellow]Nothing to release in {p}[/yellow]")
164
+ sys.exit(1)
165
+
166
+
167
+ @main.command("clean-todo")
168
+ @click.option("--todo", default="TODO.md", show_default=True)
169
+ @click.confirmation_option(prompt="This will erase TODO.md content. Continue?")
170
+ def clean_todo(todo: str) -> None:
171
+ """Reset TODO.md to an empty header. Use after a release."""
172
+ empty_todo(Path(todo))
173
+ console.print(f"[green]✓[/green] {todo} reset")
174
+
175
+
176
+ # ─── starter templates ──────────────────────────────────────────────────
177
+
178
+ STARTER_YAML = """# taskill.yaml — daily project hygiene config
179
+ # Run: taskill status (preview)
180
+ # taskill run (execute when triggers fire)
181
+ # taskill run --force --dry-run (preview the actual output)
182
+
183
+ # Files taskill manages
184
+ files:
185
+ readme: README.md
186
+ changelog: CHANGELOG.md
187
+ todo: TODO.md
188
+ sumd: SUMD.md
189
+ sumr: SUMR.md
190
+
191
+ # When should taskill run? (OR semantics by default — any one true → run)
192
+ triggers:
193
+ min_hours_since_last_run: 24
194
+ min_commits_since_last_run: 1
195
+ changed_files_threshold: 1
196
+ coverage_change_pct: 2.0 # absolute pp; null to disable
197
+ failed_tests_changed: true
198
+ watch_files: [SUMD.md, SUMR.md]
199
+ require_all: false # set true for AND semantics
200
+
201
+ # Provider chain — first one that succeeds wins.
202
+ # Comment out any provider you don't want.
203
+ providers:
204
+ - name: windsurf_mcp
205
+ enabled: true
206
+ options:
207
+ endpoint: stdio
208
+ command: ["windsurf", "mcp", "serve"]
209
+ tool_name: complete
210
+ - name: openrouter
211
+ enabled: true
212
+ options:
213
+ base_url: https://openrouter.ai/api/v1
214
+ temperature: 0.2
215
+ max_tokens: 4096
216
+ timeout: 120
217
+ - name: algorithmic
218
+ enabled: true # always-on safety net; keep this on
219
+
220
+ # Optional: reuse other tools in your stack (loose coupling — subprocess calls)
221
+ reuse:
222
+ pyqual: true
223
+ llx: false
224
+ prefact: false
225
+
226
+ # Where state lives (tracks last run, last sha, last coverage…)
227
+ state_file: .taskill/state.json
228
+
229
+ # CI/VCS integrations — read by `taskill ci-config` (stub for now)
230
+ integrations:
231
+ github: {}
232
+ gitlab: {}
233
+ ansible: {}
234
+
235
+ dry_run: false
236
+ """
237
+
238
+ STARTER_ENV = """# Copy this file to .env and fill in your credentials.
239
+ # .env is loaded automatically by taskill (process env wins on conflict).
240
+
241
+ OPENROUTER_API_KEY=
242
+
243
+ # Default: openrouter/qwen/qwen3-coder-next
244
+ # (taskill auto-strips the openrouter/ prefix for the OpenRouter REST API)
245
+ LLM_MODEL=openrouter/qwen/qwen3-coder-next
246
+
247
+ # Optional: explicit Windsurf MCP endpoint (otherwise auto-discovered)
248
+ # WINDSURF_MCP_ENDPOINT=ws://localhost:7777
249
+ """
250
+
251
+
252
+ if __name__ == "__main__":
253
+ main()
taskill/config.py ADDED
@@ -0,0 +1,170 @@
1
+ """Config loading: taskill.yaml + .env.
2
+
3
+ Mirrors pyqual's config style (declarative, YAML-first, profile-friendly).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+ from dotenv import load_dotenv
14
+
15
+
16
+ # ───────────────────────────── data classes ─────────────────────────────
17
+
18
+ @dataclass
19
+ class Triggers:
20
+ """Conditions that must be met to run an update.
21
+
22
+ All thresholds are OR-ed by default (any one true → run).
23
+ Set `require_all: true` for AND semantics.
24
+ """
25
+ min_hours_since_last_run: float = 24.0
26
+ min_commits_since_last_run: int = 1
27
+ changed_files_threshold: int = 1
28
+ coverage_change_pct: float | None = None
29
+ failed_tests_changed: bool = False
30
+ require_all: bool = False
31
+ # File-mtime triggers (optional)
32
+ watch_files: list[str] = field(default_factory=lambda: ["SUMD.md", "SUMR.md"])
33
+
34
+
35
+ @dataclass
36
+ class ProviderConfig:
37
+ """Single provider configuration."""
38
+ name: str # "windsurf_mcp" | "openrouter" | "algorithmic"
39
+ enabled: bool = True
40
+ options: dict[str, Any] = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class IntegrationConfig:
45
+ """Optional CI / VCS / orchestrator integration."""
46
+ github: dict[str, Any] = field(default_factory=dict)
47
+ gitlab: dict[str, Any] = field(default_factory=dict)
48
+ ansible: dict[str, Any] = field(default_factory=dict)
49
+
50
+
51
+ DEFAULT_FILES = {
52
+ "readme": "README.md",
53
+ "changelog": "CHANGELOG.md",
54
+ "todo": "TODO.md",
55
+ "sumd": "SUMD.md",
56
+ "sumr": "SUMR.md",
57
+ }
58
+
59
+ DEFAULT_REUSE = {
60
+ "pyqual": True, # invoke `pyqual report` if available
61
+ "llx": False, # invoke llx for richer LLM context
62
+ "prefact": False, # invoke prefact for refactoring hints
63
+ }
64
+
65
+
66
+ @dataclass
67
+ class TaskillConfig:
68
+ project_root: Path
69
+ files: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_FILES))
70
+ triggers: Triggers = field(default_factory=Triggers)
71
+ providers: list[ProviderConfig] = field(default_factory=list)
72
+ integrations: IntegrationConfig = field(default_factory=IntegrationConfig)
73
+ state_file: str = ".taskill/state.json"
74
+ dry_run: bool = False
75
+ reuse: dict[str, bool] = field(default_factory=lambda: dict(DEFAULT_REUSE))
76
+
77
+ @property
78
+ def env_model(self) -> str:
79
+ return os.getenv("LLM_MODEL", "openrouter/qwen/qwen3-coder-next")
80
+
81
+ @property
82
+ def env_api_key(self) -> str | None:
83
+ return os.getenv("OPENROUTER_API_KEY")
84
+
85
+
86
+ # ───────────────────────────── loader ─────────────────────────────
87
+
88
+ DEFAULT_PROVIDERS = [
89
+ ProviderConfig(name="windsurf_mcp", enabled=True, options={
90
+ "endpoint": "stdio", # or ws://localhost:PORT
91
+ "tool_name": "windsurf",
92
+ }),
93
+ ProviderConfig(name="openrouter", enabled=True, options={
94
+ "base_url": "https://openrouter.ai/api/v1",
95
+ "temperature": 0.2,
96
+ "max_tokens": 4096,
97
+ "timeout": 120,
98
+ }),
99
+ ProviderConfig(name="algorithmic", enabled=True, options={}),
100
+ ]
101
+
102
+
103
+ def load_config(
104
+ path: str | Path = "taskill.yaml",
105
+ project_root: Path | None = None,
106
+ ) -> TaskillConfig:
107
+ """Load config from YAML + .env. Missing YAML → defaults.
108
+
109
+ Resolution order for project_root:
110
+ 1. explicit arg
111
+ 2. directory of taskill.yaml
112
+ 3. cwd
113
+ """
114
+ path = Path(path)
115
+ if project_root is None:
116
+ project_root = path.parent.resolve() if path.exists() else Path.cwd()
117
+
118
+ # .env has higher priority than process env? No — process env wins (CI-friendly).
119
+ env_path = project_root / ".env"
120
+ if env_path.exists():
121
+ load_dotenv(env_path, override=False)
122
+
123
+ raw: dict[str, Any] = {}
124
+ if path.exists():
125
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
126
+
127
+ # Triggers
128
+ trig_raw = raw.get("triggers", {})
129
+ triggers = Triggers(
130
+ min_hours_since_last_run=trig_raw.get("min_hours_since_last_run", 24.0),
131
+ min_commits_since_last_run=trig_raw.get("min_commits_since_last_run", 1),
132
+ changed_files_threshold=trig_raw.get("changed_files_threshold", 1),
133
+ coverage_change_pct=trig_raw.get("coverage_change_pct"),
134
+ failed_tests_changed=trig_raw.get("failed_tests_changed", False),
135
+ require_all=trig_raw.get("require_all", False),
136
+ watch_files=trig_raw.get("watch_files", ["SUMD.md", "SUMR.md"]),
137
+ )
138
+
139
+ # Providers (preserve order from YAML; if missing, use defaults)
140
+ providers_raw = raw.get("providers")
141
+ if providers_raw:
142
+ providers = [
143
+ ProviderConfig(
144
+ name=p["name"],
145
+ enabled=p.get("enabled", True),
146
+ options=p.get("options", {}),
147
+ )
148
+ for p in providers_raw
149
+ ]
150
+ else:
151
+ providers = DEFAULT_PROVIDERS
152
+
153
+ # Integrations
154
+ integ_raw = raw.get("integrations", {})
155
+ integrations = IntegrationConfig(
156
+ github=integ_raw.get("github", {}),
157
+ gitlab=integ_raw.get("gitlab", {}),
158
+ ansible=integ_raw.get("ansible", {}),
159
+ )
160
+
161
+ return TaskillConfig(
162
+ project_root=Path(raw.get("project_root", project_root)).resolve(),
163
+ files={**DEFAULT_FILES, **raw.get("files", {})},
164
+ triggers=triggers,
165
+ providers=providers,
166
+ integrations=integrations,
167
+ state_file=raw.get("state_file", ".taskill/state.json"),
168
+ dry_run=raw.get("dry_run", False),
169
+ reuse={**DEFAULT_REUSE, **raw.get("reuse", {})},
170
+ )
taskill/core.py ADDED
@@ -0,0 +1,211 @@
1
+ """Taskill core orchestrator.
2
+
3
+ Pipeline:
4
+ 1. Load config + state
5
+ 2. Collect snapshot (git + filesystem + optional pyqual report)
6
+ 3. Evaluate triggers — bail if thresholds not met
7
+ 4. Walk provider chain until one succeeds
8
+ 5. Apply updates to README/CHANGELOG/TODO
9
+ 6. Persist new state
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import subprocess
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from taskill.config import TaskillConfig, load_config
21
+ from taskill.git_state import ProjectSnapshot, collect_snapshot
22
+ from taskill.providers import build_chain, GeneratedDocs, ProviderError
23
+ from taskill.state import TaskillState, load_state, save_state
24
+ from taskill.triggers import TriggerEvaluation, evaluate
25
+ from taskill.updaters import update_changelog, update_readme, update_todo
26
+
27
+ log = logging.getLogger("taskill")
28
+
29
+
30
+ @dataclass
31
+ class TaskillResult:
32
+ ran: bool
33
+ trigger_eval: TriggerEvaluation
34
+ provider_used: str | None = None
35
+ docs: GeneratedDocs | None = None
36
+ files_changed: list[str] = field(default_factory=list)
37
+ errors: list[str] = field(default_factory=list)
38
+
39
+ def as_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "ran": self.ran,
42
+ "should_have_run": self.trigger_eval.should_run,
43
+ "reasons": self.trigger_eval.reasons,
44
+ "skipped_because": self.trigger_eval.skipped,
45
+ "provider": self.provider_used,
46
+ "summary": self.docs.summary if self.docs else "",
47
+ "changelog_added": len(self.docs.changelog_entries) if self.docs else 0,
48
+ "todo_completed": len(self.docs.todo_completed) if self.docs else 0,
49
+ "todo_new": len(self.docs.todo_new) if self.docs else 0,
50
+ "files_changed": self.files_changed,
51
+ "errors": self.errors,
52
+ }
53
+
54
+
55
+ class Taskill:
56
+ def __init__(self, config: TaskillConfig | None = None, config_path: str = "taskill.yaml"):
57
+ self.config = config or load_config(config_path)
58
+ self.state_path = self.config.project_root / self.config.state_file
59
+ self.state = load_state(self.state_path)
60
+
61
+ # ── public API ─────────────────────────────────────────────────────
62
+
63
+ def run(self, force: bool = False) -> TaskillResult:
64
+ snapshot = self._snapshot()
65
+ trig = evaluate(self.config.triggers, self.state, snapshot, self.config.project_root)
66
+
67
+ if not (trig.should_run or force):
68
+ log.info("Skipping: %s", trig.summary())
69
+ return TaskillResult(ran=False, trigger_eval=trig)
70
+
71
+ # build chain — only currently-available providers
72
+ chain = build_chain(self.config.providers)
73
+ if not chain:
74
+ return TaskillResult(
75
+ ran=False, trigger_eval=trig,
76
+ errors=["No providers configured"],
77
+ )
78
+
79
+ context = self._build_context(snapshot)
80
+ docs: GeneratedDocs | None = None
81
+ used: str | None = None
82
+ errors: list[str] = []
83
+
84
+ for provider in chain:
85
+ if not provider.is_available():
86
+ log.info("Provider %s unavailable, skipping", provider.name)
87
+ continue
88
+ try:
89
+ log.info("Trying provider: %s", provider.name)
90
+ docs = provider.generate(context)
91
+ used = docs.provider_name or provider.name
92
+ break
93
+ except ProviderError as e:
94
+ msg = f"{provider.name}: {e}"
95
+ log.warning(msg)
96
+ errors.append(msg)
97
+ continue
98
+
99
+ if docs is None:
100
+ return TaskillResult(
101
+ ran=False, trigger_eval=trig,
102
+ errors=errors or ["All providers failed or unavailable"],
103
+ )
104
+
105
+ # apply updates
106
+ files_changed: list[str] = []
107
+ if not self.config.dry_run:
108
+ files_changed = self._apply(docs, snapshot)
109
+ self._update_state(snapshot)
110
+ save_state(self.state_path, self.state)
111
+ else:
112
+ log.info("Dry-run: not writing files or state")
113
+
114
+ return TaskillResult(
115
+ ran=True,
116
+ trigger_eval=trig,
117
+ provider_used=used,
118
+ docs=docs,
119
+ files_changed=files_changed,
120
+ errors=errors, # non-fatal failures from providers we skipped past
121
+ )
122
+
123
+ def status(self) -> dict[str, Any]:
124
+ """Inspect current trigger state without running anything."""
125
+ snapshot = self._snapshot()
126
+ trig = evaluate(self.config.triggers, self.state, snapshot, self.config.project_root)
127
+ return {
128
+ "would_run": trig.should_run,
129
+ "reasons": trig.reasons,
130
+ "skipped_because": trig.skipped,
131
+ "head": snapshot.head_sha,
132
+ "commits_pending": len(snapshot.commits_since_last_run),
133
+ "coverage": snapshot.coverage_pct,
134
+ "failed_tests": snapshot.failed_tests,
135
+ "last_run": self.state.last_run_iso,
136
+ }
137
+
138
+ # ── internals ──────────────────────────────────────────────────────
139
+
140
+ def _snapshot(self) -> ProjectSnapshot:
141
+ return collect_snapshot(
142
+ self.config.project_root,
143
+ self.config.files,
144
+ self.state.last_commit_sha,
145
+ )
146
+
147
+ def _build_context(self, snapshot: ProjectSnapshot) -> dict[str, Any]:
148
+ root = self.config.project_root
149
+
150
+ def _read(name: str) -> str:
151
+ p = root / self.config.files.get(name, f"{name.upper()}.md")
152
+ return p.read_text(encoding="utf-8") if p.exists() else ""
153
+
154
+ ctx: dict[str, Any] = {
155
+ "snapshot": snapshot,
156
+ "existing_readme": _read("readme"),
157
+ "existing_changelog": _read("changelog"),
158
+ "existing_todo": _read("todo"),
159
+ "sumd": snapshot.sumd_text,
160
+ "sumr": snapshot.sumr_text,
161
+ "project_name": root.name,
162
+ }
163
+
164
+ # optional: enrich with pyqual report (loose coupling — subprocess)
165
+ if self.config.reuse.get("pyqual"):
166
+ ctx["pyqual_report"] = self._maybe_pyqual_report()
167
+
168
+ return ctx
169
+
170
+ def _maybe_pyqual_report(self) -> dict[str, Any] | None:
171
+ """Run `pyqual report --json` if pyqual is on PATH. Tolerate failure."""
172
+ try:
173
+ res = subprocess.run(
174
+ ["pyqual", "report", "--json"],
175
+ cwd=self.config.project_root,
176
+ capture_output=True, text=True, timeout=30, check=False,
177
+ )
178
+ if res.returncode == 0 and res.stdout.strip():
179
+ return json.loads(res.stdout)
180
+ except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
181
+ pass
182
+ return None
183
+
184
+ def _apply(self, docs: GeneratedDocs, snapshot: ProjectSnapshot) -> list[str]:
185
+ root = self.config.project_root
186
+ changed: list[str] = []
187
+
188
+ cl = root / self.config.files["changelog"]
189
+ if update_changelog(cl, docs.changelog_entries):
190
+ changed.append(str(cl.relative_to(root)))
191
+
192
+ td = root / self.config.files["todo"]
193
+ if update_todo(td, docs.todo_completed, docs.todo_new):
194
+ changed.append(str(td.relative_to(root)))
195
+
196
+ rm = root / self.config.files["readme"]
197
+ if update_readme(rm, snapshot, docs.summary):
198
+ changed.append(str(rm.relative_to(root)))
199
+
200
+ return changed
201
+
202
+ def _update_state(self, snapshot: ProjectSnapshot) -> None:
203
+ self.state.stamp()
204
+ self.state.last_commit_sha = snapshot.head_sha
205
+ self.state.last_coverage_pct = snapshot.coverage_pct
206
+ self.state.last_failed_tests = snapshot.failed_tests
207
+ self.state.last_sumd_hash = snapshot.sumd_hash
208
+ for rel in self.config.triggers.watch_files:
209
+ p = self.config.project_root / rel
210
+ if p.exists():
211
+ self.state.file_mtimes[rel] = p.stat().st_mtime