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/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())