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/__init__.py +8 -0
- nightshift/__main__.py +4 -0
- nightshift/adapters/__init__.py +61 -0
- nightshift/adapters/_process.py +219 -0
- nightshift/adapters/base.py +171 -0
- nightshift/adapters/claude_code.py +372 -0
- nightshift/adapters/codex.py +360 -0
- nightshift/adapters/copilot.py +51 -0
- nightshift/budget.py +150 -0
- nightshift/cli.py +602 -0
- nightshift/config.py +447 -0
- nightshift/cron.py +96 -0
- nightshift/events.py +293 -0
- nightshift/lock.py +197 -0
- nightshift/prompts/code_review.md +24 -0
- nightshift/prompts/dead_links.md +21 -0
- nightshift/prompts/deps_audit.md +23 -0
- nightshift/prompts/docs_drift.md +21 -0
- nightshift/prompts/security_audit.md +23 -0
- nightshift/prompts.py +66 -0
- nightshift/queue.py +92 -0
- nightshift/report.py +394 -0
- nightshift/scheduler.py +425 -0
- nightshift/sessions.py +62 -0
- nightshift/store.py +48 -0
- nightshift_cli-0.1.0.dist-info/METADATA +430 -0
- nightshift_cli-0.1.0.dist-info/RECORD +30 -0
- nightshift_cli-0.1.0.dist-info/WHEEL +4 -0
- nightshift_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nightshift_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|