@easysociable/cli 0.1.3 → 0.1.4

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 (59) 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/__pycache__/build.cpython-310.pyc +0 -0
  33. package/content-engine/workbench/build.py +306 -97
  34. package/content-engine/workbench/serve.py +0 -0
  35. package/content-engine/workbench/template.html +173 -27
  36. package/dist/easysociable.js +191 -133
  37. package/dist/easysociable.js.map +1 -1
  38. package/package.json +13 -14
  39. package/skills/ONBOARDING.md +14 -15
  40. package/skills/easysociable-content/SKILL.md +100 -19
  41. package/skills/easysociable-content/workflows/INTERVIEW.md +70 -0
  42. package/skills/easysociable-content/workflows/NEEDS.md +82 -0
  43. package/skills/easysociable-slideshow/SKILL.md +79 -45
  44. package/skills/easysociable-slideshow/references/ARC.md +44 -0
  45. package/skills/easysociable-slideshow/references/CONTENT_PACK.md +34 -6
  46. package/skills/easysociable-slideshow/workflows/CREATE_RUN.md +24 -5
  47. package/skills/easysociable-slideshow/workflows/OUT_OF_SCOPE.md +3 -0
  48. package/skills/easysociable-template/SKILL.md +13 -16
  49. package/skills/easysociable-template/references/CATALOG_METADATA.md +21 -18
  50. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
  51. package/skills/easysociable-template/workflows/FILL_AND_RENDER.md +4 -83
  52. package/skills/easysociable-template/workflows/OUT_OF_SCOPE.md +3 -2
  53. package/content-engine/starter/discovery/README.md +0 -18
  54. package/content-engine/starter/discovery/_index.md +0 -27
  55. package/content-engine/starter/discovery/_template.md +0 -41
  56. package/content-engine/starter/topics/WATCHLIST.md +0 -65
  57. package/content-engine/starter/topics/watchlists/README.md +0 -10
  58. package/content-engine/starter/topics/watchlists/_shared.md +0 -14
  59. 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,6 +644,8 @@
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}",
649
651
  kv_profile: "Profile",
@@ -657,6 +659,7 @@
657
659
  doc_voice: "Voice",
658
660
  doc_audience: "Audience",
659
661
  doc_pillars: "Pillars",
662
+ doc_lanes: "Lanes",
660
663
  empty_profile_t: "点选一个 Profile",
661
664
  empty_profile_b: "右侧会显示该 IP 的支柱、账号、档案与断链。",
662
665
  acct_handle: "Handle",
@@ -680,6 +683,7 @@
680
683
  lib_claim_ev: "命题证据在本 claim 的 Evidence 表;命中率看 n/win/loss。",
681
684
  f_status: "状态(登记)", f_pillar: "Pillar", f_src: "来源", f_use: "使用", f_unused: "未开单", f_promoted: "已开单",
682
685
  f_sort: "排序",
686
+ f_sort_recommend: "报题优先",
683
687
  f_sort_bookmarks: "Bookmarks ↓",
684
688
  f_sort_replies: "Replies ↓",
685
689
  f_sort_likes: "Likes ↓",
@@ -702,7 +706,8 @@
702
706
  f_cap_ingest: "入库",
703
707
  f_cap_ingested: "已入库",
704
708
  f_cap_not_ingested: "未入库",
705
- no_topic: "无匹配选题", no_discovery: "无匹配发现", no_note: "无匹配记录", no_pub: "尚无已发帖",
709
+ no_topic: "无匹配选题", no_note: "无匹配记录", no_need: "尚无需求原话", no_pub: "尚无已发帖",
710
+ need_empty_hint: "对 Agent 说「记需求:……」写入第一条读者原话。不要编造。",
706
711
  no_stage: "无阶段文件",
707
712
  empty_t: "从左边拾起一件东西",
708
713
  empty_b: "它从哪来、有没有被写过、发过没有、值不值得再拿出来——会在这里连成一条。",
@@ -728,7 +733,7 @@
728
733
  thin_warn: "数据不足:result 不是 win / flat / loss,不画趋势。",
729
734
  topic_bind: "选题绑定:{t} · swipe {s} · claim {c}",
730
735
  supersedes: "取代 {id}", superseded_by: "被取代 {id}",
731
- kind_raw: "Raw", kind_capture: "Capture", kind_discovery: "发现", kind_topic: "选题", kind_swipe: "Swipe",
736
+ kind_raw: "Raw", kind_capture: "Capture", kind_need: "需求", kind_product: "产品", kind_recommendation: "推荐对象", kind_topic: "选题", kind_swipe: "Swipe",
732
737
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "发布",
733
738
  kv_kind: "类型", kv_date: "日期", kv_status: "状态", kv_plat: "平台", kv_score: "分数", kv_relevance: "关联度", kv_ownership: "归属度", kv_product_relevance: "产品关联",
734
739
  kv_use: "使用", kv_src: "来源", kv_result: "结果", kv_pillar: "支柱",
@@ -740,11 +745,11 @@
740
745
  title: "Content Engine · Observatory",
741
746
  brand: "Observatory",
742
747
  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",
748
+ overview: "Overview", needs: "Needs", products: "Products", recommendations: "Recommendations", topics: "Topics", notes: "Notes & library", runs: "Run files", pub: "Shipped & review",
749
+ notes_in: "Captured", notes_needs: "Needs", notes_lib: "Catalog",
745
750
  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",
751
+ kpi_cap: "Captured", kpi_needs: "Needs", kpi_lib: "Catalog", kpi_topics: "Topics", kpi_out: "Outcomes",
752
+ kpi_cap_sub: "Capture", kpi_needs_sub: "Audience quotes", kpi_lib_sub: "Swipe / Atom / Claim",
748
753
  kpi_topics_sub: "{n} ready, never opened as a run", kpi_out_sub: "Runs / shipped URLs",
749
754
  ip_section: "IP · Profiles",
750
755
  ip_all: "All profiles",
@@ -755,6 +760,8 @@
755
760
  gap_section: "Breaks",
756
761
  gap_none: "No obvious breaks.",
757
762
  gap_ready_n: "{n} ready topics with no run",
763
+ gap_needs_thin_n: "Needs under 3 quotes (graphics hard gate) · have {n}",
764
+ gap_needs_n: "{n} needs not yet in a topic",
758
765
  gap_lib_n: "{n} captures not ingested",
759
766
  gap_review_n: "{n} shipped posts not reviewable",
760
767
  kv_profile: "Profile",
@@ -768,6 +775,7 @@
768
775
  doc_voice: "Voice",
769
776
  doc_audience: "Audience",
770
777
  doc_pillars: "Pillars",
778
+ doc_lanes: "Lanes",
771
779
  empty_profile_t: "Pick a Profile",
772
780
  empty_profile_b: "The inspector shows that IP’s pillars, accounts, files, and breaks.",
773
781
  acct_handle: "Handle",
@@ -791,6 +799,7 @@
791
799
  lib_claim_ev: "Proposition evidence is this claim’s Evidence table; hit rate is n/win/loss.",
792
800
  f_status: "Registry status", f_pillar: "Pillar", f_src: "Source", f_use: "Use", f_unused: "unused", f_promoted: "promoted",
793
801
  f_sort: "Sort",
802
+ f_sort_recommend: "Report first",
794
803
  f_sort_bookmarks: "Bookmarks ↓",
795
804
  f_sort_replies: "Replies ↓",
796
805
  f_sort_likes: "Likes ↓",
@@ -813,7 +822,8 @@
813
822
  f_cap_ingest: "Ingest",
814
823
  f_cap_ingested: "Ingested",
815
824
  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",
825
+ no_topic: "No matching topics", no_note: "No matching notes", no_need: "No audience quotes yet", no_pub: "No shipped posts",
826
+ need_empty_hint: "Ask the Agent: “capture audience quotes: …” with a verbatim quote. Do not invent quotes.",
817
827
  no_stage: "no stage files",
818
828
  empty_t: "Pick something on the left",
819
829
  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 +849,7 @@
839
849
  thin_warn: "Too thin to review: result is not win / flat / loss. No chart.",
840
850
  topic_bind: "Topic: {t} · swipe {s} · claim {c}",
841
851
  supersedes: "supersedes {id}", superseded_by: "superseded by {id}",
842
- kind_raw: "Raw", kind_capture: "Capture", kind_discovery: "Discovery", kind_topic: "Topic", kind_swipe: "Swipe",
852
+ kind_raw: "Raw", kind_capture: "Capture", kind_need: "Need", kind_product: "Product", kind_recommendation: "Recommendation", kind_topic: "Topic", kind_swipe: "Swipe",
843
853
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "Shipped",
844
854
  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
855
  kv_use: "Use", kv_src: "Source", kv_result: "Result", kv_pillar: "Pillar",
@@ -937,7 +947,7 @@
937
947
  let topicSrc = "";
938
948
  let topicUsage = "";
939
949
  let discStatus = "";
940
- let discSort = "bookmarks";
950
+ let discSort = "recommend";
941
951
  let capIngest = "";
942
952
  let selected = null;
943
953
  const $list = document.getElementById("list");
@@ -1283,7 +1293,7 @@
1283
1293
  sel.innerHTML = `<option value="">${t("ip_all")}</option>` +
1284
1294
  ids.map(id => `<option value="${id}" ${id === profileScope ? "selected" : ""}>${id}</option>`).join("");
1285
1295
  tabs.innerHTML = "";
1286
- for (const id of ["overview", "discovery", "topics", "notes", "runs", "pub"]) {
1296
+ for (const id of ["overview", "needs", "products", "recommendations", "topics", "notes", "runs", "pub"]) {
1287
1297
  const b = document.createElement("button");
1288
1298
  b.textContent = t(id);
1289
1299
  b.dataset.view = id;
@@ -1343,7 +1353,7 @@
1343
1353
  function qmatch(n) {
1344
1354
  const q = $q.value.trim().toLowerCase();
1345
1355
  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();
1356
+ 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.product_ids, n.product_fit, n.recommendation_ids].filter(Boolean).join(" ").toLowerCase();
1347
1357
  return blob.includes(q);
1348
1358
  }
1349
1359
  function connected(id) {
@@ -1423,11 +1433,16 @@
1423
1433
  function heatMetric(n, key) {
1424
1434
  return numCompact(n[`src_${key}`]);
1425
1435
  }
1436
+ function hasHeat(n) {
1437
+ return ["likes", "replies", "bookmarks", "views"].some(k => heatMetric(n, k) !== null);
1438
+ }
1426
1439
  function heatStripHtml(n, opts = {}) {
1440
+ // No strip when every metric is missing — empty "—" chips inflate row height.
1441
+ if (!hasHeat(n)) return "";
1427
1442
  const hint = opts.hint ? `<span class="heat-note">${esc(t("heat_hint"))}</span>` : "";
1428
1443
  const items = [
1429
1444
  { key: "likes", sym: "♥", title: "likes" },
1430
- { key: "replies", sym: "↩", title: "replies" },
1445
+ { key: "replies", sym: "💬", title: "replies" },
1431
1446
  { key: "bookmarks", sym: "🔖", title: "bookmarks" },
1432
1447
  { key: "views", sym: "👁", title: "views" },
1433
1448
  ];
@@ -1458,6 +1473,25 @@
1458
1473
  </div>`;
1459
1474
  return d;
1460
1475
  }
1476
+ function angleRecommendRank(n) {
1477
+ const r = String(n.angle_recommend || "").toLowerCase();
1478
+ if (r === "promote") return 0;
1479
+ if (r === "library") return 1;
1480
+ if (r === "hold") return 2;
1481
+ if (r === "reject") return 3;
1482
+ if (String(n.next_action || "") === "promote_to_topic") return 0;
1483
+ return 4;
1484
+ }
1485
+ function discoveryUnprocessed(n) {
1486
+ const st = String(n.status || "");
1487
+ if (st === "promoted" || st === "rejected") return false;
1488
+ const ang = String(n.angle_status || "");
1489
+ if (ang === "accepted") return false;
1490
+ if (ang === "held" || ang === "rejected") return false;
1491
+ if (ang === "proposed" && (n.angle_recommend === "promote" || !n.angle_recommend)) return true;
1492
+ if (String(n.next_action || "") === "promote_to_topic") return true;
1493
+ return false;
1494
+ }
1461
1495
  function sortDiscoveries(rows, mode) {
1462
1496
  const missing = -1;
1463
1497
  const num = (n, key) => {
@@ -1465,6 +1499,10 @@
1465
1499
  return Number.isFinite(v) ? v : missing;
1466
1500
  };
1467
1501
  return rows.slice().sort((a, b) => {
1502
+ if (mode === "recommend" || !mode) {
1503
+ const rec = angleRecommendRank(a) - angleRecommendRank(b);
1504
+ if (rec !== 0) return rec;
1505
+ }
1468
1506
  let cmp = 0;
1469
1507
  if (mode === "likes") cmp = num(b, "likes") - num(a, "likes");
1470
1508
  else if (mode === "replies") cmp = num(b, "replies") - num(a, "replies");
@@ -1477,11 +1515,20 @@
1477
1515
  }
1478
1516
  function pills(n) {
1479
1517
  const bits = [];
1518
+ if (n.kind === "need") {
1519
+ const st = n.status || "";
1520
+ const cls = st === "promoted" ? "ok" : (st === "captured" ? "gap" : "");
1521
+ if (st) bits.push(`<span class="pill ${cls}">${esc(st)}</span>`);
1522
+ if (n.source_type) bits.push(`<span class="pill">${esc(n.source_type)}</span>`);
1523
+ if (n.frequency) bits.push(`<span class="pill">×${esc(n.frequency)}</span>`);
1524
+ }
1480
1525
  if (n.kind === "discovery") {
1481
1526
  const st = n.status || "";
1482
1527
  const cls = st === "read" || st === "promoted" ? "ok"
1483
1528
  : (st === "saved" || st === "rejected" || st === "unread" ? "gap" : "");
1484
1529
  if (st) bits.push(`<span class="pill ${cls}">${esc(st)}</span>`);
1530
+ if (n.angle_recommend) bits.push(`<span class="pill ${n.angle_recommend === "promote" && discoveryUnprocessed(n) ? "gap" : ""}">${esc(n.angle_recommend)}</span>`);
1531
+ else if (discoveryUnprocessed(n)) bits.push(`<span class="pill gap">${esc(t("gap_angle"))}</span>`);
1485
1532
  if (n.source_role) bits.push(`<span class="pill">${esc(n.source_role)}</span>`);
1486
1533
  if (n.next_action && n.next_action !== "none") bits.push(`<span class="pill">${esc(n.next_action)}</span>`);
1487
1534
  }
@@ -1526,7 +1573,9 @@
1526
1573
  function render() {
1527
1574
  for (const b of tabs.querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === view);
1528
1575
  if (view === "overview") renderOverview();
1529
- if (view === "discovery") renderDiscovery();
1576
+ if (view === "needs") renderNeeds();
1577
+ if (view === "products") renderProducts();
1578
+ if (view === "recommendations") renderRecommendations();
1530
1579
  if (view === "topics") renderTopics();
1531
1580
  if (view === "notes") renderNotes();
1532
1581
  if (view === "runs") renderRuns();
@@ -1534,6 +1583,69 @@
1534
1583
  renderInsp();
1535
1584
  }
1536
1585
 
1586
+ function renderNeedsList(into) {
1587
+ const cards = document.createElement("div");
1588
+ cards.className = "cards";
1589
+ const needs = nodesOf("need").filter(qmatch).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
1590
+ needs.forEach(n => {
1591
+ const meta = [
1592
+ n.source_type || "",
1593
+ n.status || "",
1594
+ n.frequency ? `×${n.frequency}` : "",
1595
+ n.topic_ids && n.topic_ids !== "none" ? n.topic_ids : "",
1596
+ ].filter(Boolean).join(" · ");
1597
+ cards.appendChild(discoveryRowEl(n, meta));
1598
+ });
1599
+ if (!needs.length) {
1600
+ cards.innerHTML = `<p class='empty'>${t("no_need")}</p><p class="note">${esc(t("need_empty_hint"))}</p>`;
1601
+ }
1602
+ into.appendChild(cards);
1603
+ }
1604
+
1605
+ function renderNeeds() {
1606
+ $list.innerHTML = "";
1607
+ $list.insertAdjacentHTML("beforeend", `<p class="lead">${esc(t("need_empty_hint"))}</p>`);
1608
+ renderNeedsList($list);
1609
+ }
1610
+
1611
+ function renderProducts() {
1612
+ $list.innerHTML = "";
1613
+ const products = nodesOf("product").filter(qmatch).sort((a, b) => String(a.label).localeCompare(String(b.label)));
1614
+ $list.insertAdjacentHTML("beforeend", `<p class="lead">Needs describe demand. Products describe the offer. Topics connect them only when the fit is honest.</p>`);
1615
+ const cards = document.createElement("div");
1616
+ cards.className = "cards";
1617
+ products.forEach(product => {
1618
+ const meta = [
1619
+ product.status,
1620
+ product.audience,
1621
+ product.price,
1622
+ product.effective_from,
1623
+ ].filter(Boolean).join(" · ");
1624
+ cards.appendChild(rowEl(product, meta));
1625
+ });
1626
+ if (!products.length) cards.innerHTML = `<p class='empty'>No products in products/_index.md</p>`;
1627
+ $list.appendChild(cards);
1628
+ }
1629
+
1630
+ function renderRecommendations() {
1631
+ $list.innerHTML = "";
1632
+ const recommendations = nodesOf("recommendation").filter(qmatch).sort((a, b) => String(a.label).localeCompare(String(b.label)));
1633
+ $list.insertAdjacentHTML("beforeend", `<p class="lead">Recommendations are third-party review subjects, not owned Products.</p>`);
1634
+ const cards = document.createElement("div");
1635
+ cards.className = "cards";
1636
+ recommendations.forEach(item => {
1637
+ const meta = [
1638
+ item.status,
1639
+ item.category,
1640
+ item.relationship,
1641
+ item.last_researched,
1642
+ ].filter(Boolean).join(" · ");
1643
+ cards.appendChild(rowEl(item, meta));
1644
+ });
1645
+ if (!recommendations.length) cards.innerHTML = `<p class='empty'>No recommendations in recommendations/_index.md</p>`;
1646
+ $list.appendChild(cards);
1647
+ }
1648
+
1537
1649
  function renderOverview() {
1538
1650
  $list.innerHTML = "";
1539
1651
  $list.insertAdjacentHTML("beforeend", `<p class="lead">${t("lead")}</p>`);
@@ -1601,6 +1713,7 @@
1601
1713
  $list.innerHTML = "";
1602
1714
  const statuses = [...new Set(nodesOf("discovery").map(x => x.status).filter(Boolean))];
1603
1715
  const sortOpts = [
1716
+ ["recommend", "f_sort_recommend"],
1604
1717
  ["bookmarks", "f_sort_bookmarks"],
1605
1718
  ["replies", "f_sort_replies"],
1606
1719
  ["likes", "f_sort_likes"],
@@ -1615,7 +1728,7 @@
1615
1728
  const sortEl = w.querySelector("#d-sort");
1616
1729
  const paint = () => {
1617
1730
  discStatus = status.value;
1618
- discSort = sortEl.value || "bookmarks";
1731
+ discSort = sortEl.value || "recommend";
1619
1732
  const cards = document.createElement("div");
1620
1733
  cards.className = "cards";
1621
1734
  let rows = nodesOf("discovery").filter(qmatch);
@@ -1623,6 +1736,7 @@
1623
1736
  rows = sortDiscoveries(rows, discSort);
1624
1737
  rows.forEach(x => {
1625
1738
  const meta = [
1739
+ x.angle_recommend || "",
1626
1740
  x.source_role,
1627
1741
  x.next_action && x.next_action !== "none" ? x.next_action : "",
1628
1742
  `@${x.source_handle || "—"}`,
@@ -1646,7 +1760,7 @@
1646
1760
  filterBar(
1647
1761
  `<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
1762
  <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>
1763
+ <select id="f-src"><option value="">${t("f_src")}</option>${["watchlist", "inbox", "user", "discovery", "need"].map(s => `<option value="${s}" ${s === topicSrc ? "selected" : ""}>${s}</option>`).join("")}</select>
1650
1764
  <select id="f-u"><option value="">${t("f_use")}</option>
1651
1765
  <option value="unused" ${topicUsage === "unused" ? "selected" : ""}>${t("f_unused")}</option>
1652
1766
  <option value="promoted" ${topicUsage === "promoted" ? "selected" : ""}>${t("f_promoted")}</option></select>`,
@@ -1685,7 +1799,7 @@
1685
1799
  $list.innerHTML = "";
1686
1800
  const sub = document.createElement("div");
1687
1801
  sub.className = "subtabs";
1688
- sub.innerHTML = `<button data-t="notes">${t("notes_in")}</button><button data-t="lib">${t("notes_lib")}</button>`;
1802
+ 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
1803
  $list.appendChild(sub);
1690
1804
  sub.querySelectorAll("button").forEach(b => b.classList.toggle("on", b.dataset.t === notesTab));
1691
1805
  sub.onclick = (e) => {
@@ -1712,11 +1826,15 @@
1712
1826
  let caps = nodesOf("capture").filter(qmatch).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
1713
1827
  if (capIngest === "ingested") caps = caps.filter(c => c.status === "ingested");
1714
1828
  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")}`)));
1829
+ caps.forEach(c => cards.appendChild(discoveryRowEl(c, `${c.type} · ${c.status} · raw ${c.has_raw ? t("raw_yes") : t("raw_no")}`)));
1716
1830
  if (!caps.length) cards.innerHTML = `<p class='empty'>${t("no_note")}</p>`;
1717
1831
  $list.appendChild(cards);
1718
1832
  return;
1719
1833
  }
1834
+ if (notesTab === "needs") {
1835
+ renderNeedsList($list);
1836
+ return;
1837
+ }
1720
1838
  const items = GRAPH.nodes.filter(n => n.lib && inScope(n));
1721
1839
  const subtypeOf = (n) => n.atom_type || n.form || n.claim_type || "";
1722
1840
  const kinds = [...new Set(items.map(n => n.kind))];
@@ -1907,6 +2025,7 @@
1907
2025
  function profileCounts(profileId) {
1908
2026
  return {
1909
2027
  capture: nodesOfAll("capture").filter(n => n.profile === profileId).length,
2028
+ need: nodesOfAll("need").filter(n => n.profile === profileId).length,
1910
2029
  topic: nodesOfAll("topic").filter(n => n.profile === profileId).length,
1911
2030
  lib: GRAPH.nodes.filter(n => n.lib && (String(n.profiles || "").includes(profileId) || String(n.profiles || "").includes("*"))).length,
1912
2031
  run: nodesOfAll("run").filter(n => n.profile === profileId).length,
@@ -1918,6 +2037,7 @@
1918
2037
  if (profileId) return profileCounts(profileId);
1919
2038
  return {
1920
2039
  capture: nodesOfAll("capture").length,
2040
+ need: nodesOfAll("need").length,
1921
2041
  topic: nodesOfAll("topic").length,
1922
2042
  lib: GRAPH.nodes.filter(n => n.lib).length,
1923
2043
  run: nodesOfAll("run").length,
@@ -1927,18 +2047,28 @@
1927
2047
 
1928
2048
  function gapsFor(profileId) {
1929
2049
  const topics = profileId ? nodesOfAll("topic").filter(n => n.profile === profileId) : nodesOfAll("topic");
2050
+ const discs = profileId ? nodesOfAll("discovery").filter(n => n.profile === profileId) : nodesOfAll("discovery");
2051
+ const needs = profileId ? nodesOfAll("need").filter(n => n.profile === profileId) : nodesOfAll("need");
1930
2052
  const caps = profileId ? nodesOfAll("capture").filter(n => n.profile === profileId) : nodesOfAll("capture");
1931
2053
  const pubs = profileId ? nodesOfAll("published").filter(n => n.profile === profileId) : nodesOfAll("published");
1932
- return [
2054
+ const needCount = needs.length;
2055
+ const gaps = [
1933
2056
  [topics.filter(x => x.status === "ready" && x.usage === "unused").length, "gap_ready_n"],
2057
+ [discs.filter(discoveryUnprocessed).length, "gap_angle_n"],
2058
+ [needs.filter(n => n.status === "captured" && (!n.topic_ids || n.topic_ids === "none")).length, "gap_needs_n"],
1934
2059
  [caps.filter(c => c.status !== "ingested").length, "gap_lib_n"],
1935
2060
  [pubs.filter(p => !p.reviewable).length, "gap_review_n"],
1936
2061
  ].filter(([n]) => n > 0);
2062
+ if (GRAPH.needs_index_present && needCount < 3) {
2063
+ gaps.splice(2, 0, [needCount, "gap_needs_thin_n"]);
2064
+ }
2065
+ return gaps;
1937
2066
  }
1938
2067
 
1939
2068
  function statsHtml(counts) {
1940
2069
  return `<div class="prof-stats">
1941
2070
  <div><b>${counts.capture}</b><span>${t("kpi_cap")}</span></div>
2071
+ <div><b>${counts.need || 0}</b><span>${t("kpi_needs")}</span></div>
1942
2072
  <div><b>${counts.topic}</b><span>${t("kpi_topics")}</span></div>
1943
2073
  <div><b>${counts.lib}</b><span>${t("kpi_lib")}</span></div>
1944
2074
  <div><b>${counts.run}</b><span>Run</span></div>
@@ -1972,6 +2102,7 @@
1972
2102
  ["voice", t("doc_voice")],
1973
2103
  ["audience", t("doc_audience")],
1974
2104
  ["pillars", t("doc_pillars")],
2105
+ ["lanes", t("doc_lanes")],
1975
2106
  ];
1976
2107
  const pillarRows = Object.entries(pillars).map(([id, p]) => {
1977
2108
  const title = lang === "zh" ? p.zh : p.en;
@@ -2035,16 +2166,31 @@
2035
2166
  ? `${n.win || 0}w / ${n.loss || 0}l / n=${n.n}`
2036
2167
  : "",
2037
2168
  [t("kv_pillar")]: formatPillar(n.pillar),
2169
+ Lane: n.kind === "topic" ? (n.lane || "") : "",
2170
+ "Generation mode": n.kind === "topic" ? (n.generation_mode || "") : "",
2038
2171
  [t("kv_plat")]: n.platform || n.platforms,
2039
2172
  [t("kv_profile")]: n.profile || n.profile_id || (n.profiles || ""),
2040
2173
  [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,
2174
+ [t("kv_use")]: n.kind === "discovery" ? (n.angle_recommend || n.next_action || "") : n.usage,
2042
2175
  [t("kv_src")]: n.kind === "discovery" ? (n.source_role || "") : n.source_type,
2176
+ Angle: n.kind === "discovery" ? ([n.angle_status, n.need_ids].filter(Boolean).join(" · ")) : "",
2177
+ Quote: n.kind === "need" ? (n.quote || "") : "",
2178
+ Speaker: n.kind === "need" ? (n.speaker || "") : "",
2179
+ Frequency: n.kind === "need" ? (n.frequency || "") : "",
2180
+ Topics: n.kind === "need" ? (n.topic_ids || "") : "",
2181
+ Products: n.kind === "topic" ? (n.product_ids || "") : "",
2182
+ "Product fit": n.kind === "topic" ? (n.product_fit || "") : "",
2183
+ Audience: n.kind === "product" ? (n.audience || "") : "",
2184
+ Price: n.kind === "product" ? (n.price || "") : "",
2185
+ "Effective from": n.kind === "product" ? (n.effective_from || "") : "",
2186
+ Category: n.kind === "recommendation" ? (n.category || "") : "",
2187
+ Relationship: n.kind === "recommendation" ? (n.relationship || "") : "",
2188
+ "Last researched": n.kind === "recommendation" ? (n.last_researched || "") : "",
2043
2189
  Handle: n.source_handle || n.src_handle || "",
2044
- [t("kv_heat_at")]: n.kind === "discovery" ? (n.engagement_at || "") : "",
2190
+ [t("kv_heat_at")]: (n.kind === "discovery" || n.kind === "capture") ? (n.engagement_at || "") : "",
2045
2191
  [t("kv_result")]: n.result,
2046
2192
  }).filter(([, v]) => v);
2047
- const heatBlock = n.kind === "discovery"
2193
+ const heatBlock = (n.kind === "discovery" || n.kind === "capture")
2048
2194
  ? `${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
2195
  : "";
2050
2196
  $insp.innerHTML = `<h2>${t("obj")}</h2>