@easysociable/cli 0.1.0 → 0.1.2

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 (108) hide show
  1. package/README.md +38 -69
  2. package/dist/easysociable.js +301 -2829
  3. package/dist/easysociable.js.map +1 -1
  4. package/package.json +17 -12
  5. package/skills/ONBOARDING.md +3 -1
  6. package/skills/easysociable-content/INSTALL.md +60 -0
  7. package/skills/easysociable-content/SKILL.md +107 -96
  8. package/skills/easysociable-content/fixtures/golden-run-en/draft.md +13 -0
  9. package/skills/easysociable-content/fixtures/golden-run-en/idea.md +26 -0
  10. package/skills/easysociable-content/fixtures/golden-run-en/pack-tiktok.json +11 -0
  11. package/skills/easysociable-content/platforms/claude.md +1 -2
  12. package/skills/easysociable-content/platforms/codex.md +1 -2
  13. package/skills/easysociable-content/platforms/hermes.md +1 -2
  14. package/skills/easysociable-content/platforms/openclaw.md +1 -2
  15. package/skills/easysociable-content/references/CLI.md +25 -12
  16. package/skills/easysociable-content/references/CONTRACT_SYNC.md +42 -0
  17. package/skills/easysociable-content/references/MCP.md +7 -12
  18. package/skills/easysociable-content/references/PAGE_PACK.md +128 -5
  19. package/skills/easysociable-content/references/PLATFORM_GUIDE.md +36 -0
  20. package/skills/easysociable-content/references/RUN_LAYOUT.md +73 -0
  21. package/skills/easysociable-content/references/TROUBLESHOOTING.md +11 -9
  22. package/skills/easysociable-content/references/VAULT.md +32 -12
  23. package/skills/easysociable-content/references/VOICE.md +16 -0
  24. package/skills/easysociable-content/scripts/contracts.mjs +183 -0
  25. package/skills/easysociable-content/scripts/export-contracts.mjs +76 -0
  26. package/skills/easysociable-content/scripts/fixture.test.mjs +24 -0
  27. package/skills/easysociable-content/scripts/package-skill.mjs +49 -0
  28. package/skills/easysociable-content/scripts/setup.mjs +127 -83
  29. package/skills/easysociable-content/scripts/setup.test.mjs +49 -38
  30. package/skills/easysociable-content/scripts/starter.mjs +50 -48
  31. package/skills/easysociable-content/scripts/validate-export.mjs +42 -0
  32. package/skills/easysociable-content/scripts/validate-pack.mjs +48 -0
  33. package/skills/easysociable-content/scripts/validate-run.mjs +100 -0
  34. package/skills/easysociable-content/scripts/validate.test.mjs +101 -0
  35. package/skills/easysociable-content/scripts/workbench.mjs +135 -0
  36. package/skills/easysociable-content/starter/CLAUDE.md +756 -0
  37. package/skills/easysociable-content/starter/accounts/README.md +9 -0
  38. package/skills/easysociable-content/starter/accounts/_index.md +31 -0
  39. package/skills/easysociable-content/starter/accounts/overrides/README.md +3 -0
  40. package/skills/easysociable-content/starter/discovery/README.md +18 -0
  41. package/skills/easysociable-content/starter/discovery/_index.md +27 -0
  42. package/skills/easysociable-content/starter/discovery/_template.md +41 -0
  43. package/skills/easysociable-content/starter/engine.json +19 -0
  44. package/skills/easysociable-content/starter/exports/README.md +28 -0
  45. package/skills/easysociable-content/starter/inbox/README.md +9 -0
  46. package/skills/easysociable-content/starter/inbox/_index.md +24 -0
  47. package/skills/easysociable-content/starter/inbox/entries/README.md +3 -0
  48. package/skills/easysociable-content/starter/platforms/README.md +19 -0
  49. package/skills/easysociable-content/starter/platforms/ig.md +69 -0
  50. package/skills/easysociable-content/starter/platforms/instagram.md +5 -0
  51. package/skills/easysociable-content/starter/platforms/linkedin.md +72 -0
  52. package/skills/easysociable-content/starter/platforms/threads.md +69 -0
  53. package/skills/easysociable-content/starter/platforms/tiktok.md +71 -0
  54. package/skills/easysociable-content/starter/platforms/x.md +78 -0
  55. package/skills/easysociable-content/starter/platforms/xiaohongshu.md +19 -0
  56. package/skills/easysociable-content/starter/profiles/_index.md +13 -0
  57. package/skills/easysociable-content/starter/profiles/default/README.md +11 -0
  58. package/skills/easysociable-content/starter/profiles/default/audience.md +38 -0
  59. package/skills/easysociable-content/starter/profiles/default/pillars.md +33 -0
  60. package/skills/easysociable-content/starter/profiles/default/voice.md +24 -0
  61. package/skills/easysociable-content/starter/published/_index.md +20 -0
  62. package/skills/easysociable-content/starter/raw/README.md +4 -0
  63. package/skills/easysociable-content/starter/raw/_index.md +12 -0
  64. package/skills/easysociable-content/starter/runs/README.md +22 -0
  65. package/skills/easysociable-content/starter/runs/_index.md +41 -0
  66. package/skills/easysociable-content/starter/runs/_template/brief.md +66 -0
  67. package/skills/easysociable-content/starter/runs/_template/draft.md +11 -0
  68. package/skills/easysociable-content/starter/runs/_template/editor.md +18 -0
  69. package/skills/easysociable-content/starter/runs/_template/feedback.md +15 -0
  70. package/skills/easysociable-content/starter/runs/_template/idea.md +27 -0
  71. package/skills/easysociable-content/starter/runs/_template/pack.md +47 -0
  72. package/skills/easysociable-content/starter/runs/_template/packet.md +25 -0
  73. package/skills/easysociable-content/starter/runs/_template/rubric.md +19 -0
  74. package/skills/easysociable-content/starter/swipe/_index.md +27 -0
  75. package/skills/easysociable-content/starter/swipe/_template.md +34 -0
  76. package/skills/easysociable-content/starter/topics/README.md +25 -0
  77. package/skills/easysociable-content/starter/topics/WATCHLIST.md +65 -0
  78. package/skills/easysociable-content/starter/topics/_index.md +44 -0
  79. package/skills/easysociable-content/starter/topics/_template-item.md +90 -0
  80. package/skills/easysociable-content/starter/topics/items/README.md +4 -0
  81. package/skills/easysociable-content/starter/topics/scoring.md +201 -0
  82. package/skills/easysociable-content/starter/topics/watchlists/README.md +10 -0
  83. package/skills/easysociable-content/starter/topics/watchlists/_shared.md +14 -0
  84. package/skills/easysociable-content/starter/topics/watchlists/default.md +6 -0
  85. package/skills/easysociable-content/starter/wiki/README.md +13 -0
  86. package/skills/easysociable-content/starter/wiki/atoms/_index.md +23 -0
  87. package/skills/easysociable-content/starter/wiki/atoms/_template.md +31 -0
  88. package/skills/easysociable-content/starter/wiki/claims/_index.md +26 -0
  89. package/skills/easysociable-content/starter/wiki/claims/_template.md +38 -0
  90. package/skills/easysociable-content/starter/workbench/README.md +17 -0
  91. package/skills/easysociable-content/workbench/build.py +1074 -0
  92. package/skills/easysociable-content/workbench/serve.py +53 -0
  93. package/skills/easysociable-content/workbench/template.html +2119 -0
  94. package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +14 -5
  95. package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +16 -11
  96. package/skills/easysociable-content/workflows/GETTING_STARTED.md +40 -0
  97. package/skills/easysociable-content/workflows/MCP_FIRST.md +3 -8
  98. package/skills/easysociable-content/workflows/OUT_OF_SCOPE.md +18 -0
  99. package/skills/easysociable-content/workflows/PACK_AUTHORING.md +42 -0
  100. package/skills/easysociable-content/workflows/SWIPE_CAPTURE.md +18 -0
  101. package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +28 -4
  102. package/skills/easysociable-content/workflows/WORKING_DRAFT.md +17 -8
  103. package/skills/easysociable-slideshow/SKILL.md +20 -13
  104. package/skills/easysociable-slideshow/references/VAULT.md +13 -11
  105. package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
  106. package/skills/easysociable-content/workflows/DEVICE_TASKS.md +0 -5
  107. package/skills/easysociable-content/workflows/EXPERIMENTS.md +0 -6
  108. package/skills/easysociable-content/workflows/PUBLICATION.md +0 -5
@@ -0,0 +1,1074 @@
1
+ #!/usr/bin/env python3
2
+ """Scan Content Engine indexes → workbench/index.html (read-only)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ ROOT = Path(__file__).resolve().parent.parent
12
+ OUT = Path(__file__).resolve().parent / "index.html"
13
+ TPL = Path(__file__).resolve().parent / "template.html"
14
+
15
+ PILLAR_HEAD = re.compile(r"^###\s+(P\d+)\s+[—–-]\s+(.+)$", re.M)
16
+ # Known Chinese titles keyed by English heading from pillars.md (profile-agnostic fallbacks by P-id still OK for current vault).
17
+ PILLAR_ZH_BY_EN = {
18
+ "Agency content problems": "机构内容生产问题",
19
+ "AI content systems": "AI 内容系统",
20
+ "Building EasySociable": "建造 EasySociable",
21
+ }
22
+ PILLAR_ZH_BY_ID = {
23
+ "P1": "机构内容生产问题",
24
+ "P2": "AI 内容系统",
25
+ "P3": "建造 EasySociable",
26
+ }
27
+ CAPTURE_RE = re.compile(r"C-\d{8}-\d{2}")
28
+ SWIPE_RE = re.compile(r"\bS\d+\b")
29
+ ATOM_RE = re.compile(r"\bA-[a-z0-9-]+\b")
30
+ CLAIM_RE = re.compile(r"\bC-(?!\d{8})[a-z0-9-]+\b")
31
+ RUN_RE = re.compile(r"RUN-[A-Za-z0-9-]+")
32
+ WIKI = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
33
+
34
+
35
+ def parse_pillars_file(path: Path) -> dict:
36
+ if not path.exists():
37
+ return {}
38
+ out = {}
39
+ for m in PILLAR_HEAD.finditer(path.read_text(encoding="utf-8")):
40
+ pid, title = m.group(1), m.group(2)
41
+ title = re.sub(r"\s*\([^)]*\)\s*$", "", title).strip()
42
+ out[pid] = {
43
+ "en": title,
44
+ "zh": PILLAR_ZH_BY_EN.get(title) or PILLAR_ZH_BY_ID.get(pid, title),
45
+ }
46
+ return out
47
+
48
+
49
+ def parse_registry() -> tuple[list[dict], list[dict], dict]:
50
+ """profiles, accounts, pillars_by_profile."""
51
+ profiles = []
52
+ for row in table_with(read("profiles/_index.md"), "id", "path", "voice", "one_liner", "accounts"):
53
+ if not row["id"] or row["id"].startswith("---"):
54
+ continue
55
+ path = row["path"].strip().strip("`") or f"profiles/{row['id']}/"
56
+ if not path.endswith("/"):
57
+ path = f"{path}/"
58
+ voice = row["voice"].strip().strip("`")
59
+ voice = re.split(r"\s*\(", voice, 1)[0].strip().strip("`")
60
+ profiles.append(
61
+ {
62
+ "id": row["id"],
63
+ "path": path,
64
+ "voice": voice,
65
+ "one_liner": row["one_liner"],
66
+ "accounts": [a.strip() for a in row["accounts"].split(",") if a.strip()],
67
+ }
68
+ )
69
+
70
+ accounts = []
71
+ for row in table_with(
72
+ read("accounts/_index.md"),
73
+ "handle",
74
+ "profile_id",
75
+ "voice",
76
+ "platforms",
77
+ "default_platform",
78
+ "overrides",
79
+ "status",
80
+ ):
81
+ if not row["handle"] or row["handle"].startswith("---"):
82
+ continue
83
+ accounts.append(
84
+ {
85
+ "handle": row["handle"],
86
+ "profile_id": row["profile_id"],
87
+ "voice": row["voice"],
88
+ "platforms": row["platforms"],
89
+ "default_platform": row["default_platform"],
90
+ "overrides": row["overrides"],
91
+ "status": row["status"],
92
+ }
93
+ )
94
+
95
+ pillars_by_profile = {}
96
+ for p in profiles:
97
+ pillars_by_profile[p["id"]] = parse_pillars_file(ROOT / p["path"] / "pillars.md")
98
+ return profiles, accounts, pillars_by_profile
99
+
100
+
101
+ def read(rel: str) -> str:
102
+ return (ROOT / rel).read_text(encoding="utf-8")
103
+
104
+
105
+ def extract_tables(md: str) -> list[dict]:
106
+ blocks: list[list[str]] = []
107
+ cur: list[str] = []
108
+ for line in md.splitlines():
109
+ if line.strip().startswith("|"):
110
+ cur.append(line)
111
+ elif cur:
112
+ blocks.append(cur)
113
+ cur = []
114
+ if cur:
115
+ blocks.append(cur)
116
+ tables = []
117
+ for block in blocks:
118
+ rows = []
119
+ for line in block:
120
+ raw = line.replace("\\|", "\x1e")
121
+ cells = [c.replace("\x1e", "|").strip() for c in raw.strip().strip("|").split("|")]
122
+ rows.append(cells)
123
+ if len(rows) < 2:
124
+ continue
125
+ headers = [h.strip() for h in rows[0]]
126
+ if all(set(h) <= set("-: ") for h in rows[1]):
127
+ body = rows[2:]
128
+ else:
129
+ body = rows[1:]
130
+ recs = []
131
+ for row in body:
132
+ if not any(row):
133
+ continue
134
+ rec = {}
135
+ for i, h in enumerate(headers):
136
+ rec[h] = row[i].strip() if i < len(row) else ""
137
+ recs.append(rec)
138
+ tables.append({"headers": headers, "rows": recs})
139
+ return tables
140
+
141
+
142
+ def table_with(md: str, *need: str) -> list[dict]:
143
+ need_l = [n.lower() for n in need]
144
+ for t in extract_tables(md):
145
+ hs = [h.lower() for h in t["headers"]]
146
+ if all(n in hs for n in need_l):
147
+ key = {h.lower(): h for h in t["headers"]}
148
+ out = []
149
+ for row in t["rows"]:
150
+ out.append({n: row.get(key[n], "") for n in need_l})
151
+ return out
152
+ return []
153
+
154
+
155
+ def hit_int(row: dict, key: str) -> int:
156
+ try:
157
+ return int(str(row.get(key, "") or "0").strip() or 0)
158
+ except ValueError:
159
+ return 0
160
+
161
+
162
+ HTTP_RE = re.compile(r"https?://[^\s\]\)>'\"|]+")
163
+
164
+
165
+ def wiki_path(cell: str) -> str:
166
+ m = WIKI.search(cell or "")
167
+ if not m:
168
+ return ""
169
+ p = m.group(1).strip()
170
+ if p.endswith(".md"):
171
+ return p
172
+ return p
173
+
174
+
175
+ def run_folder_from_cell(cell: str) -> str:
176
+ p = wiki_path(cell)
177
+ parts = [x for x in p.replace("\\", "/").split("/") if x]
178
+ if len(parts) >= 2 and parts[0] == "runs":
179
+ return parts[1]
180
+ return ""
181
+
182
+
183
+ def frontmatter(text: str) -> dict[str, str]:
184
+ if not text.startswith("---"):
185
+ return {}
186
+ end = text.find("\n---", 3)
187
+ if end < 0:
188
+ return {}
189
+ meta = {}
190
+ for line in text[4:end].splitlines():
191
+ if ":" not in line:
192
+ continue
193
+ k, v = line.split(":", 1)
194
+ meta[k.strip()] = v.strip().strip("\"'")
195
+ return meta
196
+
197
+
198
+ def parse_library(cell: str) -> list[tuple[str, str]]:
199
+ if not cell or cell.strip() in ("none", "—", "-"):
200
+ return []
201
+ out = []
202
+ for part in cell.split(";"):
203
+ part = part.strip()
204
+ if ":" not in part:
205
+ continue
206
+ kind, rest = part.split(":", 1)
207
+ kind = kind.strip().lower()
208
+ kind = {"atoms": "atom", "claims": "claim", "swipes": "swipe"}.get(kind, kind)
209
+ for tok in rest.split(","):
210
+ tok = tok.strip()
211
+ if tok:
212
+ out.append((kind, tok))
213
+ return out
214
+
215
+
216
+ def nid(kind: str, ident: str) -> str:
217
+ return f"{kind}:{ident}"
218
+
219
+
220
+ def vault_file(path: str) -> str:
221
+ path = path.replace("\\", "/").lstrip("/")
222
+ if path and not path.endswith(".md") and "." not in Path(path).name:
223
+ # folder wiki → idea.md if run folder
224
+ pass
225
+ return f"Content Engine/{path}"
226
+
227
+
228
+ def obsidian_uri(engine_rel: str) -> str:
229
+ file_enc = vault_file(engine_rel).replace(" ", "%20")
230
+ return f"obsidian://open?vault=Obsidian%20Vault&file={file_enc}"
231
+
232
+
233
+ def node(
234
+ kind: str,
235
+ ident: str,
236
+ label: str,
237
+ path: str,
238
+ extra: dict,
239
+ ) -> dict:
240
+ d = {
241
+ "id": nid(kind, ident),
242
+ "kind": kind,
243
+ "ident": ident,
244
+ "label": label,
245
+ "path": path,
246
+ "obsidian": obsidian_uri(path) if path else "",
247
+ **extra,
248
+ }
249
+ return d
250
+
251
+
252
+ def ids_in(text: str) -> dict[str, list[str]]:
253
+ return {
254
+ "capture": sorted(set(CAPTURE_RE.findall(text or ""))),
255
+ "swipe": sorted(set(SWIPE_RE.findall(text or ""))),
256
+ "atom": sorted(set(ATOM_RE.findall(text or ""))),
257
+ "claim": sorted(set(CLAIM_RE.findall(text or ""))),
258
+ "run": sorted(set(RUN_RE.findall(text or ""))),
259
+ }
260
+
261
+
262
+ def field_ids(val: str, kind: str) -> list[str]:
263
+ v = (val or "").strip()
264
+ if not v or v.lower() == "none":
265
+ return []
266
+ return ids_in(v).get(kind, [])
267
+
268
+
269
+ STAGE_NAMES = (
270
+ "idea",
271
+ "brief",
272
+ "packet",
273
+ "draft",
274
+ "editor",
275
+ "rubric",
276
+ "pack",
277
+ "feedback",
278
+ )
279
+
280
+
281
+ def run_stage_files(folder: Path, folder_name: str) -> dict[str, str]:
282
+ """Stage name → vault-relative .md path (only files that exist)."""
283
+ base = f"runs/{folder_name}"
284
+ out: dict[str, str] = {}
285
+ for name in STAGE_NAMES:
286
+ p = folder / f"{name}.md"
287
+ if p.exists():
288
+ out[name] = f"{base}/{name}.md"
289
+ if "pack" not in out:
290
+ packs = sorted(folder.glob("pack-*.md"))
291
+ if packs:
292
+ out["pack"] = f"{base}/{packs[0].name}"
293
+ return out
294
+
295
+
296
+ def parse_feedback_metrics(text: str) -> dict:
297
+ out = {"has_numbers": False, "result": ""}
298
+ fm = frontmatter(text)
299
+ if fm.get("result"):
300
+ out["result"] = fm["result"]
301
+ if re.search(r"(views|likes|replies|bookmarks)\s*:", text, re.I):
302
+ out["has_numbers"] = True
303
+ return out
304
+
305
+
306
+ ENGAGEMENT_SNAP_RE = re.compile(
307
+ r"engagement\s+snapshot:\s*(.+?)(?:\n|$)",
308
+ re.I,
309
+ )
310
+ ENGAGEMENT_PART_RE = re.compile(
311
+ r"(likes|replies|bookmarks|views)\s+(\d[\d,]*)",
312
+ re.I,
313
+ )
314
+ ENGAGEMENT_AT_RE = re.compile(r"@\s*(\d{4}-\d{2}-\d{2})")
315
+
316
+
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 = {
324
+ "src_likes": "",
325
+ "src_replies": "",
326
+ "src_bookmarks": "",
327
+ "src_views": "",
328
+ "engagement_at": "",
329
+ }
330
+ if not text:
331
+ return out
332
+ m = ENGAGEMENT_SNAP_RE.search(text)
333
+ if not m:
334
+ 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)
345
+ return out
346
+
347
+ def build_graph() -> dict:
348
+ nodes: dict[str, dict] = {}
349
+ edges: list[dict] = []
350
+ profiles, accounts, pillars_by_profile = parse_registry()
351
+
352
+ def add_node(n: dict) -> None:
353
+ nodes[n["id"]] = n
354
+
355
+ def edge(a: str, b: str, rel: str) -> None:
356
+ if a in nodes and b in nodes:
357
+ edges.append({"from": a, "to": b, "rel": rel})
358
+
359
+ for p in profiles:
360
+ base = p["path"].rstrip("/") + "/"
361
+ files = {}
362
+ for name in ("README", "voice", "audience", "pillars"):
363
+ rel = f"{base}{name}.md"
364
+ if (ROOT / rel).exists():
365
+ files[name.lower()] = rel
366
+ readme = files.get("readme") or files.get("pillars") or f"{base}pillars.md"
367
+ add_node(
368
+ node(
369
+ "profile",
370
+ p["id"],
371
+ p["one_liner"] or p["id"],
372
+ readme,
373
+ {
374
+ "one_liner": p["one_liner"],
375
+ "voice": p["voice"],
376
+ "accounts": p["accounts"],
377
+ "pillar_ids": list(pillars_by_profile.get(p["id"], {}).keys()),
378
+ "files": files,
379
+ "dir": base,
380
+ },
381
+ )
382
+ )
383
+ for a in accounts:
384
+ add_node(
385
+ node(
386
+ "account",
387
+ a["handle"],
388
+ a["handle"],
389
+ "accounts/_index.md",
390
+ {
391
+ "profile_id": a["profile_id"],
392
+ "platforms": a["platforms"],
393
+ "default_platform": a["default_platform"],
394
+ "status": a["status"],
395
+ "voice": a["voice"],
396
+ "overrides": a["overrides"],
397
+ },
398
+ )
399
+ )
400
+ if a["profile_id"]:
401
+ edges.append(
402
+ {"from": nid("account", a["handle"]), "to": nid("profile", a["profile_id"]), "rel": "uses_profile"}
403
+ )
404
+
405
+ for row in table_with(read("inbox/_index.md"), "id", "date", "type", "one_liner", "platforms", "profile", "entry", "raw", "library", "status"):
406
+ ident = row["id"]
407
+ if not ident.startswith("C-"):
408
+ continue
409
+ entry = wiki_path(row["entry"]) or f"inbox/entries/{ident}"
410
+ if not entry.endswith(".md"):
411
+ entry = f"{entry}.md"
412
+ raw_path = wiki_path(row["raw"])
413
+ add_node(
414
+ node(
415
+ "capture",
416
+ ident,
417
+ row["one_liner"],
418
+ entry,
419
+ {
420
+ "date": row["date"],
421
+ "type": row["type"],
422
+ "platforms": row["platforms"],
423
+ "profile": row["profile"],
424
+ "status": row["status"],
425
+ "library_cell": row["library"],
426
+ "raw_path": raw_path,
427
+ "has_raw": bool(raw_path),
428
+ "ingested": row["status"] == "ingested",
429
+ },
430
+ )
431
+ )
432
+ for kind, lib_id in parse_library(row["library"]):
433
+ edges.append({"from": nid("capture", ident), "to": nid(kind, lib_id), "rel": "ingested_as"})
434
+
435
+ for row in table_with(read("raw/_index.md"), "id", "date", "capture_id", "file", "type", "source_url", "one_liner"):
436
+ ident = row["id"]
437
+ fpath = wiki_path(row["file"])
438
+ if fpath and not fpath.endswith(".md"):
439
+ fpath = f"{fpath}.md"
440
+ add_node(
441
+ node(
442
+ "raw",
443
+ ident,
444
+ row["one_liner"],
445
+ fpath,
446
+ {
447
+ "date": row["date"],
448
+ "type": row["type"],
449
+ "source_url": row["source_url"],
450
+ "capture_id": row["capture_id"],
451
+ },
452
+ )
453
+ )
454
+ if row["capture_id"]:
455
+ edge(nid("capture", row["capture_id"]), nid("raw", ident), "has_raw")
456
+
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,
477
+ "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",
488
+ "status",
489
+ "next_action",
490
+ "candidates",
491
+ "topic_ids",
492
+ "file",
493
+ )
494
+ for row in discovery_rows:
495
+ ident = row["id"]
496
+ if not ident.startswith("D-"):
497
+ continue
498
+ fpath = wiki_path(row["file"])
499
+ if fpath and not fpath.endswith(".md"):
500
+ 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
+ add_node(
504
+ node(
505
+ "discovery",
506
+ ident,
507
+ row["source_claim"] or f"Discovery {ident}",
508
+ fpath,
509
+ {
510
+ "date": row["date"],
511
+ "profile": row["profile"],
512
+ "platform": row["platform"],
513
+ "source_handle": row["source_handle"],
514
+ "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,
525
+ },
526
+ )
527
+ )
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")
531
+
532
+ topic_md = read("topics/_index.md")
533
+ # Current: opp + aud (5C opportunity). Legacy: craft + fit, then single total.
534
+ topic_rows = table_with(
535
+ topic_md,
536
+ "id",
537
+ "date",
538
+ "opp",
539
+ "aud",
540
+ "status",
541
+ "purpose",
542
+ "scout",
543
+ "platform",
544
+ "pillar",
545
+ "one_liner",
546
+ "item",
547
+ "source_type",
548
+ "src_url",
549
+ "src_handle",
550
+ "src_likes",
551
+ "src_replies",
552
+ "src_bookmarks",
553
+ "engagement_at",
554
+ "capture_id",
555
+ "run",
556
+ )
557
+ if topic_rows:
558
+ topic_rows = [
559
+ {
560
+ **row,
561
+ "craft": row.get("opp", "") or "",
562
+ "fit": row.get("aud", "") or "",
563
+ }
564
+ for row in topic_rows
565
+ ]
566
+ if not topic_rows:
567
+ topic_rows = table_with(
568
+ topic_md,
569
+ "id",
570
+ "date",
571
+ "craft",
572
+ "fit",
573
+ "status",
574
+ "purpose",
575
+ "scout",
576
+ "platform",
577
+ "pillar",
578
+ "one_liner",
579
+ "item",
580
+ "source_type",
581
+ "src_url",
582
+ "src_handle",
583
+ "src_likes",
584
+ "src_replies",
585
+ "src_bookmarks",
586
+ "engagement_at",
587
+ "capture_id",
588
+ "run",
589
+ )
590
+ if not topic_rows:
591
+ topic_rows = [
592
+ {
593
+ **row,
594
+ "craft": row["total"],
595
+ "fit": "",
596
+ "purpose": "",
597
+ "scout": "",
598
+ }
599
+ for row in table_with(
600
+ topic_md,
601
+ "id",
602
+ "date",
603
+ "total",
604
+ "status",
605
+ "platform",
606
+ "pillar",
607
+ "one_liner",
608
+ "item",
609
+ "source_type",
610
+ "src_url",
611
+ "src_handle",
612
+ "src_likes",
613
+ "src_replies",
614
+ "src_bookmarks",
615
+ "engagement_at",
616
+ "capture_id",
617
+ "run",
618
+ )
619
+ ]
620
+
621
+ for row in topic_rows:
622
+ ident = row["id"]
623
+ if not ident.startswith("T-"):
624
+ continue
625
+ item = wiki_path(row["item"])
626
+ if item and not item.endswith(".md"):
627
+ item = f"{item}.md"
628
+ usage = "killed" if row["status"] == "killed" else ("promoted" if row["run"].strip() else "unused")
629
+ craft = row.get("craft", "") or ""
630
+ fit = row.get("fit", "") or ""
631
+ # Sort key: opp (aliased as craft) / legacy total
632
+ total = craft or row.get("total", "") or ""
633
+ profile = ""
634
+ if item and (ROOT / item).exists():
635
+ fm = re.match(r"^---\n(.*?)\n---", read(item), re.S)
636
+ if fm:
637
+ mprof = re.search(r"^profile:\s*(\S+)", fm.group(1), re.M)
638
+ if mprof:
639
+ profile = mprof.group(1).strip()
640
+ add_node(
641
+ node(
642
+ "topic",
643
+ ident,
644
+ row["one_liner"],
645
+ item,
646
+ {
647
+ "date": row["date"],
648
+ "total": total,
649
+ "opp": craft,
650
+ "aud": fit,
651
+ "craft": craft,
652
+ "fit": fit,
653
+ "purpose": row.get("purpose", "") or "",
654
+ "scout": row.get("scout", "") or "",
655
+ "status": row["status"],
656
+ "platform": row["platform"],
657
+ "pillar": row["pillar"],
658
+ "profile": profile,
659
+ "source_type": row["source_type"],
660
+ "src_url": row["src_url"],
661
+ "src_handle": row["src_handle"],
662
+ "src_likes": row["src_likes"],
663
+ "src_replies": row["src_replies"],
664
+ "src_bookmarks": row["src_bookmarks"],
665
+ "engagement_at": row["engagement_at"],
666
+ "capture_id": row["capture_id"],
667
+ "run_id": row["run"].strip(),
668
+ "usage": usage,
669
+ "src_heat_note": "来源帖热度,不是你的帖",
670
+ },
671
+ )
672
+ )
673
+ if row["capture_id"]:
674
+ edge(nid("topic", ident), nid("capture", row["capture_id"]), "from_capture")
675
+ if row["run"].strip().startswith("RUN-"):
676
+ edge(nid("topic", ident), nid("run", row["run"].strip()), "opened_run")
677
+ if item and (ROOT / item).exists():
678
+ body = read(item)
679
+ chunk = body
680
+ m = re.search(r"^## Suggested production.*?(?=^## |\Z)", body, re.M | re.S)
681
+ if m:
682
+ chunk = m.group(0)
683
+ for line in chunk.splitlines():
684
+ if ":" not in line:
685
+ continue
686
+ key, rest = line.split(":", 1)
687
+ key, rest = key.strip().lower(), rest.strip()
688
+ if rest.lower().startswith("none"):
689
+ continue
690
+ if "swipe" in key:
691
+ for sid in ids_in(rest)["swipe"]:
692
+ edges.append({"from": nid("topic", ident), "to": nid("swipe", sid), "rel": "suggested"})
693
+ elif "atom" in key:
694
+ for aid in ids_in(rest)["atom"]:
695
+ edges.append({"from": nid("topic", ident), "to": nid("atom", aid), "rel": "suggested"})
696
+ elif "claim" in key:
697
+ for cid in ids_in(rest)["claim"]:
698
+ edges.append({"from": nid("topic", ident), "to": nid("claim", cid), "rel": "suggested"})
699
+
700
+ for row in table_with(
701
+ read("swipe/_index.md"),
702
+ "id",
703
+ "file",
704
+ "platforms",
705
+ "profiles",
706
+ "form",
707
+ "beats_or_pages",
708
+ "status",
709
+ "n",
710
+ "win",
711
+ "loss",
712
+ "one_liner",
713
+ "source",
714
+ ):
715
+ ident = row["id"]
716
+ fpath = row["file"]
717
+ if fpath and not fpath.startswith("swipe/"):
718
+ fpath = f"swipe/{fpath}"
719
+ if fpath and not fpath.endswith(".md"):
720
+ fpath = f"{fpath}.md"
721
+ add_node(
722
+ node(
723
+ "swipe",
724
+ ident,
725
+ row["one_liner"],
726
+ fpath,
727
+ {
728
+ "platforms": row["platforms"],
729
+ "profiles": row["profiles"],
730
+ "form": row["form"],
731
+ "beats": row["beats_or_pages"],
732
+ "status": row["status"],
733
+ "n": hit_int(row, "n"),
734
+ "win": hit_int(row, "win"),
735
+ "loss": hit_int(row, "loss"),
736
+ "source_cell": row["source"],
737
+ "lib": True,
738
+ },
739
+ )
740
+ )
741
+ for cid in CAPTURE_RE.findall(row["source"]):
742
+ edge(nid("swipe", ident), nid("capture", cid), "sourced_from")
743
+
744
+ for row in table_with(
745
+ read("wiki/atoms/_index.md"),
746
+ "id",
747
+ "file",
748
+ "type",
749
+ "platforms",
750
+ "profiles",
751
+ "status",
752
+ "n",
753
+ "win",
754
+ "loss",
755
+ "one_liner",
756
+ ):
757
+ ident = row["id"]
758
+ fpath = row["file"]
759
+ if fpath and not fpath.startswith("wiki/"):
760
+ fpath = f"wiki/atoms/{fpath}"
761
+ if fpath and not fpath.endswith(".md"):
762
+ fpath = f"{fpath}.md"
763
+ add_node(
764
+ node(
765
+ "atom",
766
+ ident,
767
+ row["one_liner"],
768
+ fpath,
769
+ {
770
+ "atom_type": row["type"],
771
+ "platforms": row["platforms"],
772
+ "profiles": row["profiles"],
773
+ "status": row["status"],
774
+ "n": hit_int(row, "n"),
775
+ "win": hit_int(row, "win"),
776
+ "loss": hit_int(row, "loss"),
777
+ "lib": True,
778
+ },
779
+ )
780
+ )
781
+ if (ROOT / fpath).exists():
782
+ for cid in CAPTURE_RE.findall(read(fpath)):
783
+ edge(nid("atom", ident), nid("capture", cid), "sourced_from")
784
+
785
+ for row in table_with(
786
+ read("wiki/claims/_index.md"),
787
+ "id",
788
+ "file",
789
+ "type",
790
+ "platforms",
791
+ "profiles",
792
+ "status",
793
+ "n",
794
+ "win",
795
+ "loss",
796
+ "one_liner",
797
+ ):
798
+ ident = row["id"]
799
+ fpath = row["file"]
800
+ if fpath and not fpath.startswith("wiki/"):
801
+ fpath = f"wiki/claims/{fpath}"
802
+ if fpath and not fpath.endswith(".md"):
803
+ fpath = f"{fpath}.md"
804
+ evidence_runs = []
805
+ if (ROOT / fpath).exists():
806
+ body = read(fpath)
807
+ for cid in CAPTURE_RE.findall(body):
808
+ edge(nid("claim", ident), nid("capture", cid), "sourced_from")
809
+ in_ev = False
810
+ for line in body.splitlines():
811
+ if line.startswith("## Evidence"):
812
+ in_ev = True
813
+ continue
814
+ if in_ev and line.startswith("## "):
815
+ break
816
+ if in_ev:
817
+ evidence_runs += RUN_RE.findall(line)
818
+ add_node(
819
+ node(
820
+ "claim",
821
+ ident,
822
+ row["one_liner"],
823
+ fpath,
824
+ {
825
+ "claim_type": row["type"],
826
+ "platforms": row["platforms"],
827
+ "profiles": row["profiles"],
828
+ "status": row["status"],
829
+ "n": hit_int(row, "n"),
830
+ "win": hit_int(row, "win"),
831
+ "loss": hit_int(row, "loss"),
832
+ "evidence_runs": evidence_runs,
833
+ "has_evidence": bool(evidence_runs),
834
+ "lib": True,
835
+ },
836
+ )
837
+ )
838
+
839
+ for row in table_with(
840
+ read("runs/_index.md"),
841
+ "run_id",
842
+ "path",
843
+ "date",
844
+ "status",
845
+ "account",
846
+ "profile",
847
+ "primary_platform",
848
+ "platforms",
849
+ "topic_id",
850
+ "pillar",
851
+ "one_liner",
852
+ "pack",
853
+ "export",
854
+ "notes",
855
+ ):
856
+ ident = row["run_id"]
857
+ if not ident.startswith("RUN-"):
858
+ continue
859
+ folder_wiki = wiki_path(row["path"])
860
+ folder = folder_wiki.replace("runs/", "").strip("/")
861
+ rdir = ROOT / "runs" / folder if folder else None
862
+ stage_files = run_stage_files(rdir, folder) if rdir and rdir.is_dir() and folder else {}
863
+ stages = {n: n in stage_files for n in STAGE_NAMES}
864
+ idea_path = stage_files.get("idea") or (f"runs/{folder}/idea.md" if folder else "")
865
+ selection = {"swipe": "none", "atoms": "none", "claim": "none"}
866
+ supersedes = ""
867
+ superseded_by = ""
868
+ capture_ids = []
869
+ if idea_path and (ROOT / idea_path).exists():
870
+ idea = read(idea_path)
871
+ fm = frontmatter(idea)
872
+ supersedes = fm.get("supersedes", "")
873
+ superseded_by = fm.get("superseded_by", "")
874
+ for line in idea.splitlines():
875
+ if "swipe_id" in line:
876
+ selection["swipe"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**swipe_id:**", "").strip()
877
+ if "**atoms:**" in line or line.strip().startswith("- **atoms:**"):
878
+ selection["atoms"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**atoms:**", "").strip()
879
+ if "hypothesis" in line and "swipe_why" not in line:
880
+ selection["claim"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**hypothesis:**", "").strip()
881
+ if "claim_id" in line:
882
+ selection["claim"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**claim_id:**", "").strip()
883
+ if "capture_ids" in line:
884
+ capture_ids = CAPTURE_RE.findall(line)
885
+ sm = re.search(r"\*\*swipe_id:\*\*\s*(\S+)", idea)
886
+ if sm:
887
+ selection["swipe"] = sm.group(1)
888
+ am = re.search(r"\*\*atoms:\*\*\s*(.+)", idea)
889
+ if am:
890
+ selection["atoms"] = am.group(1).strip()
891
+ cm = re.search(r"\*\*(?:claim_id|hypothesis):\*\*\s*(\S+)", idea)
892
+ if cm:
893
+ selection["claim"] = cm.group(1)
894
+ metrics = {}
895
+ ship_urls = []
896
+ fb = rdir / "feedback.md" if rdir else None
897
+ if fb and fb.exists():
898
+ fb_text = fb.read_text(encoding="utf-8")
899
+ metrics = parse_feedback_metrics(fb_text)
900
+ ship_urls = [u.rstrip(".,;") for u in HTTP_RE.findall(fb_text)]
901
+ add_node(
902
+ node(
903
+ "run",
904
+ ident,
905
+ row["one_liner"],
906
+ f"runs/{folder}" if folder else "",
907
+ {
908
+ "date": row["date"],
909
+ "status": row["status"],
910
+ "account": row["account"],
911
+ "profile": row["profile"],
912
+ "primary_platform": row["primary_platform"],
913
+ "platforms": row["platforms"],
914
+ "topic_id": row["topic_id"] if row["topic_id"] != "none" else "",
915
+ "pillar": row["pillar"],
916
+ "pack": row["pack"],
917
+ "export": row["export"],
918
+ "notes": row["notes"],
919
+ "folder": folder,
920
+ "stages": stages,
921
+ "stage_files": stage_files,
922
+ "selection": selection,
923
+ "supersedes": supersedes,
924
+ "superseded_by": superseded_by,
925
+ "metrics": metrics,
926
+ "ship_urls": ship_urls,
927
+ "open_file": idea_path,
928
+ "obsidian": obsidian_uri(idea_path) if idea_path else "",
929
+ },
930
+ )
931
+ )
932
+ if row["topic_id"] not in ("", "none"):
933
+ edge(nid("run", ident), nid("topic", row["topic_id"]), "from_topic")
934
+ for cid in capture_ids:
935
+ edge(nid("run", ident), nid("capture", cid), "from_capture")
936
+ for sid in field_ids(selection["swipe"], "swipe"):
937
+ edge(nid("run", ident), nid("swipe", sid), "uses")
938
+ for aid in field_ids(selection["atoms"], "atom"):
939
+ edge(nid("run", ident), nid("atom", aid), "uses")
940
+ for cid in field_ids(selection["claim"], "claim"):
941
+ edge(nid("run", ident), nid("claim", cid), "uses")
942
+ if supersedes.startswith("RUN-"):
943
+ edges.append({"from": nid("run", ident), "to": nid("run", supersedes), "rel": "supersedes"})
944
+
945
+ folder_to_run = {
946
+ n["folder"]: n["ident"] for n in nodes.values() if n["kind"] == "run" and n.get("folder")
947
+ }
948
+
949
+ for row in table_with(
950
+ read("published/_index.md"),
951
+ "date",
952
+ "platform",
953
+ "url",
954
+ "run",
955
+ "pillar",
956
+ "swipe",
957
+ "atoms",
958
+ "claim",
959
+ "result",
960
+ "notes",
961
+ ):
962
+ if not row["url"].startswith("http"):
963
+ continue
964
+ run_cell = row["run"]
965
+ run_folder = run_folder_from_cell(run_cell)
966
+ run_id = (RUN_RE.findall(run_cell) or [""])[0]
967
+ if not run_id and run_folder in folder_to_run:
968
+ run_id = folder_to_run[run_folder]
969
+ run_node = nodes.get(nid("run", run_id)) if run_id else None
970
+ profile = (run_node or {}).get("profile", "")
971
+ ident = f"{row['date']}-{row['platform']}-{run_id or 'unknown'}"
972
+ add_node(
973
+ node(
974
+ "published",
975
+ ident,
976
+ row["url"],
977
+ "",
978
+ {
979
+ "date": row["date"],
980
+ "platform": row["platform"],
981
+ "url": row["url"],
982
+ "run_id": run_id,
983
+ "run_folder": run_folder,
984
+ "profile": profile,
985
+ "topic_id": (run_node or {}).get("topic_id", "") or "",
986
+ "pillar": row["pillar"],
987
+ "swipe": row["swipe"],
988
+ "atoms": row["atoms"],
989
+ "claim": row["claim"],
990
+ "result": row["result"],
991
+ "notes": row["notes"],
992
+ "reviewable": row["result"] in ("win", "flat", "loss"),
993
+ "obsidian": obsidian_uri("published/_index.md"),
994
+ "path": "published/_index.md",
995
+ },
996
+ )
997
+ )
998
+ if run_id:
999
+ edge(nid("published", ident), nid("run", run_id), "from_run")
1000
+ for sid in field_ids(row["swipe"], "swipe"):
1001
+ edge(nid("published", ident), nid("swipe", sid), "uses")
1002
+ for aid in field_ids(row["atoms"], "atom"):
1003
+ edge(nid("published", ident), nid("atom", aid), "uses")
1004
+ for cid in field_ids(row["claim"], "claim"):
1005
+ edge(nid("published", ident), nid("claim", cid), "uses")
1006
+
1007
+ # Capture → surface source_url from linked Raw
1008
+ for n in list(nodes.values()):
1009
+ if n["kind"] != "capture":
1010
+ continue
1011
+ for e in edges:
1012
+ if e["rel"] != "has_raw" or e["from"] != n["id"]:
1013
+ 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
1019
+
1020
+ # drop edges to missing nodes
1021
+ edges = [e for e in edges if e["from"] in nodes and e["to"] in nodes]
1022
+ seen = set()
1023
+ uniq = []
1024
+ for e in edges:
1025
+ k = (e["from"], e["to"], e["rel"])
1026
+ if k not in seen:
1027
+ seen.add(k)
1028
+ uniq.append(e)
1029
+
1030
+ run_uses: dict[str, set[str]] = {}
1031
+ for e in uniq:
1032
+ if e["rel"] != "uses":
1033
+ continue
1034
+ src = nodes.get(e["from"])
1035
+ dst = e["to"]
1036
+ if not src:
1037
+ continue
1038
+ if src["kind"] == "run":
1039
+ run_uses.setdefault(dst, set()).add(src["id"])
1040
+ elif src["kind"] == "published":
1041
+ run_uses.setdefault(dst, set()).add(nid("run", src["run_id"]) if src.get("run_id") else src["id"])
1042
+ for n in nodes.values():
1043
+ if n["kind"] in ("swipe", "atom", "claim"):
1044
+ n["refs"] = len(run_uses.get(n["id"], ()))
1045
+
1046
+ # flat pillars: prefer first profile for legacy callers; JS uses pillars_by_profile
1047
+ flat_pillars = {}
1048
+ for pid in sorted(pillars_by_profile.keys()):
1049
+ for k, v in pillars_by_profile[pid].items():
1050
+ flat_pillars.setdefault(k, v)
1051
+
1052
+ return {
1053
+ "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
1054
+ "vault": str(ROOT),
1055
+ "profiles": profiles,
1056
+ "accounts": accounts,
1057
+ "pillars_by_profile": pillars_by_profile,
1058
+ "pillars": flat_pillars,
1059
+ "nodes": list(nodes.values()),
1060
+ "edges": uniq,
1061
+ }
1062
+
1063
+
1064
+ def main() -> None:
1065
+ graph = build_graph()
1066
+ html = TPL.read_text(encoding="utf-8")
1067
+ payload = json.dumps(graph, ensure_ascii=False)
1068
+ html = html.replace("/*__GRAPH__*/null", payload)
1069
+ OUT.write_text(html, encoding="utf-8")
1070
+ print(f"wrote {OUT} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)")
1071
+
1072
+
1073
+ if __name__ == "__main__":
1074
+ main()