@easysociable/cli 0.1.2 → 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 (148) hide show
  1. package/README.md +32 -21
  2. package/{skills/easysociable-content → content-engine}/scripts/starter.mjs +3 -1
  3. package/{skills/easysociable-content → content-engine}/scripts/workbench.mjs +13 -8
  4. package/{skills/easysociable-content → content-engine}/starter/CLAUDE.md +217 -128
  5. package/{skills/easysociable-content → 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 +16 -0
  15. package/{skills/easysociable-content → 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/{skills/easysociable-content → 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/{skills/easysociable-content → content-engine}/starter/runs/_template/brief.md +1 -1
  25. package/{skills/easysociable-content → content-engine}/starter/runs/_template/pack.md +15 -7
  26. package/{skills/easysociable-content → content-engine}/starter/runs/_template/packet.md +6 -0
  27. package/{skills/easysociable-content → content-engine}/starter/swipe/_template.md +3 -1
  28. package/content-engine/starter/topics/README.md +21 -0
  29. package/{skills/easysociable-content → content-engine}/starter/topics/_index.md +12 -5
  30. package/content-engine/starter/topics/_template-item.md +103 -0
  31. package/{skills/easysociable-content → content-engine}/starter/topics/scoring.md +55 -4
  32. package/content-engine/workbench/__pycache__/build.cpython-310.pyc +0 -0
  33. package/{skills/easysociable-content → content-engine}/workbench/build.py +306 -97
  34. package/{skills/easysociable-content → content-engine}/workbench/serve.py +0 -0
  35. package/{skills/easysociable-content → content-engine}/workbench/template.html +173 -27
  36. package/dist/easysociable.js +629 -142
  37. package/dist/easysociable.js.map +1 -1
  38. package/package.json +15 -15
  39. package/skills/ONBOARDING.md +21 -18
  40. package/skills/easysociable-content/SKILL.md +104 -98
  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 -44
  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 +29 -30
  49. package/skills/easysociable-template/references/CATALOG_METADATA.md +23 -20
  50. package/skills/easysociable-template/references/DESIGN_V2_1.md +53 -2
  51. package/skills/easysociable-template/references/GATEWAY.md +7 -2
  52. package/skills/easysociable-template/references/PLATFORM_SPECS.md +11 -3
  53. package/skills/easysociable-template/references/THEME_TOKENS.md +1 -1
  54. package/skills/easysociable-template/references/fixtures/image-reference.design-plan.json +74 -0
  55. package/skills/easysociable-template/references/fixtures/image-reference.visual-qa.json +28 -0
  56. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
  57. package/skills/easysociable-template/scripts/validate-design-v2.mjs +97 -0
  58. package/skills/easysociable-template/workflows/ASSET_PLAN.md +2 -1
  59. package/skills/easysociable-template/workflows/AUTHORING_FROM_BRIEF.md +4 -4
  60. package/skills/easysociable-template/workflows/AUTHORING_FROM_IMAGE.md +66 -16
  61. package/skills/easysociable-template/workflows/AUTHORING_STUDIO.md +28 -15
  62. package/skills/easysociable-template/workflows/DESIGN_BRIEF.md +4 -3
  63. package/skills/easysociable-template/workflows/FILL_AND_RENDER.md +4 -83
  64. package/skills/easysociable-template/workflows/MULTI_PLATFORM_FAMILY.md +81 -0
  65. package/skills/easysociable-template/workflows/OUT_OF_SCOPE.md +3 -2
  66. package/skills/easysociable-template/workflows/PUBLISH_FROM_DRAFT.md +1 -1
  67. package/skills/easysociable-template/workflows/VISUAL_QA.md +10 -0
  68. package/skills/easysociable-content/INSTALL.md +0 -60
  69. package/skills/easysociable-content/platforms/claude.md +0 -4
  70. package/skills/easysociable-content/platforms/codex.md +0 -4
  71. package/skills/easysociable-content/platforms/hermes.md +0 -4
  72. package/skills/easysociable-content/platforms/openclaw.md +0 -4
  73. package/skills/easysociable-content/references/CLI.md +0 -40
  74. package/skills/easysociable-content/references/CONTRACT_SYNC.md +0 -42
  75. package/skills/easysociable-content/references/MCP.md +0 -15
  76. package/skills/easysociable-content/references/PAGE_PACK.md +0 -129
  77. package/skills/easysociable-content/references/PLATFORM_GUIDE.md +0 -36
  78. package/skills/easysociable-content/references/RUN_LAYOUT.md +0 -73
  79. package/skills/easysociable-content/references/TROUBLESHOOTING.md +0 -13
  80. package/skills/easysociable-content/references/VAULT.md +0 -40
  81. package/skills/easysociable-content/references/VOICE.md +0 -16
  82. package/skills/easysociable-content/scripts/package-skill.mjs +0 -49
  83. package/skills/easysociable-content/scripts/setup.mjs +0 -200
  84. package/skills/easysociable-content/scripts/setup.test.mjs +0 -82
  85. package/skills/easysociable-content/starter/discovery/README.md +0 -18
  86. package/skills/easysociable-content/starter/discovery/_index.md +0 -27
  87. package/skills/easysociable-content/starter/discovery/_template.md +0 -41
  88. package/skills/easysociable-content/starter/profiles/_index.md +0 -13
  89. package/skills/easysociable-content/starter/topics/README.md +0 -25
  90. package/skills/easysociable-content/starter/topics/WATCHLIST.md +0 -65
  91. package/skills/easysociable-content/starter/topics/_template-item.md +0 -90
  92. package/skills/easysociable-content/starter/topics/watchlists/README.md +0 -10
  93. package/skills/easysociable-content/starter/topics/watchlists/_shared.md +0 -14
  94. package/skills/easysociable-content/starter/topics/watchlists/default.md +0 -6
  95. package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +0 -17
  96. package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +0 -24
  97. package/skills/easysociable-content/workflows/GETTING_STARTED.md +0 -40
  98. package/skills/easysociable-content/workflows/MCP_FIRST.md +0 -5
  99. package/skills/easysociable-content/workflows/OUT_OF_SCOPE.md +0 -18
  100. package/skills/easysociable-content/workflows/PACK_AUTHORING.md +0 -42
  101. package/skills/easysociable-content/workflows/SWIPE_CAPTURE.md +0 -18
  102. package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +0 -29
  103. package/skills/easysociable-content/workflows/WORKING_DRAFT.md +0 -19
  104. /package/{skills/easysociable-content → content-engine}/fixtures/golden-run-en/draft.md +0 -0
  105. /package/{skills/easysociable-content → content-engine}/fixtures/golden-run-en/idea.md +0 -0
  106. /package/{skills/easysociable-content → content-engine}/fixtures/golden-run-en/pack-tiktok.json +0 -0
  107. /package/{skills/easysociable-content → content-engine}/scripts/contracts.mjs +0 -0
  108. /package/{skills/easysociable-content → content-engine}/scripts/export-contracts.mjs +0 -0
  109. /package/{skills/easysociable-content → content-engine}/scripts/fixture.test.mjs +0 -0
  110. /package/{skills/easysociable-content → content-engine}/scripts/validate-export.mjs +0 -0
  111. /package/{skills/easysociable-content → content-engine}/scripts/validate-pack.mjs +0 -0
  112. /package/{skills/easysociable-content → content-engine}/scripts/validate-run.mjs +0 -0
  113. /package/{skills/easysociable-content → content-engine}/scripts/validate.test.mjs +0 -0
  114. /package/{skills/easysociable-content → content-engine}/starter/accounts/README.md +0 -0
  115. /package/{skills/easysociable-content → content-engine}/starter/accounts/_index.md +0 -0
  116. /package/{skills/easysociable-content → content-engine}/starter/accounts/overrides/README.md +0 -0
  117. /package/{skills/easysociable-content → content-engine}/starter/exports/README.md +0 -0
  118. /package/{skills/easysociable-content → content-engine}/starter/inbox/README.md +0 -0
  119. /package/{skills/easysociable-content → content-engine}/starter/inbox/_index.md +0 -0
  120. /package/{skills/easysociable-content → content-engine}/starter/inbox/entries/README.md +0 -0
  121. /package/{skills/easysociable-content → content-engine}/starter/platforms/README.md +0 -0
  122. /package/{skills/easysociable-content → content-engine}/starter/platforms/ig.md +0 -0
  123. /package/{skills/easysociable-content → content-engine}/starter/platforms/instagram.md +0 -0
  124. /package/{skills/easysociable-content → content-engine}/starter/platforms/linkedin.md +0 -0
  125. /package/{skills/easysociable-content → content-engine}/starter/platforms/threads.md +0 -0
  126. /package/{skills/easysociable-content → content-engine}/starter/platforms/tiktok.md +0 -0
  127. /package/{skills/easysociable-content → content-engine}/starter/platforms/x.md +0 -0
  128. /package/{skills/easysociable-content → content-engine}/starter/platforms/xiaohongshu.md +0 -0
  129. /package/{skills/easysociable-content → content-engine}/starter/profiles/default/audience.md +0 -0
  130. /package/{skills/easysociable-content → content-engine}/starter/profiles/default/pillars.md +0 -0
  131. /package/{skills/easysociable-content → content-engine}/starter/published/_index.md +0 -0
  132. /package/{skills/easysociable-content → content-engine}/starter/raw/README.md +0 -0
  133. /package/{skills/easysociable-content → content-engine}/starter/raw/_index.md +0 -0
  134. /package/{skills/easysociable-content → content-engine}/starter/runs/README.md +0 -0
  135. /package/{skills/easysociable-content → content-engine}/starter/runs/_index.md +0 -0
  136. /package/{skills/easysociable-content → content-engine}/starter/runs/_template/draft.md +0 -0
  137. /package/{skills/easysociable-content → content-engine}/starter/runs/_template/editor.md +0 -0
  138. /package/{skills/easysociable-content → content-engine}/starter/runs/_template/feedback.md +0 -0
  139. /package/{skills/easysociable-content → content-engine}/starter/runs/_template/idea.md +0 -0
  140. /package/{skills/easysociable-content → content-engine}/starter/runs/_template/rubric.md +0 -0
  141. /package/{skills/easysociable-content → content-engine}/starter/swipe/_index.md +0 -0
  142. /package/{skills/easysociable-content → content-engine}/starter/topics/items/README.md +0 -0
  143. /package/{skills/easysociable-content → content-engine}/starter/wiki/README.md +0 -0
  144. /package/{skills/easysociable-content → content-engine}/starter/wiki/atoms/_index.md +0 -0
  145. /package/{skills/easysociable-content → content-engine}/starter/wiki/atoms/_template.md +0 -0
  146. /package/{skills/easysociable-content → content-engine}/starter/wiki/claims/_index.md +0 -0
  147. /package/{skills/easysociable-content → content-engine}/starter/wiki/claims/_template.md +0 -0
  148. /package/{skills/easysociable-content → content-engine}/starter/workbench/README.md +0 -0
@@ -29,6 +29,8 @@ SWIPE_RE = re.compile(r"\bS\d+\b")
29
29
  ATOM_RE = re.compile(r"\bA-[a-z0-9-]+\b")
30
30
  CLAIM_RE = re.compile(r"\bC-(?!\d{8})[a-z0-9-]+\b")
31
31
  RUN_RE = re.compile(r"RUN-[A-Za-z0-9-]+")
32
+ PRODUCT_RE = re.compile(r"\bP-[A-Za-z0-9-]+\b")
33
+ RECOMMENDATION_RE = re.compile(r"\bR-[A-Za-z0-9-]+\b")
32
34
  WIKI = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
33
35
 
34
36
 
@@ -165,7 +167,8 @@ HTTP_RE = re.compile(r"https?://[^\s\]\)>'\"|]+")
165
167
  def wiki_path(cell: str) -> str:
166
168
  m = WIKI.search(cell or "")
167
169
  if not m:
168
- return ""
170
+ raw = (cell or "").strip().strip("`")
171
+ return raw if raw.endswith(".md") and "://" not in raw else ""
169
172
  p = m.group(1).strip()
170
173
  if p.endswith(".md"):
171
174
  return p
@@ -304,44 +307,128 @@ def parse_feedback_metrics(text: str) -> dict:
304
307
 
305
308
 
306
309
  ENGAGEMENT_SNAP_RE = re.compile(
307
- r"engagement\s+snapshot:\s*(.+?)(?:\n|$)",
310
+ r"(?:engagement\s+snapshot|snapshot\s+engagement)[^\n:]*:\s*(.+?)(?:\n|$)",
311
+ re.I,
312
+ )
313
+ ENGAGEMENT_SECTION_RE = re.compile(
314
+ r"^##\s+(?:Engagement\s+snapshot|Snapshot\s+metrics(?:\s*\([^)]*\))?)\s*\n+(.*?)(?=^##\s|\Z)",
315
+ re.I | re.M | re.S,
316
+ )
317
+ ENGAGEMENT_KIND = r"likes?|replies|reply|bookmarks?|views?"
318
+ ENGAGEMENT_NUM = r"~?\d[\d,]*(?:\.\d+)?[kKmMbB]?"
319
+ # kind-first: likes 113 / likes: 3
320
+ ENGAGEMENT_KIND_NUM_RE = re.compile(
321
+ rf"\b({ENGAGEMENT_KIND})\s*[:=]?\s*({ENGAGEMENT_NUM})\b",
308
322
  re.I,
309
323
  )
310
- ENGAGEMENT_PART_RE = re.compile(
311
- r"(likes|replies|bookmarks|views)\s+(\d[\d,]*)",
324
+ # num-first: 1086 likes / ~92.9k views
325
+ ENGAGEMENT_NUM_KIND_RE = re.compile(
326
+ rf"({ENGAGEMENT_NUM})\s+({ENGAGEMENT_KIND})\b",
312
327
  re.I,
313
328
  )
314
329
  ENGAGEMENT_AT_RE = re.compile(r"@\s*(\d{4}-\d{2}-\d{2})")
330
+ ENGAGEMENT_FETCH_AT_RE = re.compile(
331
+ r"(?:fetch|at\s+capture|snapshot_at)\s*[:=]?\s*(\d{4}-\d{2}-\d{2})",
332
+ re.I,
333
+ )
315
334
 
316
335
 
317
- def parse_engagement_snapshot(text: str) -> dict[str, str]:
318
- """Parse Discovery `engagement snapshot` line into src_* fields.
319
-
320
- Only the primary snapshot counts. Parenthetical notes like
321
- `(quoted earlier demo: likes 67 / bookmarks 115)` are ignored.
322
- """
323
- out = {
336
+ def _empty_engagement() -> dict[str, str]:
337
+ return {
324
338
  "src_likes": "",
325
339
  "src_replies": "",
326
340
  "src_bookmarks": "",
327
341
  "src_views": "",
328
342
  "engagement_at": "",
329
343
  }
344
+
345
+
346
+ def _normalize_metric_kind(kind: str) -> str:
347
+ k = kind.lower()
348
+ if k.startswith("like"):
349
+ return "likes"
350
+ if k.startswith("repl"):
351
+ return "replies"
352
+ if k.startswith("bookmark"):
353
+ return "bookmarks"
354
+ if k.startswith("view"):
355
+ return "views"
356
+ return k
357
+
358
+
359
+ def _parse_compact_count(raw: str) -> str:
360
+ """'92.9k' → '92900'; '1.1M' → '1100000'; '9,574' → '9574'."""
361
+ s = raw.strip().lstrip("~").replace(",", "")
362
+ if not s:
363
+ return ""
364
+ m = re.fullmatch(r"(\d+(?:\.\d+)?)([kKmMbB])?", s)
365
+ if not m:
366
+ digits = re.sub(r"[^\d]", "", s)
367
+ return digits
368
+ n = float(m.group(1))
369
+ suf = (m.group(2) or "").lower()
370
+ if suf == "k":
371
+ n *= 1_000
372
+ elif suf == "m":
373
+ n *= 1_000_000
374
+ elif suf == "b":
375
+ n *= 1_000_000_000
376
+ return str(int(round(n)))
377
+
378
+
379
+ def _fill_engagement_from_blob(out: dict[str, str], blob: str) -> None:
380
+ if not blob:
381
+ return
382
+ # Prefer kind-first matches (discovery + list form), then num-first (capture prose).
383
+ for kind, num in ENGAGEMENT_KIND_NUM_RE.findall(blob):
384
+ key = f"src_{_normalize_metric_kind(kind)}"
385
+ if key in out and not out[key]:
386
+ out[key] = _parse_compact_count(num)
387
+ for num, kind in ENGAGEMENT_NUM_KIND_RE.findall(blob):
388
+ key = f"src_{_normalize_metric_kind(kind)}"
389
+ if key in out and not out[key]:
390
+ out[key] = _parse_compact_count(num)
391
+ if not out["engagement_at"]:
392
+ at = ENGAGEMENT_AT_RE.search(blob) or ENGAGEMENT_FETCH_AT_RE.search(blob)
393
+ if at:
394
+ out["engagement_at"] = at.group(1)
395
+
396
+
397
+ def parse_engagement_snapshot(text: str) -> dict[str, str]:
398
+ """Parse engagement into src_* fields from Discovery or Capture/Raw notes.
399
+
400
+ Supports:
401
+ - Discovery: `engagement snapshot: likes 113 / replies 18 / ... @ 2026-08-30`
402
+ - Capture prose: `Engagement snapshot (fetch DATE): ~92.9k views · 1086 likes · ...`
403
+ - Raw list under `## Engagement snapshot` with `- likes: 3`
404
+ Parenthetical secondary demos after the primary snapshot are ignored.
405
+ """
406
+ out = _empty_engagement()
330
407
  if not text:
331
408
  return out
332
409
  m = ENGAGEMENT_SNAP_RE.search(text)
333
- if not m:
410
+ if m:
411
+ blob = m.group(1).split("(", 1)[0]
412
+ # Date often sits in the label: Engagement snapshot (fetch 2026-08-25)
413
+ label = text[m.start() : m.start(1)]
414
+ _fill_engagement_from_blob(out, blob)
415
+ if not out["engagement_at"]:
416
+ at = ENGAGEMENT_FETCH_AT_RE.search(label) or ENGAGEMENT_AT_RE.search(label)
417
+ if at:
418
+ out["engagement_at"] = at.group(1)
334
419
  return out
335
- # Keep only the primary snapshot; drop parenthetical secondary demos.
336
- blob = m.group(1).split("(", 1)[0]
337
- for kind, num in ENGAGEMENT_PART_RE.findall(blob):
338
- key = f"src_{kind.lower()}"
339
- # First occurrence wins (do not let later noise overwrite).
340
- if key in out and not out[key]:
341
- out[key] = num.replace(",", "")
342
- at = ENGAGEMENT_AT_RE.search(blob)
343
- if at:
344
- out["engagement_at"] = at.group(1)
420
+ sec = ENGAGEMENT_SECTION_RE.search(text)
421
+ if sec:
422
+ _fill_engagement_from_blob(out, sec.group(1))
423
+ return out
424
+
425
+
426
+ def merge_engagement(*parts: dict[str, str]) -> dict[str, str]:
427
+ out = _empty_engagement()
428
+ for part in parts:
429
+ for k, v in part.items():
430
+ if k in out and v and not out[k]:
431
+ out[k] = v
345
432
  return out
346
433
 
347
434
  def build_graph() -> dict:
@@ -359,7 +446,7 @@ def build_graph() -> dict:
359
446
  for p in profiles:
360
447
  base = p["path"].rstrip("/") + "/"
361
448
  files = {}
362
- for name in ("README", "voice", "audience", "pillars"):
449
+ for name in ("README", "voice", "audience", "pillars", "lanes"):
363
450
  rel = f"{base}{name}.md"
364
451
  if (ROOT / rel).exists():
365
452
  files[name.lower()] = rel
@@ -432,102 +519,142 @@ def build_graph() -> dict:
432
519
  for kind, lib_id in parse_library(row["library"]):
433
520
  edges.append({"from": nid("capture", ident), "to": nid(kind, lib_id), "rel": "ingested_as"})
434
521
 
435
- for row in table_with(read("raw/_index.md"), "id", "date", "capture_id", "file", "type", "source_url", "one_liner"):
522
+ needs_md = read("needs/_index.md")
523
+ needs_rows = table_with(
524
+ needs_md,
525
+ "id",
526
+ "date",
527
+ "profile",
528
+ "source_type",
529
+ "quote",
530
+ "speaker",
531
+ "frequency",
532
+ "status",
533
+ "topic_ids",
534
+ "file",
535
+ )
536
+ for row in needs_rows:
436
537
  ident = row["id"]
437
- fpath = wiki_path(row["file"])
438
- if fpath and not fpath.endswith(".md"):
538
+ if not ident.startswith("N-"):
539
+ continue
540
+ fpath = wiki_path(row["file"]) or f"needs/entries/{ident}.md"
541
+ if not fpath.endswith(".md"):
439
542
  fpath = f"{fpath}.md"
543
+ topic_ids = (row.get("topic_ids") or "").strip()
544
+ quote = (row.get("quote") or "").strip()
440
545
  add_node(
441
546
  node(
442
- "raw",
547
+ "need",
443
548
  ident,
444
- row["one_liner"],
549
+ quote or f"Need {ident}",
445
550
  fpath,
446
551
  {
447
552
  "date": row["date"],
448
- "type": row["type"],
449
- "source_url": row["source_url"],
450
- "capture_id": row["capture_id"],
553
+ "profile": row["profile"],
554
+ "source_type": row["source_type"],
555
+ "quote": quote,
556
+ "speaker": row.get("speaker", "") or "",
557
+ "frequency": row.get("frequency", "") or "1",
558
+ "status": row["status"],
559
+ "topic_ids": topic_ids,
451
560
  },
452
561
  )
453
562
  )
454
- if row["capture_id"]:
455
- edge(nid("capture", row["capture_id"]), nid("raw", ident), "has_raw")
563
+ # Topic edges are wired after topic nodes exist (see post-pass below).
456
564
 
457
- discovery_md = read("discovery/_index.md")
458
- # Current minimal schema. Legacy rows may still carry relevance/ownership/candidates.
459
- discovery_rows = table_with(
460
- discovery_md,
461
- "id",
462
- "date",
463
- "profile",
464
- "platform",
465
- "source_handle",
466
- "source_url",
467
- "source_claim",
468
- "source_role",
469
- "status",
470
- "next_action",
471
- "topic_ids",
472
- "file",
473
- )
474
- if not discovery_rows:
475
- discovery_rows = table_with(
476
- discovery_md,
565
+ products_path = ROOT / "products" / "_index.md"
566
+ if products_path.exists():
567
+ product_rows = table_with(
568
+ products_path.read_text(encoding="utf-8"),
477
569
  "id",
478
- "date",
479
- "profile",
480
- "platform",
481
- "source_handle",
482
- "source_url",
483
- "source_claim",
484
- "source_role",
485
- "relevance",
486
- "ownership",
487
- "product_relevance",
570
+ "name",
571
+ "status",
572
+ "audience",
573
+ "price",
574
+ "effective_from",
575
+ "file",
576
+ )
577
+ for row in product_rows:
578
+ ident = row["id"]
579
+ if not ident.startswith("P-"):
580
+ continue
581
+ fpath = wiki_path(row["file"]) or f"products/entries/{ident}.md"
582
+ if not fpath.endswith(".md"):
583
+ fpath = f"{fpath}.md"
584
+ add_node(
585
+ node(
586
+ "product",
587
+ ident,
588
+ row["name"] or ident,
589
+ fpath,
590
+ {
591
+ "status": row["status"],
592
+ "audience": row["audience"],
593
+ "price": row["price"],
594
+ "effective_from": row["effective_from"],
595
+ },
596
+ )
597
+ )
598
+
599
+ recommendations_path = ROOT / "recommendations" / "_index.md"
600
+ if recommendations_path.exists():
601
+ recommendation_rows = table_with(
602
+ recommendations_path.read_text(encoding="utf-8"),
603
+ "id",
604
+ "name",
488
605
  "status",
489
- "next_action",
490
- "candidates",
491
- "topic_ids",
606
+ "category",
607
+ "profiles",
608
+ "pillars",
609
+ "last_researched",
610
+ "relationship",
492
611
  "file",
493
612
  )
494
- for row in discovery_rows:
613
+ for row in recommendation_rows:
614
+ ident = row["id"]
615
+ if not ident.startswith("R-"):
616
+ continue
617
+ fpath = wiki_path(row["file"]) or f"recommendations/{ident}.md"
618
+ if not fpath.endswith(".md"):
619
+ fpath = f"{fpath}.md"
620
+ add_node(
621
+ node(
622
+ "recommendation",
623
+ ident,
624
+ row["name"] or ident,
625
+ fpath,
626
+ {
627
+ "status": row["status"],
628
+ "category": row["category"],
629
+ "profiles": row["profiles"],
630
+ "pillars": row["pillars"],
631
+ "last_researched": row["last_researched"],
632
+ "relationship": row["relationship"],
633
+ },
634
+ )
635
+ )
636
+
637
+ for row in table_with(read("raw/_index.md"), "id", "date", "capture_id", "file", "type", "source_url", "one_liner"):
495
638
  ident = row["id"]
496
- if not ident.startswith("D-"):
497
- continue
498
639
  fpath = wiki_path(row["file"])
499
640
  if fpath and not fpath.endswith(".md"):
500
641
  fpath = f"{fpath}.md"
501
- topic_ids = (row.get("topic_ids") or "").strip()
502
- eng = parse_engagement_snapshot(read(fpath) if fpath and (ROOT / fpath).exists() else "")
503
642
  add_node(
504
643
  node(
505
- "discovery",
644
+ "raw",
506
645
  ident,
507
- row["source_claim"] or f"Discovery {ident}",
646
+ row["one_liner"],
508
647
  fpath,
509
648
  {
510
649
  "date": row["date"],
511
- "profile": row["profile"],
512
- "platform": row["platform"],
513
- "source_handle": row["source_handle"],
650
+ "type": row["type"],
514
651
  "source_url": row["source_url"],
515
- "source_claim": row["source_claim"],
516
- "source_role": row["source_role"],
517
- "relevance": row.get("relevance", "") or "",
518
- "ownership": row.get("ownership", "") or "",
519
- "product_relevance": row.get("product_relevance", "") or "",
520
- "status": row["status"],
521
- "next_action": row.get("next_action", "") or "",
522
- "candidates": row.get("candidates", "") or "",
523
- "topic_ids": topic_ids,
524
- **eng,
652
+ "capture_id": row["capture_id"],
525
653
  },
526
654
  )
527
655
  )
528
- linked = re.findall(r"\bT-\d{8}-\d{2}\b", f"{topic_ids} {row.get('candidates') or ''}")
529
- for tid in linked:
530
- edge(nid("discovery", ident), nid("topic", tid), "candidate")
656
+ if row["capture_id"]:
657
+ edge(nid("capture", row["capture_id"]), nid("raw", ident), "has_raw")
531
658
 
532
659
  topic_md = read("topics/_index.md")
533
660
  # Current: opp + aud (5C opportunity). Legacy: craft + fit, then single total.
@@ -542,6 +669,8 @@ def build_graph() -> dict:
542
669
  "scout",
543
670
  "platform",
544
671
  "pillar",
672
+ "lane",
673
+ "generation_mode",
545
674
  "one_liner",
546
675
  "item",
547
676
  "source_type",
@@ -552,6 +681,9 @@ def build_graph() -> dict:
552
681
  "src_bookmarks",
553
682
  "engagement_at",
554
683
  "capture_id",
684
+ "need_ids",
685
+ "product_ids",
686
+ "product_fit",
555
687
  "run",
556
688
  )
557
689
  if topic_rows:
@@ -655,6 +787,8 @@ def build_graph() -> dict:
655
787
  "status": row["status"],
656
788
  "platform": row["platform"],
657
789
  "pillar": row["pillar"],
790
+ "lane": row.get("lane", "") or "",
791
+ "generation_mode": row.get("generation_mode", "") or "",
658
792
  "profile": profile,
659
793
  "source_type": row["source_type"],
660
794
  "src_url": row["src_url"],
@@ -664,6 +798,9 @@ def build_graph() -> dict:
664
798
  "src_bookmarks": row["src_bookmarks"],
665
799
  "engagement_at": row["engagement_at"],
666
800
  "capture_id": row["capture_id"],
801
+ "need_ids": row.get("need_ids", "") or "",
802
+ "product_ids": row.get("product_ids", "") or "",
803
+ "product_fit": row.get("product_fit", "") or "",
667
804
  "run_id": row["run"].strip(),
668
805
  "usage": usage,
669
806
  "src_heat_note": "来源帖热度,不是你的帖",
@@ -676,8 +813,22 @@ def build_graph() -> dict:
676
813
  edge(nid("topic", ident), nid("run", row["run"].strip()), "opened_run")
677
814
  if item and (ROOT / item).exists():
678
815
  body = read(item)
816
+ fm = re.match(r"^---\n(.*?)\n---", body, re.S)
817
+ if fm:
818
+ mneeds = re.search(r"^need_ids:\s*(.*)$", fm.group(1), re.M)
819
+ if mneeds:
820
+ for need_id in re.findall(r"\bN-\d{8}-\d{2}\b", mneeds.group(1)):
821
+ edge(nid("topic", ident), nid("need", need_id), "from_need")
822
+ mproducts = re.search(r"^product_ids:\s*(.*)$", fm.group(1), re.M)
823
+ if mproducts:
824
+ for product_id in PRODUCT_RE.findall(mproducts.group(1)):
825
+ edge(nid("topic", ident), nid("product", product_id), "uses_product")
826
+ mrecommendations = re.search(r"^recommendation_ids:\s*(.*)$", fm.group(1), re.M)
827
+ if mrecommendations:
828
+ for recommendation_id in RECOMMENDATION_RE.findall(mrecommendations.group(1)):
829
+ edge(nid("topic", ident), nid("recommendation", recommendation_id), "reviews")
679
830
  chunk = body
680
- m = re.search(r"^## Suggested production.*?(?=^## |\Z)", body, re.M | re.S)
831
+ m = re.search(r"^## Suggested production.*?(?=^## |\Z)|^## Trace.*?(?=^## |\Z)", body, re.M | re.S)
681
832
  if m:
682
833
  chunk = m.group(0)
683
834
  for line in chunk.splitlines():
@@ -697,6 +848,12 @@ def build_graph() -> dict:
697
848
  for cid in ids_in(rest)["claim"]:
698
849
  edges.append({"from": nid("topic", ident), "to": nid("claim", cid), "rel": "suggested"})
699
850
 
851
+ for n in nodes.values():
852
+ if n["kind"] != "need":
853
+ continue
854
+ for tid in re.findall(r"\bT-\d{8}-\d{2}\b", n.get("topic_ids") or ""):
855
+ edge(n["id"], nid("topic", tid), "evidence_for")
856
+
700
857
  for row in table_with(
701
858
  read("swipe/_index.md"),
702
859
  "id",
@@ -1004,18 +1161,35 @@ def build_graph() -> dict:
1004
1161
  for cid in field_ids(row["claim"], "claim"):
1005
1162
  edge(nid("published", ident), nid("claim", cid), "uses")
1006
1163
 
1007
- # Capture → surface source_url from linked Raw
1164
+ # Capture → source_url from Raw + engagement from entry/raw notes
1008
1165
  for n in list(nodes.values()):
1009
1166
  if n["kind"] != "capture":
1010
1167
  continue
1168
+ raw_node = None
1011
1169
  for e in edges:
1012
1170
  if e["rel"] != "has_raw" or e["from"] != n["id"]:
1013
1171
  continue
1014
- raw = nodes.get(e["to"])
1015
- if raw and raw.get("source_url"):
1016
- n["source_url"] = raw["source_url"]
1017
- n["raw_id"] = raw["ident"]
1018
- break
1172
+ raw_node = nodes.get(e["to"])
1173
+ if raw_node and raw_node.get("source_url"):
1174
+ n["source_url"] = raw_node["source_url"]
1175
+ n["raw_id"] = raw_node["ident"]
1176
+ break
1177
+ entry_text = read(n["path"]) if n.get("path") and (ROOT / n["path"]).exists() else ""
1178
+ raw_text = ""
1179
+ raw_path = ""
1180
+ if raw_node and raw_node.get("path"):
1181
+ raw_path = raw_node["path"]
1182
+ elif n.get("raw_path"):
1183
+ raw_path = n["raw_path"] if str(n["raw_path"]).endswith(".md") else f'{n["raw_path"]}.md'
1184
+ if raw_path and (ROOT / raw_path).exists():
1185
+ raw_text = read(raw_path)
1186
+ eng = merge_engagement(
1187
+ parse_engagement_snapshot(entry_text),
1188
+ parse_engagement_snapshot(raw_text),
1189
+ )
1190
+ for k, v in eng.items():
1191
+ if v:
1192
+ n[k] = v
1019
1193
 
1020
1194
  # drop edges to missing nodes
1021
1195
  edges = [e for e in edges if e["from"] in nodes and e["to"] in nodes]
@@ -1056,11 +1230,41 @@ def build_graph() -> dict:
1056
1230
  "accounts": accounts,
1057
1231
  "pillars_by_profile": pillars_by_profile,
1058
1232
  "pillars": flat_pillars,
1233
+ "needs_index_present": (ROOT / "needs" / "_index.md").exists(),
1234
+ "products_index_present": (ROOT / "products" / "_index.md").exists(),
1059
1235
  "nodes": list(nodes.values()),
1060
1236
  "edges": uniq,
1061
1237
  }
1062
1238
 
1063
1239
 
1240
+ def _self_check() -> None:
1241
+ # ponytail: tiny parser asserts; expand if capture/discovery formats drift again.
1242
+ d = parse_engagement_snapshot(
1243
+ "- **engagement snapshot:** likes 113 / replies 18 / bookmarks 17 / views 9574 @ 2026-08-30\n"
1244
+ )
1245
+ assert d == {
1246
+ "src_likes": "113",
1247
+ "src_replies": "18",
1248
+ "src_bookmarks": "17",
1249
+ "src_views": "9574",
1250
+ "engagement_at": "2026-08-30",
1251
+ }, d
1252
+ c = parse_engagement_snapshot(
1253
+ "6. **Engagement snapshot (fetch 2026-08-25):** ~92.9k views · 1086 likes · 1380 bookmarks · 738 replies · 70 reposts.\n"
1254
+ )
1255
+ assert c["src_views"] == "92900" and c["src_likes"] == "1086", c
1256
+ assert c["src_bookmarks"] == "1380" and c["src_replies"] == "738", c
1257
+ assert c["engagement_at"] == "2026-08-25", c
1258
+ r = parse_engagement_snapshot(
1259
+ "## Engagement snapshot\n\n- likes: 3\n- replies: 3\n- views: 426\n- bookmarks: 2\n- snapshot_at: 2026-09-13\n"
1260
+ )
1261
+ assert r["src_likes"] == "3" and r["src_views"] == "426" and r["engagement_at"] == "2026-09-13", r
1262
+ m = parse_engagement_snapshot(
1263
+ "## Snapshot metrics (at capture)\n\n- Views: ~8091\n- Likes: 103\n- Bookmarks: 29\n- Replies: 20\n"
1264
+ )
1265
+ assert m["src_views"] == "8091" and m["src_likes"] == "103", m
1266
+ assert m["src_bookmarks"] == "29" and m["src_replies"] == "20", m
1267
+ print("parse_engagement_snapshot self-check ok")
1064
1268
  def main() -> None:
1065
1269
  graph = build_graph()
1066
1270
  html = TPL.read_text(encoding="utf-8")
@@ -1071,4 +1275,9 @@ def main() -> None:
1071
1275
 
1072
1276
 
1073
1277
  if __name__ == "__main__":
1074
- main()
1278
+ import sys
1279
+
1280
+ if "--self-check" in sys.argv:
1281
+ _self_check()
1282
+ else:
1283
+ main()