nightshift-cli 0.1.0__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.
nightshift/events.py ADDED
@@ -0,0 +1,293 @@
1
+ """Append-only event logs, so a run can be watched from another process.
2
+
3
+ cron starts each run in its own process, so a dashboard opened at 2am has no
4
+ callback to subscribe to and no way back into a run that began an hour ago.
5
+ Every run therefore publishes what it sees to a file as it goes, and
6
+ ``nightshift watch`` tails it.
7
+
8
+ Nothing here is load-bearing. The digest is built from stored results, never
9
+ from these logs; a run whose event log could not be written is still a correct
10
+ run, and deleting the whole directory loses nothing but the view.
11
+
12
+ The files hold tool inputs and snippets of the repo being reviewed, so they are
13
+ created ``0600`` under a ``0700`` directory rather than left world-readable.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import time
21
+ from datetime import date, datetime
22
+ from pathlib import Path
23
+ from typing import Iterator
24
+
25
+ from nightshift.adapters.base import Event, RunResult
26
+ from nightshift.config import state_dir
27
+
28
+ #: Event logs older than this are pruned on the next run.
29
+ KEEP_DAYS = 14
30
+
31
+ #: How long a reader waits before checking a file for new lines again.
32
+ POLL_S = 0.2
33
+
34
+ #: A log with no new line for this long is treated as abandoned. Its writer
35
+ #: was killed, or wedged, and no ``end`` is ever coming — a reader that waits
36
+ #: for one would block forever. Comfortably longer than the default run
37
+ #: timeout so a slow-but-live run is never mistaken for a dead one.
38
+ STALE_AFTER_S = 900.0
39
+
40
+
41
+ def events_dir() -> Path:
42
+ return state_dir() / "events"
43
+
44
+
45
+ def day_dir(on: date) -> Path:
46
+ return events_dir() / on.isoformat()
47
+
48
+
49
+ def _slug(text: str) -> str:
50
+ keep = [c if (c.isalnum() or c in "-_") else "-" for c in text.strip().lower()]
51
+ return "".join(keep).strip("-") or "unknown"
52
+
53
+
54
+ def log_path(project: str, task: str, started: datetime, attempt: int = 1) -> Path:
55
+ stem = f"{_slug(project)}-{_slug(task)}-{started.strftime('%H%M%S')}"
56
+ if attempt > 1:
57
+ stem += f"-retry{attempt - 1}"
58
+ return day_dir(started.date()) / f"{stem}.ndjson"
59
+
60
+
61
+ class EventLog:
62
+ """One run's events, appended as newline-delimited JSON.
63
+
64
+ Every method swallows its own errors. A full disk or a read-only home must
65
+ cost the view, not the run.
66
+ """
67
+
68
+ def __init__(self, path: Path):
69
+ self.path = path
70
+ self._fh = None
71
+
72
+ # ---- writing ------------------------------------------------------
73
+
74
+ @classmethod
75
+ def open(
76
+ cls,
77
+ project: str,
78
+ task: str,
79
+ provider: str,
80
+ started: datetime,
81
+ attempt: int = 1,
82
+ ) -> EventLog:
83
+ log = cls(log_path(project, task, started, attempt))
84
+ try:
85
+ # mkdir's mode applies to the leaf only, so events/ itself would
86
+ # otherwise be born with whatever the umask says.
87
+ events_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
88
+ log.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
89
+ fd = os.open(str(log.path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
90
+ log._fh = os.fdopen(fd, "a", encoding="utf-8")
91
+ except OSError:
92
+ log._fh = None
93
+ return log
94
+ log._append(
95
+ {
96
+ "kind": "meta",
97
+ "project": project,
98
+ "task": task,
99
+ "provider": provider,
100
+ "started_at": started.isoformat(),
101
+ "attempt": attempt,
102
+ }
103
+ )
104
+ return log
105
+
106
+ def _append(self, payload: dict) -> None:
107
+ if self._fh is None:
108
+ return
109
+ payload.setdefault("t", datetime.now().isoformat(timespec="milliseconds"))
110
+ try:
111
+ # One json.dumps per line, flushed but not fsynced: a reader in
112
+ # another process sees it immediately, and a lost tail on a crash
113
+ # costs nothing the result file does not already hold.
114
+ self._fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
115
+ self._fh.flush()
116
+ except (OSError, ValueError, TypeError):
117
+ self.close_quietly()
118
+
119
+ def write(self, event: Event) -> None:
120
+ """Record one adapter event. Safe to pass straight to ``on_event``."""
121
+ self._append(
122
+ {
123
+ "kind": event.kind,
124
+ "text": event.text,
125
+ "tool": event.tool,
126
+ "detail": event.detail,
127
+ }
128
+ )
129
+
130
+ def finish(self, result: RunResult, findings: int = 0) -> None:
131
+ self._append(
132
+ {
133
+ "kind": "end",
134
+ "status": result.status,
135
+ "duration_s": round(result.duration_s, 3),
136
+ "detail": result.detail,
137
+ "findings": findings,
138
+ }
139
+ )
140
+ self.close_quietly()
141
+
142
+ def close_quietly(self) -> None:
143
+ fh, self._fh = self._fh, None
144
+ if fh is None:
145
+ return
146
+ try:
147
+ fh.close()
148
+ except OSError:
149
+ pass
150
+
151
+
152
+ # ---- reading ----------------------------------------------------------
153
+
154
+
155
+ def parse(line: str) -> dict | None:
156
+ """One event, or ``None`` for a blank, torn, or unreadable line."""
157
+ line = line.strip()
158
+ if not line:
159
+ return None
160
+ try:
161
+ payload = json.loads(line)
162
+ except json.JSONDecodeError:
163
+ return None
164
+ return payload if isinstance(payload, dict) else None
165
+
166
+
167
+ def read(path: Path) -> list[dict]:
168
+ try:
169
+ text = path.read_text(encoding="utf-8", errors="replace")
170
+ except OSError:
171
+ return []
172
+ return [p for p in (parse(line) for line in text.splitlines()) if p is not None]
173
+
174
+
175
+ def is_finished(path: Path) -> bool:
176
+ events = read(path)
177
+ return bool(events) and events[-1].get("kind") == "end"
178
+
179
+
180
+ def is_stale(path: Path, stale_after_s: float = STALE_AFTER_S) -> bool:
181
+ """Has nothing been appended to ``path`` for a long time?
182
+
183
+ An interrupted run leaves a log with no ``end``; without this a reader
184
+ cannot tell it from a run still in flight, and waits on it forever.
185
+ """
186
+ try:
187
+ return (time.time() - path.stat().st_mtime) > stale_after_s
188
+ except OSError:
189
+ return True
190
+
191
+
192
+ def logs_for(on: date) -> list[Path]:
193
+ try:
194
+ return sorted(day_dir(on).glob("*.ndjson"))
195
+ except OSError:
196
+ return []
197
+
198
+
199
+ def recent_logs(limit: int = 20) -> list[Path]:
200
+ """Newest run logs first, across every day we still keep.
201
+
202
+ Ordered by mtime rather than by name: a log's name starts with the project
203
+ slug, so sorting on it groups by project and only then by clock — which is
204
+ not "recent" at all once two projects are registered.
205
+ """
206
+ try:
207
+ found = list(events_dir().rglob("*.ndjson"))
208
+ except OSError:
209
+ return []
210
+
211
+ def when(path: Path) -> float:
212
+ try:
213
+ return path.stat().st_mtime
214
+ except OSError:
215
+ return 0.0
216
+
217
+ found.sort(key=when, reverse=True)
218
+ return found[:limit]
219
+
220
+
221
+ def follow(
222
+ path: Path,
223
+ stop_after_end: bool = True,
224
+ stale_after_s: float = STALE_AFTER_S,
225
+ ) -> Iterator[dict]:
226
+ """Yield events from ``path``, waiting for more until the run ends.
227
+
228
+ Returns on the ``end`` event, or once the log has gone quiet for
229
+ ``stale_after_s``. Waiting only for ``end`` would hang forever on the log
230
+ of a run that was killed, and take the whole watcher down with it.
231
+
232
+ A partial final line — the writer was mid-append — is left alone and
233
+ retried rather than parsed as garbage.
234
+ """
235
+ try:
236
+ fh = path.open("r", encoding="utf-8", errors="replace")
237
+ except OSError:
238
+ return
239
+ try:
240
+ buffer = ""
241
+ last_line_at = time.monotonic()
242
+ while True:
243
+ chunk = fh.readline()
244
+ if not chunk:
245
+ if time.monotonic() - last_line_at > stale_after_s:
246
+ return
247
+ time.sleep(POLL_S)
248
+ continue
249
+ last_line_at = time.monotonic()
250
+ buffer += chunk
251
+ if not buffer.endswith("\n"):
252
+ # Torn write: wait for the writer to finish the line.
253
+ continue
254
+ payload = parse(buffer)
255
+ buffer = ""
256
+ if payload is None:
257
+ continue
258
+ yield payload
259
+ if stop_after_end and payload.get("kind") == "end":
260
+ return
261
+ finally:
262
+ try:
263
+ fh.close()
264
+ except OSError:
265
+ pass
266
+
267
+
268
+ # ---- housekeeping -----------------------------------------------------
269
+
270
+
271
+ def prune(keep_days: int = KEEP_DAYS, today: date | None = None) -> int:
272
+ """Delete event logs older than ``keep_days``. Returns days removed."""
273
+ today = today or date.today()
274
+ removed = 0
275
+ try:
276
+ days = [d for d in events_dir().iterdir() if d.is_dir()]
277
+ except OSError:
278
+ return 0
279
+ for directory in days:
280
+ try:
281
+ on = date.fromisoformat(directory.name)
282
+ except ValueError:
283
+ continue # not ours; leave it alone
284
+ if (today - on).days <= keep_days:
285
+ continue
286
+ try:
287
+ for child in directory.iterdir():
288
+ child.unlink()
289
+ directory.rmdir()
290
+ removed += 1
291
+ except OSError:
292
+ continue
293
+ return removed
nightshift/lock.py ADDED
@@ -0,0 +1,197 @@
1
+ """A cooperative lockfile so two cron ticks never run at once."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import json
7
+ import logging
8
+ import os
9
+ import time as _time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from nightshift.config import state_dir
14
+
15
+ log = logging.getLogger("nightshift")
16
+
17
+ #: How far past the longest legitimate run a lock may live before we presume
18
+ #: its holder died. This multiplies the *whole* run budget — every attempt at
19
+ #: its full timeout — and must exceed it rather than equal it: a threshold a
20
+ #: healthy run can reach is a live lock waiting to be broken.
21
+ STALE_MULTIPLIER = 2
22
+
23
+
24
+ def lock_path() -> Path:
25
+ return state_dir() / "lock"
26
+
27
+
28
+ class LockBusy(Exception):
29
+ """Another nightshift run holds the lock."""
30
+
31
+
32
+ @dataclass
33
+ class LockInfo:
34
+ pid: int
35
+ acquired_at: float
36
+
37
+ @property
38
+ def age_s(self) -> float:
39
+ return max(0.0, _time.time() - self.acquired_at)
40
+
41
+
42
+ class Lock:
43
+ """An exclusive lock built on ``O_CREAT | O_EXCL``.
44
+
45
+ Stale locks — left behind by a run that was killed before it could clean up
46
+ — are broken automatically once they outlive :attr:`stale_after_s`.
47
+
48
+ That threshold is measured against the longest a healthy holder can take,
49
+ which is every attempt running to its full timeout, not one. This docstring
50
+ used to say "twice the run timeout, strictly longer than any healthy run can
51
+ survive"; with two attempts allowed and a multiplier of two those were the
52
+ same number, so a healthy run that used its whole budget landed exactly on
53
+ the threshold and any overhead put it past. Its live lock would be broken
54
+ and a second run would start beside it — the one thing the lock exists to
55
+ prevent.
56
+
57
+ Callers that retry must therefore say so via ``attempts``, or the lock will
58
+ size the threshold for a single run.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ path: Path | None = None,
64
+ timeout_s: int = 600,
65
+ attempts: int = 1,
66
+ ):
67
+ self.path = path or lock_path()
68
+ self.timeout_s = timeout_s
69
+ #: Total tries the holder may make, each up to ``timeout_s`` — the first
70
+ #: one included, not retries stacked on top of it. ``attempts=2`` is one
71
+ #: try and one retry, matching the scheduler's ``range(1, MAX_ATTEMPTS + 1)``.
72
+ #: Read as "retries" it would double the threshold it was meant to set.
73
+ self.attempts = max(int(attempts), 1)
74
+ self._held = False
75
+ #: Stamp we wrote when we took the lock. Together with the pid it is
76
+ #: what makes a lockfile *ours* rather than merely present — see
77
+ #: :meth:`release`. A pid alone cannot say: two Lock objects in one
78
+ #: process share one, and the OS reuses pids.
79
+ self._acquired_at: float | None = None
80
+
81
+ @property
82
+ def max_run_s(self) -> float:
83
+ """The longest a healthy holder can legitimately take."""
84
+ return self.timeout_s * self.attempts
85
+
86
+ @property
87
+ def stale_after_s(self) -> float:
88
+ return self.max_run_s * STALE_MULTIPLIER
89
+
90
+ def read(self) -> LockInfo | None:
91
+ try:
92
+ data = json.loads(self.path.read_text(encoding="utf-8"))
93
+ return LockInfo(pid=int(data["pid"]), acquired_at=float(data["acquired_at"]))
94
+ except FileNotFoundError:
95
+ return None
96
+ except (json.JSONDecodeError, KeyError, ValueError, OSError, TypeError):
97
+ # An unreadable lock is indistinguishable from an abandoned one; fall
98
+ # back to its mtime so it can still go stale and be broken.
99
+ try:
100
+ return LockInfo(pid=-1, acquired_at=self.path.stat().st_mtime)
101
+ except OSError:
102
+ return None
103
+
104
+ def is_stale(self, info: LockInfo | None = None) -> bool:
105
+ info = info or self.read()
106
+ return info is not None and info.age_s > self.stale_after_s
107
+
108
+ def _write(self) -> None:
109
+ acquired_at = _time.time()
110
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
111
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
112
+ json.dump({"pid": os.getpid(), "acquired_at": acquired_at}, fh)
113
+ self._acquired_at = acquired_at
114
+
115
+ def _is_ours(self, info: LockInfo | None) -> bool:
116
+ """Is the lockfile on disk the one *we* wrote?
117
+
118
+ The pid cannot settle this alone: two Lock objects in one process share
119
+ it, and the OS reuses pids. The acquisition stamp can — nothing else
120
+ wrote that float.
121
+ """
122
+ if info is None or self._acquired_at is None:
123
+ return False
124
+ return info.pid == os.getpid() and info.acquired_at == self._acquired_at
125
+
126
+ def acquire(self) -> None:
127
+ """Take the lock, breaking it first if it has gone stale.
128
+
129
+ Raises :class:`LockBusy` if a live run holds it.
130
+ """
131
+ self.path.parent.mkdir(parents=True, exist_ok=True)
132
+ try:
133
+ self._write()
134
+ self._held = True
135
+ return
136
+ except FileExistsError:
137
+ pass
138
+ except OSError as exc: # pragma: no cover - platform dependent
139
+ if exc.errno != errno.EEXIST:
140
+ raise
141
+
142
+ info = self.read()
143
+ if info is None:
144
+ # Vanished between the failed create and the read — retry once.
145
+ try:
146
+ self._write()
147
+ self._held = True
148
+ return
149
+ except FileExistsError:
150
+ raise LockBusy("another nightshift run just took the lock") from None
151
+
152
+ if not self.is_stale(info):
153
+ raise LockBusy(
154
+ f"another nightshift run is in progress (pid {info.pid}, "
155
+ f"started {info.age_s:.0f}s ago)"
156
+ )
157
+
158
+ self.break_stale()
159
+ try:
160
+ self._write()
161
+ self._held = True
162
+ except FileExistsError:
163
+ raise LockBusy("another nightshift run just took the lock") from None
164
+
165
+ def break_stale(self) -> None:
166
+ try:
167
+ self.path.unlink()
168
+ except FileNotFoundError:
169
+ pass
170
+
171
+ def release(self) -> None:
172
+ """Give up the lock, but only if the lockfile is still ours.
173
+
174
+ A run that overran had its lock broken as stale, and someone else is
175
+ working now. Unlinking on the way out would delete *their* lockfile and
176
+ let a third run start alongside a live one — the exact thing the lock
177
+ exists to prevent, brought about by the cleanup rather than the overrun.
178
+ """
179
+ if not self._held:
180
+ return
181
+ self._held = False
182
+
183
+ if not self._is_ours(self.read()):
184
+ log.debug("not releasing %s — it is no longer our lock", self.path)
185
+ return
186
+
187
+ try:
188
+ self.path.unlink()
189
+ except FileNotFoundError:
190
+ pass
191
+
192
+ def __enter__(self) -> Lock:
193
+ self.acquire()
194
+ return self
195
+
196
+ def __exit__(self, *exc_info: object) -> None:
197
+ self.release()
@@ -0,0 +1,24 @@
1
+ You are reviewing a codebase as a careful senior engineer.
2
+
3
+ **You may only read.** Do not modify, create, or delete any file. Do not run
4
+ commands that mutate state. If you cannot inspect something without changing
5
+ it, skip it and say so.
6
+
7
+ Review the project for correctness and maintainability problems that a
8
+ reviewer would genuinely raise: logic errors, unhandled failure modes, race
9
+ conditions, resource leaks, misleading names, and code that will surprise the
10
+ next reader. Prefer a few real problems over many nitpicks.
11
+
12
+ Output format — a markdown list, one finding per line, each line:
13
+
14
+ `- HIGH|MED|LOW <repo-relative/path.ext>:<line> — <one-line recommendation>`
15
+
16
+ Rules:
17
+ - Prefix every finding with exactly one of `HIGH`, `MED`, or `LOW`.
18
+ - `HIGH` = likely to cause incorrect behaviour, data loss, or a security hole.
19
+ `MED` = real bug or risk with limited blast radius. `LOW` = worth fixing but
20
+ harmless today.
21
+ - Every finding must cite a repo-relative `file:line`.
22
+ - Give exactly one line of recommendation per finding — say what to do, not
23
+ just what is wrong.
24
+ - If the project is clean, output exactly: `No findings.`
@@ -0,0 +1,21 @@
1
+ You are checking a project's documentation for dead references.
2
+
3
+ **You may only read.** Do not modify, create, or delete any file. Do not fetch
4
+ the network — judge links from the repository contents alone.
5
+
6
+ Look for: relative links and image paths that point at files which do not exist
7
+ in the repo, anchors pointing at headings that are absent, references to moved
8
+ or deleted docs, links to repositories or paths that contradict the project's
9
+ own name and layout, and TODO placeholder URLs left in published docs.
10
+
11
+ Output format — a markdown list, one finding per line, each line:
12
+
13
+ `- HIGH|MED|LOW <repo-relative/path.ext>:<line> — <one-line recommendation>`
14
+
15
+ Rules:
16
+ - Prefix every finding with exactly one of `HIGH`, `MED`, or `LOW`.
17
+ - `HIGH` = a link in the README's install or quickstart path is broken.
18
+ `MED` = a broken link in body documentation. `LOW` = a cosmetic or TODO link.
19
+ - Every finding must cite the repo-relative `file:line` of the link itself.
20
+ - Name the correct target in the recommendation when you can infer it.
21
+ - If every reference resolves, output exactly: `No findings.`
@@ -0,0 +1,23 @@
1
+ You are auditing a project's dependencies.
2
+
3
+ **You may only read.** Do not modify, create, or delete any file. Do not run
4
+ installers, upgrades, or any command that mutates the lockfile or environment.
5
+
6
+ Read the manifests and lockfiles present (`requirements.txt`, `pyproject.toml`,
7
+ `package.json`, `package-lock.json`, `go.mod`, `Cargo.toml`, `Dockerfile`, …).
8
+ Look for: dependencies with known advisories, unpinned or floating versions,
9
+ unpinned container base images, abandoned packages, and duplicate or
10
+ conflicting version constraints.
11
+
12
+ Output format — a markdown list, one finding per line, each line:
13
+
14
+ `- HIGH|MED|LOW <repo-relative/path.ext>:<line> — <one-line recommendation>`
15
+
16
+ Rules:
17
+ - Prefix every finding with exactly one of `HIGH`, `MED`, or `LOW`.
18
+ - `HIGH` = known exploitable advisory, or a wholly unpinned base image.
19
+ `MED` = advisory needing a precondition, or a floating major version.
20
+ `LOW` = routine upgrade available.
21
+ - Every finding must cite a repo-relative `file:line`.
22
+ - Name the package and the target version in the recommendation.
23
+ - If the project is clean, output exactly: `No findings.`
@@ -0,0 +1,21 @@
1
+ You are checking whether a project's documentation still matches its code.
2
+
3
+ **You may only read.** Do not modify, create, or delete any file.
4
+
5
+ Compare the README, docs, and inline setup instructions against what the code
6
+ actually does: command names and Makefile targets that were renamed or removed,
7
+ documented flags and environment variables that no longer exist, install steps
8
+ that would fail on a clean machine, examples importing symbols that moved, and
9
+ documented defaults that no longer match the code.
10
+
11
+ Output format — a markdown list, one finding per line, each line:
12
+
13
+ `- HIGH|MED|LOW <repo-relative/path.ext>:<line> — <one-line recommendation>`
14
+
15
+ Rules:
16
+ - Prefix every finding with exactly one of `HIGH`, `MED`, or `LOW`.
17
+ - `HIGH` = documented quickstart is broken and a new user would be blocked.
18
+ `MED` = a documented command or flag is wrong. `LOW` = stale prose.
19
+ - Cite the **documentation** `file:line` that is wrong, and name the code that
20
+ contradicts it in the recommendation.
21
+ - If the docs match the code, output exactly: `No findings.`
@@ -0,0 +1,23 @@
1
+ You are auditing a codebase for security problems.
2
+
3
+ **You may only read.** Do not modify, create, or delete any file. Do not run
4
+ commands that mutate state, and never exfiltrate secrets you find — cite the
5
+ location, not the value.
6
+
7
+ Look for: injection (SQL, shell, template), missing authentication or
8
+ authorization on routes and handlers, secrets committed to the repo, unsafe
9
+ deserialization, path traversal, weak or missing crypto, permissive CORS,
10
+ tokens without expiry, and unsafe defaults in configuration.
11
+
12
+ Output format — a markdown list, one finding per line, each line:
13
+
14
+ `- HIGH|MED|LOW <repo-relative/path.ext>:<line> — <one-line recommendation>`
15
+
16
+ Rules:
17
+ - Prefix every finding with exactly one of `HIGH`, `MED`, or `LOW`.
18
+ - `HIGH` = exploitable, or a secret is exposed. `MED` = a real weakness needing
19
+ a precondition. `LOW` = hardening.
20
+ - Every finding must cite a repo-relative `file:line`.
21
+ - If you find a credential, write `<redacted>` in place of its value.
22
+ - Give exactly one line of recommendation per finding.
23
+ - If the project is clean, output exactly: `No findings.`
nightshift/prompts.py ADDED
@@ -0,0 +1,66 @@
1
+ """Prompt template resolution.
2
+
3
+ Two sources, in order: the user's own ``~/.nightshift/prompts/`` and the
4
+ templates packaged with nightshift. Any ``.md`` file in either directory is a
5
+ valid task name, so a user can add a task by dropping in a file — and can
6
+ override a shipped template by using the same filename.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from nightshift.config import state_dir
14
+
15
+ PACKAGED_PROMPTS = Path(__file__).parent / "prompts"
16
+
17
+
18
+ class PromptError(Exception):
19
+ """A prompt template could not be resolved."""
20
+
21
+
22
+ def user_prompts() -> Path:
23
+ return state_dir() / "prompts"
24
+
25
+
26
+ def prompt_dirs() -> list[Path]:
27
+ """Search path, highest precedence first."""
28
+ return [user_prompts(), PACKAGED_PROMPTS]
29
+
30
+
31
+ def available_tasks() -> list[str]:
32
+ """Every task name resolvable right now."""
33
+ tasks: set[str] = set()
34
+ for directory in prompt_dirs():
35
+ if not directory.is_dir():
36
+ continue
37
+ for path in directory.glob("*.md"):
38
+ if path.is_file():
39
+ tasks.add(path.stem)
40
+ return sorted(tasks)
41
+
42
+
43
+ def find(task: str) -> Path | None:
44
+ for directory in prompt_dirs():
45
+ candidate = directory / f"{task}.md"
46
+ if candidate.is_file():
47
+ return candidate
48
+ return None
49
+
50
+
51
+ def load(task: str) -> str:
52
+ """Read the template for ``task``."""
53
+ path = find(task)
54
+ if path is None:
55
+ known = ", ".join(available_tasks()) or "none found"
56
+ raise PromptError(
57
+ f"no prompt template for task {task!r} — looked in "
58
+ f"{', '.join(str(d) for d in prompt_dirs())}. Available tasks: {known}"
59
+ )
60
+ try:
61
+ text = path.read_text(encoding="utf-8").strip()
62
+ except OSError as exc:
63
+ raise PromptError(f"cannot read prompt {path}: {exc}") from exc
64
+ if not text:
65
+ raise PromptError(f"prompt template {path} is empty")
66
+ return text