opsroom-console 0.3.0__tar.gz

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.
Files changed (37) hide show
  1. opsroom_console-0.3.0/LICENSE +21 -0
  2. opsroom_console-0.3.0/PKG-INFO +144 -0
  3. opsroom_console-0.3.0/README.md +120 -0
  4. opsroom_console-0.3.0/opsroom/__init__.py +2 -0
  5. opsroom_console-0.3.0/opsroom/cli.py +199 -0
  6. opsroom_console-0.3.0/opsroom/collectors/__init__.py +81 -0
  7. opsroom_console-0.3.0/opsroom/collectors/chat.py +126 -0
  8. opsroom_console-0.3.0/opsroom/collectors/cli.py +165 -0
  9. opsroom_console-0.3.0/opsroom/collectors/codex.py +132 -0
  10. opsroom_console-0.3.0/opsroom/collectors/fs.py +54 -0
  11. opsroom_console-0.3.0/opsroom/collectors/git.py +165 -0
  12. opsroom_console-0.3.0/opsroom/collectors/notes.py +72 -0
  13. opsroom_console-0.3.0/opsroom/config.py +131 -0
  14. opsroom_console-0.3.0/opsroom/dashboard.py +542 -0
  15. opsroom_console-0.3.0/opsroom/db.py +131 -0
  16. opsroom_console-0.3.0/opsroom/demo.py +214 -0
  17. opsroom_console-0.3.0/opsroom/enrich.py +304 -0
  18. opsroom_console-0.3.0/opsroom/ops.py +205 -0
  19. opsroom_console-0.3.0/opsroom/redact.py +77 -0
  20. opsroom_console-0.3.0/opsroom/serve.py +257 -0
  21. opsroom_console-0.3.0/opsroom/setup.py +84 -0
  22. opsroom_console-0.3.0/opsroom/state.py +454 -0
  23. opsroom_console-0.3.0/opsroom/ventures.py +82 -0
  24. opsroom_console-0.3.0/opsroom/views.py +294 -0
  25. opsroom_console-0.3.0/opsroom_console.egg-info/PKG-INFO +144 -0
  26. opsroom_console-0.3.0/opsroom_console.egg-info/SOURCES.txt +35 -0
  27. opsroom_console-0.3.0/opsroom_console.egg-info/dependency_links.txt +1 -0
  28. opsroom_console-0.3.0/opsroom_console.egg-info/entry_points.txt +2 -0
  29. opsroom_console-0.3.0/opsroom_console.egg-info/top_level.txt +1 -0
  30. opsroom_console-0.3.0/pyproject.toml +37 -0
  31. opsroom_console-0.3.0/setup.cfg +4 -0
  32. opsroom_console-0.3.0/tests/test_chat_openai.py +92 -0
  33. opsroom_console-0.3.0/tests/test_codex.py +98 -0
  34. opsroom_console-0.3.0/tests/test_demo.py +31 -0
  35. opsroom_console-0.3.0/tests/test_redaction.py +69 -0
  36. opsroom_console-0.3.0/tests/test_serve.py +112 -0
  37. opsroom_console-0.3.0/tests/test_state.py +160 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Malek Adjeb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: opsroom-console
3
+ Version: 0.3.0
4
+ Summary: Local-first operator console: fuses your AI agent sessions (Claude Code, Codex CLI, ChatGPT), git repos, and markdown notes into one dashboard that tells you what to do next.
5
+ Author: Malek Adjeb
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/malekadjeb-ai/opsroom
8
+ Project-URL: Issues, https://github.com/malekadjeb-ai/opsroom/issues
9
+ Keywords: dashboard,productivity,founder,claude-code,codex,chatgpt,ai-agents,local-first,obsidian,time-tracking
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Office/Business
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # ⚡ opsroom
26
+
27
+ ![ci](https://github.com/malekadjeb-ai/opsroom/actions/workflows/ci.yml/badge.svg)
28
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
29
+ ![deps](https://img.shields.io/badge/dependencies-zero-brightgreen)
30
+ ![license](https://img.shields.io/badge/license-MIT-green)
31
+
32
+ **Your AI agents write code all day. opsroom shows you whether any of it pays.**
33
+
34
+ ![opsroom demo — sitrep, by-agent, trap-zone alert](docs/launch/demo.gif)
35
+
36
+ One console for **every AI coding agent you run** — Claude Code, OpenAI Codex CLI, ChatGPT, Claude web — plus your **git repos** and your **markdown notes** (Obsidian or plain folders), fused into one local dashboard that answers the only question that matters:
37
+
38
+ > *What should I do right now to make money?*
39
+
40
+ No cloud. No accounts. No dependencies. One Python package, one SQLite file on your disk, one HTML file as your console.
41
+
42
+ ```
43
+ pipx install git+https://github.com/malekadjeb-ai/opsroom # or: uv tool install git+https://github.com/malekadjeb-ai/opsroom
44
+ opsroom demo # see a fully loaded console in 10 seconds (fictional data)
45
+ opsroom init # wire up YOUR repos, goal, and notes
46
+ opsroom sync && opsroom dash
47
+ ```
48
+
49
+ ## What you get
50
+
51
+ **🎯 NOW — an action queue, not a report.** Drafts staged in your outreach tracker become a send list with an open-your-drafts button. Phone-first targets become tap-to-dial links. Stale leads become a rescue block. The single highest cash action sits on top, pulled from your own dashboard note.
52
+
53
+ ![opsroom console — the NOW action queue](docs/launch/console-now.png)
54
+
55
+ **🏢 VENTURES — every project, with a brief.** Click any venture: an ordered **DO NEXT** list generated from live pipeline state ("UNBLOCK FIRST: …" when your notes say something's blocking), a **DONE** timeline auto-pulled from your decisions log, live numbers, and every researched target as a searchable click-to-open card — pain hypothesis, decision-makers, phones linked.
56
+
57
+ **💰 MONEY — the goal math.** Collected vs goal, days left, needed per day. Cash counts only when collected, not quoted.
58
+
59
+ **📊 ACTIVITY — where your time actually went, by agent.** A **BY AGENT** table (Claude vs Codex vs ChatGPT: sessions, active hours, top venture), sessions per venture, commits, and the two features that hurt (in a good way):
60
+
61
+ ![opsroom ACTIVITY — by agent, drift, open loops](docs/launch/console-activity.png)
62
+
63
+ - **Trap-zone drift**: time spent in $0-revenue builds vs revenue ventures, with a red alert when building beats selling this week. The engineer's trap, made visible.
64
+ - **Open loops**: things you started and silently dropped — plan language in AI transcripts with no follow-up commit, stale branches, uncommitted work, in-progress notes going stale.
65
+
66
+ ## The sitrep
67
+
68
+ ```
69
+ $ opsroom sitrep
70
+
71
+ SITREP · 2026-07-16
72
+
73
+ - DATE / DAYS TO GOAL: 2026-07-16 / 19d
74
+ - CASH COLLECTED vs Q3 $50K sprint: $8,250 collected
75
+ - OPEN LEADS: ~14 aged ~8d
76
+ - LIVE PIPELINE: Meridian: 2 proposals out ($12K + $18K) · 2 drafted, 1 sent, 2 call sheet
77
+ - TOP LEAK RIGHT NOW: ~14 open leads aged ~8d — paid-for money rotting uncalled
78
+ - SINGLE HIGHEST CASH ACTION TODAY: Finish the case-study PDF and send both proposals
79
+ ```
80
+
81
+ Six lines, every morning, from live data. `--write` appends it to a daily note.
82
+
83
+ ## How it works
84
+
85
+ Read-only collectors, one local SQLite ledger, deterministic rendering:
86
+
87
+ | Source | What it reads |
88
+ |---|---|
89
+ | `cli` | Claude Code session logs (`~/.claude/projects`) — prompts, duration, outcomes |
90
+ | `codex` | OpenAI Codex CLI session logs (`~/.codex/sessions`) — same treatment, same attribution |
91
+ | `git` | every repo under your scan roots — commits, branches, uncommitted work |
92
+ | `fs` | mtime scan for non-git work |
93
+ | `notes` | your markdown roots — frontmatter, staleness, your dashboard note's `## Live state` table |
94
+ | `chat` | manual drop of a chat export — **both** Claude (Anthropic) and ChatGPT (OpenAI) exports, sniffed by shape |
95
+
96
+ Attribution is by path: work done in `~/code/acme` counts toward venture `acme`. Sessions are gap-capped active minutes, not wall clock.
97
+
98
+ ### Your notes are the API
99
+
100
+ opsroom doesn't make you adopt a system — it reads simple markdown conventions you can start using in five minutes:
101
+
102
+ - A dashboard note with a `## Live state` table (`| Metric | Value | As of |`) — rows are matched by key words (`days to goal`, `cash`, anything with `lead`), never by position, so reformatting won't break it. Ages keep ticking: "aged ~6 days" as-of last week reads correctly today.
103
+ - A `## Today's one move` section → the console's hero action.
104
+ - Pipeline trackers with `## Totals` lines and `## TOUCH LOG` tables (`Target | Channel | Status | Next`) → your send/call queues. Any wide table in the same file → your searchable target list.
105
+ - A `Decisions Log.md` with `- **YYYY-MM-DD** — …` bullets → per-venture DONE timelines.
106
+
107
+ ## Security posture
108
+
109
+ This tool reads your terminal history and notes, so it's built paranoid:
110
+
111
+ - **Fail-closed secret redaction** — every event passes a redactor *before* touching the DB (API keys, AWS/GitHub/Slack/Stripe/Google tokens, JWTs, DB URIs, private keys, high-entropy `KEY=value`). If redaction errors, the event is dropped, never written.
112
+ - **No network egress. Ever.** Nothing phones home; the console HTML loads zero external resources. The only URLs are ones *you* configured as buttons.
113
+ - **Local only** — SQLite at `~/.local/share/opsroom` with `600` perms, refuses to live under a cloud-sync root (iCloud/Dropbox/OneDrive).
114
+ - **Read-only on all sources** — your notes are never written; the only writes are the ledger and an optional append-only daily note.
115
+ - `opsroom purge --source=X` / `--before=DATE` to shrink the blast radius any time.
116
+
117
+ ## Philosophy
118
+
119
+ Built during a 23-day cash sprint by an operator running eight ventures, out of one frustration: every productivity tool tracks *activity*; none of them track *whether the activity pays*. The rules encoded here are blunt:
120
+
121
+ 1. Cash counts when **collected** — not quoted, not booked, not "basically closed".
122
+ 2. Building instead of selling is a **trap** — visible on the board, hours counted.
123
+ 3. Everything you start and drop is a **loop** — it stays on the board until closed or dismissed.
124
+ 4. The console must answer *"what do I do next"* with names, numbers, and buttons — or it's just letters on a screen.
125
+
126
+ ## FAQ
127
+
128
+ **Do I need Claude Code / Codex / Obsidian?** No. Every source is optional and degrades gracefully. Git repos alone give you drift + loops + sessions; add notes when you want the money features.
129
+
130
+ **Which AI agents are supported?** Claude Code and OpenAI Codex CLI are read live from their local logs. ChatGPT and Claude web/desktop ingest via each vendor's data-export file dropped in a folder. Gemini CLI and Cursor are next — collectors are ~100-line files with one `collect(con)` entry point, PRs welcome.
131
+
132
+ **macOS says the notes are unreadable.** Grant your terminal Full Disk Access. If access drops mid-session, opsroom serves the last cached snapshot and says so.
133
+
134
+ **Windows?** WSL works. Native Windows is untested.
135
+
136
+ ## Development
137
+
138
+ ```
139
+ git clone https://github.com/malekadjeb-ai/opsroom && cd opsroom
140
+ for t in tests/test_*.py; do python "$t" || break; done # five gates, all must print green
141
+ python -m opsroom.cli demo
142
+ ```
143
+
144
+ Stdlib only — there is nothing to `pip install` for development. MIT license.
@@ -0,0 +1,120 @@
1
+ # ⚡ opsroom
2
+
3
+ ![ci](https://github.com/malekadjeb-ai/opsroom/actions/workflows/ci.yml/badge.svg)
4
+ ![python](https://img.shields.io/badge/python-3.11%2B-blue)
5
+ ![deps](https://img.shields.io/badge/dependencies-zero-brightgreen)
6
+ ![license](https://img.shields.io/badge/license-MIT-green)
7
+
8
+ **Your AI agents write code all day. opsroom shows you whether any of it pays.**
9
+
10
+ ![opsroom demo — sitrep, by-agent, trap-zone alert](docs/launch/demo.gif)
11
+
12
+ One console for **every AI coding agent you run** — Claude Code, OpenAI Codex CLI, ChatGPT, Claude web — plus your **git repos** and your **markdown notes** (Obsidian or plain folders), fused into one local dashboard that answers the only question that matters:
13
+
14
+ > *What should I do right now to make money?*
15
+
16
+ No cloud. No accounts. No dependencies. One Python package, one SQLite file on your disk, one HTML file as your console.
17
+
18
+ ```
19
+ pipx install git+https://github.com/malekadjeb-ai/opsroom # or: uv tool install git+https://github.com/malekadjeb-ai/opsroom
20
+ opsroom demo # see a fully loaded console in 10 seconds (fictional data)
21
+ opsroom init # wire up YOUR repos, goal, and notes
22
+ opsroom sync && opsroom dash
23
+ ```
24
+
25
+ ## What you get
26
+
27
+ **🎯 NOW — an action queue, not a report.** Drafts staged in your outreach tracker become a send list with an open-your-drafts button. Phone-first targets become tap-to-dial links. Stale leads become a rescue block. The single highest cash action sits on top, pulled from your own dashboard note.
28
+
29
+ ![opsroom console — the NOW action queue](docs/launch/console-now.png)
30
+
31
+ **🏢 VENTURES — every project, with a brief.** Click any venture: an ordered **DO NEXT** list generated from live pipeline state ("UNBLOCK FIRST: …" when your notes say something's blocking), a **DONE** timeline auto-pulled from your decisions log, live numbers, and every researched target as a searchable click-to-open card — pain hypothesis, decision-makers, phones linked.
32
+
33
+ **💰 MONEY — the goal math.** Collected vs goal, days left, needed per day. Cash counts only when collected, not quoted.
34
+
35
+ **📊 ACTIVITY — where your time actually went, by agent.** A **BY AGENT** table (Claude vs Codex vs ChatGPT: sessions, active hours, top venture), sessions per venture, commits, and the two features that hurt (in a good way):
36
+
37
+ ![opsroom ACTIVITY — by agent, drift, open loops](docs/launch/console-activity.png)
38
+
39
+ - **Trap-zone drift**: time spent in $0-revenue builds vs revenue ventures, with a red alert when building beats selling this week. The engineer's trap, made visible.
40
+ - **Open loops**: things you started and silently dropped — plan language in AI transcripts with no follow-up commit, stale branches, uncommitted work, in-progress notes going stale.
41
+
42
+ ## The sitrep
43
+
44
+ ```
45
+ $ opsroom sitrep
46
+
47
+ SITREP · 2026-07-16
48
+
49
+ - DATE / DAYS TO GOAL: 2026-07-16 / 19d
50
+ - CASH COLLECTED vs Q3 $50K sprint: $8,250 collected
51
+ - OPEN LEADS: ~14 aged ~8d
52
+ - LIVE PIPELINE: Meridian: 2 proposals out ($12K + $18K) · 2 drafted, 1 sent, 2 call sheet
53
+ - TOP LEAK RIGHT NOW: ~14 open leads aged ~8d — paid-for money rotting uncalled
54
+ - SINGLE HIGHEST CASH ACTION TODAY: Finish the case-study PDF and send both proposals
55
+ ```
56
+
57
+ Six lines, every morning, from live data. `--write` appends it to a daily note.
58
+
59
+ ## How it works
60
+
61
+ Read-only collectors, one local SQLite ledger, deterministic rendering:
62
+
63
+ | Source | What it reads |
64
+ |---|---|
65
+ | `cli` | Claude Code session logs (`~/.claude/projects`) — prompts, duration, outcomes |
66
+ | `codex` | OpenAI Codex CLI session logs (`~/.codex/sessions`) — same treatment, same attribution |
67
+ | `git` | every repo under your scan roots — commits, branches, uncommitted work |
68
+ | `fs` | mtime scan for non-git work |
69
+ | `notes` | your markdown roots — frontmatter, staleness, your dashboard note's `## Live state` table |
70
+ | `chat` | manual drop of a chat export — **both** Claude (Anthropic) and ChatGPT (OpenAI) exports, sniffed by shape |
71
+
72
+ Attribution is by path: work done in `~/code/acme` counts toward venture `acme`. Sessions are gap-capped active minutes, not wall clock.
73
+
74
+ ### Your notes are the API
75
+
76
+ opsroom doesn't make you adopt a system — it reads simple markdown conventions you can start using in five minutes:
77
+
78
+ - A dashboard note with a `## Live state` table (`| Metric | Value | As of |`) — rows are matched by key words (`days to goal`, `cash`, anything with `lead`), never by position, so reformatting won't break it. Ages keep ticking: "aged ~6 days" as-of last week reads correctly today.
79
+ - A `## Today's one move` section → the console's hero action.
80
+ - Pipeline trackers with `## Totals` lines and `## TOUCH LOG` tables (`Target | Channel | Status | Next`) → your send/call queues. Any wide table in the same file → your searchable target list.
81
+ - A `Decisions Log.md` with `- **YYYY-MM-DD** — …` bullets → per-venture DONE timelines.
82
+
83
+ ## Security posture
84
+
85
+ This tool reads your terminal history and notes, so it's built paranoid:
86
+
87
+ - **Fail-closed secret redaction** — every event passes a redactor *before* touching the DB (API keys, AWS/GitHub/Slack/Stripe/Google tokens, JWTs, DB URIs, private keys, high-entropy `KEY=value`). If redaction errors, the event is dropped, never written.
88
+ - **No network egress. Ever.** Nothing phones home; the console HTML loads zero external resources. The only URLs are ones *you* configured as buttons.
89
+ - **Local only** — SQLite at `~/.local/share/opsroom` with `600` perms, refuses to live under a cloud-sync root (iCloud/Dropbox/OneDrive).
90
+ - **Read-only on all sources** — your notes are never written; the only writes are the ledger and an optional append-only daily note.
91
+ - `opsroom purge --source=X` / `--before=DATE` to shrink the blast radius any time.
92
+
93
+ ## Philosophy
94
+
95
+ Built during a 23-day cash sprint by an operator running eight ventures, out of one frustration: every productivity tool tracks *activity*; none of them track *whether the activity pays*. The rules encoded here are blunt:
96
+
97
+ 1. Cash counts when **collected** — not quoted, not booked, not "basically closed".
98
+ 2. Building instead of selling is a **trap** — visible on the board, hours counted.
99
+ 3. Everything you start and drop is a **loop** — it stays on the board until closed or dismissed.
100
+ 4. The console must answer *"what do I do next"* with names, numbers, and buttons — or it's just letters on a screen.
101
+
102
+ ## FAQ
103
+
104
+ **Do I need Claude Code / Codex / Obsidian?** No. Every source is optional and degrades gracefully. Git repos alone give you drift + loops + sessions; add notes when you want the money features.
105
+
106
+ **Which AI agents are supported?** Claude Code and OpenAI Codex CLI are read live from their local logs. ChatGPT and Claude web/desktop ingest via each vendor's data-export file dropped in a folder. Gemini CLI and Cursor are next — collectors are ~100-line files with one `collect(con)` entry point, PRs welcome.
107
+
108
+ **macOS says the notes are unreadable.** Grant your terminal Full Disk Access. If access drops mid-session, opsroom serves the last cached snapshot and says so.
109
+
110
+ **Windows?** WSL works. Native Windows is untested.
111
+
112
+ ## Development
113
+
114
+ ```
115
+ git clone https://github.com/malekadjeb-ai/opsroom && cd opsroom
116
+ for t in tests/test_*.py; do python "$t" || break; done # five gates, all must print green
117
+ python -m opsroom.cli demo
118
+ ```
119
+
120
+ Stdlib only — there is nothing to `pip install` for development. MIT license.
@@ -0,0 +1,2 @@
1
+ """opsroom — local-first operator console. See https://github.com/malekadjeb-ai/opsroom"""
2
+ __version__ = "0.3.0"
@@ -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))