luckiest-co 1.0.59 → 1.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ads/CHANGELOG.md +3 -0
  5. package/skills/ads/SKILL.md +12 -3
  6. package/skills/churn-prevention/CHANGELOG.md +3 -0
  7. package/skills/churn-prevention/SKILL.md +5 -3
  8. package/skills/churn-prevention/scripts/test_triage.py +100 -0
  9. package/skills/churn-prevention/scripts/triage.py +151 -0
  10. package/skills/community-marketing/CHANGELOG.md +3 -0
  11. package/skills/community-marketing/SKILL.md +5 -4
  12. package/skills/community-marketing/scripts/laya_tag.py +180 -0
  13. package/skills/community-marketing/scripts/test_laya_tag.py +113 -0
  14. package/skills/customer-research/CHANGELOG.md +3 -0
  15. package/skills/customer-research/SKILL.md +17 -3
  16. package/skills/customer-research/scripts/test_triage.py +100 -0
  17. package/skills/customer-research/scripts/triage.py +151 -0
  18. package/skills/launch/CHANGELOG.md +3 -0
  19. package/skills/launch/SKILL.md +12 -4
  20. package/skills/model-router/CHANGELOG.md +10 -0
  21. package/skills/model-router/SKILL.md +12 -3
  22. package/skills/model-router/scripts/route.py +120 -20
  23. package/skills/model-router/scripts/test_route.py +88 -0
  24. package/skills/plan/SKILL.md +3 -1
  25. package/skills/research/CHANGELOG.md +3 -0
  26. package/skills/research/SKILL.md +5 -3
  27. package/skills/research/scripts/laya_tag.py +180 -0
  28. package/skills/research/scripts/test_laya_tag.py +113 -0
  29. package/skills/skill-audit-security/CHANGELOG.md +3 -0
  30. package/skills/skill-audit-security/SKILL.md +15 -3
  31. package/skills/skill-audit-security/scripts/laya_tag.py +180 -0
  32. package/skills/skill-audit-security/scripts/test_laya_tag.py +113 -0
  33. package/skills/social/CHANGELOG.md +3 -0
  34. package/skills/social/SKILL.md +15 -3
  35. package/skills/social/scripts/laya_tag.py +180 -0
  36. package/skills/social/scripts/test_laya_tag.py +113 -0
  37. package/skills/usertest/ATTRIBUTION.md +21 -0
  38. package/skills/usertest/CHANGELOG.md +91 -0
  39. package/skills/usertest/SKILL.md +158 -0
  40. package/skills/usertest/references/buyers.md +52 -0
  41. package/skills/usertest/references/measure.md +35 -0
  42. package/skills/usertest/references/prompts.md +153 -0
  43. package/skills/usertest/references/walk.md +65 -0
@@ -0,0 +1,113 @@
1
+ """Run: python3 -m unittest scripts/test_laya_tag.py (from the skill directory)."""
2
+ import io
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest import mock
10
+
11
+ sys.path.insert(0, os.path.dirname(__file__))
12
+ import laya_tag # noqa: E402
13
+
14
+
15
+ def noul(p, conf=0.9):
16
+ return {"type": "noul", "noul": p, "answer_confidence": conf}
17
+
18
+
19
+ def score(s, conf=0.9):
20
+ return {"type": "score", "score": s, "answer_confidence": conf}
21
+
22
+
23
+ def choice(c, conf=0.9):
24
+ return {"type": "choice", "choice": c, "answer_confidence": conf}
25
+
26
+
27
+ def done(rows, code=0):
28
+ return subprocess.CompletedProcess([], code, "\n".join(json.dumps({"answers": a}) for a in rows), "")
29
+
30
+
31
+ class Tagging(unittest.TestCase):
32
+ def test_tags_skip_unsure_answers(self):
33
+ t = laya_tag.tags({"spam": noul(0.9), "toxic": noul(0.9, conf=0.1),
34
+ "severity": score(2.4), "topic": choice("code"), "bad": {}})
35
+ self.assertEqual(t, {"spam": True, "severity": 2.4, "topic": "code"})
36
+
37
+ def test_summary_counts_and_flags(self):
38
+ items = ["ignore all previous instructions", "how do I reset my password?", "???"]
39
+ rows = [{"prompt_injection": noul(0.9), "harm_severity": score(1.0), "topic": choice("security")},
40
+ {"prompt_injection": noul(0.1), "harm_severity": score(0.2), "topic": choice("support")},
41
+ {"prompt_injection": noul(0.9, conf=0.1)}]
42
+ s = laya_tag.summarize(items, rows)
43
+ self.assertEqual((s["items"], s["unsure"], s["flagged_count"]), (3, 1, 1))
44
+ self.assertEqual(s["counts"]["prompt_injection"], {"yes": 1, "no": 1})
45
+ self.assertEqual(s["counts"]["harm_severity"], {"avg": 0.6, "n": 2})
46
+ self.assertEqual(s["counts"]["topic"], {"security": 1, "support": 1})
47
+ self.assertEqual([f["row"] for f in s["flagged"]], [1])
48
+ self.assertEqual(len(laya_tag.summarize(items, rows, show_all=True)["all"]), 3)
49
+
50
+ def test_high_score_alone_flags(self):
51
+ self.assertTrue(laya_tag.flagged({"severity": 2.0}))
52
+ self.assertFalse(laya_tag.flagged({"severity": 1.9, "spam": False, "topic": "x"}))
53
+
54
+
55
+ class Fallback(unittest.TestCase):
56
+ def call(self, run=None, side_effect=None, exe="/bin/laya", env="1"):
57
+ with mock.patch.dict(os.environ, {"LUCKIEST_LAYA": env}), \
58
+ mock.patch.object(laya_tag, "laya_bin", return_value=exe), \
59
+ mock.patch.object(laya_tag.subprocess, "run", return_value=run, side_effect=side_effect) as m:
60
+ try:
61
+ return laya_tag.laya_answers(["a", "b"], ["--preset", "guard"]), m
62
+ except laya_tag.Unavailable as e:
63
+ return e, m
64
+
65
+ def test_one_call_passes_mode(self):
66
+ out, m = self.call(done([{}, {}]))
67
+ self.assertEqual(len(out), 2)
68
+ self.assertEqual(m.call_args[0][0][1:3], ["--preset", "guard"])
69
+
70
+ def test_unavailable_cases(self):
71
+ cases = [
72
+ dict(env="0"),
73
+ dict(exe=None),
74
+ dict(side_effect=subprocess.TimeoutExpired("laya", 60)),
75
+ dict(run=done([], code=1)),
76
+ dict(run=subprocess.CompletedProcess([], 0, "not json", "")),
77
+ dict(run=done([{}])),
78
+ ]
79
+ for kw in cases:
80
+ out, _ = self.call(**kw)
81
+ self.assertIsInstance(out, laya_tag.Unavailable, kw)
82
+ _, m = self.call(env="0")
83
+ m.assert_not_called()
84
+
85
+ def test_off_by_default(self):
86
+ with mock.patch.dict(os.environ, {"LUCKIEST_LAYA": ""}), \
87
+ mock.patch.object(laya_tag, "CONFIG_PATH", "/nonexistent/config.json"):
88
+ self.assertFalse(laya_tag.laya_preferred())
89
+
90
+ def test_main_exit_codes(self):
91
+ with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
92
+ f.write("one\n\ntwo\n")
93
+ err = io.StringIO()
94
+ with mock.patch.dict(os.environ, {"LUCKIEST_LAYA": "0"}), mock.patch("sys.stderr", err):
95
+ self.assertEqual(laya_tag.main(["--preset", "guard", f.name]), 2)
96
+ self.assertIn("by hand", err.getvalue())
97
+ for bad in ([f.name], ["--preset", "guard", "--questions", "q.json", f.name],
98
+ ["--questions", "/nonexistent.json", f.name]):
99
+ with self.assertRaises(SystemExit):
100
+ laya_tag.main(bad)
101
+ os.unlink(f.name)
102
+
103
+
104
+ class Input(unittest.TestCase):
105
+ def test_csv_column(self):
106
+ with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, newline="") as f:
107
+ f.write('id,text\n1,"multi\nline"\n2,\n3,ok\n')
108
+ self.assertEqual(laya_tag.read_items(f.name, "text"), ["multi line", "ok"])
109
+ os.unlink(f.name)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ unittest.main()
@@ -1,5 +1,8 @@
1
1
  # Changelog — luckiest-customer-research
2
2
 
3
+ ## 1.3.0 — 2026-11-01
4
+ - Optional Laya tagging: `scripts/triage.py` tags reviews, tickets and survey answers locally (intent, refund, frustration, urgency, churn risk) when the user prefers Laya (`{"laya": true}` or `LUCKIEST_LAYA=1`). Exit 2 means skip it; the skill works as before without Laya.
5
+
3
6
  ## 1.2.0 — 2026-10-08
4
7
  Upstream sync with coreyhaines31/marketingskills dda3841 (issue #112).
5
8
 
@@ -3,7 +3,7 @@ name: customer-research
3
3
  description: "When the user wants to conduct, analyze, or synthesize customer research. Use when the user mentions \"customer research,\" \"ICP research,\" \"talk to customers,\" \"analyze transcripts,\" \"customer interviews,\" \"survey analysis,\" \"support ticket analysis,\" \"voice of customer,\" \"VOC,\" \"build personas,\" \"customer personas,\" \"jobs to be done,\" \"JTBD,\" \"what do customers say,\" \"what are customers struggling with,\" \"Reddit mining,\" \"G2 reviews,\" \"review mining,\" \"digital watering holes,\" \"community research,\" \"forum research,\" \"competitor reviews,\" \"customer sentiment,\" or \"find out why customers churn/convert/buy.\" Use for both analyzing existing research assets AND gathering new research from online sources. For writing copy informed by research, see luckiest-copywriting. For acting on research to improve pages, see luckiest-cro."
4
4
  license: See ATTRIBUTION.md
5
5
  metadata:
6
- version: "1.2.0"
6
+ version: "1.3.0"
7
7
  listing_id: luckiest-customer-research
8
8
  author: luckiest
9
9
  ---
@@ -16,9 +16,9 @@ This skill acts as an expert customer researcher. Its goal is to help uncover wh
16
16
 
17
17
  ## Staying current
18
18
 
19
- On activation, call the Luckiest MCP check_updates tool with { listingId: "8e289fdd-4950-4503-ae10-c1426e861a12", installedSemver: "1.2.0" }. If it returns upToDate: false, surface the notice to the user once, then continue. Do nothing further if upToDate: true. Never block on this check — if the tool is unavailable, proceed.
19
+ On activation, call the Luckiest MCP check_updates tool with { listingId: "8e289fdd-4950-4503-ae10-c1426e861a12", installedSemver: "1.3.0" }. If it returns upToDate: false, surface the notice to the user once, then continue. Do nothing further if upToDate: true. Never block on this check — if the tool is unavailable, proceed.
20
20
 
21
- When the skill's work is done, call the Luckiest MCP report_usage tool once with { listing_id: "luckiest-customer-research", skill_version: "1.2.0", matched: true, success: <true if the skill completed, false otherwise> }. Metadata only, never prompt text. Never block on it — if the tool is unavailable, skip silently.
21
+ When the skill's work is done, call the Luckiest MCP report_usage tool once with { listing_id: "luckiest-customer-research", skill_version: "1.3.0", matched: true, success: <true if the skill completed, false otherwise> }. Metadata only, never prompt text. Never block on it — if the tool is unavailable, skip silently.
22
22
 
23
23
  ## Before Starting
24
24
 
@@ -66,6 +66,20 @@ Most engagements combine both. Establish which mode applies before proceeding.
66
66
  - Passives and detractors are higher signal than promoters for improvement work
67
67
  - Pair scores with verbatims — a 9 with a specific complaint beats a 10 with no comment
68
68
 
69
+ ### Optional: fast tagging with Laya
70
+
71
+ When there are more than about 30 short messages (reviews, tickets, survey answers, call notes split one per line), try tagging them locally first:
72
+
73
+ ```bash
74
+ python3 scripts/triage.py messages.txt # one message per line
75
+ python3 scripts/triage.py reviews.csv --column body
76
+ ```
77
+
78
+ - It runs only when the user prefers Laya (`{"laya": true}` in `~/.luckiest/config.json`, or `LUCKIEST_LAYA=1`) and Laya is installed.
79
+ - Exit code 2 means Laya is off, missing, or failed. Skip this step and work through the messages by hand as below. Nothing else changes.
80
+ - On success it prints intent counts, the share of messages that are urgent, ask for a refund, or hint at leaving, average frustration (0 to 3), and up to 20 flagged messages. Use the counts for frequency and intensity scoring, and read the flagged messages first for pains and money quotes. Laya tags support intent, not jobs to be done, so still run the extraction framework on what you read.
81
+ - Messages Laya is unsure about are counted as `unsure`. Read a sample of them by hand.
82
+
69
83
  ### Extraction Framework
70
84
 
71
85
  For each asset, extract:
@@ -0,0 +1,100 @@
1
+ """Run: python3 -m unittest scripts/test_triage.py (from the skill directory)."""
2
+ import io
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest import mock
10
+
11
+ sys.path.insert(0, os.path.dirname(__file__))
12
+ import triage # noqa: E402
13
+
14
+
15
+ def ans(intent="refund", conf=0.9, frustration=2.5, urgent=0.1, refund=0.9, churn=0.1):
16
+ return {"intent": {"choice": intent, "answer_confidence": conf},
17
+ "frustration": {"score": frustration}, "is_urgent": {"noul": urgent},
18
+ "refund_requested": {"noul": refund}, "churn_risk": {"noul": churn}}
19
+
20
+
21
+ def done(rows, code=0):
22
+ return subprocess.CompletedProcess([], code, "\n".join(json.dumps({"answers": a}) for a in rows), "")
23
+
24
+
25
+ class Tagging(unittest.TestCase):
26
+ def test_tag_and_unsure(self):
27
+ self.assertEqual(triage.tag(ans()), {"intent": "refund", "frustration": 2.5,
28
+ "urgent": False, "refund": True, "churn_risk": False})
29
+ self.assertIsNone(triage.tag(ans(conf=0.2)))
30
+ self.assertIsNone(triage.tag({"intent": {}}))
31
+
32
+ def test_summary(self):
33
+ msgs = ["give my money back now", "how do I export?", "switching to a rival", "???"]
34
+ rows = [ans(), ans("information", frustration=0.2, refund=0.1),
35
+ ans("cancellation", frustration=2.0, refund=0.1, churn=0.9), ans(conf=0.1)]
36
+ s = triage.summarize(msgs, rows)
37
+ self.assertEqual((s["messages"], s["tagged"], s["unsure"]), (4, 3, 1))
38
+ self.assertEqual(s["intent"]["refund"], 1)
39
+ self.assertEqual((s["refund_pct"], s["churn_risk_pct"]), (33, 33))
40
+ self.assertEqual([f["row"] for f in s["flagged"]], [3, 1]) # leaving + angry outranks refund
41
+ self.assertEqual(s["flagged"][0]["text"], "switching to a rival")
42
+
43
+
44
+ class Fallback(unittest.TestCase):
45
+ def call(self, run=None, side_effect=None, exe="/bin/laya", env="1"):
46
+ with mock.patch.dict(os.environ, {"LUCKIEST_LAYA": env}), \
47
+ mock.patch.object(triage, "laya_bin", return_value=exe), \
48
+ mock.patch.object(triage.subprocess, "run", return_value=run, side_effect=side_effect) as m:
49
+ try:
50
+ return triage.laya_answers(["a", "b"]), m
51
+ except triage.Unavailable as e:
52
+ return e, m
53
+
54
+ def test_one_call_when_on(self):
55
+ out, m = self.call(done([ans(), ans()]))
56
+ self.assertEqual(len(out), 2)
57
+ self.assertEqual(m.call_count, 1)
58
+
59
+ def test_unavailable_cases(self):
60
+ cases = [
61
+ dict(env="0"), # not preferred: Laya never runs
62
+ dict(exe=None), # not installed
63
+ dict(side_effect=subprocess.TimeoutExpired("laya", 60)),
64
+ dict(run=done([], code=1)),
65
+ dict(run=subprocess.CompletedProcess([], 0, "not json", "")),
66
+ dict(run=done([ans()])), # wrong row count
67
+ ]
68
+ for kw in cases:
69
+ out, m = self.call(**kw)
70
+ self.assertIsInstance(out, triage.Unavailable, kw)
71
+ out, m = self.call(env="0")
72
+ m.assert_not_called()
73
+
74
+ def test_off_by_default(self):
75
+ with mock.patch.dict(os.environ, {"LUCKIEST_LAYA": ""}), \
76
+ mock.patch.object(triage, "CONFIG_PATH", "/nonexistent/config.json"):
77
+ self.assertFalse(triage.laya_preferred())
78
+
79
+ def test_main_exits_2_and_says_why(self):
80
+ with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
81
+ f.write("refund please\n\nthis is broken\n")
82
+ err = io.StringIO()
83
+ with mock.patch.dict(os.environ, {"LUCKIEST_LAYA": "0"}), mock.patch("sys.stderr", err):
84
+ self.assertEqual(triage.main([f.name]), 2)
85
+ self.assertIn("Tag messages by hand", err.getvalue())
86
+ os.unlink(f.name)
87
+
88
+
89
+ class Input(unittest.TestCase):
90
+ def test_csv_column_and_blank_lines(self):
91
+ with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, newline="") as f:
92
+ f.write('id,body\n1,"line one\nstill one"\n2,\n3,second\n')
93
+ self.assertEqual(triage.read_messages(f.name, "body"), ["line one still one", "second"])
94
+ with self.assertRaises(SystemExit):
95
+ triage.read_messages(f.name, "missing")
96
+ os.unlink(f.name)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ unittest.main()
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """Tag customer messages (reviews, tickets, survey answers) with Laya, locally.
3
+
4
+ Usage:
5
+ python3 triage.py messages.txt # one message per line
6
+ python3 triage.py reviews.csv --column body # one CSV column
7
+ cat messages.txt | python3 triage.py
8
+
9
+ Optional. It runs only when the user prefers Laya ({"laya": true} in
10
+ ~/.luckiest/config.json, or LUCKIEST_LAYA=1) and Laya is installed. Otherwise
11
+ it exits 2 with one line on stderr, and the skill tags messages by hand as
12
+ before. Exit 1 means bad input.
13
+
14
+ Output: a JSON summary on stdout: intent counts, the share of messages that are
15
+ urgent, ask for a refund, or hint at leaving, average frustration (0 calm to 3
16
+ very angry), and the most flagged messages to read first. Messages Laya is
17
+ unsure about are counted in `unsure` and left out of the numbers.
18
+
19
+ Same copy in luckiest-customer-research and luckiest-churn-prevention.
20
+ """
21
+ import csv
22
+ import json
23
+ import os
24
+ import shutil
25
+ import subprocess
26
+ import sys
27
+
28
+ CONFIDENCE_FLOOR = 0.4
29
+ FLAGGED_LIMIT = 20
30
+ CONFIG_PATH = os.path.expanduser("~/.luckiest/config.json")
31
+ INTENTS = ["refund", "technical_help", "billing_question", "information", "cancellation", "other"]
32
+
33
+
34
+ class Unavailable(Exception):
35
+ """Laya is off, missing, or failed. The skill falls back to tagging by hand."""
36
+
37
+
38
+ def laya_preferred():
39
+ env = os.environ.get("LUCKIEST_LAYA")
40
+ if env:
41
+ return env.strip().lower() in ("1", "true", "yes", "on")
42
+ try:
43
+ with open(CONFIG_PATH) as f:
44
+ return json.load(f).get("laya") is True
45
+ except (OSError, ValueError, AttributeError):
46
+ return False
47
+
48
+
49
+ def laya_bin():
50
+ venv = os.path.expanduser("~/.venvs/laya/bin/laya")
51
+ return shutil.which("laya") or (venv if os.access(venv, os.X_OK) else None)
52
+
53
+
54
+ def read_messages(path=None, column=None):
55
+ f = open(path, newline="", encoding="utf-8") if path else sys.stdin
56
+ with f:
57
+ if column:
58
+ reader = csv.DictReader(f)
59
+ if column not in (reader.fieldnames or []):
60
+ sys.exit(f"column {column!r} not found, have: {', '.join(reader.fieldnames or [])}")
61
+ rows = [r[column] or "" for r in reader]
62
+ else:
63
+ rows = f.read().splitlines()
64
+ return [" ".join(r.split()) for r in rows if r.strip()]
65
+
66
+
67
+ def laya_answers(messages):
68
+ """One Laya batch call for all messages. Raises Unavailable on any failure."""
69
+ if not laya_preferred():
70
+ raise Unavailable("laya not preferred (set {\"laya\": true} in ~/.luckiest/config.json)")
71
+ exe = laya_bin()
72
+ if not exe:
73
+ raise Unavailable("laya not installed")
74
+ try:
75
+ run = subprocess.run([exe, "--preset", "triage", "--json", "--batch", "-"],
76
+ input="\n".join(messages) + "\n", capture_output=True, text=True,
77
+ timeout=60 + len(messages) // 2)
78
+ if run.returncode != 0:
79
+ raise Unavailable(f"laya failed: {run.stderr.strip()[:200]}")
80
+ answers = [json.loads(l)["answers"] for l in run.stdout.splitlines() if l.strip()]
81
+ except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, TypeError) as e:
82
+ raise Unavailable(f"laya failed: {type(e).__name__}")
83
+ if len(answers) != len(messages):
84
+ raise Unavailable("laya returned the wrong number of rows")
85
+ return answers
86
+
87
+
88
+ def tag(ans):
89
+ """One message's tags, or None when Laya is unsure of its intent."""
90
+ try:
91
+ intent = ans["intent"]
92
+ if intent["answer_confidence"] < CONFIDENCE_FLOOR:
93
+ return None
94
+ return {
95
+ "intent": intent["choice"],
96
+ "frustration": round(float(ans["frustration"]["score"]), 1),
97
+ "urgent": ans["is_urgent"]["noul"] >= 0.5,
98
+ "refund": ans["refund_requested"]["noul"] >= 0.5,
99
+ "churn_risk": ans["churn_risk"]["noul"] >= 0.5,
100
+ }
101
+ except (KeyError, TypeError, ValueError):
102
+ return None
103
+
104
+
105
+ def summarize(messages, answers):
106
+ tags = [(i, tag(a)) for i, a in enumerate(answers)]
107
+ rows = [(i, t) for i, t in tags if t]
108
+ n = len(rows)
109
+ pct = lambda key: round(100 * sum(t[key] for _, t in rows) / n) if n else 0
110
+ intents = {k: 0 for k in INTENTS}
111
+ for _, t in rows:
112
+ intents[t["intent"]] = intents.get(t["intent"], 0) + 1
113
+ # Read first: angriest, then leaving, refund, urgent.
114
+ weight = lambda t: t["frustration"] + 2 * t["churn_risk"] + t["refund"] + t["urgent"]
115
+ flagged = sorted((r for r in rows if weight(r[1]) >= 2), key=lambda r: -weight(r[1]))
116
+ return {
117
+ "engine": "laya",
118
+ "messages": len(messages),
119
+ "tagged": n,
120
+ "unsure": len(messages) - n,
121
+ "intent": intents,
122
+ "urgent_pct": pct("urgent"),
123
+ "refund_pct": pct("refund"),
124
+ "churn_risk_pct": pct("churn_risk"),
125
+ "avg_frustration": round(sum(t["frustration"] for _, t in rows) / n, 2) if n else 0,
126
+ "flagged": [dict(t, row=i + 1, text=messages[i][:240]) for i, t in flagged[:FLAGGED_LIMIT]],
127
+ }
128
+
129
+
130
+ def main(argv):
131
+ column = None
132
+ if "--column" in argv:
133
+ i = argv.index("--column")
134
+ if i + 1 >= len(argv):
135
+ sys.exit("--column needs a name")
136
+ column = argv[i + 1]
137
+ del argv[i:i + 2]
138
+ messages = read_messages(argv[0] if argv else None, column)
139
+ if not messages:
140
+ sys.exit("no messages found")
141
+ try:
142
+ answers = laya_answers(messages)
143
+ except Unavailable as e:
144
+ print(f"laya unavailable: {e}. Tag messages by hand.", file=sys.stderr)
145
+ return 2
146
+ print(json.dumps(summarize(messages, answers), indent=2))
147
+ return 0
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main(sys.argv[1:]))
@@ -1,5 +1,8 @@
1
1
  # Changelog — luckiest-launch
2
2
 
3
+ ## 1.3.0 - 2026-11-04
4
+ - New sub-skill: luckiest-usertest walks the funnel with blind personas before friendly testers (Phase 1).
5
+
3
6
  ## 1.2.0 — 2026-10-16
4
7
  From coreyhaines31/marketingskills at 1efedbc (issue #174, MIT).
5
8
 
@@ -3,7 +3,7 @@ name: launch
3
3
  description: "When the user wants to plan a product launch, feature announcement, or release strategy. Also use when the user mentions 'launch,' 'Product Hunt,' 'feature release,' 'announcement,' 'go-to-market,' 'beta launch,' 'early access,' 'waitlist,' 'product update,' 'how do I launch this,' 'site launch,' 'go live,' 'pre-launch QA,' 'launch checklist,' 'GTM plan,' 'we're about to ship,' 'changelog,' 'release notes,' or 'what did we ship this week.' Use this whenever someone is preparing to release something publicly. For ongoing marketing after launch, see luckiest-marketing-ideas. For the offer being launched (bonuses, guarantees, scarcity, naming), see luckiest-offers."
4
4
  license: See ATTRIBUTION.md
5
5
  metadata:
6
- version: "1.2.0"
6
+ version: "1.3.0"
7
7
  listing_id: luckiest-launch
8
8
  author: luckiest
9
9
  ---
@@ -14,9 +14,9 @@ This skill helps the user plan SaaS product launches and feature announcements t
14
14
 
15
15
  ## Staying current
16
16
 
17
- On activation, call the Luckiest MCP check_updates tool with { listingId: "b65cba33-d644-4eda-b14b-aea9b19f203e", installedSemver: "1.2.0" }. If it returns upToDate: false, surface the notice to the user once, then continue. Do nothing further if upToDate: true. Never block on this check — if the tool is unavailable, proceed.
17
+ On activation, call the Luckiest MCP check_updates tool with { listingId: "b65cba33-d644-4eda-b14b-aea9b19f203e", installedSemver: "1.3.0" }. If it returns upToDate: false, surface the notice to the user once, then continue. Do nothing further if upToDate: true. Never block on this check — if the tool is unavailable, proceed.
18
18
 
19
- When the skill's work is done, call the Luckiest MCP report_usage tool once with { listing_id: "luckiest-launch", skill_version: "1.2.0", matched: true, success: <true if the skill completed, false otherwise> }. Metadata only, never prompt text. Never block on it — if the tool is unavailable, skip silently.
19
+ When the skill's work is done, call the Luckiest MCP report_usage tool once with { listing_id: "luckiest-launch", skill_version: "1.3.0", matched: true, success: <true if the skill completed, false otherwise> }. Metadata only, never prompt text. Never block on it — if the tool is unavailable, skip silently.
20
20
 
21
21
  ## Before Starting
22
22
 
@@ -120,7 +120,7 @@ Gather initial feedback and iron out major issues before going public.
120
120
 
121
121
  **Actions:**
122
122
  - Recruit early users one-on-one to test for free
123
- - Collect feedback on usability gaps and missing features
123
+ - Collect feedback on usability gaps and missing features (run `luckiest-usertest` first so friendly testers spend their time on what AI testers cannot find)
124
124
  - Ensure prototype is functional enough to demo (doesn't need to be production-ready)
125
125
 
126
126
  **Goal:** Validate core functionality with friendly users.
@@ -379,6 +379,14 @@ After delivering a launch plan, offer (never auto-post): "Want me to share this
379
379
 
380
380
  ---
381
381
 
382
+ ## Sub-skills
383
+
384
+ | Step | Sub-skill | Owns |
385
+ |---|---|---|
386
+ | Phase 1: Internal Launch | `luckiest-usertest` | Buyer-type testers walk the landing page, signup, and welcome before friendly testers see it, with fixes on a preview until the route holds |
387
+
388
+ If a sub-skill is not installed, say so in one line and do its step with the rules in this skill's references.
389
+
382
390
  ## Related Skills
383
391
 
384
392
  - **luckiest-marketing-ideas**: For additional launch tactics (Product Hunt, Early Access Referrals)
@@ -1,5 +1,15 @@
1
1
  # Changelog — luckiest-model-router
2
2
 
3
+ ## 2.3.0 — 2026-11-01
4
+ Optional Laya engine for model mode.
5
+
6
+ - Users who prefer Laya set `{"model_router": "laya"}` in `~/.luckiest/config.json`
7
+ or `LUCKIEST_MODEL_ROUTER=laya`. One `laya --preset router --batch` call per plan.
8
+ - Rules stay in charge: Laya picks the tier only when the rules are unsure, and
9
+ otherwise moves it one step when at least 60% sure. Light floor still applies.
10
+ - Batch items take an optional `detail` (the "done means" line) for Laya.
11
+ - Missing, slow (60s + 0.5s per task), failing or unsure Laya leaves the rules' pick and adds a warning.
12
+
3
13
  ## 2.2.0 — 2026-10-16
4
14
  Model ids refreshed (issue #198), matching `server/mcp/modelHint.js`.
5
15
 
@@ -3,7 +3,7 @@ name: model-router
3
3
  description: Use when the user asks which agent or which model should handle a task. Two modes. Agent mode detects which of Claude, Codex, Cursor, Conductor, or Factory is installed and picks the best fit for the prompt. Model mode tags each task with a Haiku, Sonnet, or Opus tier by task type, so subagents run on the smallest model that fits. Triggers include "which tool should I use", "route this", "best model for this task", "haiku or opus", "which model for each task", "tag tasks with models", and /luckiest plan drafting a task list. Use it whenever someone is choosing between coding agents or model sizes, even if they don't name one.
4
4
  license: See ATTRIBUTION.md
5
5
  metadata:
6
- version: "2.2.0"
6
+ version: "2.3.0"
7
7
  listing_id: luckiest-model-router
8
8
  author: luckiest
9
9
  ---
@@ -21,9 +21,9 @@ Rules for both modes:
21
21
 
22
22
  ## Staying current
23
23
 
24
- On activation, call the Luckiest MCP check_updates tool with { listingId: "641e0cd7-0695-4caf-b97a-f7a0b3f872ec", installedSemver: "2.1.0" }. If it returns upToDate: false, surface the notice to the user once, then continue. Do nothing further if upToDate: true. Never block on this check. If the tool is unavailable, proceed.
24
+ On activation, call the Luckiest MCP check_updates tool with { listingId: "641e0cd7-0695-4caf-b97a-f7a0b3f872ec", installedSemver: "2.3.0" }. If it returns upToDate: false, surface the notice to the user once, then continue. Do nothing further if upToDate: true. Never block on this check. If the tool is unavailable, proceed.
25
25
 
26
- When the skill's work is done, call the Luckiest MCP report_usage tool once with { listing_id: "luckiest-model-router", skill_version: "2.2.0", matched: true, success: <true if the skill completed, false otherwise> }. Send metadata only, never prompt text. Never block on it. If the tool is unavailable, skip silently.
26
+ When the skill's work is done, call the Luckiest MCP report_usage tool once with { listing_id: "luckiest-model-router", skill_version: "2.3.0", matched: true, success: <true if the skill completed, false otherwise> }. Send metadata only, never prompt text. Never block on it. If the tool is unavailable, skip silently.
27
27
 
28
28
  ## Model mode
29
29
 
@@ -63,6 +63,15 @@ Use this mode when the question is about model size, or when /luckiest plan is t
63
63
  4. Treat the script as a starting point. When the title undersells the task, raise the tier and say why in the reason. Only lower a tier when the task is clearly trivial.
64
64
  5. Model IDs: light `claude-haiku-5-5`, standard `claude-sonnet-5-5`, heavy `claude-opus-5-5`. Setting `LUCKIEST_MODEL_PROVIDER=openai` or `google` switches to that provider's table, which matches the server. When showing tiers to the user, say **Haiku**, **Sonnet** or **Opus**. When a subagent runs the task, always pass its model explicitly. Claude Code subagents inherit the session model when none is set, and that is Opus by default.
65
65
 
66
+ ### Laya engine (optional)
67
+
68
+ Users who have [Laya](https://huggingface.co/convaiinnovations/laya) installed can prefer it. Turn it on with `{"laya": true}` in `~/.luckiest/config.json` (the shared switch other skills read too) or `{"model_router": "laya"}` for routing only. `LUCKIEST_LAYA=1` and `LUCKIEST_MODEL_ROUTER=laya` do the same from the environment. `LUCKIEST_MODEL_ROUTER=rules` turns Laya off for routing and wins over everything else. The script finds `laya` on PATH or at `~/.venvs/laya/bin/laya` and makes one batch call for the whole plan.
69
+
70
+ - The rules above still decide. Laya picks the tier only for tasks the rules mark unsure, and otherwise moves the rules' tier by one step when it is at least 60% sure. The light floor for tool-heavy work still applies.
71
+ - Give each batch item an optional `detail` (for example the task's "done means" line). Laya tells tasks apart much better with a sentence than with a bare title.
72
+ - A reason that contains `laya:` means Laya picked or nudged that tier.
73
+ - If Laya is missing, slow (over 60 seconds plus half a second per task), fails, or is unsure, the rules' pick stands and the output has a warning.
74
+
66
75
  ### Escalation during execution
67
76
 
68
77
  When a subagent's result fails its "done means" check twice on the same task, re-run the task one tier higher from the start and tell the user in one line. Do not hand a half-done run to the bigger model, because switching models mid-task rewrites most of the actions that follow. Tasks that depend on it and have not started yet move up to at least the new tier. Heavy is the ceiling. If a heavy run still fails, stop and hand the task back to the user.