worklog-for-claude-code 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.
- worklog/__init__.py +3 -0
- worklog/cli.py +249 -0
- worklog/collector.py +197 -0
- worklog/config.py +294 -0
- worklog/demo.py +214 -0
- worklog/hooks.py +78 -0
- worklog/identity.py +238 -0
- worklog/parser.py +180 -0
- worklog/redact.py +39 -0
- worklog/report.py +229 -0
- worklog/services.py +139 -0
- worklog/store.py +135 -0
- worklog/web.py +776 -0
- worklog_for_claude_code-0.1.0.dist-info/METADATA +112 -0
- worklog_for_claude_code-0.1.0.dist-info/RECORD +19 -0
- worklog_for_claude_code-0.1.0.dist-info/WHEEL +5 -0
- worklog_for_claude_code-0.1.0.dist-info/entry_points.txt +2 -0
- worklog_for_claude_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- worklog_for_claude_code-0.1.0.dist-info/top_level.txt +1 -0
worklog/report.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Daily report: merge records from all devices/accounts, summarize, render Markdown."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
|
|
12
|
+
from .config import DEFAULT_CLAUDE_DIR, SUMMARIZER_CWD, Config
|
|
13
|
+
from .parser import zero_tokens
|
|
14
|
+
from .redact import redact
|
|
15
|
+
from .store import GitStore
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
_MAX_AI_INPUT_CHARS = 24_000 # keep each summary call small and well below arg-size limits
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def project_key(proj: dict) -> str:
|
|
22
|
+
"""Same repo on two machines (different paths) merges via its remote slug."""
|
|
23
|
+
remote = proj.get("remote")
|
|
24
|
+
return f"{remote['host']}/{remote['slug']}" if remote else f"{proj.get('kind')}:{proj.get('name')}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ProjectDay:
|
|
29
|
+
key: str
|
|
30
|
+
name: str
|
|
31
|
+
kind: str
|
|
32
|
+
remote: dict | None
|
|
33
|
+
accounts: set[str] = field(default_factory=set)
|
|
34
|
+
devices: set[str] = field(default_factory=set)
|
|
35
|
+
sessions: int = 0
|
|
36
|
+
minutes: int = 0
|
|
37
|
+
tokens: dict[str, int] = field(default_factory=zero_tokens)
|
|
38
|
+
prompts: list[tuple[str, str]] = field(default_factory=list) # (start time, prompt)
|
|
39
|
+
files: set[str] = field(default_factory=set)
|
|
40
|
+
folders: set[str] = field(default_factory=set)
|
|
41
|
+
commits: dict[str, str] = field(default_factory=dict) # hash -> subject (deduped)
|
|
42
|
+
branches: set[str] = field(default_factory=set)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _add(dst: dict[str, int], src: object) -> None:
|
|
46
|
+
for k in dst:
|
|
47
|
+
v = src.get(k, 0) if isinstance(src, dict) else 0
|
|
48
|
+
dst[k] += v if isinstance(v, int) else 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def aggregate(records: list[dict]):
|
|
52
|
+
accounts: dict[str, dict] = {}
|
|
53
|
+
projects: dict[str, ProjectDay] = {}
|
|
54
|
+
for r in records:
|
|
55
|
+
acct = accounts.setdefault(r.get("account", "?"), {
|
|
56
|
+
"tokens": zero_tokens(), "sessions": 0, "devices": set(), "models": {}})
|
|
57
|
+
_add(acct["tokens"], r.get("tokens"))
|
|
58
|
+
acct["sessions"] += 1
|
|
59
|
+
acct["devices"].add(r.get("device", "?"))
|
|
60
|
+
for model, t in (r.get("tokens_by_model") or {}).items():
|
|
61
|
+
_add(acct["models"].setdefault(model, zero_tokens()), t)
|
|
62
|
+
|
|
63
|
+
proj = r.get("project") or {}
|
|
64
|
+
key = project_key(proj)
|
|
65
|
+
p = projects.setdefault(key, ProjectDay(key, str(proj.get("name", "?")), str(proj.get("kind")),
|
|
66
|
+
proj.get("remote")))
|
|
67
|
+
p.accounts.add(r.get("account", "?"))
|
|
68
|
+
p.devices.add(r.get("device", "?"))
|
|
69
|
+
p.sessions += 1
|
|
70
|
+
p.minutes += int(r.get("span_minutes") or 0)
|
|
71
|
+
_add(p.tokens, r.get("tokens"))
|
|
72
|
+
start = (r.get("start") or "")[11:16]
|
|
73
|
+
p.prompts.extend((start, q) for q in r.get("prompts") or [])
|
|
74
|
+
p.files.update(r.get("files_touched") or [])
|
|
75
|
+
p.branches.update(proj.get("branches") or [])
|
|
76
|
+
if proj.get("folder"):
|
|
77
|
+
p.folders.add(proj["folder"])
|
|
78
|
+
for c in r.get("commits") or []:
|
|
79
|
+
p.commits.setdefault(c.get("hash", ""), c.get("subject", ""))
|
|
80
|
+
for p in projects.values():
|
|
81
|
+
p.prompts.sort()
|
|
82
|
+
return accounts, projects
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------ summarization
|
|
86
|
+
def _claude_env(cfg: Config) -> dict[str, str]:
|
|
87
|
+
env = dict(os.environ)
|
|
88
|
+
acct = cfg.account(cfg.summarizer_account)
|
|
89
|
+
if acct and acct.config_dir != DEFAULT_CLAUDE_DIR:
|
|
90
|
+
env["CLAUDE_CONFIG_DIR"] = str(acct.config_dir)
|
|
91
|
+
else:
|
|
92
|
+
env.pop("CLAUDE_CONFIG_DIR", None)
|
|
93
|
+
return env
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ai_summary(p: ProjectDay, day: date, cfg: Config) -> list[str] | None:
|
|
97
|
+
exe = shutil.which(cfg.claude_bin)
|
|
98
|
+
if not exe:
|
|
99
|
+
log.warning("claude binary %r not found; using plain summary", cfg.claude_bin)
|
|
100
|
+
return None
|
|
101
|
+
data = {
|
|
102
|
+
"project": p.name, "kind": p.kind, "branches": sorted(p.branches), "folders": sorted(p.folders),
|
|
103
|
+
"prompts_in_order": [q for _, q in p.prompts],
|
|
104
|
+
"commits": list(p.commits.values()), "files_edited": sorted(p.files)[:80],
|
|
105
|
+
}
|
|
106
|
+
blob = json.dumps(data, ensure_ascii=False)[:_MAX_AI_INPUT_CHARS]
|
|
107
|
+
prompt = (
|
|
108
|
+
f"You are writing a developer's end-of-day work log for {day.isoformat()}. "
|
|
109
|
+
"The JSON below was extracted from their Claude Code sessions for ONE project. "
|
|
110
|
+
"Write 2-7 concise bullet points describing what was accomplished or investigated "
|
|
111
|
+
"(features implemented, bugs fixed, refactors, research). Past tense, no fluff. "
|
|
112
|
+
"Use ONLY the data given; if something is unclear, describe it generically rather than guess. "
|
|
113
|
+
"Output only the bullets, each line starting with '- '. Do not use any tools.\n\n"
|
|
114
|
+
f"<data>\n{blob}\n</data>"
|
|
115
|
+
)
|
|
116
|
+
SUMMARIZER_CWD.mkdir(parents=True, exist_ok=True) # excluded by the collector
|
|
117
|
+
try:
|
|
118
|
+
out = subprocess.run([exe, "-p", prompt, "--output-format", "text"], cwd=SUMMARIZER_CWD,
|
|
119
|
+
env=_claude_env(cfg), capture_output=True, text=True, timeout=cfg.ai_timeout)
|
|
120
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
121
|
+
log.warning("claude -p failed for %s: %s", p.name, exc)
|
|
122
|
+
return None
|
|
123
|
+
if out.returncode != 0:
|
|
124
|
+
log.warning("claude -p exited %d for %s: %s", out.returncode, p.name, out.stderr.strip()[:300])
|
|
125
|
+
return None
|
|
126
|
+
bullets = [redact(line.strip())[:400] for line in out.stdout.splitlines()
|
|
127
|
+
if line.strip().startswith(("- ", "* "))]
|
|
128
|
+
return [("- " + b[2:]) for b in bullets] or None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def plain_summary(p: ProjectDay) -> list[str]:
|
|
132
|
+
lines = [f"- {q.splitlines()[0][:140]}" for _, q in p.prompts[:6]]
|
|
133
|
+
if len(p.prompts) > 6:
|
|
134
|
+
lines.append(f"- …and {len(p.prompts) - 6} more prompt(s)")
|
|
135
|
+
return lines or ["- (no prompts captured)"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------ render
|
|
139
|
+
def _n(v: int) -> str:
|
|
140
|
+
return f"{v:,}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _dur(minutes: int) -> str:
|
|
144
|
+
h, m = divmod(minutes, 60)
|
|
145
|
+
return f"{h}h {m:02d}m" if h else f"{m}m"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def summarize_all(projects: dict[str, ProjectDay], day: date, cfg: Config, use_ai: bool) -> dict[str, dict]:
|
|
149
|
+
out = {}
|
|
150
|
+
for key, p in projects.items():
|
|
151
|
+
bullets = ai_summary(p, day, cfg) if use_ai else None
|
|
152
|
+
out[key] = {"bullets": bullets or plain_summary(p), "source": "ai" if bullets else "plain"}
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render(day: date, cfg: Config, accounts: dict, projects: dict[str, ProjectDay], summaries: dict) -> str:
|
|
157
|
+
devices = sorted({d for a in accounts.values() for d in a["devices"]})
|
|
158
|
+
total_sessions = sum(a["sessions"] for a in accounts.values())
|
|
159
|
+
out = [f"# Claude Code work log — {day.isoformat()}", "",
|
|
160
|
+
f"_Generated {datetime.now().strftime('%Y-%m-%d %H:%M')} on `{cfg.device}` · "
|
|
161
|
+
f"{total_sessions} session(s) · devices: {', '.join(devices) or '—'}_", ""]
|
|
162
|
+
if not accounts:
|
|
163
|
+
out += ["No Claude Code activity recorded for this day.", ""]
|
|
164
|
+
return "\n".join(out)
|
|
165
|
+
|
|
166
|
+
head = "| Account | Sessions | Input | Output | Cache write | Cache read | Total |"
|
|
167
|
+
out += ["## Token usage by account", "", head, "|---|---:|---:|---:|---:|---:|---:|"]
|
|
168
|
+
grand = zero_tokens()
|
|
169
|
+
for name in sorted(accounts):
|
|
170
|
+
a = accounts[name]
|
|
171
|
+
t = a["tokens"]
|
|
172
|
+
_add(grand, t)
|
|
173
|
+
out.append(f"| {name} | {a['sessions']} | {_n(t['input'])} | {_n(t['output'])} | "
|
|
174
|
+
f"{_n(t['cache_creation'])} | {_n(t['cache_read'])} | {_n(sum(t.values()))} |")
|
|
175
|
+
out.append(f"| **All** | **{total_sessions}** | **{_n(grand['input'])}** | **{_n(grand['output'])}** | "
|
|
176
|
+
f"**{_n(grand['cache_creation'])}** | **{_n(grand['cache_read'])}** | **{_n(sum(grand.values()))}** |")
|
|
177
|
+
out += ["", "### By model", "", "| Account | Model | Input | Output | Cache write | Cache read |",
|
|
178
|
+
"|---|---|---:|---:|---:|---:|"]
|
|
179
|
+
for name in sorted(accounts):
|
|
180
|
+
for model, t in sorted(accounts[name]["models"].items()):
|
|
181
|
+
out.append(f"| {name} | {model} | {_n(t['input'])} | {_n(t['output'])} | "
|
|
182
|
+
f"{_n(t['cache_creation'])} | {_n(t['cache_read'])} |")
|
|
183
|
+
out += ["", "> Token counts are usage as logged by Claude Code, not a bill. "
|
|
184
|
+
"On Pro/Max plans they count against limits rather than cost money directly.", ""]
|
|
185
|
+
|
|
186
|
+
out += ["## What I worked on", ""]
|
|
187
|
+
ordered = sorted(projects.values(), key=lambda p: (p.kind == "misc", -p.sessions, p.name))
|
|
188
|
+
for p in ordered:
|
|
189
|
+
where = f"`{p.remote['host']}/{p.remote['slug']}`" if p.remote else (
|
|
190
|
+
"folders: " + ", ".join(sorted(p.folders)) if p.folders else "no git repo")
|
|
191
|
+
out += [f"### {p.name}", "",
|
|
192
|
+
f"{where} · accounts: {', '.join(sorted(p.accounts))} · devices: {', '.join(sorted(p.devices))} · "
|
|
193
|
+
f"{p.sessions} session(s) · {_dur(p.minutes)} span · {_n(sum(p.tokens.values()))} tokens · "
|
|
194
|
+
f"{len(p.files)} file(s) edited · {len(p.commits)} commit(s)", ""]
|
|
195
|
+
out += summaries.get(p.key, {}).get("bullets") or plain_summary(p)
|
|
196
|
+
out += [""]
|
|
197
|
+
if p.commits:
|
|
198
|
+
out += ["Commits:", ""] + [f"- `{h}` {s}" for h, s in p.commits.items()] + [""]
|
|
199
|
+
return "\n".join(out)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_report(cfg: Config, day: date, use_ai: bool, write: bool) -> tuple[str, str | None]:
|
|
203
|
+
store = GitStore(cfg.store_repo)
|
|
204
|
+
with store.lock(): # short lock: sync + read only
|
|
205
|
+
store.pull()
|
|
206
|
+
records = list(store.iter_json(f"records/{day.isoformat()}"))
|
|
207
|
+
accounts, projects = aggregate(records)
|
|
208
|
+
summaries = summarize_all(projects, day, cfg, use_ai) # AI calls run without the lock
|
|
209
|
+
text = render(day, cfg, accounts, projects, summaries)
|
|
210
|
+
if not write:
|
|
211
|
+
return text, None
|
|
212
|
+
rel = f"reports/{day.isoformat()}/{cfg.device}.md"
|
|
213
|
+
meta = {"date": day.isoformat(), "device": cfg.device, "summaries": summaries,
|
|
214
|
+
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds")}
|
|
215
|
+
with store.lock():
|
|
216
|
+
store.pull()
|
|
217
|
+
store.write_text(rel, text)
|
|
218
|
+
store.write_json(f"reports/{day.isoformat()}/{cfg.device}.json", meta)
|
|
219
|
+
store.commit_and_push(f"report {day.isoformat()} ({cfg.device})")
|
|
220
|
+
return text, str(cfg.store_repo / rel)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_saved_summaries(store: GitStore, day: date) -> dict:
|
|
224
|
+
"""Newest saved summaries for `day` from any device (used by the web UI)."""
|
|
225
|
+
best: dict = {}
|
|
226
|
+
for meta in store.iter_json(f"reports/{day.isoformat()}"):
|
|
227
|
+
if isinstance(meta, dict) and meta.get("generated_at", "") > best.get("generated_at", ""):
|
|
228
|
+
best = meta
|
|
229
|
+
return best
|
worklog/services.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""systemd --user units: the web UI (always on) + collect/report timers (replace cron).
|
|
2
|
+
|
|
3
|
+
Timers use Persistent=true, so a run missed while the laptop was asleep/off happens on wake.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
UNIT_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
|
|
14
|
+
WEB, COLLECT, REPORT = "claude-worklog-web.service", "claude-worklog-collect.timer", "claude-worklog-report.timer"
|
|
15
|
+
# Set by `claude-worklog demo`: the demo must never start, stop or enable the user's real units.
|
|
16
|
+
DEMO_ENV = "CLAUDE_WORKLOG_DEMO"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def demo_mode() -> bool:
|
|
20
|
+
return os.environ.get(DEMO_ENV) == "1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _q(s: str) -> str:
|
|
24
|
+
"""Quote for a systemd unit file (and escape % specifiers)."""
|
|
25
|
+
s = s.replace("%", "%%")
|
|
26
|
+
return f'"{s}"' if any(c in s for c in ' "\\') else s
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _units(port: int) -> dict[str, str]:
|
|
30
|
+
py = _q(sys.executable) # the pipx venv's python: always has the package
|
|
31
|
+
# Timers/services don't inherit your shell PATH; freeze it so `claude` and `git` are found.
|
|
32
|
+
env = f'Environment={_q("PATH=" + os.environ.get("PATH", "/usr/bin:/bin"))}\nEnvironment=PYTHONUNBUFFERED=1'
|
|
33
|
+
oneshot = "[Service]\nType=oneshot\nNice=10\n" + env + "\n"
|
|
34
|
+
return {
|
|
35
|
+
WEB: f"""[Unit]
|
|
36
|
+
Description=claude-worklog web UI (http://worklog.localhost:{port})
|
|
37
|
+
|
|
38
|
+
[Service]
|
|
39
|
+
Type=simple
|
|
40
|
+
ExecStart={py} -m worklog.cli serve --port {port}
|
|
41
|
+
Restart=on-failure
|
|
42
|
+
RestartSec=5
|
|
43
|
+
NoNewPrivileges=yes
|
|
44
|
+
{env}
|
|
45
|
+
|
|
46
|
+
[Install]
|
|
47
|
+
WantedBy=default.target
|
|
48
|
+
""",
|
|
49
|
+
"claude-worklog-collect.service": f"""[Unit]
|
|
50
|
+
Description=claude-worklog: collect Claude Code sessions (backstop for the hook)
|
|
51
|
+
|
|
52
|
+
{oneshot}ExecStart={py} -m worklog.cli --quiet collect --days 2
|
|
53
|
+
""",
|
|
54
|
+
COLLECT: """[Unit]
|
|
55
|
+
Description=claude-worklog hourly collect
|
|
56
|
+
|
|
57
|
+
[Timer]
|
|
58
|
+
OnBootSec=3min
|
|
59
|
+
OnCalendar=hourly
|
|
60
|
+
Persistent=true
|
|
61
|
+
RandomizedDelaySec=120
|
|
62
|
+
|
|
63
|
+
[Install]
|
|
64
|
+
WantedBy=timers.target
|
|
65
|
+
""",
|
|
66
|
+
"claude-worklog-report.service": f"""[Unit]
|
|
67
|
+
Description=claude-worklog: build daily report
|
|
68
|
+
|
|
69
|
+
{oneshot}ExecStart={py} -m worklog.cli --quiet report --auto
|
|
70
|
+
""",
|
|
71
|
+
REPORT: """[Unit]
|
|
72
|
+
Description=claude-worklog daily report (23:30 today, 00:20 re-run for yesterday)
|
|
73
|
+
|
|
74
|
+
[Timer]
|
|
75
|
+
OnCalendar=*-*-* 23:30:00
|
|
76
|
+
OnCalendar=*-*-* 00:20:00
|
|
77
|
+
Persistent=true
|
|
78
|
+
|
|
79
|
+
[Install]
|
|
80
|
+
WantedBy=timers.target
|
|
81
|
+
""",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _systemctl(*args: str) -> subprocess.CompletedProcess:
|
|
86
|
+
if demo_mode():
|
|
87
|
+
raise RuntimeError("systemd is disabled in demo mode")
|
|
88
|
+
return subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True, timeout=30)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def available() -> bool:
|
|
92
|
+
return not demo_mode() and shutil.which("systemctl") is not None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def install(port: int, report: bool, start: bool = True) -> list[str]:
|
|
96
|
+
UNIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
msgs = []
|
|
98
|
+
for name, text in _units(port).items():
|
|
99
|
+
(UNIT_DIR / name).write_text(text, encoding="utf-8")
|
|
100
|
+
msgs.append(f"wrote {UNIT_DIR / name}")
|
|
101
|
+
if not start:
|
|
102
|
+
return msgs
|
|
103
|
+
if not available():
|
|
104
|
+
raise RuntimeError("systemctl not found; this needs a systemd-based system such as Ubuntu")
|
|
105
|
+
for args in (("daemon-reload",), ("enable", "--now", WEB, COLLECT), ("restart", WEB)):
|
|
106
|
+
r = _systemctl(*args)
|
|
107
|
+
if r.returncode != 0:
|
|
108
|
+
raise RuntimeError(f"systemctl --user {' '.join(args)} failed: {r.stderr.strip()}")
|
|
109
|
+
msgs.append(set_report_timer(report))
|
|
110
|
+
return msgs
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def set_report_timer(enabled: bool) -> str:
|
|
114
|
+
r = _systemctl("enable" if enabled else "disable", "--now", REPORT)
|
|
115
|
+
if r.returncode != 0:
|
|
116
|
+
raise RuntimeError(f"could not {'enable' if enabled else 'disable'} {REPORT}: {r.stderr.strip()}")
|
|
117
|
+
return f"daily report on this device: {'on' if enabled else 'off'}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def status() -> dict[str, str]:
|
|
121
|
+
if not available():
|
|
122
|
+
reason = "disabled in demo mode" if demo_mode() else "systemd not available"
|
|
123
|
+
return {u: reason for u in (WEB, COLLECT, REPORT)}
|
|
124
|
+
out = {}
|
|
125
|
+
for unit in (WEB, COLLECT, REPORT):
|
|
126
|
+
try:
|
|
127
|
+
active = _systemctl("is-active", unit).stdout.strip() or "unknown"
|
|
128
|
+
enabled = _systemctl("is-enabled", unit).stdout.strip() or "unknown"
|
|
129
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
130
|
+
active = enabled = "unknown"
|
|
131
|
+
out[unit] = f"{active}, {enabled}"
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def report_timer_enabled() -> bool:
|
|
136
|
+
try:
|
|
137
|
+
return available() and _systemctl("is-enabled", REPORT).stdout.strip() == "enabled"
|
|
138
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
139
|
+
return False
|
worklog/store.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Git-backed central store (any host: GitHub, GitLab, Bitbucket, self-hosted).
|
|
2
|
+
|
|
3
|
+
Layout (every path is device-specific, so devices never conflict):
|
|
4
|
+
records/<YYYY-MM-DD>/<device>/<account>/<session>.json
|
|
5
|
+
reports/<YYYY-MM-DD>/<device>.md
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import errno
|
|
10
|
+
import fcntl
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .config import STATE_DIR, ConfigError
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Commits in the data repo use a neutral identity (no personal e-mail stored).
|
|
24
|
+
_IDENTITY = ["-c", "user.name=claude-worklog", "-c", "user.email=claude-worklog@localhost"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GitStore:
|
|
28
|
+
def __init__(self, path: Path):
|
|
29
|
+
self.path = path
|
|
30
|
+
if not (path / ".git").exists():
|
|
31
|
+
raise ConfigError(
|
|
32
|
+
f"{path} is not a git repo. Create a PRIVATE repo on your git host and clone it there."
|
|
33
|
+
)
|
|
34
|
+
env = dict(os.environ)
|
|
35
|
+
env["GIT_TERMINAL_PROMPT"] = "0" # never hang waiting for a password (hooks/cron)
|
|
36
|
+
env.setdefault("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ConnectTimeout=15")
|
|
37
|
+
self._env = env
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- locking
|
|
40
|
+
@contextmanager
|
|
41
|
+
def lock(self, timeout: float = 90.0):
|
|
42
|
+
"""Serialize hook, cron and manual runs on this machine."""
|
|
43
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
with open(STATE_DIR / "store.lock", "w") as fh:
|
|
45
|
+
deadline = time.monotonic() + timeout
|
|
46
|
+
while True:
|
|
47
|
+
try:
|
|
48
|
+
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
49
|
+
break
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
if exc.errno not in (errno.EAGAIN, errno.EACCES) or time.monotonic() > deadline:
|
|
52
|
+
raise TimeoutError("another claude-worklog run holds the lock") from exc
|
|
53
|
+
time.sleep(1)
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------- git
|
|
60
|
+
def _git(self, *args: str, timeout: int = 60, check: bool = True) -> subprocess.CompletedProcess:
|
|
61
|
+
return subprocess.run(
|
|
62
|
+
["git", "-C", str(self.path), *args],
|
|
63
|
+
capture_output=True, text=True, timeout=timeout, check=check, env=self._env,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _has_remote(self) -> bool:
|
|
67
|
+
return bool(self._git("remote", check=False).stdout.strip())
|
|
68
|
+
|
|
69
|
+
def pull(self) -> None:
|
|
70
|
+
if not self._has_remote():
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
self._git("pull", "--rebase", "--autostash", timeout=90)
|
|
74
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
75
|
+
# Offline, empty repo, or first run: work locally, sync next time.
|
|
76
|
+
detail = getattr(exc, "stderr", "") or str(exc)
|
|
77
|
+
log.warning("git pull failed (continuing locally): %s", detail.strip()[:300])
|
|
78
|
+
if (self.path / ".git" / "rebase-merge").exists() or (self.path / ".git" / "rebase-apply").exists():
|
|
79
|
+
self._git("rebase", "--abort", check=False)
|
|
80
|
+
|
|
81
|
+
def commit_and_push(self, message: str) -> None:
|
|
82
|
+
dirs = [d for d in ("records", "reports") if (self.path / d).exists()]
|
|
83
|
+
if not dirs:
|
|
84
|
+
return
|
|
85
|
+
self._git("add", "-A", "--", *dirs)
|
|
86
|
+
if self._git("diff", "--cached", "--quiet", check=False).returncode == 0:
|
|
87
|
+
return # nothing staged
|
|
88
|
+
self._git(*_IDENTITY, "commit", "-q", "-m", message)
|
|
89
|
+
if not self._has_remote():
|
|
90
|
+
log.info("committed locally (no remote configured)")
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
self._git("push", "-q", "-u", "origin", "HEAD", timeout=90)
|
|
94
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
|
95
|
+
detail = getattr(exc, "stderr", "") or str(exc)
|
|
96
|
+
log.warning("git push failed; commit kept locally and retried next run: %s", detail.strip()[:300])
|
|
97
|
+
|
|
98
|
+
# ----------------------------------------------------------------- files
|
|
99
|
+
def _target(self, rel: str) -> Path:
|
|
100
|
+
target = (self.path / rel).resolve()
|
|
101
|
+
if self.path.resolve() not in target.parents:
|
|
102
|
+
raise ValueError(f"refusing to write outside store: {rel}")
|
|
103
|
+
return target
|
|
104
|
+
|
|
105
|
+
def write_text(self, rel: str, text: str) -> bool:
|
|
106
|
+
"""Atomically write; returns False if content is unchanged."""
|
|
107
|
+
target = self._target(rel)
|
|
108
|
+
if target.exists() and target.read_text(encoding="utf-8") == text:
|
|
109
|
+
return False
|
|
110
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
112
|
+
tmp.write_text(text, encoding="utf-8")
|
|
113
|
+
os.replace(tmp, target)
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def move(self, src_rel: str, dst_rel: str) -> None:
|
|
117
|
+
"""Rename a file inside the store (commit_and_push stages it as a rename)."""
|
|
118
|
+
src, dst = self._target(src_rel), self._target(dst_rel)
|
|
119
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
os.replace(src, dst)
|
|
121
|
+
try:
|
|
122
|
+
src.parent.rmdir() # drop the now-empty account folder, if it is empty
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def write_json(self, rel: str, data: dict) -> bool:
|
|
127
|
+
return self.write_text(rel, json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
|
|
128
|
+
|
|
129
|
+
def iter_json(self, rel_dir: str):
|
|
130
|
+
base = self.path / rel_dir
|
|
131
|
+
for p in sorted(base.rglob("*.json")) if base.exists() else []:
|
|
132
|
+
try:
|
|
133
|
+
yield json.loads(p.read_text(encoding="utf-8"))
|
|
134
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
135
|
+
log.warning("skipping unreadable record %s: %s", p, exc)
|