pmkit 0.1.1__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.
- pmkit-0.1.1/.gitignore +20 -0
- pmkit-0.1.1/PKG-INFO +29 -0
- pmkit-0.1.1/README.md +15 -0
- pmkit-0.1.1/pyproject.toml +29 -0
- pmkit-0.1.1/src/pmkit/__init__.py +8 -0
- pmkit-0.1.1/src/pmkit/backlog.py +409 -0
- pmkit-0.1.1/src/pmkit/cli.py +723 -0
- pmkit-0.1.1/src/pmkit/connectors/__init__.py +35 -0
- pmkit-0.1.1/src/pmkit/connectors/base.py +67 -0
- pmkit-0.1.1/src/pmkit/connectors/changelog.py +37 -0
- pmkit-0.1.1/src/pmkit/connectors/github.py +49 -0
- pmkit-0.1.1/src/pmkit/connectors/hn.py +42 -0
- pmkit-0.1.1/src/pmkit/connectors/reddit.py +42 -0
- pmkit-0.1.1/src/pmkit/connectors/web.py +44 -0
- pmkit-0.1.1/src/pmkit/connectors/x.py +50 -0
- pmkit-0.1.1/src/pmkit/dedup.py +64 -0
- pmkit-0.1.1/src/pmkit/discover.py +83 -0
- pmkit-0.1.1/src/pmkit/dogfood/__init__.py +7 -0
- pmkit-0.1.1/src/pmkit/dogfood/file_gaps.py +52 -0
- pmkit-0.1.1/src/pmkit/dogfood/install.py +111 -0
- pmkit-0.1.1/src/pmkit/dogfood/mcp.py +73 -0
- pmkit-0.1.1/src/pmkit/dogfood/report.py +157 -0
- pmkit-0.1.1/src/pmkit/dogfood/sample.py +32 -0
- pmkit-0.1.1/src/pmkit/dogfood/ui.py +106 -0
- pmkit-0.1.1/src/pmkit/killtest.py +31 -0
- pmkit-0.1.1/src/pmkit/launch/__init__.py +15 -0
- pmkit-0.1.1/src/pmkit/launch/collateral.py +159 -0
- pmkit-0.1.1/src/pmkit/launch/drafts.py +53 -0
- pmkit-0.1.1/src/pmkit/launch/listen.py +88 -0
- pmkit-0.1.1/src/pmkit/launch/plan.py +82 -0
- pmkit-0.1.1/src/pmkit/launch/policy.py +153 -0
- pmkit-0.1.1/src/pmkit/launch/store.py +260 -0
- pmkit-0.1.1/src/pmkit/rice.py +54 -0
- pmkit-0.1.1/tests/test_agents.py +44 -0
- pmkit-0.1.1/tests/test_backlog.py +161 -0
- pmkit-0.1.1/tests/test_cli.py +33 -0
- pmkit-0.1.1/tests/test_connectors_parse.py +68 -0
- pmkit-0.1.1/tests/test_dedup.py +38 -0
- pmkit-0.1.1/tests/test_discover.py +106 -0
- pmkit-0.1.1/tests/test_dogfood_drivers.py +86 -0
- pmkit-0.1.1/tests/test_dogfood_file_gaps.py +53 -0
- pmkit-0.1.1/tests/test_dogfood_install.py +59 -0
- pmkit-0.1.1/tests/test_dogfood_report.py +69 -0
- pmkit-0.1.1/tests/test_dogfood_skill.py +34 -0
- pmkit-0.1.1/tests/test_dogfood_streamlit.py +69 -0
- pmkit-0.1.1/tests/test_funnel.py +90 -0
- pmkit-0.1.1/tests/test_gate.py +73 -0
- pmkit-0.1.1/tests/test_killtest.py +91 -0
- pmkit-0.1.1/tests/test_launch_collateral.py +82 -0
- pmkit-0.1.1/tests/test_launch_drafts.py +74 -0
- pmkit-0.1.1/tests/test_launch_listen.py +94 -0
- pmkit-0.1.1/tests/test_launch_plan.py +72 -0
- pmkit-0.1.1/tests/test_launch_policy.py +126 -0
- pmkit-0.1.1/tests/test_launch_scenario.py +95 -0
- pmkit-0.1.1/tests/test_launch_store.py +104 -0
- pmkit-0.1.1/tests/test_rice.py +53 -0
- pmkit-0.1.1/tests/test_spec.py +65 -0
- pmkit-0.1.1/uv.lock +1899 -0
pmkit-0.1.1/.gitignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Runtime state (backlog DB and run artifacts) is local, never committed
|
|
2
|
+
state/*
|
|
3
|
+
!state/.gitkeep
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.eggs/
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
|
|
18
|
+
# Secrets / local config
|
|
19
|
+
.env
|
|
20
|
+
*.local
|
pmkit-0.1.1/PKG-INFO
ADDED
|
@@ -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).
|
pmkit-0.1.1/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# pmkit
|
|
2
|
+
|
|
3
|
+
The human-first CLI for [pm-system](../README.md). Deterministic work — fetching signals,
|
|
4
|
+
backlog CRUD, dedup, and RICE math — lives here so a person can run it directly and an agent
|
|
5
|
+
can call the exact same surface.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv tool install . # or: pip install .
|
|
9
|
+
pmkit --help
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Core is stdlib-only (no third-party dependencies), so install is trivial and offline-friendly.
|
|
13
|
+
|
|
14
|
+
The backlog database defaults to `~/.pmkit/backlog.db`; override with `PMKIT_DB_PATH`.
|
|
15
|
+
Every command accepts `--json` for machine-readable output (agent parity).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pmkit"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Human-first CLI for the pm-system opportunity funnel: discovery + backlog."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Kedar Dabhadkar" }]
|
|
9
|
+
dependencies = [] # core is stdlib-only; connectors use urllib, so install stays trivial
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
pmkit = "pmkit.cli:main"
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=7"]
|
|
16
|
+
# Live dogfood drivers (real browser + real MCP client). Core pmkit stays stdlib-only;
|
|
17
|
+
# these are imported lazily and only needed for live pm-dogfood runs.
|
|
18
|
+
dogfood = ["playwright>=1.40", "fastmcp>=3,<4"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/pmkit"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
pythonpath = ["src"]
|
|
29
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""pmkit — the human-first CLI for the pm-system opportunity funnel.
|
|
2
|
+
|
|
3
|
+
Deterministic stages (discovery, backlog, dedup, RICE math) live here. LLM-judgment
|
|
4
|
+
stages (kill-test, reranking, spec drafting) run as agents/skills in the plugin and
|
|
5
|
+
read/write the same backlog through this package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""SQLite-backed opportunity backlog with an explicit lifecycle.
|
|
2
|
+
|
|
3
|
+
The backlog is the persistent spine of the funnel and the operator's source of truth.
|
|
4
|
+
Both humans (via the CLI) and agents (via this module) read and write through it.
|
|
5
|
+
|
|
6
|
+
Lifecycle (enforced by ``TRANSITIONS``)::
|
|
7
|
+
|
|
8
|
+
new ──► survived ──► specced ──► approved ──► delegated ──► shipped
|
|
9
|
+
└──► pruned
|
|
10
|
+
|
|
11
|
+
The hard human gate lives in :meth:`Backlog.record_delegation`: an item cannot be
|
|
12
|
+
delegated unless it carries an approval record (written only by :meth:`Backlog.approve`).
|
|
13
|
+
Making the gate optional per-target later (the deferred autonomy goal) is a config concern
|
|
14
|
+
layered on top of this contract — the contract itself never lets unapproved work through.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sqlite3
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Iterable, Optional
|
|
26
|
+
|
|
27
|
+
STATUSES = (
|
|
28
|
+
"new",
|
|
29
|
+
"survived",
|
|
30
|
+
"pruned",
|
|
31
|
+
"specced",
|
|
32
|
+
"approved",
|
|
33
|
+
"delegated",
|
|
34
|
+
"shipped",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Allowed status transitions. Anything not listed is rejected.
|
|
38
|
+
TRANSITIONS: dict[str, set[str]] = {
|
|
39
|
+
"new": {"survived", "pruned"},
|
|
40
|
+
"survived": {"specced"},
|
|
41
|
+
"specced": {"approved"},
|
|
42
|
+
"approved": {"delegated"},
|
|
43
|
+
"delegated": {"shipped"},
|
|
44
|
+
"pruned": set(),
|
|
45
|
+
"shipped": set(),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
CATEGORIES = ("agent-only", "human-and-agent")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BacklogError(Exception):
|
|
52
|
+
"""Base error for illegal backlog operations."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TransitionError(BacklogError):
|
|
56
|
+
"""Raised on an illegal lifecycle transition."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GateError(BacklogError):
|
|
60
|
+
"""Raised when delegation is attempted without a recorded approval."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_db_path() -> Path:
|
|
64
|
+
"""Resolve the backlog DB path: ``$PMKIT_DB_PATH`` or ``~/.pmkit/backlog.db``."""
|
|
65
|
+
env = os.environ.get("PMKIT_DB_PATH")
|
|
66
|
+
if env:
|
|
67
|
+
return Path(env)
|
|
68
|
+
return Path.home() / ".pmkit" / "backlog.db"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def make_dedup_key(target: str, problem: str, max_words: int = 12) -> str:
|
|
72
|
+
"""Build a stable, normalized key from the target and problem statement.
|
|
73
|
+
|
|
74
|
+
Lowercases, strips punctuation, collapses whitespace, and keeps the first
|
|
75
|
+
``max_words`` tokens. Two candidates describing the same problem on the same
|
|
76
|
+
target produce the same key. Near-duplicate (non-identical) detection is the
|
|
77
|
+
job of ``pmkit.dedup``; this is the exact-match backbone.
|
|
78
|
+
"""
|
|
79
|
+
norm = re.sub(r"[^a-z0-9\s]", " ", (problem or "").lower())
|
|
80
|
+
words = norm.split()[:max_words]
|
|
81
|
+
slug = "-".join(words)
|
|
82
|
+
return f"{(target or '').strip().lower()}::{slug}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _now() -> str:
|
|
86
|
+
return datetime.now(timezone.utc).isoformat()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Backlog:
|
|
90
|
+
"""Connection wrapper over the opportunity backlog DB."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, db_path: Optional[os.PathLike[str] | str] = None) -> None:
|
|
93
|
+
self.db_path = Path(db_path) if db_path is not None else default_db_path()
|
|
94
|
+
if str(self.db_path) != ":memory:":
|
|
95
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
self.conn = sqlite3.connect(str(self.db_path))
|
|
97
|
+
self.conn.row_factory = sqlite3.Row
|
|
98
|
+
self.conn.execute("PRAGMA foreign_keys = ON")
|
|
99
|
+
self._ensure_schema()
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------ schema
|
|
102
|
+
def _ensure_schema(self) -> None:
|
|
103
|
+
self.conn.executescript(
|
|
104
|
+
"""
|
|
105
|
+
CREATE TABLE IF NOT EXISTS opportunities (
|
|
106
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
107
|
+
target TEXT NOT NULL,
|
|
108
|
+
title TEXT NOT NULL,
|
|
109
|
+
problem TEXT NOT NULL DEFAULT '',
|
|
110
|
+
dedup_key TEXT NOT NULL,
|
|
111
|
+
category TEXT,
|
|
112
|
+
status TEXT NOT NULL DEFAULT 'new',
|
|
113
|
+
low_confidence INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
sources TEXT NOT NULL DEFAULT '[]',
|
|
115
|
+
reach REAL,
|
|
116
|
+
impact REAL,
|
|
117
|
+
confidence REAL,
|
|
118
|
+
effort REAL,
|
|
119
|
+
rice REAL,
|
|
120
|
+
killtest TEXT,
|
|
121
|
+
spec_path TEXT,
|
|
122
|
+
approval TEXT,
|
|
123
|
+
delegation TEXT,
|
|
124
|
+
created_at TEXT NOT NULL,
|
|
125
|
+
updated_at TEXT NOT NULL
|
|
126
|
+
);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_opp_status ON opportunities(status);
|
|
128
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_opp_dedup
|
|
129
|
+
ON opportunities(target, dedup_key);
|
|
130
|
+
"""
|
|
131
|
+
)
|
|
132
|
+
self.conn.commit()
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
self.conn.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> "Backlog":
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *exc: object) -> None:
|
|
141
|
+
self.close()
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------- create/read
|
|
144
|
+
def add_candidate(
|
|
145
|
+
self,
|
|
146
|
+
target: str,
|
|
147
|
+
title: str,
|
|
148
|
+
problem: str = "",
|
|
149
|
+
sources: Optional[Iterable[dict]] = None,
|
|
150
|
+
dedup_key: Optional[str] = None,
|
|
151
|
+
low_confidence: bool = False,
|
|
152
|
+
) -> int:
|
|
153
|
+
"""Insert a new candidate (status ``new``). If an item with the same
|
|
154
|
+
(target, dedup_key) already exists, attach the new sources to it instead
|
|
155
|
+
and return its id — this is the exact-match arm of dedup (R10)."""
|
|
156
|
+
key = dedup_key or make_dedup_key(target, problem)
|
|
157
|
+
existing = self.find_existing(target, key)
|
|
158
|
+
if existing is not None:
|
|
159
|
+
self.attach_evidence(existing["id"], sources or [])
|
|
160
|
+
return int(existing["id"])
|
|
161
|
+
now = _now()
|
|
162
|
+
cur = self.conn.execute(
|
|
163
|
+
"""
|
|
164
|
+
INSERT INTO opportunities
|
|
165
|
+
(target, title, problem, dedup_key, status, low_confidence,
|
|
166
|
+
sources, created_at, updated_at)
|
|
167
|
+
VALUES (?, ?, ?, ?, 'new', ?, ?, ?, ?)
|
|
168
|
+
""",
|
|
169
|
+
(
|
|
170
|
+
target,
|
|
171
|
+
title,
|
|
172
|
+
problem,
|
|
173
|
+
key,
|
|
174
|
+
1 if low_confidence else 0,
|
|
175
|
+
json.dumps(list(sources or [])),
|
|
176
|
+
now,
|
|
177
|
+
now,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
self.conn.commit()
|
|
181
|
+
return int(cur.lastrowid)
|
|
182
|
+
|
|
183
|
+
def find_existing(self, target: str, dedup_key: str) -> Optional[dict]:
|
|
184
|
+
row = self.conn.execute(
|
|
185
|
+
"SELECT * FROM opportunities WHERE target = ? AND dedup_key = ?",
|
|
186
|
+
(target, dedup_key),
|
|
187
|
+
).fetchone()
|
|
188
|
+
return self._row_to_dict(row) if row else None
|
|
189
|
+
|
|
190
|
+
def get(self, opp_id: int) -> Optional[dict]:
|
|
191
|
+
row = self.conn.execute(
|
|
192
|
+
"SELECT * FROM opportunities WHERE id = ?", (opp_id,)
|
|
193
|
+
).fetchone()
|
|
194
|
+
return self._row_to_dict(row) if row else None
|
|
195
|
+
|
|
196
|
+
def list(
|
|
197
|
+
self,
|
|
198
|
+
status: Optional[str] = None,
|
|
199
|
+
sort: str = "created",
|
|
200
|
+
limit: Optional[int] = None,
|
|
201
|
+
) -> list[dict]:
|
|
202
|
+
sql = "SELECT * FROM opportunities"
|
|
203
|
+
params: list[Any] = []
|
|
204
|
+
if status:
|
|
205
|
+
sql += " WHERE status = ?"
|
|
206
|
+
params.append(status)
|
|
207
|
+
if sort == "score":
|
|
208
|
+
sql += " ORDER BY rice IS NULL, rice DESC, id ASC"
|
|
209
|
+
else:
|
|
210
|
+
sql += " ORDER BY created_at ASC, id ASC"
|
|
211
|
+
if limit:
|
|
212
|
+
sql += " LIMIT ?"
|
|
213
|
+
params.append(int(limit))
|
|
214
|
+
rows = self.conn.execute(sql, params).fetchall()
|
|
215
|
+
return [self._row_to_dict(r) for r in rows]
|
|
216
|
+
|
|
217
|
+
def counts(self) -> dict[str, int]:
|
|
218
|
+
rows = self.conn.execute(
|
|
219
|
+
"SELECT status, COUNT(*) AS n FROM opportunities GROUP BY status"
|
|
220
|
+
).fetchall()
|
|
221
|
+
out = {s: 0 for s in STATUSES}
|
|
222
|
+
for r in rows:
|
|
223
|
+
out[r["status"]] = r["n"]
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
# ----------------------------------------------------------------- mutate
|
|
227
|
+
def attach_evidence(self, opp_id: int, sources: Iterable[dict]) -> None:
|
|
228
|
+
"""Merge new sources into an existing item (dedup by url). Used when a
|
|
229
|
+
re-run rediscovers a known problem — evidence accrues, no duplicate row."""
|
|
230
|
+
item = self._require(opp_id)
|
|
231
|
+
existing = item["sources"]
|
|
232
|
+
seen = {s.get("url") for s in existing if s.get("url")}
|
|
233
|
+
for s in sources:
|
|
234
|
+
if s.get("url") and s["url"] in seen:
|
|
235
|
+
continue
|
|
236
|
+
existing.append(s)
|
|
237
|
+
if s.get("url"):
|
|
238
|
+
seen.add(s["url"])
|
|
239
|
+
self.conn.execute(
|
|
240
|
+
"UPDATE opportunities SET sources = ?, updated_at = ? WHERE id = ?",
|
|
241
|
+
(json.dumps(existing), _now(), opp_id),
|
|
242
|
+
)
|
|
243
|
+
self.conn.commit()
|
|
244
|
+
|
|
245
|
+
def _set_status(self, opp_id: int, new_status: str) -> None:
|
|
246
|
+
item = self._require(opp_id)
|
|
247
|
+
current = item["status"]
|
|
248
|
+
if new_status not in TRANSITIONS.get(current, set()):
|
|
249
|
+
raise TransitionError(
|
|
250
|
+
f"illegal transition {current!r} -> {new_status!r} for opp {opp_id}"
|
|
251
|
+
)
|
|
252
|
+
self.conn.execute(
|
|
253
|
+
"UPDATE opportunities SET status = ?, updated_at = ? WHERE id = ?",
|
|
254
|
+
(new_status, _now(), opp_id),
|
|
255
|
+
)
|
|
256
|
+
self.conn.commit()
|
|
257
|
+
|
|
258
|
+
def record_killtest(self, opp_id: int, verdicts: list[dict], survived: bool) -> None:
|
|
259
|
+
"""Store per-axis kill-test verdicts and move new -> survived | pruned."""
|
|
260
|
+
self._require(opp_id)
|
|
261
|
+
self.conn.execute(
|
|
262
|
+
"UPDATE opportunities SET killtest = ?, updated_at = ? WHERE id = ?",
|
|
263
|
+
(json.dumps(verdicts), _now(), opp_id),
|
|
264
|
+
)
|
|
265
|
+
self.conn.commit()
|
|
266
|
+
self._set_status(opp_id, "survived" if survived else "pruned")
|
|
267
|
+
|
|
268
|
+
def set_scores(
|
|
269
|
+
self,
|
|
270
|
+
opp_id: int,
|
|
271
|
+
reach: float,
|
|
272
|
+
impact: float,
|
|
273
|
+
confidence: float,
|
|
274
|
+
effort: float,
|
|
275
|
+
) -> float:
|
|
276
|
+
"""Store RICE sub-scores and the computed composite. Returns the composite.
|
|
277
|
+
Re-running updates the same row in place (R5)."""
|
|
278
|
+
from .rice import compute_rice
|
|
279
|
+
|
|
280
|
+
self._require(opp_id)
|
|
281
|
+
rice = compute_rice(reach, impact, confidence, effort)
|
|
282
|
+
self.conn.execute(
|
|
283
|
+
"""
|
|
284
|
+
UPDATE opportunities
|
|
285
|
+
SET reach = ?, impact = ?, confidence = ?, effort = ?, rice = ?,
|
|
286
|
+
updated_at = ?
|
|
287
|
+
WHERE id = ?
|
|
288
|
+
""",
|
|
289
|
+
(reach, impact, confidence, effort, rice, _now(), opp_id),
|
|
290
|
+
)
|
|
291
|
+
self.conn.commit()
|
|
292
|
+
return rice
|
|
293
|
+
|
|
294
|
+
def set_category(self, opp_id: int, category: str) -> None:
|
|
295
|
+
if category not in CATEGORIES:
|
|
296
|
+
raise BacklogError(
|
|
297
|
+
f"category must be one of {CATEGORIES}, got {category!r}"
|
|
298
|
+
)
|
|
299
|
+
self._require(opp_id)
|
|
300
|
+
self.conn.execute(
|
|
301
|
+
"UPDATE opportunities SET category = ?, updated_at = ? WHERE id = ?",
|
|
302
|
+
(category, _now(), opp_id),
|
|
303
|
+
)
|
|
304
|
+
self.conn.commit()
|
|
305
|
+
|
|
306
|
+
def promote(self, opp_id: int) -> None:
|
|
307
|
+
"""Operator selects a survived item for spec drafting: survived -> specced."""
|
|
308
|
+
self._set_status(opp_id, "specced")
|
|
309
|
+
|
|
310
|
+
def set_spec(self, opp_id: int, spec_path: str) -> None:
|
|
311
|
+
"""Record the drafted requirements doc path on a specced item."""
|
|
312
|
+
item = self._require(opp_id)
|
|
313
|
+
if item["status"] not in ("specced", "approved"):
|
|
314
|
+
raise BacklogError(
|
|
315
|
+
f"cannot attach spec to opp {opp_id} in status {item['status']!r}"
|
|
316
|
+
)
|
|
317
|
+
self.conn.execute(
|
|
318
|
+
"UPDATE opportunities SET spec_path = ?, updated_at = ? WHERE id = ?",
|
|
319
|
+
(spec_path, _now(), opp_id),
|
|
320
|
+
)
|
|
321
|
+
self.conn.commit()
|
|
322
|
+
|
|
323
|
+
def approve(self, opp_id: int, note: Optional[str] = None) -> None:
|
|
324
|
+
"""The hard human gate: specced -> approved, writing an approval record."""
|
|
325
|
+
item = self._require(opp_id)
|
|
326
|
+
if item["status"] != "specced":
|
|
327
|
+
raise TransitionError(
|
|
328
|
+
f"can only approve a 'specced' item; opp {opp_id} is {item['status']!r}"
|
|
329
|
+
)
|
|
330
|
+
record = {"approved_at": _now(), "note": note}
|
|
331
|
+
self.conn.execute(
|
|
332
|
+
"UPDATE opportunities SET approval = ? WHERE id = ?",
|
|
333
|
+
(json.dumps(record), opp_id),
|
|
334
|
+
)
|
|
335
|
+
self.conn.commit()
|
|
336
|
+
self._set_status(opp_id, "approved")
|
|
337
|
+
|
|
338
|
+
def record_delegation(
|
|
339
|
+
self, opp_id: int, spec_path: Optional[str], target: Optional[str] = None
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Delegate an approved item to implementation. Refuses without an approval
|
|
342
|
+
record — this is the gate enforced in code, not just by status (R7)."""
|
|
343
|
+
item = self._require(opp_id)
|
|
344
|
+
if not item.get("approval"):
|
|
345
|
+
raise GateError(
|
|
346
|
+
f"opp {opp_id} has no approval record; cannot delegate (gate)"
|
|
347
|
+
)
|
|
348
|
+
if item["status"] != "approved":
|
|
349
|
+
raise TransitionError(
|
|
350
|
+
f"can only delegate an 'approved' item; opp {opp_id} is {item['status']!r}"
|
|
351
|
+
)
|
|
352
|
+
record = {
|
|
353
|
+
"delegated_at": _now(),
|
|
354
|
+
"spec_path": spec_path or item.get("spec_path"),
|
|
355
|
+
"target": target or item.get("target"),
|
|
356
|
+
"category": item.get("category"),
|
|
357
|
+
}
|
|
358
|
+
self.conn.execute(
|
|
359
|
+
"UPDATE opportunities SET delegation = ? WHERE id = ?",
|
|
360
|
+
(json.dumps(record), opp_id),
|
|
361
|
+
)
|
|
362
|
+
self.conn.commit()
|
|
363
|
+
self._set_status(opp_id, "delegated")
|
|
364
|
+
|
|
365
|
+
def mark_shipped(self, opp_id: int) -> None:
|
|
366
|
+
self._set_status(opp_id, "shipped")
|
|
367
|
+
|
|
368
|
+
# ----------------------------------------------------------------- export
|
|
369
|
+
def export_markdown(self) -> str:
|
|
370
|
+
"""Render a stable, human-readable snapshot of the backlog."""
|
|
371
|
+
items = self.list(sort="score")
|
|
372
|
+
lines = ["# Opportunity backlog", ""]
|
|
373
|
+
counts = self.counts()
|
|
374
|
+
summary = ", ".join(f"{k}: {v}" for k, v in counts.items() if v)
|
|
375
|
+
lines.append(f"_{summary or 'empty'}_")
|
|
376
|
+
lines.append("")
|
|
377
|
+
for it in items:
|
|
378
|
+
score = "-" if it["rice"] is None else f"{it['rice']:.2f}"
|
|
379
|
+
cat = it["category"] or "-"
|
|
380
|
+
flag = " (low-confidence)" if it["low_confidence"] else ""
|
|
381
|
+
lines.append(f"## [{it['id']}] {it['title']}")
|
|
382
|
+
lines.append(
|
|
383
|
+
f"- target: `{it['target']}` | status: **{it['status']}** | "
|
|
384
|
+
f"RICE: {score} | category: {cat}{flag}"
|
|
385
|
+
)
|
|
386
|
+
if it["problem"]:
|
|
387
|
+
lines.append(f"- problem: {it['problem']}")
|
|
388
|
+
if it["sources"]:
|
|
389
|
+
urls = ", ".join(s.get("url", "?") for s in it["sources"][:5])
|
|
390
|
+
lines.append(f"- sources: {urls}")
|
|
391
|
+
lines.append("")
|
|
392
|
+
return "\n".join(lines)
|
|
393
|
+
|
|
394
|
+
# ----------------------------------------------------------------- helpers
|
|
395
|
+
def _require(self, opp_id: int) -> dict:
|
|
396
|
+
item = self.get(opp_id)
|
|
397
|
+
if item is None:
|
|
398
|
+
raise BacklogError(f"opportunity {opp_id} not found")
|
|
399
|
+
return item
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _row_to_dict(row: sqlite3.Row) -> dict:
|
|
403
|
+
d = dict(row)
|
|
404
|
+
d["sources"] = json.loads(d.get("sources") or "[]")
|
|
405
|
+
d["killtest"] = json.loads(d["killtest"]) if d.get("killtest") else None
|
|
406
|
+
d["approval"] = json.loads(d["approval"]) if d.get("approval") else None
|
|
407
|
+
d["delegation"] = json.loads(d["delegation"]) if d.get("delegation") else None
|
|
408
|
+
d["low_confidence"] = bool(d.get("low_confidence"))
|
|
409
|
+
return d
|