@easysociable/cli 0.1.3 → 0.1.5

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 (58) hide show
  1. package/README.md +13 -10
  2. package/content-engine/scripts/starter.mjs +3 -1
  3. package/content-engine/scripts/workbench.mjs +13 -8
  4. package/content-engine/starter/CLAUDE.md +217 -128
  5. package/content-engine/starter/engine.json +3 -0
  6. package/content-engine/starter/needs/README.md +38 -0
  7. package/content-engine/starter/needs/_index.md +31 -0
  8. package/content-engine/starter/needs/_template.md +24 -0
  9. package/content-engine/starter/needs/entries/README.md +5 -0
  10. package/content-engine/starter/products/README.md +38 -0
  11. package/content-engine/starter/products/_index.md +24 -0
  12. package/content-engine/starter/products/_template.md +56 -0
  13. package/content-engine/starter/products/entries/P-001-easysociable.md +150 -0
  14. package/content-engine/starter/profiles/_index.md +6 -3
  15. package/content-engine/starter/profiles/default/README.md +4 -1
  16. package/content-engine/starter/profiles/default/boundaries.md +14 -0
  17. package/content-engine/starter/profiles/default/expression.md +18 -0
  18. package/content-engine/starter/profiles/default/lanes.md +12 -0
  19. package/content-engine/starter/profiles/default/stories.md +15 -0
  20. package/content-engine/starter/profiles/default/voice.md +14 -0
  21. package/content-engine/starter/recommendations/README.md +21 -0
  22. package/content-engine/starter/recommendations/_index.md +21 -0
  23. package/content-engine/starter/recommendations/_template.md +44 -0
  24. package/content-engine/starter/runs/_template/brief.md +1 -1
  25. package/content-engine/starter/runs/_template/pack.md +15 -7
  26. package/content-engine/starter/runs/_template/packet.md +6 -0
  27. package/content-engine/starter/swipe/_template.md +3 -1
  28. package/content-engine/starter/topics/README.md +4 -8
  29. package/content-engine/starter/topics/_index.md +12 -5
  30. package/content-engine/starter/topics/_template-item.md +64 -51
  31. package/content-engine/starter/topics/scoring.md +55 -4
  32. package/content-engine/workbench/build.py +315 -97
  33. package/content-engine/workbench/serve.py +0 -0
  34. package/content-engine/workbench/template.html +172 -94
  35. package/dist/easysociable.js +191 -133
  36. package/dist/easysociable.js.map +1 -1
  37. package/package.json +13 -14
  38. package/skills/ONBOARDING.md +14 -15
  39. package/skills/easysociable-content/SKILL.md +100 -19
  40. package/skills/easysociable-content/workflows/INTERVIEW.md +70 -0
  41. package/skills/easysociable-content/workflows/NEEDS.md +82 -0
  42. package/skills/easysociable-slideshow/SKILL.md +79 -45
  43. package/skills/easysociable-slideshow/references/ARC.md +44 -0
  44. package/skills/easysociable-slideshow/references/CONTENT_PACK.md +34 -6
  45. package/skills/easysociable-slideshow/workflows/CREATE_RUN.md +24 -5
  46. package/skills/easysociable-slideshow/workflows/OUT_OF_SCOPE.md +3 -0
  47. package/skills/easysociable-template/SKILL.md +13 -16
  48. package/skills/easysociable-template/references/CATALOG_METADATA.md +21 -18
  49. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
  50. package/skills/easysociable-template/workflows/FILL_AND_RENDER.md +4 -83
  51. package/skills/easysociable-template/workflows/OUT_OF_SCOPE.md +3 -2
  52. package/content-engine/starter/discovery/README.md +0 -18
  53. package/content-engine/starter/discovery/_index.md +0 -27
  54. package/content-engine/starter/discovery/_template.md +0 -41
  55. package/content-engine/starter/topics/WATCHLIST.md +0 -65
  56. package/content-engine/starter/topics/watchlists/README.md +0 -10
  57. package/content-engine/starter/topics/watchlists/_shared.md +0 -14
  58. package/content-engine/starter/topics/watchlists/default.md +0 -6
@@ -158,8 +158,8 @@
158
158
  .row.sel { border-color: var(--accent); box-shadow: var(--shadow); }
159
159
  .stripe { border-radius: 14px 0 0 14px; background: var(--accent-dim); }
160
160
  .row[data-kind="capture"] .stripe { background: #7d9aa8; }
161
+ .row[data-kind="need"] .stripe { background: #5a9e8f; }
161
162
  .row[data-kind="raw"] .stripe { background: #6e7f6a; }
162
- .row[data-kind="discovery"] .stripe { background: #6b7c8a; }
163
163
  .row[data-kind="topic"] .stripe { background: var(--accent); }
164
164
  .row[data-kind="swipe"] .stripe { background: #c4a06a; }
165
165
  .row[data-kind="atom"] .stripe { background: #a78bb0; }
@@ -623,17 +623,17 @@
623
623
  </div>
624
624
  <script>
625
625
  const GRAPH = /*__GRAPH__*/null;
626
- const TRACE_ORDER = ["raw", "capture", "discovery", "topic", "swipe", "atom", "claim", "run", "published"];
626
+ const TRACE_ORDER = ["raw", "capture", "need", "product", "recommendation", "topic", "swipe", "atom", "claim", "run", "published"];
627
627
  const STR = {
628
628
  zh: {
629
629
  title: "Content Engine · 观察台",
630
630
  brand: "观察台",
631
631
  search: "搜索 ID、一句话、来源…",
632
- overview: "总览", discovery: "发现", topics: "选题库", notes: "记录与知识", runs: "Run 档案", pub: "发布与复盘",
633
- notes_in: "记下的", notes_lib: "进库的",
632
+ overview: "总览", needs: "需求库", products: "产品库", recommendations: "推荐库", topics: "选题库", notes: "记录与知识", runs: "Run 档案", pub: "发布与复盘",
633
+ notes_in: "记下的", notes_needs: "需求原话", notes_lib: "进库的",
634
634
  lead: "先认 IP,再看库存。",
635
- kpi_cap: "记下的", kpi_lib: "进库的", kpi_topics: "选题", kpi_out: "下场",
636
- kpi_cap_sub: "Capture", kpi_lib_sub: "Swipe / Atom / Claim",
635
+ kpi_cap: "记下的", kpi_needs: "需求", kpi_lib: "进库的", kpi_topics: "选题", kpi_out: "下场",
636
+ kpi_cap_sub: "Capture", kpi_needs_sub: "Needs 原话", kpi_lib_sub: "Swipe / Atom / Claim",
637
637
  kpi_topics_sub: "{n} 条 ready 未开单", kpi_out_sub: "Run / 已发帖",
638
638
  ip_section: "IP · Profiles",
639
639
  ip_all: "全部 Profile",
@@ -644,8 +644,15 @@
644
644
  gap_section: "断链",
645
645
  gap_none: "没有明显断链。",
646
646
  gap_ready_n: "ready 选题未开单 {n}",
647
+ gap_needs_thin_n: "需求原话不足 3 条(出图硬门闩)· 现有 {n}",
648
+ gap_needs_n: "需求原话未选题 {n}",
647
649
  gap_lib_n: "Capture 未入库 {n}",
648
650
  gap_review_n: "已发但无法复盘 {n}",
651
+ topics_lead: "选题必须有明确输入:need / inbox / user / product / recommendation。没有 Discovery 中间层;来源热度不是选题。",
652
+ kv_lane: "Lane",
653
+ kv_mode: "生成路径",
654
+ kv_needs: "需求",
655
+ kv_recs: "推荐对象",
649
656
  kv_profile: "Profile",
650
657
  kind_profile: "Profile", kind_account: "账号",
651
658
  profile_pillars: "支柱",
@@ -657,6 +664,7 @@
657
664
  doc_voice: "Voice",
658
665
  doc_audience: "Audience",
659
666
  doc_pillars: "Pillars",
667
+ doc_lanes: "Lanes",
660
668
  empty_profile_t: "点选一个 Profile",
661
669
  empty_profile_b: "右侧会显示该 IP 的支柱、账号、档案与断链。",
662
670
  acct_handle: "Handle",
@@ -680,6 +688,7 @@
680
688
  lib_claim_ev: "命题证据在本 claim 的 Evidence 表;命中率看 n/win/loss。",
681
689
  f_status: "状态(登记)", f_pillar: "Pillar", f_src: "来源", f_use: "使用", f_unused: "未开单", f_promoted: "已开单",
682
690
  f_sort: "排序",
691
+ f_sort_recommend: "报题优先",
683
692
  f_sort_bookmarks: "Bookmarks ↓",
684
693
  f_sort_replies: "Replies ↓",
685
694
  f_sort_likes: "Likes ↓",
@@ -702,7 +711,8 @@
702
711
  f_cap_ingest: "入库",
703
712
  f_cap_ingested: "已入库",
704
713
  f_cap_not_ingested: "未入库",
705
- no_topic: "无匹配选题", no_discovery: "无匹配发现", no_note: "无匹配记录", no_pub: "尚无已发帖",
714
+ no_topic: "无匹配选题", no_note: "无匹配记录", no_need: "尚无需求原话", no_pub: "尚无已发帖",
715
+ need_empty_hint: "对 Agent 说「记需求:……」写入第一条读者原话。不要编造。",
706
716
  no_stage: "无阶段文件",
707
717
  empty_t: "从左边拾起一件东西",
708
718
  empty_b: "它从哪来、有没有被写过、发过没有、值不值得再拿出来——会在这里连成一条。",
@@ -728,7 +738,7 @@
728
738
  thin_warn: "数据不足:result 不是 win / flat / loss,不画趋势。",
729
739
  topic_bind: "选题绑定:{t} · swipe {s} · claim {c}",
730
740
  supersedes: "取代 {id}", superseded_by: "被取代 {id}",
731
- kind_raw: "Raw", kind_capture: "Capture", kind_discovery: "发现", kind_topic: "选题", kind_swipe: "Swipe",
741
+ kind_raw: "Raw", kind_capture: "Capture", kind_need: "需求", kind_product: "产品", kind_recommendation: "推荐对象", kind_topic: "选题", kind_swipe: "Swipe",
732
742
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "发布",
733
743
  kv_kind: "类型", kv_date: "日期", kv_status: "状态", kv_plat: "平台", kv_score: "分数", kv_relevance: "关联度", kv_ownership: "归属度", kv_product_relevance: "产品关联",
734
744
  kv_use: "使用", kv_src: "来源", kv_result: "结果", kv_pillar: "支柱",
@@ -740,11 +750,11 @@
740
750
  title: "Content Engine · Observatory",
741
751
  brand: "Observatory",
742
752
  search: "Search ID, one-liner, source…",
743
- overview: "Overview", discovery: "Discovery", topics: "Topics", notes: "Notes & library", runs: "Run files", pub: "Shipped & review",
744
- notes_in: "Captured", notes_lib: "Catalog",
753
+ overview: "Overview", needs: "Needs", products: "Products", recommendations: "Recommendations", topics: "Topics", notes: "Notes & library", runs: "Run files", pub: "Shipped & review",
754
+ notes_in: "Captured", notes_needs: "Needs", notes_lib: "Catalog",
745
755
  lead: "Pick an IP, then inspect the stock.",
746
- kpi_cap: "Captured", kpi_lib: "Catalog", kpi_topics: "Topics", kpi_out: "Outcomes",
747
- kpi_cap_sub: "Capture", kpi_lib_sub: "Swipe / Atom / Claim",
756
+ kpi_cap: "Captured", kpi_needs: "Needs", kpi_lib: "Catalog", kpi_topics: "Topics", kpi_out: "Outcomes",
757
+ kpi_cap_sub: "Capture", kpi_needs_sub: "Audience quotes", kpi_lib_sub: "Swipe / Atom / Claim",
748
758
  kpi_topics_sub: "{n} ready, never opened as a run", kpi_out_sub: "Runs / shipped URLs",
749
759
  ip_section: "IP · Profiles",
750
760
  ip_all: "All profiles",
@@ -755,8 +765,15 @@
755
765
  gap_section: "Breaks",
756
766
  gap_none: "No obvious breaks.",
757
767
  gap_ready_n: "{n} ready topics with no run",
768
+ gap_needs_thin_n: "Needs under 3 quotes (graphics hard gate) · have {n}",
769
+ gap_needs_n: "{n} needs not yet in a topic",
758
770
  gap_lib_n: "{n} captures not ingested",
759
771
  gap_review_n: "{n} shipped posts not reviewable",
772
+ topics_lead: "Topics need an explicit input: need, inbox, user, product, or recommendation. No Discovery layer; source heat is not a topic.",
773
+ kv_lane: "Lane",
774
+ kv_mode: "Generation mode",
775
+ kv_needs: "Needs",
776
+ kv_recs: "Recommendations",
760
777
  kv_profile: "Profile",
761
778
  kind_profile: "Profile", kind_account: "Account",
762
779
  profile_pillars: "Pillars",
@@ -768,6 +785,7 @@
768
785
  doc_voice: "Voice",
769
786
  doc_audience: "Audience",
770
787
  doc_pillars: "Pillars",
788
+ doc_lanes: "Lanes",
771
789
  empty_profile_t: "Pick a Profile",
772
790
  empty_profile_b: "The inspector shows that IP’s pillars, accounts, files, and breaks.",
773
791
  acct_handle: "Handle",
@@ -791,6 +809,7 @@
791
809
  lib_claim_ev: "Proposition evidence is this claim’s Evidence table; hit rate is n/win/loss.",
792
810
  f_status: "Registry status", f_pillar: "Pillar", f_src: "Source", f_use: "Use", f_unused: "unused", f_promoted: "promoted",
793
811
  f_sort: "Sort",
812
+ f_sort_recommend: "Report first",
794
813
  f_sort_bookmarks: "Bookmarks ↓",
795
814
  f_sort_replies: "Replies ↓",
796
815
  f_sort_likes: "Likes ↓",
@@ -813,7 +832,8 @@
813
832
  f_cap_ingest: "Ingest",
814
833
  f_cap_ingested: "Ingested",
815
834
  f_cap_not_ingested: "Not ingested",
816
- no_topic: "No matching topics", no_discovery: "No matching discoveries", no_note: "No matching notes", no_pub: "No shipped posts",
835
+ no_topic: "No matching topics", no_note: "No matching notes", no_need: "No audience quotes yet", no_pub: "No shipped posts",
836
+ need_empty_hint: "Ask the Agent: “capture audience quotes: …” with a verbatim quote. Do not invent quotes.",
817
837
  no_stage: "no stage files",
818
838
  empty_t: "Pick something on the left",
819
839
  empty_b: "Where it came from, whether it was written, whether it shipped, whether it is worth reusing — that chain shows up here.",
@@ -839,7 +859,7 @@
839
859
  thin_warn: "Too thin to review: result is not win / flat / loss. No chart.",
840
860
  topic_bind: "Topic: {t} · swipe {s} · claim {c}",
841
861
  supersedes: "supersedes {id}", superseded_by: "superseded by {id}",
842
- kind_raw: "Raw", kind_capture: "Capture", kind_discovery: "Discovery", kind_topic: "Topic", kind_swipe: "Swipe",
862
+ kind_raw: "Raw", kind_capture: "Capture", kind_need: "Need", kind_product: "Product", kind_recommendation: "Recommendation", kind_topic: "Topic", kind_swipe: "Swipe",
843
863
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "Shipped",
844
864
  kv_kind: "Kind", kv_date: "Date", kv_status: "Status", kv_plat: "Platform", kv_score: "Score", kv_relevance: "Relevance", kv_ownership: "Ownership", kv_product_relevance: "Product relevance",
845
865
  kv_use: "Use", kv_src: "Source", kv_result: "Result", kv_pillar: "Pillar",
@@ -936,9 +956,8 @@
936
956
  let topicPillar = "";
937
957
  let topicSrc = "";
938
958
  let topicUsage = "";
939
- let discStatus = "";
940
- let discSort = "bookmarks";
941
959
  let capIngest = "";
960
+ const TOPIC_SOURCES = ["need", "inbox", "user", "product", "recommendation"];
942
961
  let selected = null;
943
962
  const $list = document.getElementById("list");
944
963
  const $insp = document.getElementById("insp");
@@ -1283,7 +1302,7 @@
1283
1302
  sel.innerHTML = `<option value="">${t("ip_all")}</option>` +
1284
1303
  ids.map(id => `<option value="${id}" ${id === profileScope ? "selected" : ""}>${id}</option>`).join("");
1285
1304
  tabs.innerHTML = "";
1286
- for (const id of ["overview", "discovery", "topics", "notes", "runs", "pub"]) {
1305
+ for (const id of ["overview", "needs", "products", "recommendations", "topics", "notes", "runs", "pub"]) {
1287
1306
  const b = document.createElement("button");
1288
1307
  b.textContent = t(id);
1289
1308
  b.dataset.view = id;
@@ -1343,7 +1362,7 @@
1343
1362
  function qmatch(n) {
1344
1363
  const q = $q.value.trim().toLowerCase();
1345
1364
  if (!q) return true;
1346
- const blob = [n.ident, n.label, n.kind, n.status, n.pillar, n.profile, n.profiles, n.src_handle, n.url, n.notes, n.type, n.atom_type, n.topic_id].filter(Boolean).join(" ").toLowerCase();
1365
+ const blob = [n.ident, n.label, n.kind, n.status, n.pillar, n.lane, n.generation_mode, n.profile, n.profiles, n.src_handle, n.url, n.notes, n.type, n.atom_type, n.topic_id, n.audience, n.need_ids, n.product_ids, n.product_fit, n.recommendation_ids, n.source_type, n.capture_id].filter(Boolean).join(" ").toLowerCase();
1347
1366
  return blob.includes(q);
1348
1367
  }
1349
1368
  function connected(id) {
@@ -1423,11 +1442,16 @@
1423
1442
  function heatMetric(n, key) {
1424
1443
  return numCompact(n[`src_${key}`]);
1425
1444
  }
1445
+ function hasHeat(n) {
1446
+ return ["likes", "replies", "bookmarks", "views"].some(k => heatMetric(n, k) !== null);
1447
+ }
1426
1448
  function heatStripHtml(n, opts = {}) {
1449
+ // No strip when every metric is missing — empty "—" chips inflate row height.
1450
+ if (!hasHeat(n)) return "";
1427
1451
  const hint = opts.hint ? `<span class="heat-note">${esc(t("heat_hint"))}</span>` : "";
1428
1452
  const items = [
1429
1453
  { key: "likes", sym: "♥", title: "likes" },
1430
- { key: "replies", sym: "↩", title: "replies" },
1454
+ { key: "replies", sym: "💬", title: "replies" },
1431
1455
  { key: "bookmarks", sym: "🔖", title: "bookmarks" },
1432
1456
  { key: "views", sym: "👁", title: "views" },
1433
1457
  ];
@@ -1439,7 +1463,7 @@
1439
1463
  }).join("");
1440
1464
  return `<div class="heat" title="${esc(t("heat_hint"))}">${chips}</div>${hint}`;
1441
1465
  }
1442
- function discoveryRowEl(n, extra) {
1466
+ function listRowEl(n, extra) {
1443
1467
  const d = document.createElement("div");
1444
1468
  d.className = "row" + (selected === n.id ? " sel" : "");
1445
1469
  d.dataset.kind = n.kind;
@@ -1458,35 +1482,19 @@
1458
1482
  </div>`;
1459
1483
  return d;
1460
1484
  }
1461
- function sortDiscoveries(rows, mode) {
1462
- const missing = -1;
1463
- const num = (n, key) => {
1464
- const v = Number(String(n[`src_${key}`] ?? "").replace(/,/g, ""));
1465
- return Number.isFinite(v) ? v : missing;
1466
- };
1467
- return rows.slice().sort((a, b) => {
1468
- let cmp = 0;
1469
- if (mode === "likes") cmp = num(b, "likes") - num(a, "likes");
1470
- else if (mode === "replies") cmp = num(b, "replies") - num(a, "replies");
1471
- else if (mode === "views") cmp = num(b, "views") - num(a, "views");
1472
- else if (mode === "date") cmp = String(b.date || "").localeCompare(String(a.date || ""));
1473
- else cmp = num(b, "bookmarks") - num(a, "bookmarks");
1474
- if (cmp !== 0) return cmp;
1475
- return String(b.date || "").localeCompare(String(a.date || ""));
1476
- });
1477
- }
1478
1485
  function pills(n) {
1479
1486
  const bits = [];
1480
- if (n.kind === "discovery") {
1487
+ if (n.kind === "need") {
1481
1488
  const st = n.status || "";
1482
- const cls = st === "read" || st === "promoted" ? "ok"
1483
- : (st === "saved" || st === "rejected" || st === "unread" ? "gap" : "");
1489
+ const cls = st === "promoted" ? "ok" : (st === "captured" ? "gap" : "");
1484
1490
  if (st) bits.push(`<span class="pill ${cls}">${esc(st)}</span>`);
1485
- if (n.source_role) bits.push(`<span class="pill">${esc(n.source_role)}</span>`);
1486
- if (n.next_action && n.next_action !== "none") bits.push(`<span class="pill">${esc(n.next_action)}</span>`);
1491
+ if (n.source_type) bits.push(`<span class="pill">${esc(n.source_type)}</span>`);
1492
+ if (n.frequency) bits.push(`<span class="pill">×${esc(n.frequency)}</span>`);
1487
1493
  }
1488
1494
  if (n.kind === "topic") {
1489
1495
  bits.push(`<span class="pill">${esc(n.status)}</span>`);
1496
+ if (n.source_type) bits.push(`<span class="pill">${esc(n.source_type)}</span>`);
1497
+ if (n.generation_mode) bits.push(`<span class="pill">${esc(n.generation_mode)}</span>`);
1490
1498
  bits.push(n.usage === "unused" ? `<span class="pill gap">${t("unused")}</span>` : `<span class="pill ok">${esc(n.usage)}</span>`);
1491
1499
  }
1492
1500
  if (n.kind === "run") {
@@ -1526,7 +1534,9 @@
1526
1534
  function render() {
1527
1535
  for (const b of tabs.querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === view);
1528
1536
  if (view === "overview") renderOverview();
1529
- if (view === "discovery") renderDiscovery();
1537
+ if (view === "needs") renderNeeds();
1538
+ if (view === "products") renderProducts();
1539
+ if (view === "recommendations") renderRecommendations();
1530
1540
  if (view === "topics") renderTopics();
1531
1541
  if (view === "notes") renderNotes();
1532
1542
  if (view === "runs") renderRuns();
@@ -1534,6 +1544,69 @@
1534
1544
  renderInsp();
1535
1545
  }
1536
1546
 
1547
+ function renderNeedsList(into) {
1548
+ const cards = document.createElement("div");
1549
+ cards.className = "cards";
1550
+ const needs = nodesOf("need").filter(qmatch).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
1551
+ needs.forEach(n => {
1552
+ const meta = [
1553
+ n.source_type || "",
1554
+ n.status || "",
1555
+ n.frequency ? `×${n.frequency}` : "",
1556
+ n.topic_ids && n.topic_ids !== "none" ? n.topic_ids : "",
1557
+ ].filter(Boolean).join(" · ");
1558
+ cards.appendChild(listRowEl(n, meta));
1559
+ });
1560
+ if (!needs.length) {
1561
+ cards.innerHTML = `<p class='empty'>${t("no_need")}</p><p class="note">${esc(t("need_empty_hint"))}</p>`;
1562
+ }
1563
+ into.appendChild(cards);
1564
+ }
1565
+
1566
+ function renderNeeds() {
1567
+ $list.innerHTML = "";
1568
+ $list.insertAdjacentHTML("beforeend", `<p class="lead">${esc(t("need_empty_hint"))}</p>`);
1569
+ renderNeedsList($list);
1570
+ }
1571
+
1572
+ function renderProducts() {
1573
+ $list.innerHTML = "";
1574
+ const products = nodesOf("product").filter(qmatch).sort((a, b) => String(a.label).localeCompare(String(b.label)));
1575
+ $list.insertAdjacentHTML("beforeend", `<p class="lead">Needs describe demand. Products describe the offer. Topics connect them only when the fit is honest.</p>`);
1576
+ const cards = document.createElement("div");
1577
+ cards.className = "cards";
1578
+ products.forEach(product => {
1579
+ const meta = [
1580
+ product.status,
1581
+ product.audience,
1582
+ product.price,
1583
+ product.effective_from,
1584
+ ].filter(Boolean).join(" · ");
1585
+ cards.appendChild(rowEl(product, meta));
1586
+ });
1587
+ if (!products.length) cards.innerHTML = `<p class='empty'>No products in products/_index.md</p>`;
1588
+ $list.appendChild(cards);
1589
+ }
1590
+
1591
+ function renderRecommendations() {
1592
+ $list.innerHTML = "";
1593
+ const recommendations = nodesOf("recommendation").filter(qmatch).sort((a, b) => String(a.label).localeCompare(String(b.label)));
1594
+ $list.insertAdjacentHTML("beforeend", `<p class="lead">Recommendations are third-party review subjects, not owned Products.</p>`);
1595
+ const cards = document.createElement("div");
1596
+ cards.className = "cards";
1597
+ recommendations.forEach(item => {
1598
+ const meta = [
1599
+ item.status,
1600
+ item.category,
1601
+ item.relationship,
1602
+ item.last_researched,
1603
+ ].filter(Boolean).join(" · ");
1604
+ cards.appendChild(rowEl(item, meta));
1605
+ });
1606
+ if (!recommendations.length) cards.innerHTML = `<p class='empty'>No recommendations in recommendations/_index.md</p>`;
1607
+ $list.appendChild(cards);
1608
+ }
1609
+
1537
1610
  function renderOverview() {
1538
1611
  $list.innerHTML = "";
1539
1612
  $list.insertAdjacentHTML("beforeend", `<p class="lead">${t("lead")}</p>`);
@@ -1597,56 +1670,31 @@
1597
1670
  onReady(wrap);
1598
1671
  }
1599
1672
 
1600
- function renderDiscovery() {
1601
- $list.innerHTML = "";
1602
- const statuses = [...new Set(nodesOf("discovery").map(x => x.status).filter(Boolean))];
1603
- const sortOpts = [
1604
- ["bookmarks", "f_sort_bookmarks"],
1605
- ["replies", "f_sort_replies"],
1606
- ["likes", "f_sort_likes"],
1607
- ["views", "f_sort_views"],
1608
- ["date", "f_sort_date"],
1609
- ];
1610
- filterBar(
1611
- `<select id="d-st"><option value="">${t("f_status")}</option>${statuses.map(s => `<option value="${s}" ${s === discStatus ? "selected" : ""}>${esc(s)}</option>`).join("")}</select>
1612
- <select id="d-sort" title="${esc(t("f_sort"))}"><option value="" disabled>${esc(t("f_sort"))}</option>${sortOpts.map(([v, k]) => `<option value="${v}" ${v === discSort ? "selected" : ""}>${esc(t(k))}</option>`).join("")}</select>`,
1613
- (w) => {
1614
- const status = w.querySelector("#d-st");
1615
- const sortEl = w.querySelector("#d-sort");
1616
- const paint = () => {
1617
- discStatus = status.value;
1618
- discSort = sortEl.value || "bookmarks";
1619
- const cards = document.createElement("div");
1620
- cards.className = "cards";
1621
- let rows = nodesOf("discovery").filter(qmatch);
1622
- if (discStatus) rows = rows.filter(x => x.status === discStatus);
1623
- rows = sortDiscoveries(rows, discSort);
1624
- rows.forEach(x => {
1625
- const meta = [
1626
- x.source_role,
1627
- x.next_action && x.next_action !== "none" ? x.next_action : "",
1628
- `@${x.source_handle || "—"}`,
1629
- ].filter(Boolean).join(" · ");
1630
- cards.appendChild(discoveryRowEl(x, meta));
1631
- });
1632
- if (!rows.length) cards.innerHTML = `<p class='empty'>${t("no_discovery")}</p>`;
1633
- [...$list.querySelectorAll(".cards")].forEach(n => n.remove());
1634
- $list.appendChild(cards);
1635
- };
1636
- status.onchange = paint;
1637
- sortEl.onchange = paint;
1638
- paint();
1639
- }
1640
- );
1673
+ function topicMeta(x) {
1674
+ const score = x.fit ? `${x.craft}/${x.fit}` : (x.craft || x.total || "—");
1675
+ const bits = [
1676
+ score,
1677
+ formatPillar(x.pillar),
1678
+ x.lane,
1679
+ x.generation_mode,
1680
+ x.source_type,
1681
+ x.need_ids && x.need_ids !== "none" ? x.need_ids : "",
1682
+ x.product_ids && x.product_ids !== "none" ? x.product_ids : "",
1683
+ x.recommendation_ids && x.recommendation_ids !== "none" ? x.recommendation_ids : "",
1684
+ x.capture_id || "",
1685
+ ].filter(Boolean);
1686
+ return bits.join(" · ");
1641
1687
  }
1642
1688
 
1643
1689
  function renderTopics() {
1644
1690
  $list.innerHTML = "";
1691
+ $list.insertAdjacentHTML("beforeend", `<p class="lead">${esc(t("topics_lead"))}</p>`);
1692
+ if (topicSrc && !TOPIC_SOURCES.includes(topicSrc)) topicSrc = "";
1645
1693
  const statuses = [...new Set(nodesOf("topic").map(x => x.status).filter(Boolean))];
1646
1694
  filterBar(
1647
1695
  `<select id="f-st"><option value="">${t("f_status")}</option>${statuses.map(s => `<option value="${s}" ${s === topicStatus ? "selected" : ""}>${esc(s)}</option>`).join("")}</select>
1648
1696
  <select id="f-p"><option value="">${t("f_pillar")}</option>${pillarIds().map(id => `<option value="${id}" ${id === topicPillar ? "selected" : ""}>${esc(formatPillar(id))}</option>`).join("")}</select>
1649
- <select id="f-src"><option value="">${t("f_src")}</option>${["watchlist", "inbox", "user"].map(s => `<option value="${s}" ${s === topicSrc ? "selected" : ""}>${s}</option>`).join("")}</select>
1697
+ <select id="f-src"><option value="">${t("f_src")}</option>${TOPIC_SOURCES.map(s => `<option value="${s}" ${s === topicSrc ? "selected" : ""}>${s}</option>`).join("")}</select>
1650
1698
  <select id="f-u"><option value="">${t("f_use")}</option>
1651
1699
  <option value="unused" ${topicUsage === "unused" ? "selected" : ""}>${t("f_unused")}</option>
1652
1700
  <option value="promoted" ${topicUsage === "promoted" ? "selected" : ""}>${t("f_promoted")}</option></select>`,
@@ -1669,8 +1717,7 @@
1669
1717
  if (topicSrc) rows = rows.filter(x => x.source_type === topicSrc);
1670
1718
  if (topicUsage) rows = rows.filter(x => x.usage === topicUsage);
1671
1719
  rows.forEach(x => {
1672
- const score = x.fit ? `${x.craft}/${x.fit}` : (x.craft || x.total || "—");
1673
- cards.appendChild(rowEl(x, `${score} · ${formatPillar(x.pillar)} · ${x.source_type} @${x.src_handle || "—"}`));
1720
+ cards.appendChild(rowEl(x, topicMeta(x)));
1674
1721
  });
1675
1722
  if (!rows.length) cards.innerHTML = `<p class='empty'>${t("no_topic")}</p>`;
1676
1723
  $list.appendChild(cards);
@@ -1685,7 +1732,7 @@
1685
1732
  $list.innerHTML = "";
1686
1733
  const sub = document.createElement("div");
1687
1734
  sub.className = "subtabs";
1688
- sub.innerHTML = `<button data-t="notes">${t("notes_in")}</button><button data-t="lib">${t("notes_lib")}</button>`;
1735
+ sub.innerHTML = `<button data-t="notes">${t("notes_in")}</button><button data-t="needs">${t("notes_needs")}</button><button data-t="lib">${t("notes_lib")}</button>`;
1689
1736
  $list.appendChild(sub);
1690
1737
  sub.querySelectorAll("button").forEach(b => b.classList.toggle("on", b.dataset.t === notesTab));
1691
1738
  sub.onclick = (e) => {
@@ -1712,11 +1759,15 @@
1712
1759
  let caps = nodesOf("capture").filter(qmatch).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
1713
1760
  if (capIngest === "ingested") caps = caps.filter(c => c.status === "ingested");
1714
1761
  if (capIngest === "not_ingested") caps = caps.filter(c => c.status !== "ingested");
1715
- caps.forEach(c => cards.appendChild(rowEl(c, `${c.type} · ${c.status} · raw ${c.has_raw ? t("raw_yes") : t("raw_no")}`)));
1762
+ caps.forEach(c => cards.appendChild(listRowEl(c, `${c.type} · ${c.status} · raw ${c.has_raw ? t("raw_yes") : t("raw_no")}`)));
1716
1763
  if (!caps.length) cards.innerHTML = `<p class='empty'>${t("no_note")}</p>`;
1717
1764
  $list.appendChild(cards);
1718
1765
  return;
1719
1766
  }
1767
+ if (notesTab === "needs") {
1768
+ renderNeedsList($list);
1769
+ return;
1770
+ }
1720
1771
  const items = GRAPH.nodes.filter(n => n.lib && inScope(n));
1721
1772
  const subtypeOf = (n) => n.atom_type || n.form || n.claim_type || "";
1722
1773
  const kinds = [...new Set(items.map(n => n.kind))];
@@ -1907,6 +1958,7 @@
1907
1958
  function profileCounts(profileId) {
1908
1959
  return {
1909
1960
  capture: nodesOfAll("capture").filter(n => n.profile === profileId).length,
1961
+ need: nodesOfAll("need").filter(n => n.profile === profileId).length,
1910
1962
  topic: nodesOfAll("topic").filter(n => n.profile === profileId).length,
1911
1963
  lib: GRAPH.nodes.filter(n => n.lib && (String(n.profiles || "").includes(profileId) || String(n.profiles || "").includes("*"))).length,
1912
1964
  run: nodesOfAll("run").filter(n => n.profile === profileId).length,
@@ -1918,6 +1970,7 @@
1918
1970
  if (profileId) return profileCounts(profileId);
1919
1971
  return {
1920
1972
  capture: nodesOfAll("capture").length,
1973
+ need: nodesOfAll("need").length,
1921
1974
  topic: nodesOfAll("topic").length,
1922
1975
  lib: GRAPH.nodes.filter(n => n.lib).length,
1923
1976
  run: nodesOfAll("run").length,
@@ -1927,18 +1980,26 @@
1927
1980
 
1928
1981
  function gapsFor(profileId) {
1929
1982
  const topics = profileId ? nodesOfAll("topic").filter(n => n.profile === profileId) : nodesOfAll("topic");
1983
+ const needs = profileId ? nodesOfAll("need").filter(n => n.profile === profileId) : nodesOfAll("need");
1930
1984
  const caps = profileId ? nodesOfAll("capture").filter(n => n.profile === profileId) : nodesOfAll("capture");
1931
1985
  const pubs = profileId ? nodesOfAll("published").filter(n => n.profile === profileId) : nodesOfAll("published");
1932
- return [
1986
+ const needCount = needs.length;
1987
+ const gaps = [
1933
1988
  [topics.filter(x => x.status === "ready" && x.usage === "unused").length, "gap_ready_n"],
1989
+ [needs.filter(n => n.status === "captured" && (!n.topic_ids || n.topic_ids === "none")).length, "gap_needs_n"],
1934
1990
  [caps.filter(c => c.status !== "ingested").length, "gap_lib_n"],
1935
1991
  [pubs.filter(p => !p.reviewable).length, "gap_review_n"],
1936
1992
  ].filter(([n]) => n > 0);
1993
+ if (GRAPH.needs_index_present && needCount < 3) {
1994
+ gaps.splice(1, 0, [needCount, "gap_needs_thin_n"]);
1995
+ }
1996
+ return gaps;
1937
1997
  }
1938
1998
 
1939
1999
  function statsHtml(counts) {
1940
2000
  return `<div class="prof-stats">
1941
2001
  <div><b>${counts.capture}</b><span>${t("kpi_cap")}</span></div>
2002
+ <div><b>${counts.need || 0}</b><span>${t("kpi_needs")}</span></div>
1942
2003
  <div><b>${counts.topic}</b><span>${t("kpi_topics")}</span></div>
1943
2004
  <div><b>${counts.lib}</b><span>${t("kpi_lib")}</span></div>
1944
2005
  <div><b>${counts.run}</b><span>Run</span></div>
@@ -1972,6 +2033,7 @@
1972
2033
  ["voice", t("doc_voice")],
1973
2034
  ["audience", t("doc_audience")],
1974
2035
  ["pillars", t("doc_pillars")],
2036
+ ["lanes", t("doc_lanes")],
1975
2037
  ];
1976
2038
  const pillarRows = Object.entries(pillars).map(([id, p]) => {
1977
2039
  const title = lang === "zh" ? p.zh : p.en;
@@ -2035,16 +2097,32 @@
2035
2097
  ? `${n.win || 0}w / ${n.loss || 0}l / n=${n.n}`
2036
2098
  : "",
2037
2099
  [t("kv_pillar")]: formatPillar(n.pillar),
2100
+ [t("kv_lane")]: n.kind === "topic" ? (n.lane || "") : "",
2101
+ [t("kv_mode")]: n.kind === "topic" ? (n.generation_mode || "") : "",
2038
2102
  [t("kv_plat")]: n.platform || n.platforms,
2039
2103
  [t("kv_profile")]: n.profile || n.profile_id || (n.profiles || ""),
2040
2104
  [t("kv_score")]: n.fit ? `${n.craft} opp / ${n.fit} aud` : n.total,
2041
- [t("kv_use")]: n.kind === "discovery" ? (n.next_action || "") : n.usage,
2042
- [t("kv_src")]: n.kind === "discovery" ? (n.source_role || "") : n.source_type,
2105
+ [t("kv_use")]: n.usage,
2106
+ [t("kv_src")]: n.source_type,
2107
+ [t("kv_needs")]: n.kind === "topic" ? (n.need_ids || "") : "",
2108
+ Quote: n.kind === "need" ? (n.quote || "") : "",
2109
+ Speaker: n.kind === "need" ? (n.speaker || "") : "",
2110
+ Frequency: n.kind === "need" ? (n.frequency || "") : "",
2111
+ Topics: n.kind === "need" ? (n.topic_ids || "") : "",
2112
+ Products: n.kind === "topic" ? (n.product_ids || "") : "",
2113
+ "Product fit": n.kind === "topic" ? (n.product_fit || "") : "",
2114
+ [t("kv_recs")]: n.kind === "topic" ? (n.recommendation_ids || "") : "",
2115
+ Audience: n.kind === "product" ? (n.audience || "") : "",
2116
+ Price: n.kind === "product" ? (n.price || "") : "",
2117
+ "Effective from": n.kind === "product" ? (n.effective_from || "") : "",
2118
+ Category: n.kind === "recommendation" ? (n.category || "") : "",
2119
+ Relationship: n.kind === "recommendation" ? (n.relationship || "") : "",
2120
+ "Last researched": n.kind === "recommendation" ? (n.last_researched || "") : "",
2043
2121
  Handle: n.source_handle || n.src_handle || "",
2044
- [t("kv_heat_at")]: n.kind === "discovery" ? (n.engagement_at || "") : "",
2122
+ [t("kv_heat_at")]: n.kind === "capture" ? (n.engagement_at || "") : "",
2045
2123
  [t("kv_result")]: n.result,
2046
2124
  }).filter(([, v]) => v);
2047
- const heatBlock = n.kind === "discovery"
2125
+ const heatBlock = n.kind === "capture"
2048
2126
  ? `${heatStripHtml(n, { hint: true })}${n.source_url ? `<p class="note">${t("src_url")}<a href="${esc(n.source_url)}" target="_blank" rel="noreferrer">${esc(n.source_url)}</a></p>` : ""}`
2049
2127
  : "";
2050
2128
  $insp.innerHTML = `<h2>${t("obj")}</h2>