pmkit 0.1.1__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.
pmkit/launch/store.py ADDED
@@ -0,0 +1,260 @@
1
+ """Launch-stage persistence: the state ledger + the mod-policy cache.
2
+
3
+ Shares the backlog SQLite DB (one file for the whole funnel) but owns two tables it creates
4
+ idempotently on open, mirroring ``backlog.py``'s bootstrap. Deterministic and unit-testable;
5
+ no network, no posting.
6
+
7
+ - ``launch_state`` — per (product, channel) ledger row: planned vs announced, where, when.
8
+ - ``mod_policy_cache`` — per (platform, community) cached verdict + cited rules + freshness.
9
+ This module only stores/reads the cache; the *staleness* decision (re-fetch past TTL) lives
10
+ in ``policy.py`` so the verdict logic stays in one place.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sqlite3
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Optional
20
+
21
+ from ..backlog import default_db_path
22
+
23
+ # The four launch channels (Reddit gets full mod-policy research; others get norm notes).
24
+ CHANNELS = ("reddit", "hackernews", "x", "linkedin")
25
+ LAUNCH_STATUSES = ("planned", "announced")
26
+ POLICY_VERDICTS = ("block", "warn", "ok", "unavailable")
27
+
28
+
29
+ def _now() -> str:
30
+ return datetime.now(timezone.utc).isoformat()
31
+
32
+
33
+ class LaunchStore:
34
+ """Connection wrapper over the launch tables in the shared backlog DB."""
35
+
36
+ def __init__(self, db_path: Optional[str | Path] = None) -> None:
37
+ self.db_path = Path(db_path) if db_path is not None else default_db_path()
38
+ if str(self.db_path) != ":memory:":
39
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
40
+ self.conn = sqlite3.connect(str(self.db_path))
41
+ self.conn.row_factory = sqlite3.Row
42
+ self._ensure_schema()
43
+
44
+ # ------------------------------------------------------------------ schema
45
+ def _ensure_schema(self) -> None:
46
+ self.conn.executescript(
47
+ """
48
+ CREATE TABLE IF NOT EXISTS launch_state (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ product TEXT NOT NULL,
51
+ channel TEXT NOT NULL,
52
+ status TEXT NOT NULL DEFAULT 'planned',
53
+ url TEXT,
54
+ artifact_version TEXT,
55
+ opportunity_id INTEGER,
56
+ announced_at TEXT,
57
+ created_at TEXT NOT NULL,
58
+ updated_at TEXT NOT NULL
59
+ );
60
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_launch_state_pc
61
+ ON launch_state(product, channel);
62
+
63
+ CREATE TABLE IF NOT EXISTS mod_policy_cache (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ platform TEXT NOT NULL,
66
+ community TEXT NOT NULL,
67
+ verdict TEXT NOT NULL,
68
+ cited_rules TEXT NOT NULL DEFAULT '[]',
69
+ fetched_at TEXT NOT NULL,
70
+ ttl_days INTEGER NOT NULL DEFAULT 30
71
+ );
72
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_mod_policy_pc
73
+ ON mod_policy_cache(platform, community);
74
+
75
+ -- Draft STARTING-POINTS only. There is deliberately no 'status'/'final'/
76
+ -- 'postable' column: the data model itself cannot represent a finished post,
77
+ -- so nothing here can ever be mistaken for one. The human writes the final.
78
+ CREATE TABLE IF NOT EXISTS launch_drafts (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ product TEXT NOT NULL,
81
+ platform TEXT NOT NULL,
82
+ community TEXT,
83
+ kind TEXT NOT NULL DEFAULT 'starting_point',
84
+ text TEXT NOT NULL,
85
+ critic_flagged INTEGER,
86
+ critic_score REAL,
87
+ critic TEXT,
88
+ created_at TEXT NOT NULL
89
+ );
90
+ """
91
+ )
92
+ self.conn.commit()
93
+
94
+ def close(self) -> None:
95
+ self.conn.close()
96
+
97
+ def __enter__(self) -> "LaunchStore":
98
+ return self
99
+
100
+ def __exit__(self, *exc: object) -> None:
101
+ self.close()
102
+
103
+ # ------------------------------------------------------------ state ledger
104
+ def record_state(
105
+ self,
106
+ product: str,
107
+ channel: str,
108
+ *,
109
+ status: str = "planned",
110
+ url: Optional[str] = None,
111
+ artifact_version: Optional[str] = None,
112
+ opportunity_id: Optional[int] = None,
113
+ ) -> int:
114
+ """Upsert a (product, channel) ledger row. Returns its id."""
115
+ if status not in LAUNCH_STATUSES:
116
+ raise ValueError(f"status must be one of {LAUNCH_STATUSES}, got {status!r}")
117
+ now = _now()
118
+ existing = self.conn.execute(
119
+ "SELECT id FROM launch_state WHERE product = ? AND channel = ?",
120
+ (product, channel),
121
+ ).fetchone()
122
+ announced_at = now if status == "announced" else None
123
+ if existing is not None:
124
+ self.conn.execute(
125
+ """
126
+ UPDATE launch_state
127
+ SET status = ?, url = COALESCE(?, url),
128
+ artifact_version = COALESCE(?, artifact_version),
129
+ opportunity_id = COALESCE(?, opportunity_id),
130
+ announced_at = COALESCE(?, announced_at),
131
+ updated_at = ?
132
+ WHERE id = ?
133
+ """,
134
+ (status, url, artifact_version, opportunity_id, announced_at, now,
135
+ int(existing["id"])),
136
+ )
137
+ self.conn.commit()
138
+ return int(existing["id"])
139
+ cur = self.conn.execute(
140
+ """
141
+ INSERT INTO launch_state
142
+ (product, channel, status, url, artifact_version, opportunity_id,
143
+ announced_at, created_at, updated_at)
144
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
145
+ """,
146
+ (product, channel, status, url, artifact_version, opportunity_id,
147
+ announced_at, now, now),
148
+ )
149
+ self.conn.commit()
150
+ return int(cur.lastrowid)
151
+
152
+ def announce(self, product: str, channel: str, url: Optional[str] = None) -> int:
153
+ """Mark a (product, channel) as announced (status='announced', stamps time)."""
154
+ return self.record_state(product, channel, status="announced", url=url)
155
+
156
+ def list_state(self, product: Optional[str] = None) -> list[dict]:
157
+ if product:
158
+ rows = self.conn.execute(
159
+ "SELECT * FROM launch_state WHERE product = ? ORDER BY channel",
160
+ (product,),
161
+ ).fetchall()
162
+ else:
163
+ rows = self.conn.execute(
164
+ "SELECT * FROM launch_state ORDER BY product, channel"
165
+ ).fetchall()
166
+ return [dict(r) for r in rows]
167
+
168
+ def status_counts(self, product: Optional[str] = None) -> dict[str, int]:
169
+ out = {s: 0 for s in LAUNCH_STATUSES}
170
+ for row in self.list_state(product):
171
+ out[row["status"]] = out.get(row["status"], 0) + 1
172
+ return out
173
+
174
+ # ------------------------------------------------------------ policy cache
175
+ def put_policy(
176
+ self,
177
+ platform: str,
178
+ community: str,
179
+ verdict: str,
180
+ cited_rules: list[dict],
181
+ ttl_days: int = 30,
182
+ ) -> None:
183
+ """Upsert a cached policy verdict for (platform, community)."""
184
+ if verdict not in POLICY_VERDICTS:
185
+ raise ValueError(f"verdict must be one of {POLICY_VERDICTS}, got {verdict!r}")
186
+ now = _now()
187
+ self.conn.execute(
188
+ """
189
+ INSERT INTO mod_policy_cache (platform, community, verdict, cited_rules,
190
+ fetched_at, ttl_days)
191
+ VALUES (?, ?, ?, ?, ?, ?)
192
+ ON CONFLICT(platform, community) DO UPDATE SET
193
+ verdict = excluded.verdict,
194
+ cited_rules = excluded.cited_rules,
195
+ fetched_at = excluded.fetched_at,
196
+ ttl_days = excluded.ttl_days
197
+ """,
198
+ (platform, community, verdict, json.dumps(cited_rules), now, int(ttl_days)),
199
+ )
200
+ self.conn.commit()
201
+
202
+ def get_policy(self, platform: str, community: str) -> Optional[dict]:
203
+ """Return the cached verdict row (cited_rules decoded), or None. Freshness is the
204
+ caller's call — the row carries ``fetched_at`` and ``ttl_days``."""
205
+ row = self.conn.execute(
206
+ "SELECT * FROM mod_policy_cache WHERE platform = ? AND community = ?",
207
+ (platform, community),
208
+ ).fetchone()
209
+ if row is None:
210
+ return None
211
+ d: dict[str, Any] = dict(row)
212
+ d["cited_rules"] = json.loads(d.get("cited_rules") or "[]")
213
+ return d
214
+
215
+ # ----------------------------------------------------------- draft storage
216
+ # NOTE: ``kind`` is hardcoded to 'starting_point' on insert and there is no setter to
217
+ # change it — the never-final guardrail is structural, not a convention.
218
+ def add_draft(
219
+ self,
220
+ product: str,
221
+ platform: str,
222
+ text: str,
223
+ *,
224
+ community: Optional[str] = None,
225
+ critic: Optional[dict] = None,
226
+ ) -> int:
227
+ cur = self.conn.execute(
228
+ """
229
+ INSERT INTO launch_drafts
230
+ (product, platform, community, kind, text,
231
+ critic_flagged, critic_score, critic, created_at)
232
+ VALUES (?, ?, ?, 'starting_point', ?, ?, ?, ?, ?)
233
+ """,
234
+ (
235
+ product, platform, community, text,
236
+ (1 if critic and critic.get("flagged") else 0) if critic else None,
237
+ (critic.get("score") if critic else None),
238
+ (json.dumps(critic) if critic else None),
239
+ _now(),
240
+ ),
241
+ )
242
+ self.conn.commit()
243
+ return int(cur.lastrowid)
244
+
245
+ def list_drafts(self, product: Optional[str] = None) -> list[dict]:
246
+ if product:
247
+ rows = self.conn.execute(
248
+ "SELECT * FROM launch_drafts WHERE product = ? ORDER BY id", (product,)
249
+ ).fetchall()
250
+ else:
251
+ rows = self.conn.execute(
252
+ "SELECT * FROM launch_drafts ORDER BY id"
253
+ ).fetchall()
254
+ out = []
255
+ for r in rows:
256
+ d = dict(r)
257
+ d["critic"] = json.loads(d["critic"]) if d.get("critic") else None
258
+ d["critic_flagged"] = bool(d["critic_flagged"]) if d["critic_flagged"] is not None else None
259
+ out.append(d)
260
+ return out
pmkit/rice.py ADDED
@@ -0,0 +1,54 @@
1
+ """RICE value-per-effort scoring.
2
+
3
+ The composite is the classic RICE form: (reach * impact * confidence) / effort.
4
+
5
+ - reach: how many users/instances feel this (>= 0)
6
+ - impact: per-instance value when addressed (>= 0)
7
+ - confidence: 0..1 multiplier on how trustworthy the estimate is
8
+ - effort: cost to build, in arbitrary effort units (> 0)
9
+
10
+ Sub-scores are stored separately on the backlog item so the weighting can become
11
+ tunable later (a deferred goal); this module is the single source of truth for the
12
+ composite so the CLI and the reranker agree.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+
18
+ class RiceError(ValueError):
19
+ """Raised when RICE inputs are invalid (e.g., non-positive effort)."""
20
+
21
+
22
+ def compute_rice(reach: float, impact: float, confidence: float, effort: float) -> float:
23
+ """Return the RICE composite. Effort must be > 0; confidence is clamped to 0..1.
24
+
25
+ Raises RiceError on non-positive effort (division would be undefined) or
26
+ negative reach/impact.
27
+ """
28
+ if effort is None or effort <= 0:
29
+ raise RiceError(f"effort must be > 0, got {effort!r}")
30
+ if reach is None or reach < 0:
31
+ raise RiceError(f"reach must be >= 0, got {reach!r}")
32
+ if impact is None or impact < 0:
33
+ raise RiceError(f"impact must be >= 0, got {impact!r}")
34
+ conf = _clamp01(confidence)
35
+ return (float(reach) * float(impact) * conf) / float(effort)
36
+
37
+
38
+ def _clamp01(value: float) -> float:
39
+ if value is None:
40
+ return 0.0
41
+ return max(0.0, min(1.0, float(value)))
42
+
43
+
44
+ def rank(items: list[dict]) -> list[dict]:
45
+ """Return items ordered by their ``rice`` value, highest first, None last.
46
+
47
+ Pure mirror of the backlog's ``list(sort="score")`` ordering — handy for the
48
+ reranker and for tests, and stable on ties by id.
49
+ """
50
+ def key(it: dict):
51
+ rice = it.get("rice")
52
+ return (rice is None, -(rice or 0.0), it.get("id", 0))
53
+
54
+ return sorted(items, key=key)
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: pmkit
3
+ Version: 0.1.1
4
+ Summary: Human-first CLI for the pm-system opportunity funnel: discovery + backlog.
5
+ Author: Kedar Dabhadkar
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7; extra == 'dev'
10
+ Provides-Extra: dogfood
11
+ Requires-Dist: fastmcp<4,>=3; extra == 'dogfood'
12
+ Requires-Dist: playwright>=1.40; extra == 'dogfood'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # pmkit
16
+
17
+ The human-first CLI for [pm-system](../README.md). Deterministic work — fetching signals,
18
+ backlog CRUD, dedup, and RICE math — lives here so a person can run it directly and an agent
19
+ can call the exact same surface.
20
+
21
+ ```bash
22
+ uv tool install . # or: pip install .
23
+ pmkit --help
24
+ ```
25
+
26
+ Core is stdlib-only (no third-party dependencies), so install is trivial and offline-friendly.
27
+
28
+ The backlog database defaults to `~/.pmkit/backlog.db`; override with `PMKIT_DB_PATH`.
29
+ Every command accepts `--json` for machine-readable output (agent parity).
@@ -0,0 +1,33 @@
1
+ pmkit/__init__.py,sha256=x0ZH-89458e4x58sbXW13EmuOxwWlRwN-xEaKwhcFtE,317
2
+ pmkit/backlog.py,sha256=CuLzqC_Uanx-6DMCQUbHEQz2riAJJ7qt2VAov0lhHog,15306
3
+ pmkit/cli.py,sha256=x-V6U2Xvv1nDMPs13P_JqJOeJf_ri4vON_zNlVdhwQE,29205
4
+ pmkit/dedup.py,sha256=bOZygM9mXBrANBLQJxbX91EpIC2r4uuzzcBAkWFG7bM,2029
5
+ pmkit/discover.py,sha256=NhcNjnPSiJZa7Mu-_zCnsAYA1qVSRgQ20gCieVLooS8,3294
6
+ pmkit/killtest.py,sha256=jVWn3afILKdIULcKDY-ovJaOyyR1PwNR9i-NO60fqgE,1596
7
+ pmkit/rice.py,sha256=xECtKswHwtWNJp1Dc0cdRhE0-2Url8NbcijvGDc1efU,1944
8
+ pmkit/connectors/__init__.py,sha256=KIazn0T1kctsuBn0R_vhxZ8iVXZz4-erdYsL_ER8f88,1026
9
+ pmkit/connectors/base.py,sha256=yTkSUSHubs_zk9IpUMdq2tLdWzsQVXNDBOmcZvrvZsI,2613
10
+ pmkit/connectors/changelog.py,sha256=7rbDPK413hQRL9d2MzjhNBRBFVJG5-eII5CKJ3MX0h8,1369
11
+ pmkit/connectors/github.py,sha256=45rmem-VXcrDIORC_qQQQMbm-b19yzU-Kf09sBItuB8,1977
12
+ pmkit/connectors/hn.py,sha256=EmtSJtRvlowng4LISYNeaYowHhkcIDjW9I8CBHUN7c8,1412
13
+ pmkit/connectors/reddit.py,sha256=i7A9c9L6c7Mk-E9FB-bzjZvbEMN5UpMoycldxOHoiWY,1483
14
+ pmkit/connectors/web.py,sha256=qoA4jNABvJ9ol46GgJKUz-3rwn_WkWO3AHMNlRiJ1dE,1519
15
+ pmkit/connectors/x.py,sha256=CZrpovieaT1ieogv-p6Edt0RmeI8UdK5Kqa6Jx5xuvA,1743
16
+ pmkit/dogfood/__init__.py,sha256=L-VZm5_Iqo22U9q9HtBKLzWWkQrNcSm0DtGA-x8Dt90,386
17
+ pmkit/dogfood/file_gaps.py,sha256=h9GwiskGFvEa5Ib4wJ3oMyVhmyL7Fm0dTKgp6zaIYgM,1924
18
+ pmkit/dogfood/install.py,sha256=ugyso_NnqjOGmTMaDHsEfj-xaFtl8FBO7r2sfL0ajHo,4017
19
+ pmkit/dogfood/mcp.py,sha256=2__gRhxYZ5qeTdO88F2uozl6i9z5qDr8vPwpoakrFKQ,3021
20
+ pmkit/dogfood/report.py,sha256=QG-eGibQDihyRrPEr-5GaqmbYkvmLupSXQIWPbh5lcQ,5230
21
+ pmkit/dogfood/sample.py,sha256=iUFQMj3TOkTtnyzeWxl7V43Kv7y226NiHrwc5FR7j18,954
22
+ pmkit/dogfood/ui.py,sha256=PmC7eg0xMGjNVj7prW_NaNNAifCdXLtYvbRejChjotU,4605
23
+ pmkit/launch/__init__.py,sha256=CBIRbHlBivy6e4W-DcIjFYQQVED8xt82Scbm7rveDhI,912
24
+ pmkit/launch/collateral.py,sha256=zkacS6f32k7F34RrCnVZp7wOEugyYR1eP4p01RGjtN8,6393
25
+ pmkit/launch/drafts.py,sha256=ZR-9OsyqnamOX2tgPNfmIaJRl8ihIkmg-6rA1j15ncQ,2214
26
+ pmkit/launch/listen.py,sha256=aXNVrqbK26FRisO09vNB48IPpptTNoThZqmRqX61zf4,3500
27
+ pmkit/launch/plan.py,sha256=XNXHhBqB2djpgrPDGEjA7qgKwlVd7e3FnYdOeWvZPNM,3195
28
+ pmkit/launch/policy.py,sha256=_dsIXNzAh9NA6BmT0vfEbqFGURx6obVxx7NxOUsjW3Y,6545
29
+ pmkit/launch/store.py,sha256=m0X_BgAO4vZwfugE8PA3IECSPK9VYvP6vR6n_M2IGHk,10458
30
+ pmkit-0.1.1.dist-info/METADATA,sha256=RITi7Tx0VLYG1e1oYUB4Hk7MXscb2Fx0A_2bN_7bJ4w,980
31
+ pmkit-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
32
+ pmkit-0.1.1.dist-info/entry_points.txt,sha256=NjlrCvIRbg8e0Kl4SQ_NsnFPuQXAmxh-ih5IzPNdfDw,41
33
+ pmkit-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pmkit = pmkit.cli:main