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/__init__.py +8 -0
- pmkit/backlog.py +409 -0
- pmkit/cli.py +723 -0
- pmkit/connectors/__init__.py +35 -0
- pmkit/connectors/base.py +67 -0
- pmkit/connectors/changelog.py +37 -0
- pmkit/connectors/github.py +49 -0
- pmkit/connectors/hn.py +42 -0
- pmkit/connectors/reddit.py +42 -0
- pmkit/connectors/web.py +44 -0
- pmkit/connectors/x.py +50 -0
- pmkit/dedup.py +64 -0
- pmkit/discover.py +83 -0
- pmkit/dogfood/__init__.py +7 -0
- pmkit/dogfood/file_gaps.py +52 -0
- pmkit/dogfood/install.py +111 -0
- pmkit/dogfood/mcp.py +73 -0
- pmkit/dogfood/report.py +157 -0
- pmkit/dogfood/sample.py +32 -0
- pmkit/dogfood/ui.py +106 -0
- pmkit/killtest.py +31 -0
- pmkit/launch/__init__.py +15 -0
- pmkit/launch/collateral.py +159 -0
- pmkit/launch/drafts.py +53 -0
- pmkit/launch/listen.py +88 -0
- pmkit/launch/plan.py +82 -0
- pmkit/launch/policy.py +153 -0
- pmkit/launch/store.py +260 -0
- pmkit/rice.py +54 -0
- pmkit-0.1.1.dist-info/METADATA +29 -0
- pmkit-0.1.1.dist-info/RECORD +33 -0
- pmkit-0.1.1.dist-info/WHEEL +4 -0
- pmkit-0.1.1.dist-info/entry_points.txt +2 -0
pmkit/cli.py
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
"""pmkit command-line interface — the human-first surface over the backlog.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
pmkit backlog list [--status S] [--sort score|created] [--limit N]
|
|
6
|
+
pmkit backlog show <id>
|
|
7
|
+
pmkit backlog add --target T --title ... --problem ... [--source URL ...]
|
|
8
|
+
pmkit backlog promote <id>
|
|
9
|
+
pmkit backlog approve <id> [--note ...]
|
|
10
|
+
pmkit backlog status
|
|
11
|
+
pmkit backlog export [--out PATH]
|
|
12
|
+
pmkit discover <target> [...] # added in U3
|
|
13
|
+
|
|
14
|
+
Every command accepts ``--json`` for machine-readable output so an agent can call the
|
|
15
|
+
same surface a human uses (parity).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
from .backlog import Backlog, BacklogError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _open(args: argparse.Namespace) -> Backlog:
|
|
29
|
+
return Backlog(getattr(args, "db", None))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _emit(args: argparse.Namespace, human: str, payload) -> None:
|
|
33
|
+
if getattr(args, "json", False):
|
|
34
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
35
|
+
else:
|
|
36
|
+
print(human)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate(text: str, width: int) -> str:
|
|
40
|
+
text = (text or "").replace("\n", " ")
|
|
41
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------- backlog
|
|
45
|
+
def cmd_backlog_list(args: argparse.Namespace) -> int:
|
|
46
|
+
with _open(args) as bl:
|
|
47
|
+
items = bl.list(status=args.status, sort=args.sort, limit=args.limit)
|
|
48
|
+
if getattr(args, "json", False):
|
|
49
|
+
print(json.dumps(items, indent=2, default=str))
|
|
50
|
+
return 0
|
|
51
|
+
if not items:
|
|
52
|
+
print("(backlog empty)")
|
|
53
|
+
return 0
|
|
54
|
+
print(f"{'ID':>3} {'STATUS':<9} {'RICE':>7} {'CATEGORY':<16} TITLE")
|
|
55
|
+
for it in items:
|
|
56
|
+
score = "-" if it["rice"] is None else f"{it['rice']:.2f}"
|
|
57
|
+
flag = " (!)" if it["low_confidence"] else ""
|
|
58
|
+
print(
|
|
59
|
+
f"{it['id']:>3} {it['status']:<9} {score:>7} "
|
|
60
|
+
f"{(it['category'] or '-'):<16} {_truncate(it['title'], 50)}{flag}"
|
|
61
|
+
)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_backlog_show(args: argparse.Namespace) -> int:
|
|
66
|
+
with _open(args) as bl:
|
|
67
|
+
item = bl.get(args.id)
|
|
68
|
+
if item is None:
|
|
69
|
+
print(f"opportunity {args.id} not found", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
if getattr(args, "json", False):
|
|
72
|
+
print(json.dumps(item, indent=2, default=str))
|
|
73
|
+
return 0
|
|
74
|
+
print(f"[{item['id']}] {item['title']}")
|
|
75
|
+
print(f" target : {item['target']}")
|
|
76
|
+
print(f" status : {item['status']}")
|
|
77
|
+
print(f" category : {item['category'] or '-'}")
|
|
78
|
+
score = "-" if item["rice"] is None else f"{item['rice']:.3f}"
|
|
79
|
+
print(
|
|
80
|
+
f" RICE : {score} "
|
|
81
|
+
f"(reach={item['reach']}, impact={item['impact']}, "
|
|
82
|
+
f"confidence={item['confidence']}, effort={item['effort']})"
|
|
83
|
+
)
|
|
84
|
+
print(f" low_conf. : {item['low_confidence']}")
|
|
85
|
+
if item["problem"]:
|
|
86
|
+
print(f" problem : {item['problem']}")
|
|
87
|
+
if item["sources"]:
|
|
88
|
+
print(" sources :")
|
|
89
|
+
for s in item["sources"]:
|
|
90
|
+
print(f" - [{s.get('type','?')}] {s.get('url','?')}")
|
|
91
|
+
if item["killtest"]:
|
|
92
|
+
print(" kill-test :")
|
|
93
|
+
for v in item["killtest"]:
|
|
94
|
+
print(f" - {v.get('axis','?')}: {v.get('verdict','?')} — {v.get('reason','')}")
|
|
95
|
+
if item["approval"]:
|
|
96
|
+
print(f" approval : {item['approval']}")
|
|
97
|
+
if item["delegation"]:
|
|
98
|
+
print(f" delegation : {item['delegation']}")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_backlog_add(args: argparse.Namespace) -> int:
|
|
103
|
+
sources = [{"type": "manual", "url": u} for u in (args.source or [])]
|
|
104
|
+
with _open(args) as bl:
|
|
105
|
+
opp_id = bl.add_candidate(
|
|
106
|
+
target=args.target,
|
|
107
|
+
title=args.title,
|
|
108
|
+
problem=args.problem or "",
|
|
109
|
+
sources=sources,
|
|
110
|
+
low_confidence=args.low_confidence,
|
|
111
|
+
)
|
|
112
|
+
_emit(args, f"added opportunity {opp_id}", {"id": opp_id})
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_backlog_promote(args: argparse.Namespace) -> int:
|
|
117
|
+
try:
|
|
118
|
+
with _open(args) as bl:
|
|
119
|
+
bl.promote(args.id)
|
|
120
|
+
except BacklogError as e:
|
|
121
|
+
print(str(e), file=sys.stderr)
|
|
122
|
+
return 1
|
|
123
|
+
_emit(args, f"opportunity {args.id} promoted to 'specced'", {"id": args.id, "status": "specced"})
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_backlog_approve(args: argparse.Namespace) -> int:
|
|
128
|
+
try:
|
|
129
|
+
with _open(args) as bl:
|
|
130
|
+
bl.approve(args.id, note=args.note)
|
|
131
|
+
except BacklogError as e:
|
|
132
|
+
print(str(e), file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
_emit(args, f"opportunity {args.id} approved (gate cleared)", {"id": args.id, "status": "approved"})
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_backlog_categorize(args: argparse.Namespace) -> int:
|
|
139
|
+
try:
|
|
140
|
+
with _open(args) as bl:
|
|
141
|
+
bl.set_category(args.id, args.category)
|
|
142
|
+
except BacklogError as e:
|
|
143
|
+
print(str(e), file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
_emit(args, f"opportunity {args.id} categorized as {args.category}",
|
|
146
|
+
{"id": args.id, "category": args.category})
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_backlog_spec(args: argparse.Namespace) -> int:
|
|
151
|
+
try:
|
|
152
|
+
with _open(args) as bl:
|
|
153
|
+
bl.set_spec(args.id, args.path)
|
|
154
|
+
except BacklogError as e:
|
|
155
|
+
print(str(e), file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
_emit(args, f"opportunity {args.id} spec recorded: {args.path}",
|
|
158
|
+
{"id": args.id, "spec_path": args.path})
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cmd_backlog_killtest(args: argparse.Namespace) -> int:
|
|
163
|
+
"""Persist kill-test verdicts and move new -> survived | pruned. Used by the
|
|
164
|
+
pm-run orchestrator (and available to humans for parity)."""
|
|
165
|
+
# --verdicts-file is the robust path: a large verdicts blob (long reasons with
|
|
166
|
+
# quotes/parens/em-dashes) corrupts when routed through an LLM agent's shell
|
|
167
|
+
# command, which silently empties the array and makes decide_survival mis-gate.
|
|
168
|
+
# A clean file path can't be mangled. --verdicts-file wins if both are given.
|
|
169
|
+
raw = args.verdicts
|
|
170
|
+
if getattr(args, "verdicts_file", None):
|
|
171
|
+
try:
|
|
172
|
+
with open(args.verdicts_file, encoding="utf-8") as fh:
|
|
173
|
+
raw = fh.read()
|
|
174
|
+
except OSError as e:
|
|
175
|
+
print(f"cannot read --verdicts-file: {e}", file=sys.stderr)
|
|
176
|
+
return 1
|
|
177
|
+
try:
|
|
178
|
+
verdicts = json.loads(raw) if raw else []
|
|
179
|
+
except json.JSONDecodeError as e:
|
|
180
|
+
print(f"bad verdicts JSON: {e}", file=sys.stderr)
|
|
181
|
+
return 1
|
|
182
|
+
reason = ""
|
|
183
|
+
if getattr(args, "decide", False):
|
|
184
|
+
from .killtest import decide_survival
|
|
185
|
+
survived, reason = decide_survival(verdicts)
|
|
186
|
+
else:
|
|
187
|
+
survived = args.survived
|
|
188
|
+
try:
|
|
189
|
+
with _open(args) as bl:
|
|
190
|
+
bl.record_killtest(args.id, verdicts, survived=survived)
|
|
191
|
+
except BacklogError as e:
|
|
192
|
+
print(str(e), file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
state = "survived" if survived else "pruned"
|
|
195
|
+
human = f"opportunity {args.id} {state}" + (f" ({reason})" if reason else "")
|
|
196
|
+
_emit(args, human, {"id": args.id, "status": state, "reason": reason})
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_backlog_score(args: argparse.Namespace) -> int:
|
|
201
|
+
try:
|
|
202
|
+
with _open(args) as bl:
|
|
203
|
+
rice = bl.set_scores(args.id, args.reach, args.impact, args.confidence, args.effort)
|
|
204
|
+
except (BacklogError, ValueError) as e:
|
|
205
|
+
print(str(e), file=sys.stderr)
|
|
206
|
+
return 1
|
|
207
|
+
_emit(args, f"opportunity {args.id} scored: RICE={rice:.3f}",
|
|
208
|
+
{"id": args.id, "rice": rice})
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cmd_backlog_delegate(args: argparse.Namespace) -> int:
|
|
213
|
+
"""Record delegation of an approved item. Refuses without an approval record
|
|
214
|
+
(the gate, enforced in backlog.record_delegation). The actual handoff to
|
|
215
|
+
ce-plan/lfg is performed by the pm-run skill; this records the contract."""
|
|
216
|
+
try:
|
|
217
|
+
with _open(args) as bl:
|
|
218
|
+
bl.record_delegation(args.id, spec_path=args.spec, target=args.target)
|
|
219
|
+
except BacklogError as e:
|
|
220
|
+
print(str(e), file=sys.stderr)
|
|
221
|
+
return 1
|
|
222
|
+
_emit(args, f"opportunity {args.id} delegated", {"id": args.id, "status": "delegated"})
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cmd_backlog_ship(args: argparse.Namespace) -> int:
|
|
227
|
+
try:
|
|
228
|
+
with _open(args) as bl:
|
|
229
|
+
bl.mark_shipped(args.id)
|
|
230
|
+
except BacklogError as e:
|
|
231
|
+
print(str(e), file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
_emit(args, f"opportunity {args.id} marked shipped", {"id": args.id, "status": "shipped"})
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_backlog_status(args: argparse.Namespace) -> int:
|
|
238
|
+
with _open(args) as bl:
|
|
239
|
+
counts = bl.counts()
|
|
240
|
+
if getattr(args, "json", False):
|
|
241
|
+
print(json.dumps(counts, indent=2))
|
|
242
|
+
return 0
|
|
243
|
+
total = sum(counts.values())
|
|
244
|
+
print(f"backlog: {total} opportunities")
|
|
245
|
+
for status, n in counts.items():
|
|
246
|
+
if n:
|
|
247
|
+
print(f" {status:<10} {n}")
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def cmd_backlog_export(args: argparse.Namespace) -> int:
|
|
252
|
+
with _open(args) as bl:
|
|
253
|
+
md = bl.export_markdown()
|
|
254
|
+
if args.out:
|
|
255
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
256
|
+
fh.write(md)
|
|
257
|
+
_emit(args, f"exported backlog to {args.out}", {"out": args.out})
|
|
258
|
+
else:
|
|
259
|
+
print(md)
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_discover(args: argparse.Namespace) -> int:
|
|
264
|
+
from .connectors import get_connectors
|
|
265
|
+
from .connectors.base import Config
|
|
266
|
+
from .discover import run_discovery
|
|
267
|
+
|
|
268
|
+
cfg = Config.from_env()
|
|
269
|
+
try:
|
|
270
|
+
connectors = get_connectors(args.source)
|
|
271
|
+
except ValueError as e:
|
|
272
|
+
print(str(e), file=sys.stderr)
|
|
273
|
+
return 2
|
|
274
|
+
with _open(args) as bl:
|
|
275
|
+
summary = run_discovery(bl, args.target, connectors=connectors, cfg=cfg)
|
|
276
|
+
if getattr(args, "json", False):
|
|
277
|
+
print(json.dumps(summary, indent=2, default=str))
|
|
278
|
+
return 0
|
|
279
|
+
print(
|
|
280
|
+
f"discovered for {summary['target']}: "
|
|
281
|
+
f"{summary['new']} new, {summary['merged']} merged, "
|
|
282
|
+
f"{summary['fetched']} signals fetched "
|
|
283
|
+
f"({summary['low_confidence']} low-confidence)"
|
|
284
|
+
)
|
|
285
|
+
for src, n in summary["by_source"].items():
|
|
286
|
+
print(f" {src:<8} {n} signals")
|
|
287
|
+
for skip in summary["skipped"]:
|
|
288
|
+
print(f" {skip['source']:<8} skipped - {skip['reason']}")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------- parser
|
|
293
|
+
def cmd_dogfood_install(args: argparse.Namespace) -> int:
|
|
294
|
+
from .dogfood.install import run_documented_install
|
|
295
|
+
rep = run_documented_install(args.cmd or [])
|
|
296
|
+
if getattr(args, "json", False):
|
|
297
|
+
print(json.dumps(rep.to_dict(), indent=2))
|
|
298
|
+
else:
|
|
299
|
+
for s in rep.steps:
|
|
300
|
+
tag = "ok " if s.ok else "GAP"
|
|
301
|
+
print(f" [{tag}] {s.command}" + ("" if s.ok else f" -- {s.reason}"))
|
|
302
|
+
return 0 if rep.all_ok else 1
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _emit_obs(args: argparse.Namespace, obs: list) -> int:
|
|
306
|
+
if not obs:
|
|
307
|
+
print("no observations recorded (empty step/call plan?)", file=sys.stderr)
|
|
308
|
+
return 1 # exercising nothing is not a pass
|
|
309
|
+
if getattr(args, "json", False):
|
|
310
|
+
print(json.dumps(obs, indent=2, default=str))
|
|
311
|
+
else:
|
|
312
|
+
for o in obs:
|
|
313
|
+
print(f" [{'ok ' if o.get('ok') else 'FAIL'}] {o.get('step', '')}")
|
|
314
|
+
return 0 if all(o.get("ok") for o in obs) else 1
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cmd_dogfood_ui(args: argparse.Namespace) -> int:
|
|
318
|
+
from .dogfood.ui import drive_ui
|
|
319
|
+
try:
|
|
320
|
+
obs = drive_ui(args.url, json.loads(args.steps))
|
|
321
|
+
except Exception as e:
|
|
322
|
+
print(str(e), file=sys.stderr)
|
|
323
|
+
return 1
|
|
324
|
+
return _emit_obs(args, obs)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_dogfood_mcp(args: argparse.Namespace) -> int:
|
|
328
|
+
import shlex
|
|
329
|
+
|
|
330
|
+
from .dogfood.mcp import drive_mcp
|
|
331
|
+
try:
|
|
332
|
+
obs = drive_mcp(shlex.split(args.server), json.loads(args.calls))
|
|
333
|
+
except Exception as e:
|
|
334
|
+
print(str(e), file=sys.stderr)
|
|
335
|
+
return 1
|
|
336
|
+
return _emit_obs(args, obs)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# --------------------------------------------------------------------- launch
|
|
340
|
+
def cmd_launch_announce(args: argparse.Namespace) -> int:
|
|
341
|
+
from .launch.store import LaunchStore
|
|
342
|
+
with LaunchStore(getattr(args, "db", None)) as st:
|
|
343
|
+
sid = st.announce(args.product, args.channel, url=args.url)
|
|
344
|
+
_emit(args, f"recorded announcement of {args.product} on {args.channel}",
|
|
345
|
+
{"id": sid, "product": args.product, "channel": args.channel, "status": "announced"})
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def cmd_launch_state(args: argparse.Namespace) -> int:
|
|
350
|
+
from .launch.store import LaunchStore
|
|
351
|
+
with LaunchStore(getattr(args, "db", None)) as st:
|
|
352
|
+
rows = st.list_state(product=args.product)
|
|
353
|
+
if getattr(args, "json", False):
|
|
354
|
+
print(json.dumps(rows, indent=2, default=str))
|
|
355
|
+
return 0
|
|
356
|
+
if not rows:
|
|
357
|
+
print("(no launch state recorded)")
|
|
358
|
+
return 0
|
|
359
|
+
print(f"{'PRODUCT':<20} {'CHANNEL':<11} {'STATUS':<9} URL")
|
|
360
|
+
for r in rows:
|
|
361
|
+
print(f"{_truncate(r['product'],20):<20} {r['channel']:<11} "
|
|
362
|
+
f"{r['status']:<9} {r.get('url') or '-'}")
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_launch_status(args: argparse.Namespace) -> int:
|
|
367
|
+
from .launch.store import LaunchStore
|
|
368
|
+
with LaunchStore(getattr(args, "db", None)) as st:
|
|
369
|
+
counts = st.status_counts(product=args.product)
|
|
370
|
+
if getattr(args, "json", False):
|
|
371
|
+
print(json.dumps(counts, indent=2))
|
|
372
|
+
return 0
|
|
373
|
+
scope = f" for {args.product}" if args.product else ""
|
|
374
|
+
print(f"launch state{scope}:")
|
|
375
|
+
for status, n in counts.items():
|
|
376
|
+
print(f" {status:<10} {n}")
|
|
377
|
+
return 0
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def cmd_launch_draft(args: argparse.Namespace) -> int:
|
|
381
|
+
from .launch.drafts import record_draft
|
|
382
|
+
from .launch.store import LaunchStore
|
|
383
|
+
critic = None
|
|
384
|
+
if args.critic:
|
|
385
|
+
try:
|
|
386
|
+
critic = json.loads(args.critic)
|
|
387
|
+
except json.JSONDecodeError as e:
|
|
388
|
+
print(f"bad --critic JSON: {e}", file=sys.stderr)
|
|
389
|
+
return 1
|
|
390
|
+
with LaunchStore(getattr(args, "db", None)) as st:
|
|
391
|
+
did = record_draft(st, args.product, args.platform, args.text,
|
|
392
|
+
community=args.community, critic=critic)
|
|
393
|
+
_emit(args, f"recorded starting-point draft {did} (you write the final post)",
|
|
394
|
+
{"id": did, "kind": "starting_point"})
|
|
395
|
+
return 0
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def cmd_launch_drafts(args: argparse.Namespace) -> int:
|
|
399
|
+
from .launch.drafts import emit
|
|
400
|
+
from .launch.store import LaunchStore
|
|
401
|
+
with LaunchStore(getattr(args, "db", None)) as st:
|
|
402
|
+
drafts = st.list_drafts(product=args.product)
|
|
403
|
+
if getattr(args, "json", False):
|
|
404
|
+
print(json.dumps(drafts, indent=2, default=str))
|
|
405
|
+
return 0
|
|
406
|
+
print(emit(drafts))
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def cmd_launch_capture(args: argparse.Namespace) -> int:
|
|
411
|
+
from .launch.collateral import plan_capture, run_capture
|
|
412
|
+
try:
|
|
413
|
+
spec = json.loads(args.spec) if args.spec else []
|
|
414
|
+
plan = plan_capture(spec)
|
|
415
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
416
|
+
print(str(e), file=sys.stderr)
|
|
417
|
+
return 1
|
|
418
|
+
results = run_capture(plan, args.outdir)
|
|
419
|
+
if getattr(args, "json", False):
|
|
420
|
+
print(json.dumps(results, indent=2, default=str))
|
|
421
|
+
else:
|
|
422
|
+
for r in results:
|
|
423
|
+
if r.get("ok"):
|
|
424
|
+
tag = "ok "
|
|
425
|
+
elif r.get("skipped"):
|
|
426
|
+
tag = "skip"
|
|
427
|
+
else:
|
|
428
|
+
tag = "FAIL"
|
|
429
|
+
detail = r.get("path") or r.get("reason") or ""
|
|
430
|
+
print(f" [{tag}] {r['kind']}:{r['name']} {detail}")
|
|
431
|
+
# only a hard error (ran but failed) is a nonzero exit; skips are environmental.
|
|
432
|
+
return 1 if any((not r.get("ok") and not r.get("skipped")) for r in results) else 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def cmd_launch_plan(args: argparse.Namespace) -> int:
|
|
436
|
+
from .launch.plan import build_plan, render_markdown
|
|
437
|
+
try:
|
|
438
|
+
targets = json.loads(args.targets) if args.targets else []
|
|
439
|
+
except json.JSONDecodeError as e:
|
|
440
|
+
print(f"bad --targets JSON: {e}", file=sys.stderr)
|
|
441
|
+
return 1
|
|
442
|
+
try:
|
|
443
|
+
plan = build_plan(args.product, targets)
|
|
444
|
+
except ValueError as e:
|
|
445
|
+
print(str(e), file=sys.stderr)
|
|
446
|
+
return 1
|
|
447
|
+
if getattr(args, "json", False):
|
|
448
|
+
print(json.dumps(plan, indent=2, default=str))
|
|
449
|
+
else:
|
|
450
|
+
print(render_markdown(plan))
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_launch_listen(args: argparse.Namespace) -> int:
|
|
455
|
+
from .connectors import get_connectors
|
|
456
|
+
from .connectors.base import Config
|
|
457
|
+
from .launch.listen import run_listen
|
|
458
|
+
|
|
459
|
+
cfg = Config.from_env()
|
|
460
|
+
try:
|
|
461
|
+
connectors = get_connectors(args.source)
|
|
462
|
+
except ValueError as e:
|
|
463
|
+
print(str(e), file=sys.stderr)
|
|
464
|
+
return 2
|
|
465
|
+
with _open(args) as bl:
|
|
466
|
+
summary = run_listen(bl, args.target, connectors=connectors, cfg=cfg)
|
|
467
|
+
if getattr(args, "json", False):
|
|
468
|
+
print(json.dumps(summary, indent=2, default=str))
|
|
469
|
+
return 0
|
|
470
|
+
print(f"listened for {summary['target']}: {summary['new']} new, "
|
|
471
|
+
f"{summary['merged']} folded into existing, {summary['fetched']} reactions "
|
|
472
|
+
f"({summary['low_confidence']} low-confidence)")
|
|
473
|
+
for skip in summary["skipped"]:
|
|
474
|
+
print(f" {skip['source']:<8} skipped - {skip['reason']}")
|
|
475
|
+
return 0
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def cmd_launch_policy(args: argparse.Namespace) -> int:
|
|
479
|
+
from .launch.policy import resolve_policy
|
|
480
|
+
from .launch.store import LaunchStore
|
|
481
|
+
with LaunchStore(getattr(args, "db", None)) as st:
|
|
482
|
+
result = resolve_policy(
|
|
483
|
+
st, args.community, platform=args.platform,
|
|
484
|
+
ttl_days=args.ttl_days, use_cache=not args.no_cache,
|
|
485
|
+
)
|
|
486
|
+
if getattr(args, "json", False):
|
|
487
|
+
print(json.dumps(result, indent=2, default=str))
|
|
488
|
+
else:
|
|
489
|
+
v = result["verdict"].upper()
|
|
490
|
+
print(f"[{v}] {args.platform} {args.community}"
|
|
491
|
+
+ (" (cached)" if result.get("cached") else ""))
|
|
492
|
+
for r in result.get("cited_rules", []):
|
|
493
|
+
print(f" - rule: {_truncate(r.get('text',''), 100)}")
|
|
494
|
+
if result.get("note"):
|
|
495
|
+
print(f" note: {result['note']}")
|
|
496
|
+
if result.get("error"):
|
|
497
|
+
print(f" (rules unavailable: {result['error']})")
|
|
498
|
+
# block is the only verdict that should fail the command (a hard "do not post here").
|
|
499
|
+
return 1 if result["verdict"] == "block" else 0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
503
|
+
# Shared global flags, attached to each leaf command so they work *after* the
|
|
504
|
+
# subcommand (e.g. `pmkit backlog list --json`), which is what users/agents type.
|
|
505
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
506
|
+
common.add_argument(
|
|
507
|
+
"--db", help="path to the backlog SQLite DB (default: ~/.pmkit/backlog.db)")
|
|
508
|
+
common.add_argument(
|
|
509
|
+
"--json", action="store_true", help="machine-readable JSON output")
|
|
510
|
+
|
|
511
|
+
parser = argparse.ArgumentParser(prog="pmkit", description="pm-system opportunity funnel CLI")
|
|
512
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
513
|
+
|
|
514
|
+
# discover (U3)
|
|
515
|
+
p_disc = sub.add_parser("discover", parents=[common],
|
|
516
|
+
help="ingest signals for a target into candidates")
|
|
517
|
+
p_disc.add_argument("target", help="owner/repo or ecosystem target")
|
|
518
|
+
p_disc.add_argument("--source", action="append", help="limit to specific source(s)")
|
|
519
|
+
p_disc.set_defaults(func=cmd_discover)
|
|
520
|
+
|
|
521
|
+
# backlog
|
|
522
|
+
p_bl = sub.add_parser("backlog", help="inspect and act on the opportunity backlog")
|
|
523
|
+
bsub = p_bl.add_subparsers(dest="backlog_command", required=True)
|
|
524
|
+
|
|
525
|
+
p_list = bsub.add_parser("list", parents=[common], help="list opportunities")
|
|
526
|
+
p_list.add_argument("--status", choices=[
|
|
527
|
+
"new", "survived", "pruned", "specced", "approved", "delegated", "shipped"])
|
|
528
|
+
p_list.add_argument("--sort", choices=["score", "created"], default="score")
|
|
529
|
+
p_list.add_argument("--limit", type=int)
|
|
530
|
+
p_list.set_defaults(func=cmd_backlog_list)
|
|
531
|
+
|
|
532
|
+
p_show = bsub.add_parser("show", parents=[common], help="show one opportunity")
|
|
533
|
+
p_show.add_argument("id", type=int)
|
|
534
|
+
p_show.set_defaults(func=cmd_backlog_show)
|
|
535
|
+
|
|
536
|
+
p_add = bsub.add_parser("add", parents=[common], help="add an opportunity manually")
|
|
537
|
+
p_add.add_argument("--target", required=True)
|
|
538
|
+
p_add.add_argument("--title", required=True)
|
|
539
|
+
p_add.add_argument("--problem", default="")
|
|
540
|
+
p_add.add_argument("--source", action="append", help="source URL (repeatable)")
|
|
541
|
+
p_add.add_argument("--low-confidence", dest="low_confidence", action="store_true")
|
|
542
|
+
p_add.set_defaults(func=cmd_backlog_add)
|
|
543
|
+
|
|
544
|
+
p_prom = bsub.add_parser("promote", parents=[common],
|
|
545
|
+
help="promote a survived item to 'specced'")
|
|
546
|
+
p_prom.add_argument("id", type=int)
|
|
547
|
+
p_prom.set_defaults(func=cmd_backlog_promote)
|
|
548
|
+
|
|
549
|
+
p_appr = bsub.add_parser("approve", parents=[common],
|
|
550
|
+
help="approve a specced item (the human gate)")
|
|
551
|
+
p_appr.add_argument("id", type=int)
|
|
552
|
+
p_appr.add_argument("--note")
|
|
553
|
+
p_appr.set_defaults(func=cmd_backlog_approve)
|
|
554
|
+
|
|
555
|
+
p_cat = bsub.add_parser("categorize", parents=[common],
|
|
556
|
+
help="set a product category on an opportunity")
|
|
557
|
+
p_cat.add_argument("id", type=int)
|
|
558
|
+
p_cat.add_argument("--category", required=True,
|
|
559
|
+
choices=["agent-only", "human-and-agent"])
|
|
560
|
+
p_cat.set_defaults(func=cmd_backlog_categorize)
|
|
561
|
+
|
|
562
|
+
p_spec = bsub.add_parser("spec", parents=[common],
|
|
563
|
+
help="record the drafted requirements doc path")
|
|
564
|
+
p_spec.add_argument("id", type=int)
|
|
565
|
+
p_spec.add_argument("--path", required=True)
|
|
566
|
+
p_spec.set_defaults(func=cmd_backlog_spec)
|
|
567
|
+
|
|
568
|
+
p_kt = bsub.add_parser("killtest", parents=[common],
|
|
569
|
+
help="record kill-test result (new -> survived|pruned)")
|
|
570
|
+
p_kt.add_argument("id", type=int)
|
|
571
|
+
kt_grp = p_kt.add_mutually_exclusive_group(required=True)
|
|
572
|
+
kt_grp.add_argument("--survived", dest="survived", action="store_true")
|
|
573
|
+
kt_grp.add_argument("--pruned", dest="survived", action="store_false")
|
|
574
|
+
kt_grp.add_argument("--decide", action="store_true",
|
|
575
|
+
help="apply the survival rule from --verdicts (already-solved is dispositive)")
|
|
576
|
+
p_kt.add_argument("--verdicts", help="JSON array of per-axis verdicts")
|
|
577
|
+
p_kt.add_argument("--verdicts-file",
|
|
578
|
+
help="path to a JSON file of per-axis verdicts (robust alternative to "
|
|
579
|
+
"--verdicts; avoids shell-mangling a large blob). Wins if both given.")
|
|
580
|
+
p_kt.set_defaults(func=cmd_backlog_killtest, survived=None)
|
|
581
|
+
|
|
582
|
+
p_score = bsub.add_parser("score", parents=[common],
|
|
583
|
+
help="set RICE sub-scores (computes the composite)")
|
|
584
|
+
p_score.add_argument("id", type=int)
|
|
585
|
+
p_score.add_argument("--reach", type=float, required=True)
|
|
586
|
+
p_score.add_argument("--impact", type=float, required=True)
|
|
587
|
+
p_score.add_argument("--confidence", type=float, required=True)
|
|
588
|
+
p_score.add_argument("--effort", type=float, required=True)
|
|
589
|
+
p_score.set_defaults(func=cmd_backlog_score)
|
|
590
|
+
|
|
591
|
+
p_del = bsub.add_parser("delegate", parents=[common],
|
|
592
|
+
help="record delegation of an approved item (gated)")
|
|
593
|
+
p_del.add_argument("id", type=int)
|
|
594
|
+
p_del.add_argument("--spec", help="spec path (defaults to the recorded spec_path)")
|
|
595
|
+
p_del.add_argument("--target", help="implementation target (defaults to the opportunity target)")
|
|
596
|
+
p_del.set_defaults(func=cmd_backlog_delegate)
|
|
597
|
+
|
|
598
|
+
p_ship = bsub.add_parser("ship", parents=[common],
|
|
599
|
+
help="mark a delegated item as shipped")
|
|
600
|
+
p_ship.add_argument("id", type=int)
|
|
601
|
+
p_ship.set_defaults(func=cmd_backlog_ship)
|
|
602
|
+
|
|
603
|
+
p_stat = bsub.add_parser("status", parents=[common],
|
|
604
|
+
help="show counts by lifecycle status")
|
|
605
|
+
p_stat.set_defaults(func=cmd_backlog_status)
|
|
606
|
+
|
|
607
|
+
p_exp = bsub.add_parser("export", parents=[common], help="export a markdown snapshot")
|
|
608
|
+
p_exp.add_argument("--out", help="write to this path instead of stdout")
|
|
609
|
+
p_exp.set_defaults(func=cmd_backlog_export)
|
|
610
|
+
|
|
611
|
+
# dogfood — acceptance-test helpers (pm-dogfood skill composes these)
|
|
612
|
+
p_df = sub.add_parser("dogfood", help="acceptance-test a shipped product")
|
|
613
|
+
dsub = p_df.add_subparsers(dest="dogfood_command", required=True)
|
|
614
|
+
|
|
615
|
+
p_dfi = dsub.add_parser("install", parents=[common],
|
|
616
|
+
help="run documented install commands in a clean room")
|
|
617
|
+
p_dfi.add_argument("--cmd", action="append", required=True,
|
|
618
|
+
help="a documented command, verbatim (repeatable)")
|
|
619
|
+
p_dfi.set_defaults(func=cmd_dogfood_install)
|
|
620
|
+
|
|
621
|
+
p_dfu = dsub.add_parser("ui", parents=[common],
|
|
622
|
+
help="drive a running app's UI in a real browser")
|
|
623
|
+
p_dfu.add_argument("--url", required=True)
|
|
624
|
+
p_dfu.add_argument("--steps", required=True, help="JSON list of {action,target,value}")
|
|
625
|
+
p_dfu.set_defaults(func=cmd_dogfood_ui)
|
|
626
|
+
|
|
627
|
+
p_dfm = dsub.add_parser("mcp", parents=[common],
|
|
628
|
+
help="drive an MCP server as a real client")
|
|
629
|
+
p_dfm.add_argument("--server", required=True, help="server launch command (quoted)")
|
|
630
|
+
p_dfm.add_argument("--calls", required=True, help="JSON list of {tool,args}")
|
|
631
|
+
p_dfm.set_defaults(func=cmd_dogfood_mcp)
|
|
632
|
+
|
|
633
|
+
# launch — the launch/amplify stage (logistics; never posts). Built up across units:
|
|
634
|
+
# state/status (U1), policy (U2), listen (U3), plan (U4), capture (U5), draft (U7).
|
|
635
|
+
p_lc = sub.add_parser("launch", help="prepare a product launch (logistics; never posts)")
|
|
636
|
+
lsub = p_lc.add_subparsers(dest="launch_command", required=True)
|
|
637
|
+
|
|
638
|
+
p_lan = lsub.add_parser("announce", parents=[common],
|
|
639
|
+
help="record that a product was announced on a channel")
|
|
640
|
+
p_lan.add_argument("--product", required=True)
|
|
641
|
+
p_lan.add_argument("--channel", required=True,
|
|
642
|
+
choices=["reddit", "hackernews", "x", "linkedin"])
|
|
643
|
+
p_lan.add_argument("--url")
|
|
644
|
+
p_lan.set_defaults(func=cmd_launch_announce)
|
|
645
|
+
|
|
646
|
+
p_lst = lsub.add_parser("state", parents=[common], help="list launch-state ledger rows")
|
|
647
|
+
p_lst.add_argument("--product")
|
|
648
|
+
p_lst.set_defaults(func=cmd_launch_state)
|
|
649
|
+
|
|
650
|
+
p_lstat = lsub.add_parser("status", parents=[common],
|
|
651
|
+
help="show launch-state counts by status")
|
|
652
|
+
p_lstat.add_argument("--product")
|
|
653
|
+
p_lstat.set_defaults(func=cmd_launch_status)
|
|
654
|
+
|
|
655
|
+
p_lpol = lsub.add_parser("policy", parents=[common],
|
|
656
|
+
help="research a community's mod policy (block/warn/ok + cited rule)")
|
|
657
|
+
p_lpol.add_argument("--community", required=True, help="e.g. r/gis")
|
|
658
|
+
p_lpol.add_argument("--platform", default="reddit",
|
|
659
|
+
choices=["reddit", "hackernews", "x", "linkedin"])
|
|
660
|
+
p_lpol.add_argument("--ttl-days", dest="ttl_days", type=int, default=30)
|
|
661
|
+
p_lpol.add_argument("--no-cache", dest="no_cache", action="store_true")
|
|
662
|
+
p_lpol.set_defaults(func=cmd_launch_policy)
|
|
663
|
+
|
|
664
|
+
p_lli = lsub.add_parser("listen", parents=[common],
|
|
665
|
+
help="ingest post-launch reactions into the backlog (read-only)")
|
|
666
|
+
p_lli.add_argument("target", help="owner/repo or ecosystem target the launch was for")
|
|
667
|
+
p_lli.add_argument("--source", action="append", help="limit to specific source(s)")
|
|
668
|
+
p_lli.set_defaults(func=cmd_launch_listen)
|
|
669
|
+
|
|
670
|
+
p_lpl = lsub.add_parser("plan", parents=[common],
|
|
671
|
+
help="render an emit-only launch plan from structured targets")
|
|
672
|
+
p_lpl.add_argument("--product", required=True)
|
|
673
|
+
p_lpl.add_argument("--targets", required=True,
|
|
674
|
+
help="JSON list of {platform, community, [thread], [angle], [day], [policy]}")
|
|
675
|
+
p_lpl.set_defaults(func=cmd_launch_plan)
|
|
676
|
+
|
|
677
|
+
p_lcap = lsub.add_parser("capture", parents=[common],
|
|
678
|
+
help="capture Tier-A collateral (record the real product working)")
|
|
679
|
+
p_lcap.add_argument("--spec", required=True,
|
|
680
|
+
help="JSON list of capture requests {kind, ...}")
|
|
681
|
+
p_lcap.add_argument("--outdir", required=True, help="directory to write artifacts to")
|
|
682
|
+
p_lcap.set_defaults(func=cmd_launch_capture)
|
|
683
|
+
|
|
684
|
+
p_ldr = lsub.add_parser("draft", parents=[common],
|
|
685
|
+
help="store an agent-produced draft STARTING-POINT (never a post)")
|
|
686
|
+
p_ldr.add_argument("--product", required=True)
|
|
687
|
+
p_ldr.add_argument("--platform", required=True,
|
|
688
|
+
choices=["reddit", "hackernews", "x", "linkedin"])
|
|
689
|
+
p_ldr.add_argument("--community")
|
|
690
|
+
p_ldr.add_argument("--text", required=True)
|
|
691
|
+
p_ldr.add_argument("--critic", help="JSON slop-critic verdict {flagged,score,tells,suggestion}")
|
|
692
|
+
p_ldr.set_defaults(func=cmd_launch_draft)
|
|
693
|
+
|
|
694
|
+
p_ldrs = lsub.add_parser("drafts", parents=[common],
|
|
695
|
+
help="list/emit stored draft starting-points (with critic flags)")
|
|
696
|
+
p_ldrs.add_argument("--product")
|
|
697
|
+
p_ldrs.set_defaults(func=cmd_launch_drafts)
|
|
698
|
+
|
|
699
|
+
return parser
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _force_utf8_output() -> None:
|
|
703
|
+
"""Print arbitrary web content (emoji, CJK) without crashing on legacy consoles.
|
|
704
|
+
|
|
705
|
+
Windows defaults stdout to cp1252, which raises UnicodeEncodeError on any
|
|
706
|
+
non-Latin1 character in a fetched title. Reconfigure to UTF-8 with replacement.
|
|
707
|
+
"""
|
|
708
|
+
for stream in (sys.stdout, sys.stderr):
|
|
709
|
+
try:
|
|
710
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
711
|
+
except Exception:
|
|
712
|
+
pass
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
716
|
+
_force_utf8_output()
|
|
717
|
+
parser = build_parser()
|
|
718
|
+
args = parser.parse_args(argv)
|
|
719
|
+
return args.func(args)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
if __name__ == "__main__":
|
|
723
|
+
raise SystemExit(main())
|