opsroom-console 0.3.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.
opsroom/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """opsroom — local-first operator console. See https://github.com/malekadjeb-ai/opsroom"""
2
+ __version__ = "0.3.0"
opsroom/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """opsroom — a local-first operator console. Read-only on all sources.
3
+ Commands: serve · init · sync · sitrep · dash · today · week · loops · drift · venture ·
4
+ search · daily · demo · purge · status"""
5
+ import argparse
6
+ import sys
7
+ import time
8
+
9
+ from . import config, db, enrich, views
10
+
11
+
12
+ def cmd_sync(args):
13
+ con = db.connect()
14
+ sources = args.source.split(",") if args.source else ["cli", "codex", "git", "fs", "notes", "chat"]
15
+ t0 = time.time()
16
+ results, degraded = {}, []
17
+ git_result, notes_result = {}, {}
18
+ from .collectors import (cli as c_cli, codex as c_codex, git as c_git, fs as c_fs,
19
+ notes as c_notes, chat as c_chat)
20
+ registry = {"cli": c_cli, "codex": c_codex, "git": c_git, "fs": c_fs,
21
+ "notes": c_notes, "chat": c_chat}
22
+ for name in sources:
23
+ mod = registry.get(name)
24
+ if not mod:
25
+ print(f"unknown source: {name}")
26
+ continue
27
+ try:
28
+ r = mod.collect(con, dry_run=args.dry_run)
29
+ results[name] = r
30
+ if name == "git":
31
+ git_result = r
32
+ if name == "notes":
33
+ notes_result = r
34
+ if r.get("degraded"):
35
+ degraded.append(f"notes: {r['degraded']}")
36
+ if not args.dry_run:
37
+ db.set_watermark(con, name, "degraded" if (name == "notes" and r.get("degraded")) else "ok",
38
+ last_ts=r.get("watermark"))
39
+ except Exception as e:
40
+ degraded.append(f"{name}: {type(e).__name__}: {e}")
41
+ if not args.dry_run:
42
+ db.set_watermark(con, name, "failed")
43
+ print(f" [{name}] DEGRADED: {e}", file=sys.stderr)
44
+ if not args.dry_run:
45
+ n_sessions = enrich.build_sessions(con)
46
+ loop_stats = enrich.detect_loops(con, git_result, notes_result)
47
+ con.commit()
48
+ db.enforce_perms()
49
+ else:
50
+ n_sessions, loop_stats = "-", {"open": "-"}
51
+ dt = time.time() - t0
52
+ print(f"\nopsroom sync {'(dry-run) ' if args.dry_run else ''}done in {dt:.1f}s")
53
+ for name, r in results.items():
54
+ print(f" {name:<6} new={r.get('events_new', 0):<6} seen={r.get('events_seen', 0):<7} "
55
+ f"dropped={r.get('dropped', 0)}")
56
+ print(f" sessions built: {n_sessions} · open loops: {loop_stats['open']}")
57
+ if degraded:
58
+ print(f" DEGRADED SOURCES: {degraded}")
59
+ if not args.dry_run:
60
+ row = con.execute("SELECT COUNT(*) c FROM events").fetchone()
61
+ print(f" events total: {row['c']}")
62
+ con.close()
63
+ return 0
64
+
65
+
66
+ def cmd_purge(args):
67
+ con = db.connect()
68
+ if args.source:
69
+ for t in ("events", "sessions"):
70
+ con.execute(f"DELETE FROM {t} WHERE source=?", (args.source,))
71
+ con.execute("DELETE FROM loops WHERE signal LIKE ?", (f"%{args.source}%",))
72
+ con.execute("DELETE FROM watermarks WHERE source=? OR source LIKE ?",
73
+ (args.source, f"{args.source}:%"))
74
+ if args.source == "cli":
75
+ con.execute("DELETE FROM file_state WHERE path LIKE '%/.claude/%'")
76
+ if args.source == "codex":
77
+ con.execute("DELETE FROM file_state WHERE path LIKE '%/.codex/%'")
78
+ print(f"purged source={args.source}")
79
+ if args.before:
80
+ con.execute("DELETE FROM events WHERE ts < ?", (args.before,))
81
+ con.execute("DELETE FROM sessions WHERE ended_at < ?", (args.before,))
82
+ print(f"purged events before {args.before}")
83
+ con.commit()
84
+ con.execute("VACUUM")
85
+ db.enforce_perms()
86
+ con.close()
87
+ return 0
88
+
89
+
90
+ def cmd_status(args):
91
+ con = db.connect()
92
+ print(f"\nconfig: {config.config_dir() / 'config.toml'}"
93
+ f"{'' if (config.config_dir() / 'config.toml').is_file() else ' (not found — opsroom init)'}")
94
+ print(f"data: {config.data_dir()}\n\nSOURCE STATUS")
95
+ for r in con.execute("SELECT * FROM watermarks WHERE source NOT LIKE 'git:%' ORDER BY source"):
96
+ print(f" {r['source']:<8} {r['status']:<10} last run {r['last_run'] or '-'}")
97
+ for r in con.execute("SELECT source, COUNT(*) c FROM events GROUP BY source"):
98
+ print(f" {r['source']:<8} {r['c']} events")
99
+ con.close()
100
+ return 0
101
+
102
+
103
+ def main():
104
+ p = argparse.ArgumentParser(prog="opsroom", description=__doc__)
105
+ sub = p.add_subparsers(dest="cmd") # no subcommand → `opsroom serve` (the app)
106
+ ip = sub.add_parser("init", help="interactive setup: ventures, goal, notes, trackers")
107
+ ip.add_argument("--yes", action="store_true", help="accept detected defaults, no prompts")
108
+ sp = sub.add_parser("sync", help="ingest all sources (read-only on sources)")
109
+ sp.add_argument("--source", help="comma-separated subset: cli,codex,git,fs,notes,chat")
110
+ sp.add_argument("--dry-run", action="store_true", help="parse + count, write nothing")
111
+ st = sub.add_parser("sitrep", help="operator SITREP: goal clock, cash, leads, pipeline, leak, one move")
112
+ st.add_argument("--write", action="store_true", help="append to the daily note (default: print only)")
113
+ sub.add_parser("dash", help="operator console (single local HTML file)").add_argument(
114
+ "--no-open", action="store_true")
115
+ vp2 = sub.add_parser("serve", help="the console as a live local app: buttons write back "
116
+ "(touches, follow-ups, cash, leads). Loopback only.")
117
+ vp2.add_argument("--port", type=int, default=7337)
118
+ vp2.add_argument("--no-open", action="store_true")
119
+ vp2.add_argument("--always-on", action="store_true",
120
+ help="macOS: install a launchd agent so the console survives reboots")
121
+ sub.add_parser("today", help="what did I actually do today").add_argument(
122
+ "--offset", type=int, default=0, help="days back")
123
+ sub.add_parser("week", help="7-day activity + drift")
124
+ lp = sub.add_parser("loops", help="open loops with evidence")
125
+ lp.add_argument("--all", action="store_true")
126
+ lp.add_argument("--dismiss", metavar="ID_PREFIX")
127
+ dp = sub.add_parser("drift", help="effort vs revenue for the week")
128
+ dp.add_argument("--weeks-ago", type=int, default=0)
129
+ sub.add_parser("venture", help="one venture's ledger").add_argument("name")
130
+ sub.add_parser("search", help="full-text search the ledger").add_argument("query", nargs="+")
131
+ vp = sub.add_parser("daily", help="append-only activity ledger into the daily note")
132
+ vp.add_argument("--write", action="store_true")
133
+ sub.add_parser("demo", help="spin up a fictional portfolio and open the console")
134
+ pp = sub.add_parser("purge", help="shrink the blast radius")
135
+ pp.add_argument("--source")
136
+ pp.add_argument("--before", metavar="ISO_DATE")
137
+ sub.add_parser("status", help="config + watermarks + row counts per source")
138
+ args = p.parse_args()
139
+
140
+ if args.cmd is None: # bare `opsroom` = the live console
141
+ from . import serve as _serve
142
+ return _serve.serve() or 0
143
+ if args.cmd == "init":
144
+ from . import setup
145
+ return setup.run(yes=args.yes)
146
+ if args.cmd == "demo":
147
+ from . import demo
148
+ return demo.run()
149
+ if args.cmd == "serve":
150
+ from . import serve as _serve
151
+ if args.always_on:
152
+ return 0 if _serve.install_always_on(port=args.port) else 1
153
+ return _serve.serve(port=args.port, open_browser=not args.no_open) or 0
154
+ if args.cmd == "sync":
155
+ return cmd_sync(args)
156
+ if args.cmd == "purge":
157
+ if not (args.source or args.before):
158
+ p.error("purge needs --source or --before")
159
+ return cmd_purge(args)
160
+ if args.cmd == "status":
161
+ return cmd_status(args)
162
+
163
+ con = db.connect()
164
+ try:
165
+ if args.cmd == "today":
166
+ views.today(con, args.offset)
167
+ elif args.cmd == "week":
168
+ views.week(con)
169
+ elif args.cmd == "loops":
170
+ if args.dismiss:
171
+ views.dismiss_loop(con, args.dismiss)
172
+ con.commit()
173
+ else:
174
+ views.loops(con, args.all)
175
+ elif args.cmd == "drift":
176
+ views.drift(con, args.weeks_ago)
177
+ elif args.cmd == "venture":
178
+ views.venture_view(con, args.name)
179
+ elif args.cmd == "search":
180
+ views.search(con, " ".join(args.query))
181
+ elif args.cmd == "dash":
182
+ path = views.dash(con)
183
+ if not args.no_open:
184
+ import subprocess
185
+ import sys as _s
186
+ opener = {"darwin": "open", "linux": "xdg-open"}.get(_s.platform)
187
+ if opener:
188
+ subprocess.run([opener, path], check=False)
189
+ elif args.cmd == "sitrep":
190
+ views.sitrep(con, write=args.write)
191
+ elif args.cmd == "daily":
192
+ views.daily_writeback(con, dry_run=not args.write)
193
+ finally:
194
+ con.close()
195
+ return 0
196
+
197
+
198
+ if __name__ == "__main__":
199
+ sys.exit(main())
@@ -0,0 +1,81 @@
1
+ """Shared collector plumbing: redact-gated event emission, per-file watermarks."""
2
+ import hashlib
3
+ import json
4
+ import sys
5
+ from datetime import datetime, timezone
6
+
7
+ from .. import redact as _redact
8
+
9
+ MAX_DETAIL = 4096
10
+
11
+
12
+ def norm_ts(ts: str) -> str:
13
+ """Normalize any ISO8601 timestamp (Z, ±hh:mm, or naive) to canonical UTC 'YYYY-MM-DDTHH:MM:SS.mmmZ'
14
+ so lexicographic SQL comparisons are correct across sources."""
15
+ dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
16
+ if dt.tzinfo is None:
17
+ dt = dt.replace(tzinfo=timezone.utc)
18
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
19
+
20
+
21
+ class Emitter:
22
+ """Buffers redacted events and writes them idempotently. Fail-closed on redaction errors."""
23
+
24
+ def __init__(self, con, dry_run: bool = False):
25
+ self.con = con
26
+ self.dry_run = dry_run
27
+ self.inserted = 0
28
+ self.seen = 0
29
+ self.dropped = 0
30
+
31
+ def emit(self, *, ts, source, kind, actor, summary, detail="", session_id=None,
32
+ venture=None, project=None, artifacts=None, raw_ref=None, is_sidechain=0):
33
+ self.seen += 1
34
+ try:
35
+ ts = norm_ts(ts)
36
+ except (ValueError, TypeError):
37
+ self.dropped += 1
38
+ return
39
+ try:
40
+ # redact BEFORE truncating: a secret straddling the cut would otherwise be
41
+ # sliced so the pattern no longer matches, storing its unredacted head.
42
+ rs, h1 = _redact.redact(summary or "")
43
+ rd, h2 = _redact.redact(detail or "")
44
+ summary, detail = rs[:512], rd[:MAX_DETAIL]
45
+ redacted = 1 if (h1 + h2) else 0
46
+ except Exception as e: # fail closed: never write unredacted content
47
+ self.dropped += 1
48
+ print(f" [redactor] dropped event ({source}/{kind}): {type(e).__name__}", file=sys.stderr)
49
+ return
50
+ eid = hashlib.sha256(
51
+ f"{source}|{session_id}|{ts}|{kind}|{raw_ref}|{summary[:200]}".encode()).hexdigest()
52
+ if self.dry_run:
53
+ self.inserted += 1
54
+ return
55
+ cur = self.con.execute(
56
+ """INSERT OR IGNORE INTO events
57
+ (id, ts, source, session_id, venture, project, kind, actor, summary, detail,
58
+ artifacts, raw_ref, is_sidechain, redacted)
59
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
60
+ (eid, ts, source, session_id, venture, project, kind, actor, summary, detail,
61
+ json.dumps(artifacts) if artifacts else None, raw_ref, is_sidechain, redacted))
62
+ self.inserted += cur.rowcount
63
+
64
+
65
+ def file_changed(con, path: str, mtime: float, size: int):
66
+ """Return (changed, lines_already_ingested). Reparse fully if the file shrank."""
67
+ row = con.execute("SELECT mtime, size, lines FROM file_state WHERE path=?", (path,)).fetchone()
68
+ if row is None:
69
+ return True, 0
70
+ if row["mtime"] == mtime and row["size"] == size:
71
+ return False, row["lines"]
72
+ if size < row["size"]:
73
+ return True, 0 # truncated/rewritten: reparse
74
+ return True, row["lines"]
75
+
76
+
77
+ def record_file(con, path: str, mtime: float, size: int, lines: int):
78
+ con.execute(
79
+ """INSERT INTO file_state(path, mtime, size, lines) VALUES (?,?,?,?)
80
+ ON CONFLICT(path) DO UPDATE SET mtime=excluded.mtime, size=excluded.size, lines=excluded.lines""",
81
+ (path, mtime, size, lines))
@@ -0,0 +1,126 @@
1
+ """Collector: Desktop/web chat via the vendor data-export flow (manual drop).
2
+ Drop conversations.json (or the export .zip) into the configured chat_drop_dir —
3
+ Anthropic (Claude) and OpenAI (ChatGPT) exports are both recognized by shape.
4
+ The zip is deleted after successful ingest (spec P0 #7); extracted JSON is kept out of
5
+ sync roots and removed too."""
6
+ import json
7
+ import zipfile
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from .. import ventures
12
+ from . import Emitter
13
+
14
+ DROP_DIR = Path(ventures.CHAT_DROP_DIR)
15
+
16
+
17
+ def _ingest_conversations(em: Emitter, data: list, ref: str) -> int:
18
+ n = 0
19
+ for conv in data:
20
+ cid = conv.get("uuid") or conv.get("id") or "?"
21
+ name = conv.get("name") or "(untitled chat)"
22
+ venture = ventures.attribute_text(name)
23
+ for i, m in enumerate(conv.get("chat_messages", [])):
24
+ text = m.get("text")
25
+ if not text and isinstance(m.get("content"), list):
26
+ text = "\n".join(c.get("text", "") for c in m["content"]
27
+ if isinstance(c, dict) and c.get("type") == "text")
28
+ text = (text or "").strip()
29
+ if not text:
30
+ continue
31
+ sender = m.get("sender", "?")
32
+ ts = m.get("created_at") or conv.get("created_at") or datetime.now(timezone.utc).isoformat()
33
+ v = venture if venture != "unknown" else ventures.attribute_text(text[:500])
34
+ em.emit(ts=ts, source="chat", kind="prompt" if sender == "human" else "response",
35
+ actor="you" if sender == "human" else "assistant",
36
+ session_id=f"chat:{cid}", venture=v, project=name[:60],
37
+ summary=text.strip().split("\n")[0][:180], detail=text,
38
+ raw_ref=f"{ref}#conv={cid}&msg={i}")
39
+ n += 1
40
+ return n
41
+
42
+
43
+ def _openai_text(msg: dict) -> str:
44
+ content = msg.get("content") or {}
45
+ if content.get("content_type") not in (None, "text", "multimodal_text"):
46
+ return ""
47
+ parts = content.get("parts") or []
48
+ return "\n".join(p for p in parts if isinstance(p, str)).strip()
49
+
50
+
51
+ def _ingest_openai(em: Emitter, data: list, ref: str) -> int:
52
+ """OpenAI ChatGPT export: each conversation is a mapping-tree of nodes."""
53
+ n = 0
54
+ for conv in data:
55
+ cid = conv.get("conversation_id") or conv.get("id") or "?"
56
+ name = conv.get("title") or "(untitled chat)"
57
+ venture = ventures.attribute_text(name)
58
+ nodes = conv.get("mapping") or {}
59
+ msgs = []
60
+ for node in nodes.values():
61
+ m = (node or {}).get("message")
62
+ if not isinstance(m, dict):
63
+ continue
64
+ role = ((m.get("author") or {}).get("role"))
65
+ if role not in ("user", "assistant"):
66
+ continue
67
+ text = _openai_text(m)
68
+ if not text:
69
+ continue
70
+ msgs.append((m.get("create_time") or conv.get("create_time") or 0, role, text, m.get("id", "?")))
71
+ for ct, role, text, mid in sorted(msgs):
72
+ ts = datetime.fromtimestamp(ct, timezone.utc).isoformat() if ct else \
73
+ datetime.now(timezone.utc).isoformat()
74
+ v = venture if venture != "unknown" else ventures.attribute_text(text[:500])
75
+ em.emit(ts=ts, source="chat", kind="prompt" if role == "user" else "response",
76
+ actor="you" if role == "user" else "chatgpt",
77
+ session_id=f"chatgpt:{cid}", venture=v, project=name[:60],
78
+ summary=text.split("\n")[0][:180], detail=text,
79
+ raw_ref=f"{ref}#conv={cid}&msg={mid}")
80
+ n += 1
81
+ return n
82
+
83
+
84
+ def _ingest_any(em: Emitter, data, ref: str) -> int:
85
+ """Sniff export format: Anthropic conversations carry chat_messages, OpenAI carry mapping."""
86
+ if not isinstance(data, list):
87
+ return 0
88
+ if any(isinstance(c, dict) and "mapping" in c for c in data):
89
+ return _ingest_openai(em, data, ref)
90
+ return _ingest_conversations(em, data, ref)
91
+
92
+
93
+ def collect(con, dry_run: bool = False) -> dict:
94
+ em = Emitter(con, dry_run)
95
+ if not DROP_DIR.is_dir():
96
+ return {"status": "no drop dir", "events_new": 0, "events_seen": 0, "dropped": 0}
97
+ ingested, cleanup = [], []
98
+ for f in sorted(DROP_DIR.iterdir()):
99
+ try:
100
+ if f.suffix == ".json" and "conversation" in f.name.lower():
101
+ data = json.loads(f.read_text(errors="replace"))
102
+ _ingest_any(em, data, str(f))
103
+ ingested.append(f.name)
104
+ cleanup.append(f)
105
+ elif f.suffix == ".zip":
106
+ with zipfile.ZipFile(f) as z:
107
+ for info in z.infolist():
108
+ if info.filename.endswith("conversations.json"):
109
+ if info.file_size > 500_000_000: # decompression-bomb cap
110
+ print(f" [chat] skipped {info.filename}: "
111
+ f"{info.file_size // 1_000_000}MB exceeds 500MB cap")
112
+ continue
113
+ data = json.loads(z.read(info).decode(errors="replace"))
114
+ _ingest_any(em, data, f"{f}!{info.filename}")
115
+ ingested.append(f"{f.name}!{info.filename}")
116
+ cleanup.append(f)
117
+ except Exception as e:
118
+ print(f" [chat] failed on {f.name}: {e}")
119
+ if not dry_run:
120
+ for f in cleanup:
121
+ try:
122
+ f.unlink() # spec: delete export after ingest to shrink blast radius
123
+ except OSError:
124
+ pass
125
+ return {"ingested": ingested, "events_new": em.inserted, "events_seen": em.seen,
126
+ "dropped": em.dropped}
@@ -0,0 +1,165 @@
1
+ """Collector: Claude Code CLI session logs (~/.claude/projects/**/*.jsonl) + task state
2
+ (~/.claude/tasks/<sessionId>/*.json). Strictly read-only on sources."""
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from .. import ventures
8
+ from . import Emitter, file_changed, record_file
9
+
10
+ CLAUDE_DIR = Path.home() / ".claude"
11
+ FILE_TOOLS = {"Edit", "Write", "NotebookEdit", "Read"}
12
+ EDIT_TOOLS = {"Edit", "Write", "NotebookEdit"}
13
+
14
+
15
+ def _project_of(cwd: str) -> str:
16
+ if not cwd:
17
+ return "?"
18
+ home = str(Path.home())
19
+ rel = cwd[len(home):].strip("/") if cwd.startswith(home) else cwd
20
+ return rel or "~"
21
+
22
+
23
+ def _first_line(s: str, n: int = 180) -> str:
24
+ return (s or "").strip().split("\n")[0][:n]
25
+
26
+
27
+ def _blocks(msg) -> list:
28
+ c = (msg or {}).get("content")
29
+ if isinstance(c, str):
30
+ return [{"type": "text", "text": c}]
31
+ return c if isinstance(c, list) else []
32
+
33
+
34
+ def _tool_paths(name: str, inp: dict) -> list:
35
+ paths = []
36
+ if isinstance(inp, dict):
37
+ for k in ("file_path", "path", "notebook_path"):
38
+ if isinstance(inp.get(k), str):
39
+ paths.append(inp[k])
40
+ return paths
41
+
42
+
43
+ def _parse_line(em: Emitter, d: dict, raw_ref: str) -> None:
44
+ t = d.get("type")
45
+ if t not in ("user", "assistant") or d.get("isMeta"):
46
+ return
47
+ ts = d.get("timestamp")
48
+ if not ts:
49
+ return
50
+ base = dict(ts=ts, source="cli", session_id=d.get("sessionId"),
51
+ venture=ventures.attribute(d.get("cwd", "")),
52
+ project=_project_of(d.get("cwd", "")), raw_ref=raw_ref,
53
+ is_sidechain=1 if d.get("isSidechain") else 0)
54
+ msg = d.get("message") or {}
55
+ if t == "user":
56
+ texts, results = [], []
57
+ for b in _blocks(msg):
58
+ if not isinstance(b, dict):
59
+ continue
60
+ if b.get("type") == "text":
61
+ texts.append(b.get("text") or "")
62
+ elif b.get("type") == "tool_result":
63
+ results.append(b)
64
+ text = "\n".join(x for x in texts if x).strip()
65
+ if text and not text.startswith(("<system-reminder", "<command-name", "<local-command")):
66
+ em.emit(kind="prompt", actor="you", summary=_first_line(text), detail=text, **base)
67
+ for r in results:
68
+ content = r.get("content")
69
+ if isinstance(content, list):
70
+ content = "\n".join(str(x.get("text", "")) for x in content if isinstance(x, dict))
71
+ content = str(content or "")
72
+ failed = bool(r.get("is_error"))
73
+ em.emit(kind="error" if failed else "tool_result", actor="claude",
74
+ summary=("FAILED: " if failed else "") + _first_line(content),
75
+ detail=content, **base)
76
+ else: # assistant
77
+ if d.get("isApiErrorMessage"):
78
+ em.emit(kind="error", actor="claude",
79
+ summary=f"API error {d.get('apiErrorStatus', '?')}", **base)
80
+ return
81
+ for b in _blocks(msg):
82
+ if not isinstance(b, dict):
83
+ continue
84
+ if b.get("type") == "text" and (b.get("text") or "").strip():
85
+ em.emit(kind="response", actor="claude", summary=_first_line(b["text"]),
86
+ detail=b["text"], **base)
87
+ elif b.get("type") == "tool_use":
88
+ name = b.get("name", "?")
89
+ inp = b.get("input") or {}
90
+ paths = _tool_paths(name, inp)
91
+ kind = "file_edit" if name in EDIT_TOOLS else "tool_call"
92
+ hint = paths[0] if paths else _first_line(json.dumps(inp)[:150], 120)
93
+ em.emit(kind=kind, actor="claude", summary=f"{name}: {hint}",
94
+ artifacts=paths or None, **base)
95
+
96
+
97
+ def _collect_tasks(em: Emitter, con, session_meta: dict) -> None:
98
+ tasks_dir = CLAUDE_DIR / "tasks"
99
+ if not tasks_dir.is_dir():
100
+ return
101
+ for f in tasks_dir.glob("*/*.json"):
102
+ st = f.stat()
103
+ changed, _ = file_changed(con, str(f), st.st_mtime, st.st_size)
104
+ if not changed and not em.dry_run:
105
+ continue
106
+ try:
107
+ d = json.loads(f.read_text(errors="replace"))
108
+ except Exception:
109
+ continue
110
+ sid = f.parent.name
111
+ meta = session_meta.get(sid) or _session_meta_from_db(con, sid)
112
+ ts = datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat()
113
+ em.emit(ts=ts, source="cli", kind="task", actor="claude", session_id=sid,
114
+ venture=meta.get("venture", "unknown"), project=meta.get("project", "?"),
115
+ summary=f"[{d.get('status', '?')}] {d.get('subject', '?')}",
116
+ detail=d.get("description", ""), raw_ref=str(f))
117
+ if not em.dry_run:
118
+ record_file(con, str(f), st.st_mtime, st.st_size, 1)
119
+
120
+
121
+ def _session_meta_from_db(con, sid: str) -> dict:
122
+ row = con.execute(
123
+ "SELECT venture, project FROM events WHERE session_id=? AND venture!='unknown' LIMIT 1",
124
+ (sid,)).fetchone()
125
+ return dict(row) if row else {}
126
+
127
+
128
+ def collect(con, dry_run: bool = False) -> dict:
129
+ em = Emitter(con, dry_run)
130
+ files = sorted((CLAUDE_DIR / "projects").glob("*/*.jsonl"))
131
+ parsed_files = 0
132
+ session_meta = {}
133
+ for f in files:
134
+ st = f.stat()
135
+ changed, start_line = file_changed(con, str(f), st.st_mtime, st.st_size)
136
+ if not changed:
137
+ continue
138
+ parsed_files += 1
139
+ lineno = 0
140
+ try:
141
+ with open(f, errors="replace") as fh:
142
+ for lineno, line in enumerate(fh, 1):
143
+ if lineno <= start_line:
144
+ continue
145
+ line = line.strip()
146
+ if not line:
147
+ continue
148
+ try:
149
+ d = json.loads(line)
150
+ except json.JSONDecodeError:
151
+ continue
152
+ if d.get("sessionId") and d.get("cwd"):
153
+ session_meta[d["sessionId"]] = {
154
+ "venture": ventures.attribute(d["cwd"]),
155
+ "project": _project_of(d["cwd"])}
156
+ _parse_line(em, d, f"{f}:{lineno}")
157
+ except OSError as e:
158
+ print(f" [cli] unreadable {f}: {e}")
159
+ continue
160
+ if not dry_run:
161
+ record_file(con, str(f), st.st_mtime, st.st_size, lineno)
162
+ _collect_tasks(em, con, session_meta)
163
+ return {"files_scanned": len(files), "files_parsed": parsed_files,
164
+ "sessions_seen": len(session_meta),
165
+ "events_new": em.inserted, "events_seen": em.seen, "dropped": em.dropped}