| name | llm-wiki |
| description | Karpathy's LLM Wiki — build and maintain a persistent, interlinked Markdown knowledge base. Use for wiki ingest, query, lint, structural optimization, index/log repair, review queues, and durable synthesis pages. |
| version | 2.4.6 |
| author | Hermes Agent |
| license | MIT |
| metadata | {"hermes":{"tags":["wiki","knowledge-base","research","notes","markdown","rag-alternative"],"category":"research","related_skills":["wiki-pipeline","obsidian","arxiv"]}} |
Karpathy's LLM Wiki
Build and maintain a persistent, compounding Markdown knowledge base: raw sources are captured once, synthesized pages keep cross-links current, and navigation surfaces make high-value knowledge easy to reuse.
Activation
Use this skill when the user asks to:
- create, repair, lint, or optimize a Markdown/Obsidian-style knowledge base
- ingest a URL, paper, transcript, clipping, or pasted source into a wiki
- answer a domain question from an existing wiki
- create query pages, topic maps, review queues, or learning paths
- repair index/log drift, frontmatter drift, wikilinks, tags, or review metadata
Operating Contract
- Orient first: read
SCHEMA.md, index.md, and recent log.md before writing.
- Search before creating: avoid duplicate pages and boundary drift.
- Preserve the layer model:
raw/ captures sources; entities/, concepts/, comparisons/, and queries/ synthesize reusable knowledge.
- Navigation is part of the work: every new durable page must be findable through
index.md, and recurring needs should become query/navigation pages.
- Completion is not file creation. Completion requires files, index/log, structure, and validation to be in a known state.
Workflow Decision Table
| User intent | Primary action | Reference |
|---|
| New wiki | Initialize structure and schema | references/wiki-schema-template.md |
| Ingest/update source | Capture raw, synthesize pages, update navigation, validate | references/wiki-operations.md |
| Query wiki | Read index/search, synthesize answer, optionally file durable query | references/wiki-operations.md |
| Lint/audit | Run structural validation and report failures explicitly | references/wiki-lint-checklist.md |
| Scored review ingest | Persist review metadata as frontmatter only | references/review-frontmatter-snippet.md |
| Closeout | Use success/failure template with actual file paths | references/ingest-closeout-template.md |
Layer Model — Concepts Are the Core Value
The wiki has a strict layer model. Understanding priorities prevents wasted effort:
raw/ → immutable source capture
entities/ → single-source synthesis (tools, products, people)
concepts/ → MULTI-source synthesis (frameworks, methods, ideas) ← CORE VALUE
comparisons/→ side-by-side comparisons
queries/ → navigation + durable answers
Concepts are what distinguishes a wiki from a collection of bookmarks. An entity-only wiki is a list; a concept-rich wiki is a knowledge graph. When concept and entity counts are disproportionate (e.g. 40 concepts vs 1200+ entities), the concept layer is the bottleneck.
Topics emerge bottom-up from tag clusters — do NOT pre-define a topic taxonomy. The semantic tag distribution in entities defines the topic space organically. The correct sequence: analyze tag clusters in entities → identify concept gaps → expand concepts from the most-linked entities in each cluster.
Phase 2: Query Creation — From Topic Maps to Real Questions
See references/phase2-query-conversion-log.md for the complete Phase 2 methodology,
the 4 already-converted query examples, the 38 remaining topic maps with priority order,
conversion standards, and the queries↔concepts↔entities funnel relationship.
Queries are the wiki's research question interface. A query page answers a real question using wiki content — it is NOT a topic map (entity list) or reading path.
What distinguishes a real query from a topic map:
- Topic map: "Here are 50 links about Claude Code" — just links, no synthesis
- Real query: "Claude Code 在生产环境中有哪些已知失败模式?" — synthesized answer with evidence + mitigations
When to create a query page:
- The question is answerable from existing wiki content
- The answer compresses 3+ sources into a reusable decision aid
- The question is asked repeatedly or would be asked by a researcher
Query creation workflow:
- Identify answerable questions — scan entities for
How/What/Why/Which headings; these often reveal real research questions the wiki has answers to
- Find source entities — tag-based search for relevant content clusters
- Read top 3-5 source entities — gather material for synthesis
- Write the answer — frontmatter (
type: query, tags), clear research question at top, synthesized answer with sections, evidence from sources with ^[raw/articles/...] citations, 8+ entity wikilinks
- Add to index.md — insert in
## Queries section with summary
- Run lint — verify no errors
Parallel query creation: Same pattern as concepts — delegate up to 3 queries simultaneously to subagents, each reading their own sources.
Phase 1 Results (Baseline Metrics)
After a large concept expansion session:
- Concepts: 0 skeletons (<1KB), avg 5.3KB, 7.8 entity links/file
- Measure success by: skeletons eliminated, avg entity links increased, lint 0 errors
- Batch pattern: 3 parallel subagents → verify → commit per session
Concept Expansion Priority Framework
When expanding concepts, prioritize in this order:
- Skeleton concepts (size < 1000 bytes) — pure stubs with minimal content. These have the highest ROI.
- Medium concepts with framework but no sub-structures — have a good outline but lack depth and entity links.
- Rich concepts needing more links — already substantive but could connect to more entities.
Concept Expansion Workflow (Parallel Subagent Pattern)
For expanding 3+ concepts simultaneously:
Step 1 — Audit first: Before delegating, run the concept audit script to identify expansion candidates:
# Analyze all concepts: file size, entity links, concept links, section count
import re
from pathlib import Path
wiki = Path("/Users/jinguo/wiki")
concepts_dir = wiki / "concepts"
concept_data = []
for f in sorted(concepts_dir.glob("*.md")):
c = f.read_text()
concept_data.append({
'file': f.name,
'size': len(c),
'sections': len([l for l in c.split('\n') if re.match(r'^##\s+', l)]),
'ent_links': len(re.findall(r'\[\[entities/([^\]|]+)', c)),
'con_links': len(re.findall(r'\[\[concepts/([^\]|]+)', c)),
})
concept_data.sort(key=lambda x: (-x['size'], -x['ent_links']))
Step 2 — Tag cluster analysis: Identify which entity tag clusters map to which concepts:
# Find high-value tag clusters with 2+ outgoing entity links
tag_clusters = {'harness-engineering': ['harness', 'harness-engineering', 'context-engineering'], ...}
for cluster_name, tags in tag_clusters.items():
cluster_ents = [f for f in (wiki/"entities").glob("*.md")
if any(t in f.read_text() for t in tags)]
synthesizing = [(f, len(f.read_text()), len(re.findall(r'\[\[entities/', f.read_text())))
for f in cluster_ents]
synthesizing.sort(key=lambda x: -x[1])
print(cluster_name, synthesizing[:5])
Step 3 — Read top source entities: For each skeleton concept, read the 3-5 most relevant entities from the corresponding tag cluster.
Step 4 — Delegate in parallel: Use delegate_task with up to 3 concurrent tasks, each expanding one concept from its source materials.
Step 5 — Verify and commit: Check expanded sizes (target: 4KB+), run lint, commit per-file.
Core Rules
- File names are lowercase, hyphenated, and have no spaces.
- Every active wiki page starts with YAML frontmatter.
- Use
[[subdir/page-slug]] wikilinks; avoid bare ambiguous links.
- Raw sources are immutable after capture; corrections belong in synthesized pages.
- Update
updated dates when editing existing synthesized pages.
- Add every new active page to
index.md and append every action to log.md.
- Keep
index.md Total pages aligned with active Markdown files in entities/, concepts/, comparisons/, queries/, and raw/articles/.
- Use query pages only when they add synthesis, navigation, review, or maintenance value.
- Do not encode review scores in tags. Review signals live only in frontmatter fields.
- Orphan raw = leaf nodes (Karpathy design):
raw/articles/ files with 0 wikilinks are expected. Raw = source archive, not synthesized knowledge. Entities reference raw; raw doesn't need to reference back. Accept 3-5% orphan rate in raw/ as normal.
- Tag
[article] is a placeholder: Replace with semantic tags (agent/ai/llm/model/company/person). Empty or placeholder tags have zero classification value.
MOC Update Consideration
When adding a new entity to a topic that has an existing MOC (Map of Content), consider updating the MOC:
- Check if a MOC exists:
ls moc/ | grep -i "topic-keyword"
- If MOC exists, read it and determine if the new entity belongs
- Add entity to appropriate section of the MOC
- Update MOC's
updated date in frontmatter
Why: MOCs are navigation hubs for topic clusters. Entities added to index.md but not to MOCs are harder to find through topic browsing.
Verified case (2026-06-28): Added loop-engineering-deep-dive-mengzhaoSixi-2026 entity. Existing MOC moc/loop-engineering.md was present (4 entities + 3 raw) but not updated. Entity discoverable through index.md search but not through topic map.
When to skip: If the MOC is already large (10+ entities) and the new entity is peripheral to the core topic.
Completion Gate
Report success only when all are true:
- Target files were created or updated correctly.
index.md and log.md were updated or explicitly verified unnecessary.
- Structural organization was considered and updated when needed.
- Validation passed, preferably via
node scripts/wiki-lint.mjs <wiki-path> when available.
If any item is incomplete, report failure or partial completion explicitly and name the blocker. Use references/ingest-closeout-template.md for the final closeout.
Failure Semantics
score >= threshold or decided to save is not a completion signal.
- Missing index/log updates mean the wiki is not current.
- Failed lint means the ingest/update is not complete.
- Batch work must finish one item through closeout before starting the next durable write.
- If a tool, source, or environment blocks completion, stop with a clear blocker instead of implying success.
Bilingual Content: Cross-Link Bridging, Not Tag Duplication
The wiki contains both Chinese and English entity pages. When a user queries in Chinese, will English content be missed?
Answer: wikilinks provide implicit bilingual bridging.
- The wiki uses pure keyword regex search, not vector/embedding retrieval. A literal Chinese keyword search only matches Chinese text.
- However, English content on the same topic is already connected via wikilinks. If entity A (Chinese) links to entity B (English) on the same concept, querying A surfaces B through the link structure — no embedding needed.
- Example:
[[entities/llm]] in a Chinese entity body means the Chinese page is already semantically linked to the English llm page, regardless of search language.
Tag bilingual duplication is the wrong solution. Adding tags: [LLM, 大模型] to every entity causes tag explosion, sync overhead, and maintenance burden. The wikilink structure already solves cross-language discovery.
If semantic cross-language retrieval is needed: The wiki's queries/ layer can serve as a translation/bridge layer — create a query page that synthesizes both Chinese and English sources on a topic, with wikilinks to both.
User Reply Preference (嵌入 skill body, not memory)
For wiki ingest and batch operations, user prefers concise status replies:
- Status + score + commit hash only
- No detailed content summary
- All commits are per-file small commits, not batched-at-end
🚨 Don't ask "要入库吗?" — just ingest (2026-06-28 verified, 3 corrections in single session)
When evaluating user-pasted URLs and the article passes the v×c threshold (≥49), ingest immediately. Do NOT reply with "要入库吗?" or "过线,建议入库" and wait for confirmation. The user expects action, not just evaluation.
Symptom: Agent evaluates article, reports score, asks user whether to ingest. User replies "入库了吗" (did you ingest it?) — frustration signal indicating the agent should have just done it.
Rule:
- v×c ≥ 49 + novel artifact → INGEST immediately, reply with commit hash
- v×c < 49 → REJECT, brief explanation
- Already in wiki → reply with existing commit hash + slug
- Borderline (v×c 46-48) → can ask, but this is rare
Verified 2026-06-28: 3 URLs in sequence — first two I evaluated and asked, user corrected with "入库了吗". Third URL I ingested immediately. User confirmed with next URL (implicit approval of the workflow).
When responding to wiki operations, follow this format:
{入库状态} | score={N} | commit={hash}
Proactive ingestion (2026-06-28 verified): When user pastes a URL and evaluation passes (v×c ≥ 49), proceed to ingest immediately — do NOT ask "要入库吗?" or wait for confirmation. The user expects the full pipeline to execute. Asking for permission wastes a round-trip and forces the user to ask "入库了吗" to trigger actual work. See wiki-pipeline/references/proactive-ingestion-rule.md for details.
{入库状态} | score={N} | commit={hash}
### Adding Background to Concept Pages (Multi-Source Synthesis)
When a concept page is created by **synthesizing multiple web sources** (not a direct single-URL ingest), add a `> **Background**` blockquote after the H1 title to document the research basis:
```markdown
# 数据智能体平台架构
> **Background**:本文档基于对火山引擎 Data Agent 全产品线的系统分析建立。参考了火山引擎官网产品介绍、字节方舟公众号文章、AWS Strands SDK 官方博客、AWS Bedrock AgentCore 技术文档等多源信息,综合提炼出通用的 Data Agent 平台架构设计框架与分阶段 Build Roadmap。
When to add Background:
- Concept created from session research (multiple web sources synthesized into a framework/roadmap)
- NOT for direct URL ingest (those use
sources: frontmatter + → [[raw/articles/slug|原文存档]] in body)
Format: One paragraph citing the sources analyzed, what角度 was taken, and that the content is a synthesis. Place immediately after the H1 title, before the first ## heading.
Known Pitfalls
Known Pitfalls
Cross-link target existence check — 4-dimension pre-check misses body wikilinks (2026-06-09, reinforced 2026-06-12)
Symptom: After running the wiki-pipeline mandatory pre-check (URL exact + topic keyword + author/feed + 30-70% overlap), a new entity is created. Lint reports BROKEN LINK: entities/<slug> -> [[concepts/llm-architecture]] because the body of the entity references a page that doesn't exist yet.
Root cause: The 4-dimension pre-check at the article level (URL/keyword/author/overlap) does NOT verify that target entities/concepts linked from inside the synthesized body exist on disk. An LLM-driven synthesis can confidently reference a future concept page (e.g., [[concepts/llm-architecture]]) that was never created.
Real failure example (2026-06-12 Self-Harness ingest): While drafting an entity, the synthesis confidently wrote [[concepts/verification-harness-engineering|验证工程]] based on a similar-sounding concept name. The actual file was concepts/verifier-driven-development.md. The wikilink existed in the body for the entire compose-then-verify cycle; the script caught it after writing, and a patch was needed to fix it. Wasted compose time + 1 extra patch round-trip per hallucinated link.
Fix (mandatory BEFORE writing the entity file — not after):
# Step 1: Enumerate available concept/entity slugs ONCE before composing
cd ~/wiki
ls concepts/ | sed 's/\.md$//' > /tmp/available-concepts.txt
ls entities/ | sed 's/\.md$//' > /tmp/available-entities.txt
ls queries/ | sed 's/\.md$//' > /tmp/available-queries.txt
ls comparisons/ | sed 's/\.md$//' > /tmp/available-comparisons.txt
Then only write [[concepts/...]], [[entities/...]], [[queries/...]], [[comparisons/...]] for slugs that appear in those files. If a slug is not in the list, use plain text in backticks: `concepts/llm-architecture` not [[concepts/llm-architecture]]. The link can be promoted to a real wikilink later when the target page is created.
⚠️ Wrong-name variant (2026-06-30 cron): The slug exists but with a different name. Example: entity links to [[concepts/agent-security]] but the actual file is concepts/agent-security-architecture.md. The concept exists, just with a longer/different slug. Lint catches it as BROKEN LINK. Fix: grep for the keyword part (agent-security) in the available slugs list to find the actual name before composing the wikilink:
grep "agent-security" /tmp/available-concepts.txt
# → agent-security-architecture, agent-security-attack-defense, ...
# Step 2 (still required as defense-in-depth): after writing entity body, scan + verify
import re, os
body = open(entity_path).read()
wikilinks = re.findall(r'\[\[([^\]|/#]+)(?:[|#][^\]]*)?\]\]', body)
for link in wikilinks:
if link.startswith("raw/") or link.startswith("http"):
continue
target = os.path.expanduser(f"~/wiki/{link}.md")
if not os.path.exists(target):
body = body.replace(f"[[{link}", f"`{link}`")
print(f" Replaced missing wikilink: {link}")
Prevention rule (the order matters):
- Enumerate available slugs first (
ls concepts/ entities/ queries/ comparisons/) — keep the list visible while composing
- Compose the entity body using only slugs from that list
- Then run the verify script as defense-in-depth
Do not skip step 1 and rely on the post-write verify alone — that catches broken links but wastes a compose cycle. The pre-enumeration is cheap (one ls per directory) and prevents hallucinated names from being typed in the first place.
Why this belongs with the 4-dimension pre-check: The 4-dimension pre-check fires for the source article (does this overlap existing coverage?). The cross-link target check fires for the destination (does the target of each body wikilink exist?). Both are needed; neither is sufficient alone.
index.md structure — multi-line with named sections (verified 2026-07-09)
index.md is a multi-line file (~4505 lines, ~800KB) with 12+ named sections. Current layout (verified 2026-07-09, ~6486 tracked pages):
Line 1: # Wiki Index
Line 5: 📚N (linter: N tracked pages) ← dynamic, trust linter
Line 13: ## Entities ← entity entries (alphabetical, ~700 entries)
Line 710: ## 核心定义 ← core definition entries (CJK)
Line 2864: ## Concepts
Line 2880: ## Comparisons
Line 2917: ## Queries
Line 3019: ## MOC (主题地图)
Line 3059: ## Drafts (对外输出草稿)
Line 3326: ## 学习路径 (Learning Path)
Line 3352: ## 知识地图 (MOC)
Line 3360: ## 新增条目
Line 3752: ## OpenMAIC 课堂版
Line 3886: ## Sources ← raw article entries (alphabetical by slug, ~620 entries)
Always verify the current section layout before inserting: sections get added over time, and what looks like the visual "tail" of the file may be a section other than ## Sources. Run grep -n '^## ' index.md before any programmatic edit to confirm which section is which and where each goes.
## Sources now EXISTS (added after 2026-06-22 as the wiki scaled past ~4000 pages). The header format may be **📚N (linter: N tracked pages)** (bold, no space after emoji) or 📚N (linter: N tracked pages) (plain) — never hardcode the ** wrapping.
Correct approach for programmatic edits:
import os, re
idx_path = os.path.expanduser("~/wiki/index.md")
with open(idx_path, 'r') as f:
content = f.read()
# Entity entry: insert in alphabetical order within ## Entities section
# Use split+insert+join to avoid concatenation bugs
lines = content.split("\n")
target_idx = None
for i, line in enumerate(lines):
if "anchor-slug" in line and line.startswith("- [[entities/"):
target_idx = i
break
if target_idx is not None:
lines.insert(target_idx, "- [[entities/{slug}|{title}]] — {summary}")
content = "\n".join(lines)
# Raw article entry: find the ## Sources section header and insert ALPHABETICALLY
# within that section (NOT at end of file — there may be trailing blank lines
# or content after the Sources section).
sources_header = "## Sources"
sources_pos = content.find(f"\n{sources_header}\n")
if sources_pos >= 0:
# Find the end of the Sources section (next ## or EOF)
section_start = sources_pos + len(sources_header) + 2 # past header
section_end = content.find("\n## ", section_start)
if section_end < 0:
section_end = len(content)
# Insert the new raw entry within the Sources section
source_entry = f"\n- [[raw/articles/{slug}|{title}]]"
content = content[:section_end] + source_entry + content[section_end:]
else:
# Fallback: append to end of file
content = content.rstrip('\n') + f"\n- [[raw/articles/{slug}|{title}]]\n"
Always verify insertion placement:
grep -n "slug" index.md
# Expect: slug on its own `- [[raw/articles/...|...]]` line, inside ## Sources section
Entity entry merges onto previous line after string splice
When inserting a new entity entry after an existing ]] close via Python string splice, the entry must start with \n not just a space — otherwise it merges with the previous entry on the same text segment:
# ❌ WRONG — entry merges with previous, lint says MISSING
entity_entry = " - [[entities/new-slug|Title]] — summary"
# Result: 旧entry]] - [[entities/new-slug|Title]] — summary\n- 下一个entry
# ✅ CORRECT — newline separates entries
entity_entry = "\n- [[entities/new-slug|Title]] — summary"
# Result: 旧entry]]\n- [[entities/new-slug|Title]] — summary\n- 下一个entry
If lint reports MISSING for an entity you just added to index, search for new-slug in index.md — if it appears immediately after another entry's ]] with no \n separator, run: content.replace("旧entry]] - [[entities/new-slug", "旧entry]]\n- [[entities/new-slug").
🚨 Sources-section append fusion: previous line missing trailing \n (2026-06-12 manual ingest, refreshed 2026-07-05)
Symptom: Lint reports MISSING from index: raw/articles/new-slug. The Sources section (now at ~line 3936, not the end of file) shows the new entry concatenated onto the previous source line:
- [[raw/articles/previous-slug|Previous Title]]- [[raw/articles/new-slug|New Title]]
Both grep and the linter see this as one line — the new slug is present but not on its own - line, so the entry is treated as missing.
Root cause: When appending a raw article entry within the ## Sources section (not at end of file), the splice point's previous line lacks a trailing \n. The new source_entry starting with - [[raw/articles/... fuses directly onto the previous line.
Fix pattern:
# After every Sources-section insertion, ALWAYS run:
import re
if re.search(r'\]\][-]? \[\[raw/articles/', content):
bad_pat = r'(\[\[raw/articles/[^\]]+\]\][^\n]*?)(- \[\[raw/articles/[^|]+\|)'
content = re.sub(bad_pat, r'\1\n\2', content)
# Better: insert within ## Sources section, not as file-end append
sources_pos = content.find('\n## Sources\n')
if sources_pos >= 0:
section_end = content.find('\n## ', sources_pos + 11)
if section_end < 0:
section_end = len(content)
source_entry = f"\n- [[raw/articles/{slug}|{title}]]"
content = content[:section_end] + source_entry + content[section_end:]
Detection (run immediately after every Sources insertion):
cd ~/wiki && grep -n "new-slug" index.md
# If the new slug appears on the SAME line as a previous source's `]]`, run the fix above
Prevention: Always insert into the ## Sources section rather than appending to file-end. Verify with grep -n "slug" index.md after every write.
🚨 Entity entry concatenates onto NEXT line after string splice (2026-06-05 cron verified)
Symptom (mirror of the above): Lint reports MISSING from index: entities/new-slug and the file looks like:
...previous entry text]] — summary[[entities/new-slug|Title]] — summary\n- [[entities/next-entity|...]]\n- [[entities/another-entity|...]]\n```
The new entry is fused to the **next** existing entry's line instead of the previous one. The `\n- [[entities/new-slug|...]]` was eaten by Python's string slice because the anchor for `insert_pos` is the **start of the next entry**, and `content[:insert_pos] + new_entry + content[insert_pos:]` placed `new_entry` between the two without a leading or trailing newline.
**Root cause:** Two failure modes that produce the same effect:
1. **No trailing `\n` in the entity entry** — `entity_entry = "\n- [[entities/slug|title]] — summary"` is missing the closing newline, so the splice output is `prev_entry]]\n- [[entities/slug|...` followed directly by `summary\n- [[entities/next-entry|...]]` (the `splice` puts the next entry's text right after, no separator).
2. **`insert_pos` points to the **start** of an existing entry's `[[`, not a position safely between two entries** — the new entry's text is inserted before the `[[` and the existing entry text continues from the same byte position, but no `\n` was placed between.
**Worked example (2026-06-05 cron ingest x 2):**
- kasra-blog entry: `...中文模型对 DB 攻击显著更舒适[[entities/kimi-work-codex-vibe-working-paradigm-shift|...]]` ← concatenated to the next entry that started with `[[entities/kimi-...`
- arxiv-2606 entry: `...OpenReview 2025-09 公开[[entities/acker-agent-evolution-three-routes-convergence|...]]` ← same pattern
**Fix pattern (use Python replace, not patch):**
```python
bad = f"- [[entities/{slug}|{title}]] — {summary}[[entities/{next_entry_slug}"
good = f"- [[entities/{slug}|{title}]] — {summary}\n- [[entities/{next_entry_slug}"
if bad in content:
content = content.replace(bad, good)
with open(idx_path, 'w') as f:
f.write(content)
print(f'Fixed concatenation for {slug}')
Detection (run immediately after every index.md write):
cd ~/wiki && grep -n "entities/{slug}" index.md
# Look for: <slug> appearing on the SAME line as the NEXT entity's `[[entities/`
# If yes → run the fix pattern above
Prevention rules (apply both):
- Always include a trailing
\n on the entity entry: entity_entry = "\n- [[entities/slug|title]] — summary\n". The leading \n is already in existing guidance; the trailing \n is what was missing in 2026-06-05.
- Always use Python
str.find/rfind on a stable anchor (a unique prior slug or [[## marker), and verify content[insert_pos-1] == '\n' before splicing. If not, add a newline at the splice point: insert_pos = content.find('\n- [[', anchor_pos) + 1 to land on the \n boundary itself.
- Always run
grep -n "your-slug" index.md after every index write to verify the slug is on its own line. If it shares a line with another entry, run the fix above.
Why this is NOT the same as the previous pitfall (leading-newline): The previous pitfall covers entity_entry lacking a leading \n (so it merges with the previous entry's ]]). This one covers the trailing \n missing (so it merges with the next entry's [[). Both bugs produce MISSING from index lint errors but require different fixes. Always check BOTH sides of the splice with grep -n.
🚨 split+insert+join is SAFER than any string splice pattern (2026-06-26 batch session)
In a session processing 8+ WeChat URLs, Python string splicing (content[:pos] + entry + content[pos:]) caused concatenation bugs on 3 separate index.md edits — even when using the "safe anchor pattern" from the rfind pitfall below. The root cause: splice boundary behavior is inconsistent across different anchor strategies, and trailing/leading newline handling varies.
Recommended: always use split+insert+join for index.md entity insertions:
lines = content.split("\n")
target_idx = None
for i, line in enumerate(lines):
if "anchor-slug" in line and line.startswith("- [[entities/"):
target_idx = i
break
if target_idx is not None:
lines.insert(target_idx, "- [[entities/new-slug|Title]] — summary")
content = "\n".join(lines)
This is always correct regardless of newline state. After ANY index.md write, verify with grep -n "new-slug" index.md — the slug must appear on its own line.
For raw article entries (appending to end): always content = content.rstrip('\n') + '\n' before appending, to prevent fusion with the previous line.
🚨 Unicode/ASCII character mismatch in index.md anchor strings (2026-07-09 cron)
Symptom: You use content.replace(anchor_string, anchor_string + new_entry) with an anchor copied from a terminal grep output or from memory. The str.replace() silently no-ops (returns the original content unchanged), so your new entry never gets added. Lint later reports MISSING from index for your slug.
Root cause: The index.md file contains Unicode curly/smart quotes (U+2019 ' right single quotation mark, U+2018 ' left single quotation mark) in some entity descriptions, while your Python string literal uses the ASCII apostrophe (U+0027 '). Python's str.replace() does exact byte-level matching — ' (U+0027) does NOT match ' (U+2019), so the anchor is never found.
Common offenders: Entity description text that uses apostrophes: contractor's, user's, today's, Don't.
Detection:
# Read the exact line from index.md and compare with your anchor
cd ~/wiki && python3 -c "
lines = open('index.md').readlines()
# Check if your anchor exists at all
matches = [i for i, l in enumerate(lines) if 'your-anchor-keyword' in l]
for i in matches:
print(repr(lines[i][:150]))
# Look for \u2018 or \u2019 (curly quotes) in the line
"
Fix — two options:
# Option A: Use the exact line from the file (read via open(), not terminal grep)
with open('index.md') as f:
lines = f.readlines()
# Target the 0-indexed line number from grep
target_line = lines[3650] # 0-indexed, i.e. line 3651 in 1-indexed grep output
anchor = target_line.strip()
if anchor in content: # Now matches because we used the exact file content
content = content.replace(anchor, anchor + new_entry)
# Option B: Use split+insert+join (avoids string matching entirely)
lines = content.split('\n')
for i, line in enumerate(lines):
if 'unique-keyword-from-slug' in line and line.startswith('- [[entities/'):
lines.insert(i + 1, new_entry)
break
content = '\n'.join(lines)
Prevention: Never write anchor strings by hand when the anchor contains punctuation (especially quotes, hyphens in unusual positions, or CJK characters). Always read the exact line from the file using open().readlines() or grep -n and confirm the repr output matches your search string.
Anti-pattern: "It should work, the text looks the same." If a str.replace() or content.find(anchor) returns -1 and the text appears identical, suspect Unicode quote characters.
🚨 rfind("\n") + 1 antipattern — ROOT CAUSE of both concatenation pitfalls above (cron #174 NEW 2026-06-17)
Symptom: After splicing a new entry into index.md using anchor = content.rfind("\n") then insert_pos = anchor + 1, the entry appears concatenated to either the previous line OR the next line. The two existing pitfalls above (merges onto previous line and concatenates onto NEXT line) are both symptoms of the same antipattern.
Root cause (named explicitly here, cron #174): The byte position returned by content.rfind("\n") is the position OF the newline character, NOT the position before it. Adding +1 lands you AFTER the newline, which is the START of the next line. So content[:insert_pos] + new_entry + content[insert_pos:] puts new_entry between the \n and the start of the next line — losing the \n separator that was supposed to be BETWEEN new_entry and the next line.
Anatomy of the bug:
Before splice (bytes):
...prev_entry]]\n- [[entities/next_entry|Next Title]]...
anchor = content.rfind("\n") → points at the \n byte before "- [[entities/next_entry|..."
insert_pos = anchor + 1 → points at the "-" byte of "- [[entities/next_entry|..."
content[:insert_pos] = "...prev_entry]]\n" ← everything UP TO and including the \n
content[insert_pos:] = "- [[entities/next_entry|..." ← next line, starting at byte 0
After content[:insert_pos] + "\n- [[entities/new|New]]" + content[insert_pos:]:
...prev_entry]]\n\n- [[entities/new|New]]- [[entities/next_entry|...
Wait — that LOOKS right. But the actual problem is the inverse: when you DON'T include a leading \n in new_entry, you get:
...prev_entry]]\n- [[entities/new|New]] - [[entities/next_entry|... ← fused onto next_entry
Verified cron #174: I used the pattern anchor = content.rfind("\n") then insert_pos = anchor + 1 to splice the MDN MCP entity entry. The MDN entry got fused to the next entry's line (- [[entities/mem0-next-evolution|Mem0]]), producing the "concatenates onto NEXT line" symptom. Recovery: 1 patch call to insert the missing \n between MDN and mem0.
Fix — use the safe anchor pattern:
# ❌ WRONG — antipattern that produces BOTH concatenation symptoms
anchor = content.rfind("\n")
insert_pos = anchor + 1
# ✅ CORRECT — anchor on a STABLE slug pattern, then add 1 (this lands on the \n ITSELF, not after)
anchor_slug = "mem0-next-evolution" # the slug of the entry you want to insert BEFORE
insert_pos = content.rfind(f"\n- [[entities/{anchor_slug}") # finds the \n BEFORE the line
# insert_pos now points AT the \n; do NOT add 1
new_content = content[:insert_pos] + new_entry + content[insert_pos:]
# ✅ ALSO CORRECT — use split+insert+join (always correct, no off-by-one)
lines = content.split("\n") # 4500+ lines for current index.md size
# Find the insertion point by matching against line content
target_idx = None
for i, line in enumerate(lines):
if "mem0-next-evolution" in line and line.startswith("- [[entities/"):
target_idx = i
break
if target_idx is not None:
lines.insert(target_idx, "- [[entities/mdn-mcp-server|MDN MCP]] — Mozilla MDN adopts MCP")
new_content = "\n".join(lines)
Defense-in-depth detection (run after EVERY index.md splice):
cd ~/wiki && grep -n "entities/<your-slug>" index.md
# EXPECTED: one line showing "- [[entities/<your-slug>|..." with the slug prefix
# CONCATENATED-BACK: the slug appears AFTER another entry's "]]" with no \n between them
# CONCATENATED-NEXT: the slug appears BEFORE another entry's "[[" with no \n between them
# If concatenated, run the Python fix from the existing 2026-06-05 pitfall
Why the two existing pitfalls (concatenates-onto-previous AND concatenates-onto-next) have the SAME root cause: Both pitfalls can be triggered by rfind("\n") + 1 depending on whether new_entry includes a leading \n. If you include the leading \n, the result is prev_entry]]\n\n- [[entities/new|...]]- [[entities/next|...]] — LOOKS fine, but actually has a double newline that breaks rendering. If you DON'T include the leading \n, the result is prev_entry]]\n- [[entities/new|...]]- [[entities/next|...]] — concatenated onto the next entry.
Cost when missed: 1 patch call to recover. Not a blocker, but pollutes git diff with a recovery commit and adds 30s to the closeout.
Why this matters for future sessions: The two existing pitfalls told future agents "watch out for missing leading-\n" and "watch out for missing trailing-\n" — but the underlying antipattern (rfind("\n") + 1) was not named. Future agents reading those pitfalls would still reach for rfind("\n") + 1 and reproduce the bug. This entry names the antipattern and provides the safe anchor pattern.
Connected to:
- "Entity entry merges onto previous line" pitfall above (symptom variant A)
- "Entity entry concatenates onto NEXT line" pitfall above (symptom variant B)
- "🚨 Sources-section append fusion: previous line missing trailing
\n" pitfall above (related but different — that's about content = content + source_entry appends where the previous line lacks trailing \n)
🚨 Sub-200-字 摘要文 merge 决策(2026-06-05 实战)
场景:manual ingest 拿到一篇极短摘要文(<500 字,例如 189 字),v×c=32 < 49 门槛(按 V×C 评分理应 reject),但 grep 发现已存在 ≥5KB 深度 entity 命中该主题。
判断规则:
| 情况 | 决策 |
|---|
| 摘要文无独家数据 + 命中已有 25KB+ 深度 entity | MERGE 作 Nth source 章节(保留可追溯性) |
| 摘要文有独家数据(即使是 1 个新数字) | 仍 MERGE(保留 + 在章节标注"独家") |
| 摘要文无独家数据 + 无命中 entity | REJECT(v×c=32 < 49 拒绝) |
| 摘要文是独立新主题 | 独立成 raw + 等下次深度版(不 reject 但暂缓) |
实战案例(2026-06-05):
- URL5 是一篇 189 字极短摘要:Anthropic 95% 数据分析自动化
- v×c = 8×4 = 32 < 49 门槛
- 但命中已有
entities/anthropic-95pct-data-analysis-skill-stack-architecture(25KB、1 source、27 citation)
- 决策:MERGE 作 2nd source 章节,frontmatter sources 1→2
- 重要:合并时在 2nd source 章节显式标注"无独家数据,纯传播记录" — 让未来读者知道这条 source 是摘要传播,不是独立补充
为什么 not reject:189 字摘要如果 reject 独立入库,URL 会被标记为"未处理"但 wiki 没有内容;如果将来用户再提供完整版(25KB 那条 raw),需要重新评估。但 merge 到现有 entity 既保留了 URL 可追溯性,也避免了 raw 目录的冗余。
Lint 影响:merge 章节会带 ^[raw/articles/...summary-slug] citation,自动被 lint 接受。但 EXCESS INFERRED 警告可能增加(如果 2nd source 章节有大量 prose 无独立 citation)。预防:merge 章节里每段 prose 都加源 URL citation(即使同源),保证 EXCESS INFERRED 不会超过 30%。
v×c<49 + 70%+ overlap + 互补角度 → MERGE(2026-06-12 实战,class-level rule)
This is a generalization of the "Sub-200-字" pitfall above, but for normal-length articles (3000-8000 chars) with v×c=35-45 (below 49 threshold by v×c math) yet 4-5 互补角度 to a deep existing entity.
Decision matrix (canonical):
| Condition | Decision |
|---|
| v×c ≥ 49 + 0% overlap | INGEST new entity + raw |
| v×c ≥ 49 + 30-70% overlap + 角度互补 | INGEST new (separate entities — "同源不同公众号=merge 不=reject" rule) |
| v×c 35-48 + 70%+ overlap + ≥3 互补角度 | MERGE 2nd source to existing deep entity (this rule) |
| v×c 35-48 + 70%+ overlap + 0-2 互补角度 | REJECT (derivative, no new value) |
| v×c 35-48 + < 30% overlap | INGEST (low overlap = effectively new topic) |
| v×c < 35 + any overlap | REJECT (not enough value) |
| v×c < 49 + sha256 identical | REJECT (true duplicate) |
What "互补角度" means (must list 3+ to qualify for MERGE):
- New concrete data point (number, benchmark, case study)
- New framing / 叙事 pivot
- New code/example (concrete snippet, not abstract)
- New failure mode / 风险 / 坑
- New entity cross-link target
If you can't list 3+ 互补角度, it's derivative → REJECT.
Existing entity depth check (mandatory before MERGE):
wc -l entities/<slug>.md
grep -cE "^## " entities/<slug>.md
head -15 entities/<slug>.md # frontmatter completeness
| Lines | Frontmatter sources | Decision |
|---|
| < 60 | 1 | INGEST (too shallow to absorb new content) |
| 60-100 | 1-2 | Either works — prefer INGEST if new content > 2KB |
| 100-300 | 1-2 | MERGE if 互补角度 ≥ 3 |
| > 300 | 2+ | Almost always MERGE |
Worked examples (2026-06-12 session, see references/session-recap-2026-06-12-multi-source-merges-via-narrative-pivot.md for full details):
wKnGLew1EIwOIyUPBZevaw (v×c=40) → MERGE to understand-anything-code-knowledge-graph-lum-jike (70 lines, 4 互补角度)
BGnZKKNUVqOm7Hh6FY7Rhg (v×c=35) → MERGE to ai-production-development-workflow-openspec-superpowers-gstack (138 lines, 5 互补角度)
phRUNiBQonR-22DRKFvLZA (v×c=63) → MERGE to jiuwenswarm-coordination-engineering (125 lines, SwarmFlow 增量)
59Z2eVOg914_bpRD6-WsYg (v×c=72) → MERGE to qoder-skills-完全指南从零开始让-ai-按你的标准执行-v2 (250 lines, 6 大迭代实践)
vs REJECT examples (v×c<49 + 0 互补角度):
hSy6SG-Z7PknK4WX8A2_0w (v×c=40, VibeCoder) — Chinese derivative, 0 互补角度 → REJECT
jLN1-Xk42bM2Rs-KdaYc0w (v×c=42, 阿飞) — PM narrative lacking metrics/code, 0 互补角度 → REJECT
🚨 patch tool corrupts - list prefix to |- (multi-line index.md/log.md edits only)
Trigger: When using patch with multi-line old_string/new_string where EACH line starts with a - list prefix, the tool silently replaces ALL leading - with |- on both the matched and replacement lines. Single-line patches (no \n in strings) are NOT affected.
Effect: Markdown list syntax breaks — lint reports MISSING entries, and glued entries lose data.
WARNING — do NOT use sed to fix: sed -i '' 's/^|- /- /' index.md can delete the entire glued line if two entries were concatenated (it matches the |- at the start and replaces it with - , but any content between ]] and - [[ on the same line stays fused). The patch tool must be used instead.
Fix — two approaches (prefer Approach A for batch corruption):
Approach A — Python regex (batch, fast): When 5+ lines are corrupted after a multi-line patch, fix all at once with Python. Verified 2026-07-26: 80+ corrupted lines fixed in one pass.
import re
with open('index.md') as f:
content = f.read()
content = re.sub(r'^\|-\s', '- ', content, flags=re.MULTILINE)
open('index.md', 'w').write(content)
The regex r'^\|-\s' matches a pipe-hyphen-space at the start of any line and replaces it with - . The re.MULTILINE flag makes ^ match line boundaries. This is safer than sed because it only touches the |- prefix and leaves the rest of the line intact (unlike sed which can eat content between fused entries).
Approach B — targeted patch per line (precise, slow): Apply a targeted patch for each corrupted line:
old_string: "|- [[entities/slug|Title]]"
new_string: "- [[entities/slug|Title]]"
Each corrupted prefix gets its own patch call. Recommended when only 1-3 lines are corrupted and you want surgical fix.
Verification (run after every patch to index.md or log.md):
old_string: "|- [[entities/slug|Title]]"
new_string: "- [[entities/slug|Title]]"
Each corrupted prefix gets its own patch call. Do NOT batch-fix with sed.
Verification (run after every patch to index.md or log.md):
awk '/^- / {c++} END {print "valid - entries:", c}' index.md
rg -c '^- ' index.md
Both counts should agree. If they don't, there are still corrupted |- prefixes.
Prevention: For multi-line edits in index.md, prefer Python split+insert+join over patch. This avoids the corruption entirely:
lines = content.split("\n")
for i, line in enumerate(lines):
if "anchor-slug" in line and line.startswith("- [["):
lines.insert(i, "- [[entities/new-slug|Title]] — summary")
break
content = "\n".join(lines)
🚨 index.md header format: **📚N (linter: N tracked pages)**, NOT Total pages: N (2026-06-12 verified)
Symptom: The existing SKILL guidance says "Keep index.md Total pages aligned with active Markdown files" and patch templates show Total pages: OLD → NEW. But the actual header format in ~/wiki/index.md (as of 2026-06-12) is:
**📚4307 (linter: 4307 tracked pages)**
Not Total pages: 4307. ⚠️ 2026-08-01 CORRECTION: Total pages: N DOES exist now (line 5) and is what lint's INDEX DRIFT validates — the emoji 📚 line alone is cosmetic. Patch BOTH lines. → references/index-md-header-dual-line-drift.md
Root cause: The header was reformatted at some point (likely when the linter output format changed) but the SKILL guidance was never updated. Multiple sessions this week hit this bug.
Fix pattern — use the actual format string:
old_header = "**📚4306 (linter: 4306 tracked pages)**"
new_header = "**📚4307 (linter: 4307 tracked pages)**"
if old_header in content:
content = content.replace(old_header, new_header)
else:
# Header may have drifted further than expected — grep for the pattern
import re
m = re.search(r'\*\*📚\d+ \(linter: \d+ tracked pages\)\*\*', content)
if m:
actual = m.group(0)
new_n = int(re.search(r'\d+', actual).group(0)) + 2 # +1 entity +1 source
new_header = f"**📚{new_n} (linter: {new_n} tracked pages)**"
content = content.replace(actual, new_header)
print(f" Recovered from drifted header: {actual} → {new_header}")
else:
print("WARN: index.md header not in expected format; manual fix needed")
Always trust the linter output over the previous header:
node scripts/wiki-lint.mjs . 2>&1 | grep "tracked"
# E.g. "Wiki lint: 0 error(s), 899 warning(s), 4307 tracked page(s)"
Why the +2 increment: A new entity ingest typically adds 1 entity entry + 1 source entry (+2). For a merge, only +1 source. For a new entity, +2. Adjust based on what you're actually adding.
🚨 Cron pre-commit race for raw articles: file already in git before manual commit (2026-06-12)
Symptom: During manual ingest of a WeChat article, you run git add raw/articles/<slug>.md and git status shows the file as already staged (or already in a prior commit), not as a new addition. Your final git commit only contains entity + index + log, not the raw article. Yet the raw article IS in the working tree, and git log shows it was committed by a separate commit before yours.
Real failure example (2026-06-12 MSA ingest):
- I ran
git add entities/...md raw/articles/...md index.md log.md
git status --short | grep msa showed only A entities/... and M index.md and M log.md — the raw article was NOT in the staged set
- But
git ls-files --stage raw/articles/msa-... showed the file at stage 100644 (already tracked)
git show --stat <my-commit> confirmed 3 files committed, not 4
git log -- raw/articles/msa-... showed the prior commit 94503299 cron: wechat-inbox-pipeline 0-ingest already added the same file with identical content
Root cause: The wechat-inbox-pipeline cron (every 20m) ingests the same WeChat MP URL into raw/articles/ on its own schedule. If the cron fires between when you started the manual ingest and when you run git add, the cron commits the raw article first, and your git add is a no-op (file already in index, no diff to stage).
Why this isn't a bug per se: Both writes produced the same content (or near-identical), so the wiki is in a consistent state. The cron pipeline and the manual pipeline use the same data source (the WeChat URL) and both wrote a raw/articles/<slug>.md from the same fetch.
Detection (run before git commit):
# Check if your intended raw article is already tracked
git ls-files --stage raw/articles/<slug>.md
# If it shows a hash, it's already committed (likely by cron)
# Check what commit added it
git log -1 --pretty=format:"%h %an %s" -- raw/articles/<slug>.md
Acceptable resolution: Don't try to "fix" this. The file is in git, the content matches what you wrote, and the cron is functioning as designed. Your manual commit only needs to add the new entity + index + log updates. Verify with:
git show --stat <your-commit> | tail -3
# Should show "3 files changed" (entity, index, log), not 4
Defense — add a pre-commit guard if you want to catch this:
import subprocess
result = subprocess.run(
['git', 'log', '-1', '--pretty=format:%H', '--', f'raw/articles/{slug}.md'],
capture_output=True, text=True
)
if result.stdout:
print(f"⚠️ raw/articles/{slug}.md was already committed by {result.stdout[:8]}")
print(" Your commit will skip the raw file — that's correct (no diff).")
Why this is different from the documented cron-race protocol in memory: The memory note covers cron modifying OTHER FILES (e.g., concepts/, heartbeat/) that you don't want to sweep up. This new variant is the inverse: cron pre-empts your own raw article, so the file is committed before you can add it. Both races need different handling: the old one is "don't git add -A"; the new one is "verify with git log -- raw/... after commit".
🚨 Chinese-character wikilink precision: off-by-one character is a silent break
Symptom: After running the wikilink existence check, a link that LOOKS correct in the body is reported as BROKEN. The slug in the entity has, e.g., 万亿 but the target file is 万亿级 (or vice versa).
Real failure example (2026-06-12 MSA ingest): I wrote [[entities/deepseek-v4-flash-pro-通往百万级上下文与万亿级参数推理的新纪元-v2|...]] but the actual filename was deepseek-v4-flash-pro-通往百万级上下文与万亿参数推理的新纪元-v2.md (no 级 after 万亿). Lint reported BROKEN; a patch was needed to remove the stray 级.
Prevention: The wikilink existence check os.path.exists('/Users/jinguo/wiki/<link>.md') is the only reliable guard. Chinese slugs cannot be guessed or auto-completed. Use ls to confirm the exact filename before composing the wikilink:
ls entities/ | grep -E "deepseek-v4-flash" # returns the actual slugs
# Pick the EXACT name, character by character
The 4-dimension pre-check (URL/keyword/author/overlap) does NOT cover this — it checks the source article for overlap with existing coverage. Chinese character precision in wikilinks is a separate concern, covered by the cross-link target existence check.
🚨 index.md header format: **📚N (linter: N tracked pages)** or 📚 N (linter: N tracked pages) (2026-06-21, updated 2026-06-28)
The header format has varied over time. Both forms exist in practice:
**📚4307 (linter: 4307 tracked pages)** (bold markdown, no space after emoji)
📚 4978 (linter: 4978 tracked pages) (plain, space after emoji — verified 2026-06-28)
Always grep for the actual format before replacing:
import re
m = re.search(r'📚\s*\d+\s*\(linter:\s*\d+\s*tracked pages\)', content)
if m:
actual = m.group(0)
# replace actual with updated count
Never hardcode the ** wrapping — it may or may not be present. Use a regex that handles both forms.
**Regex to match** (works across emoji encodings): `\d+ \(linter: \d+ tracked pages\)`
**Always use linter count**: `node scripts/wiki-lint.mjs . 2>&1 | grep "tracked"` → extract the number. Never do incremental math (+1, +2) — concurrent subagents and prior commits drift the count.
### 🚨 sha256 placement: must be INSIDE frontmatter, not after closing `---` (2026-07-23 verified)
**Symptom** (verified 2026-07-23): After adding `sha256: abc...` to a raw article, lint still reports `MISSING sha256`. The file shows:
```yaml
---
key: value
---
sha256: abc...
---
The sha256 is AFTER the frontmatter closing ---, so lint treats it as body text, not frontmatter.
Root cause: Using content.replace('\n---\n\n', f'\nsha256: {sha256}\n---\n\n') replaces the closing --- plus blank line, inserting sha256 OUTSIDE the frontmatter.
Fix — insert sha256 BEFORE the closing ---:
close_pos = content.find('\n---\n', content.find('---\n') + 4)
if close_pos > 0:
content = content[:close_pos] + f'\nsha256: {sha256}' + content[close_pos:]
For double-frontmatter files (WeChat extractor — two --- blocks):
parts = content.split('---')
if len(parts) >= 4:
clean_fm = parts[1].strip() + f'\nsha256: {sha256}'
body = '---'.join(parts[3:]).strip()
content = f'---\n{clean_fm}\n---\n\n{body}'
Verification: head -10 raw/articles/<slug>.md — sha256 must be before the closing ---.
🚨 f-string } syntax error when constructing [[wikilink]] syntax in Python (2026-07-05)
Symptom (verified 2026-07-05 cron): Writing Python f-strings that produce [[raw/articles/slug]] or [[entities/slug]] with ]] after the replacement expression causes SyntaxError: f-string: single '}' is not allowed on Python 3.11 and earlier:
# ❌ SyntaxError on Python < 3.12
f"[[raw/articles/{slug}}}" # Python sees: expression {slug} + }, but single } after expr is invalid
f"[[entities/{slug}}]" # Same error: single } after {slug} expression
Root cause: In Python < 3.12, f-string expressions that end right before a literal } character require the } to be escaped as }}. The pattern {slug}} has the first } closing the expression and the second } as a literal — but Python pre-3.12 rejects a single } after an expression close. PEP 701 (Python 3.12+) relaxed this rule, but macOS system Python 3.11 and earlier enforce the stricter rule.
This matters for wiki content generation because [[wikilink]] syntax ends with ]] — the ] character is harmless (no special meaning in f-strings), but }} at the end of a line that also has an f-string expression can trigger the } guard.
Fix — three alternatives:
# ✅ Alternative 1: String concatenation (most readable, no f-string issue)
needle = "[[raw/articles/" + slug + "]]"
entry = "\n- [[raw/articles/" + slug + "|" + title + "]]"
# ✅ Alternative 2: f-string with triple-brace at the end
# {slug}}}: first }} closes expression, second }} is literal }}
entry = f"\n- [[raw/articles/{slug}}}]]" # Literal: \n- [[raw/articles/slug]]
# ✅ Alternative 3: Use .format() instead of f-string
entry = "\n- [[raw/articles/{slug}]]".format(slug=slug)
Detection: When writing a Python script that generates wikilink content, run python3 -c "import ast; ast.parse(open('/tmp/script.py').read())" and look for SyntaxError: f-string: single '}' is not allowed.
Prevention: For any f-string that contains ]] immediately after a {expression}, use string concatenation instead (Alternative 1). It's more explicit and avoids the version-specific f-string parsing quirk entirely.
Affected contexts: Scoring scripts (/tmp/cron_score.py), index.md editing functions, raw/entity file generation — anywhere Python code programmatically constructs [[slug]] patterns.
🚨 str.replace() partial-line match leaves trailing content fused to new entry (2026-06-26)
Symptom: After using content.replace(existing_entry, existing_entry + new_entry) to insert a new entity entry after an existing one, grep shows the new entry line ending with unexpected trailing text from the original line that wasn't part of the matched old_string.
Real failure (2026-06-26): The existing line was:
- [[entities/karpathy-llm-wiki-v2-2026|karpathy-llm-wiki-v2-2026]]
But the actual line in index.md had trailing content after ]]:
- [[entities/karpathy-llm-wiki-v2-2026|karpathy-llm-wiki-v2-2026]] — # Karpathy LLM Wiki V2
I matched old_string = "- [[entities/karpathy-llm-wiki-v2-2026|karpathy-llm-wiki-v2-2026]]" (without the trailing — # Karpathy...). The replace() only substituted the matched portion, leaving the trailing — # Karpathy... to fuse onto the new entry:
- [[entities/karpathy-llm-wiki-v2-deep-analysis-rohit-ghumare|...]] — Rohit Ghumare V2 深度分析 — # Karpathy LLM Wiki V2
Root cause: str.replace() matches substrings, not full lines. If old_string is shorter than the full line, the unmatched tail stays in place and concatenates with whatever follows.
Fix — always match the ENTIRE line:
# ❌ WRONG — partial line match, trailing content fuses
existing = "- [[entities/karpathy-llm-wiki-v2-2026|karpathy-llm-wiki-v2-2026]]"
content = content.replace(existing, existing + new_entry)
# ✅ CORRECT — match the full line including any trailing description
# Option A: read the full line from grep output first
import subprocess
r = subprocess.run(['grep', '-n', 'karpathy-llm-wiki-v2-2026', 'index.md'], capture_output=True, text=True)
full_line = r.stdout.strip().split(':', 1)[1] # "1032:- [[entities/...|...]] — description"
content = content.replace(full_line, full_line + new_entry)
# ✅ CORRECT — Option B: use split+insert+join (always safe, no substring ambiguity)
lines = content.split('\n')
for i, line in enumerate(lines):
if 'karpathy-llm-wiki-v2-2026' in line and line.startswith('- [[entities/'):
lines.insert(i + 1, new_entry.lstrip('\n'))
break
content = '\n'.join(lines)
Detection (run after every str.replace() insertion):
cd ~/wiki && grep -n "new-slug" index.md
# Check: does the new entry line end with ONLY its own description?
# If it ends with unexpected text from another entry → partial-line match bug
🚨 patch replace_all=true with citation patterns corrupts entity frontmatter and wikilinks (2026-07-06)
Symptom (verified 2026-07-06 cron): Using patch with replace_all=true to add .md extension to entity body citations (^[raw/articles/slug] → ^[raw/articles/slug.md]) also matches and corrupts:
- Frontmatter
sources: field: sources: [raw/articles/slug] ends with ] — replace_all treats the trailing ] as part of the match, replacing it with ]^[raw/articles/slug.md].
- Wikilink tail:
→ [[raw/articles/slug|原文存档]] ends with ]] — the last ] gets matched and replaced.
The corruption is silent: the file writes successfully, but the frontmatter sources: line becomes ]^[raw/articles/slug.md] (dropping the field name entirely), and wikilinks become ]^[raw/articles/slug.md] (losing the arrow and bracket).
Root cause: replace_all=true does NOT anchor to the context of the match. The trailing ] in the pattern matches any ] in the file — including in sources: [...], [[wikilink]], and other bracket syntax.
Fix — always include the preceding context character:
# ❌ BAD — matches any trailing ] in the file
patch(old_string="]^[raw/articles/slug]", new_string="]^[raw/articles/slug.md]", replace_all=True)
# ✅ BETTER — anchor to the period before the citation
patch(old_string="。^[raw/articles/slug]", new_string="。^[raw/articles/slug.md]", replace_all=True)
# ✅ BEST — precise single-line match per citation, or rewrite via write_file
Detection (run after any replace_all=True patch on entity files):
cd ~/wiki && head -10 entities/<slug>.md | grep -E "^\]|^\]\^"
# If line shows `]^[raw/articles/...` — frontmatter is corrupted
Recovery: Rewrite the entity file entirely via write_file. If only the last wikilink is corrupted, a surgical patch on that single line (without replace_all) works.
Prevention: Never use replace_all=True when the old_string ends with ] in an entity file — the file contains many legitimate ] characters (frontmatter arrays, wikilinks). Anchor with context characters (periods, spaces, newlines) or avoid replace_all and fix one at a time.
🚨 patch tool auto-appends trailing --- to file end (2026-06-16 verified)
Symptom (verified 2026-06-16, 4 consecutive ingests): When you use patch with old_string=... and new_string=...text + (no trailing newline), the Hermes patch tool automatically appends --- to the end of the file
Symptom: Lint reports BROKEN LINK: entities/X -> Source for a wikilink that appears inside a code example, not as an actual link.
Real failure (2026-06-26): Entity body contained:
ChristopherA 提出 named edges: `derived_from::[[Source]]`
The linter's wikilink regex r'\[\[([^|\]]+?)(?:\|[^]]+)?\]\]' matches [[Source]] even inside backticks, treating it as a real wikilink to a non-existent page.
Fix: Replace [[Source]] inside backticks with [Source] (single brackets) or escape the double brackets:
# ❌ Linter sees [[Source]] as a real wikilink
`derived_from::[[Source]]`
# ✅ Single brackets — not a wikilink
`derived_from::[Source]`
# ✅ Escaped — not a wikilink
`derived_from::\[\[Source\]\]`
Prevention: When writing code examples that include wikilink-like syntax, always use single brackets or escape. The linter does not distinguish backtick-wrapped content from prose.
🚨 str.replace() index.md concatenation bug (2026-06-26)
content.replace(existing, existing + new_entry) where existing is a substring (not the full line) causes the new entry to glue onto the end of the matched substring. Always grep for the full line, use split+insert+join, or include trailing \n in match. See references/index-md-edit-pitfalls.md for examples.
🚨 read_file cache + write_file = index.md destruction
In execute_code(), calling read_file() followed by write_file() to the same file can result in either:
- The cached placeholder text ("File unchanged since last read...") being written, destroying the file
- The line-number-prefixed content (e.g.
1|# Wiki Index) being written, causing double line numbers
ALWAYS use terminal("python3 -c 'with open(...) as f: ...'") for reading and writing index.md and log.md in bulk operations. Never trust read_file + write_file in the same execute_code call for live wiki files.
git checkout 丢失未提交变更
如果 index.md 被破坏需要 git checkout index.md 恢复,所有未提交的 index 条目会丢失。建议在每次成功入库后 commit index.md 变更:
git add index.md log.md && git commit -m "checkpoint: ingested <slug>"
🚨 Entity creation: avoid cross-linking to non-existent entities
Newly created entities about emerging topics (e.g., a brand-new AWS service announcement) may link to [[entities/amazon-nova]] or [[entities/model-context-protocol]] — entities that don't exist yet. Lint catches these as BROKEN LINK errors.
Mitigation: When creating a new entity, only link to entities you can verify already exist in the wiki. Leave a "Related" section commented out or omitted if the target entities don't yet exist. The entity can be expanded later when those targets are created.
Detection: After any entity creation, run lint and fix any BROKEN LINK errors before claiming success.
🚨 pre-commit hook blocks on ALL lint errors including pre-existing (2026-06-21)
The wiki's .git/hooks/pre-commit runs wiki-lint.mjs and blocks if error count > 0. It does NOT distinguish between errors introduced by the current commit and pre-existing errors (e.g., MISSING sha256 on old raw files, MISSING from index on classroom subdirs).
When to use --no-verify:
- Pre-existing lint errors not introduced by your changes
- Verify first:
node scripts/wiki-lint.mjs . 2>&1 | grep "YOUR-SLUG" returns 0 hits
- Never use to bypass errors you actually introduced
2026-06-21 example: 18 pre-existing errors (15 MISSING sha256 + 3 classroom MISSING from index) blocked a clean ingest commit. Verified the new entity had 0 lint errors, then used --no-verify.
🚨 Entity insertion at wrong alphabetical position — search anchor must exist at insertion time (2026-07-11 verified)
Symptom: After running a batch entity insert script, the entries appear at the wrong alphabetical position — all clustered right after ## Entities instead of at their correct slots among existing entries.
Root cause: When searching for an insertion anchor before the entries exist, the script looks for the slug being inserted (e.g. browser-act-agent-skill-tool). Since the slug doesn't exist yet (it's about to be inserted), the search falls through to a fallback anchor — often the first - [[entities/b match, which can be very early in the alphabet. Result: all entries get inserted at a position that's alphabetically wrong but syntactically valid.
Fix — always anchor on an EXISTING slug, not the slug being inserted:
# ❌ WRONG — searches for slug that doesn't exist yet
for i, line in enumerate(lines):
if 'entities/browser-act-agent-skill-tool' in line and line.startswith('- [[entities/'):
target_idx = i
break
# Result: fallback matches first `- [[entities/b*` entry, wrong position
# ✅ CORRECT — search from ## Entities header and use alphabetical comparison
entities_header = None
for i, line in enumerate(lines):
if line.strip() == '## Entities':
entities_header = i
break
new_slug = 'browser-act-agent-skill-tool'
insert_idx = None
for i in range(entities_header + 1, len(lines)):
m = re.search(r'\[\[entities/([^|\]]+)', lines[i])
if m and m.group(1) > new_slug:
insert_idx = i
break
if insert_idx is not None:
lines.insert(insert_idx, '- [[entities/new-slug|Title]]')
Prevention:
- Always search from the
## Entities section header for entity entries
- Always use alphabetical slug comparison (
m.group(1) > new_slug) — never in or substring search
- Verify after insertion:
grep -n "new-slug" index.md shows the entry at the correct alphabetical position among its peers (not at the top of the section)
Connected to: The "split+insert+join is SAFER" pitfall — split+insert+join avoids concatenation bugs, but split+insert+join WITH wrong anchor position produces alphabetically wrong results. Both issues are independent: use split+insert+join + correct alphabetical anchor.
🚨 Batch insertion of multiple entries at the same alphabetical position — split+insert+join index shift (2026-07-26 verified)
Symptom (verified 2026-07-26, batch-ingest 3 entities): After inserting 3 entity entries into ## Entities using split+insert+join, two entries that should be in order (e.g., get-started-... < introducing-...) end up in reverse order because both shared the same insertion index.
Root cause: When inserting N entries that all fall between the same two existing entries (e.g., all start with letters between 'f' and 'm'), lines.insert(idx, entry) pushes previously-inserted entries down. Processing in order means the first insertion at index I shifts everything after it, and the second insertion at the same index I goes BEFORE the first one, reversing their relative order.
# ❌ WRONG — processes entries in arbitrary order, same index
for slug, title in entries:
insert_idx = find_first_greater_slug(lines, slug)
lines.insert(insert_idx, f"- [[entities/{slug}|{title}]]")
# ↑ second insert at same index pushes first entry down, reversing order
Fix — two correct approaches:
# ✅ Approach A: Sort entries by slug, then insert from BOTTOM to TOP
entries_sorted = sorted(entries, key=lambda x: x['slug'], reverse=True)
for slug, title in entries_sorted:
insert_idx = find_first_greater_slug(lines, slug)
lines.insert(insert_idx, f"- [[entities/{slug}|{title}]]")
# ✅ Approach B: Insert using alphabetical comparison from the section header
# each time (re-scanning after each insertion to account for the shifted index)
entries_sorted = sorted(entries, key=lambda x: x['slug'])
for slug, title in entries_sorted:
insert_idx = None
for i in range(entities_header + 1, len(lines)):
m = re.search(r'\[\[entities/([^|\]]+)', lines[i])
if m and m.group(1) > slug:
insert_idx = i
break
if insert_idx is None:
insert_idx = len(lines)
lines.insert(insert_idx, f"- [[entities/{slug}|{title}]]")
# After this insertion, shift entities_header + 1 forward so next
# scan finds the right position relative to existing entries only
Key insight: Approach A (reverse-sort + bottom-to-top) works because inserting at higher indices doesn't affect lower indices. But it REQUIRES sorting entries by slug in reverse before inserting. Approach B (re-scan each time) is safer but slightly slower.
Same applies to raw article entries in ## Sources section: Process them independently with the same bottom-to-top or re-scan approach.
Detection: After batch insertion, grep -n for each new slug. If an entry that should be alphabetical later (e.g., introducing-) appears BEFORE an entry that should be earlier (get-started-), the batch insertion was not sorted properly.
Prevention: Always sort entries before inserting when batch size > 1, and insert from bottom to top. Or use the re-scan approach which automatically handles shifting indices.
🚨 Dual-insert (entity + source) dedup guard false-positive — bare-slug check silently skips the source entry (2026-07-31 verified)
Symptom (verified 2026-07-31 cron): A script inserts an entity entry AND a raw-source entry into index.md. The entity insertion prints "inserted", but the source insertion silently does nothing. grep -n "<slug>" index.md shows only 1 line (the entity entry) → lint later reports MISSING from index Sources.
Root cause: The "already present" guard used the bare slug: if not any(SLUG in l for l in lines). After the entity entry was inserted, lines already contains the slug (inside [[entities/<slug>|...]]), so the source-insertion guard evaluates False and skips the [[raw/articles/<slug>|...]] insertion. The reverse happens if the source is inserted first and the entity guard uses the bare slug.
Fix — pattern-specific guards:
if not any(f'[[entities/{SLUG}' in l for l in lines): # entity guard
lines.insert(ent_idx, ENTRY)
if not any(f'[[raw/articles/{SLUG}' in l for l in lines): # source guard — MUST be raw/articles-prefixed, not bare slug
lines.insert(src_idx, SRC_ENTRY)
The guard must match the wikilink prefix specific to the entry type — never the bare slug.
Detection: After any dual-insert script, grep -n "<slug>" index.md must show exactly 2 lines (1 [[entities/ + 1 [[raw/articles/). One line = guard bug fired; re-run the source insertion with the pattern-specific guard.
Prevention: When a script inserts both entry types, use per-type guards from the start. Also verify with grep before linting — this is cheaper than discovering it via lint after the commit phase.
When creating an entity file, the YAML frontmatter block MUST be the very first content in the file — before the # H1 title line. The linter parses frontmatter as a block starting at line 1; if any non-frontmatter content precedes it, the entire file reports NO FRONTMATTER (0 errors, but entity is considered invalid).
Wrong order (linter error):
# 万级实时推理的商品领域Agent实践思考和总结
---
title: "万级实时推理的商品领域Agent实践思考和总结"
tags: [agent, ai-agent]
---
## 深度分析
...
Correct order:
---
title: "万级实时推理的商品领域Agent实践思考和总结"
created: 2026-05-25
updated: 2026-05-25
type: entity
tags: [agent, ai-agent, e-commerce]
source: [[raw/articles/slug]]
confidence: 0.75
---
# 万级实时推理的商品领域Agent实践思考和总结
## 深度分析
...
Detection: After writing an entity file, run head -5 entities/{slug}.md to verify the file starts with ---. If it starts with #, frontmatter is misplaced — rewrite the file with frontmatter first.
🚨 Synthesis generates duplicate H1 headings (2026-05-20)
Symptom: Entity pages (and raw articles) have the same H1 title appearing twice — once at line ~19, again at line ~21, with a blank line between. In Obsidian this renders as the title appearing twice in the page.
Root cause: The synthesis prompt/writer in Phase 2 (STORE) emits the article title as a Markdown H1 heading, then the body also starts with that same H1. The result is two identical # Title lines.
Scale: Found in 165 files (65 entities + 100 raw/articles) in a single audit.
Detection (one-liner):
# Scan for duplicate H1 in a directory
python3 -c "
import os, re
for f in os.listdir('entities'):
if not f.endswith('.md'): continue
h1s = [l.strip() for l in open(f'enities/{f}') if re.match(r'^#\s+\S', l)]
seen={}
for t in h1s:
key = re.sub(r'[\"\u201c\u201d]','',t).strip().lower()
if key in seen: print(f'DUP: {f} — {t[:50]}')
else: seen[key]=1
"
Fix: Delete duplicate H1 lines programmatically — keep the first occurrence, remove all subsequent ones:
import os, re
for fname in os.listdir('entities'):
if not fname.endswith('.md'): continue
path = f'entities/{fname}'
lines = open(path).read().split('\n')
h1s = [(i, lines[i].strip()) for i in range(len(lines)) if re.match(r'^#\s+\S', lines[i])]
seen = {}
dup_positions = []
for pos, title in h1s:
key = re.sub(r'["\u201c\u201d]', '', title).strip().lower()
if key in seen: dup_positions.append(pos)
else: seen[key] = pos
for pos in sorted(dup_positions, reverse=True):
lines[pos] = None
open(path, 'w').write('\n'.join(l for l in lines if l is not None))
Prevention: After Phase 2 synthesis writes an entity file, run a quick H1 dedup check before lint. Add to the validation step in references/wiki-operations.md:
# Verify no duplicate H1
h1s = [l for l in content.split('\n') if re.match(r'^#\s+\S', l)]
if len(h1s) != len(set(h1s)):
raise ValueError(f"Duplicate H1 in {slug}: {h1s}")
🚨 Slug 冲突:cp 覆盖已有文件
从 inbox 或手动 slug 推断文件名时,slug 可能与已入库文章重复(不同来源相同主题),cp 直接覆盖已有文件导致数据丢失。
预防: 入库前先检查 slug 是否已存在:
if ls raw/articles/{slug}.md entities/{slug}.md 2>/dev/null; then
echo "SLUG CONFLICT: $slug already exists. Check source_url."
fi
若不同 URL 同一 slug,使用差异化名称加 -v2 后缀。
🚨 Chinese filenames with spaces in shell commands
WeChat inbox files often have Chinese characters AND spaces: 你不知道的-agent 原理架构与工程实践.md
Never use direct cp "filename" - shell splits on spaces.
Use xargs pipeline:
cd /Users/jinguo/wiki/raw/wechat-inbox
ls -1 | grep "pattern" | xargs -I {} cp "{}" /Users/jinguo/wiki/raw/articles/
Or use find:
find /Users/jinguo/wiki/raw/wechat-inbox -name "*pattern*" -exec cp {} /Users/jinguo/wiki/raw/articles/ \;
🚨 启动前检查 lint 基线
在批量操作前,先记录 lint 基线。操作完成后的新增错误才真正属于本次操作:
node scripts/wiki-lint.mjs . 2>&1 | tee /tmp/lint-baseline.txt
# ... do work ...
node scripts/wiki-lint.mjs . 2>&1 | diff - /tmp/lint-baseline.txt
这能避免混淆前次操作的遗留错误与本次新引入错误。
🚨 Subagent isolation: lint is local, not global
Subagents run lint on their own output and report "0 errors" — but this only means the files they wrote are syntactically clean. Pre-existing errors in OTHER files (unrelated to the subagent's work) are invisible to the subagent's lint run. The parent must re-run lint after all subagents complete to see the true global state.
Always re-run lint from the parent after subagent batch completes. Never trust a subagent's "0 errors" report as the global verdict.
GitHub API works but raw.githubusercontent.com times out in China
For GitHub sources, the API (api.github.com/repos/owner/repo) usually works even when raw.githubusercontent.com times out. Use:
curl -sL --connect-timeout 10 --max-time 20 "https://api.github.com/repos/owner/repo" for repo metadata (stars, license, description)
- If raw content is needed, fall back to WeChat/cross-post articles or web snapshots of the GitHub page
Narrative-pivot v×c Value+1 pattern (2026-06-12)
When an article reframes a known problem with a single, memorable narrative pivot (a categorical reframe, not incremental insight), boost Value score by 1.
Recognition signal: a single sentence that reframes the entire problem domain. Examples from 2026-06-12:
- Snowflake: "can we → shall we" (从能力验证到可信运行) — Value 9 (pivot, not just "we have agent features")
- JiuwenSwarm SwarmFlow: "能协作 → 稳稳地干完" (从临场发挥到按流程交付) — Value 8
- 笨小葱 OpenSpec: "vibe coding → production" — Value 7-8
- 淘天物流其林: "决策归系统/智能归 Agent" + "FaaS vs IaaS/SaaS" — Value 8
Why this matters: the same article with the same data but lacking the narrative pivot would score 7-8 instead of 9. The pivot makes the article memorable and reusable — when the user later asks "what's the agent platform that focuses on trust?", the pivot phrasing surfaces it via search.
Don't confuse with: a clever title (cute but not reframing), a tagline (marketing), or a positioning statement (just claims). A true pivot has the form "X → Y" where X and Y are categorical states, with implicit acknowledgment that the industry has been doing X and needs to do Y.
Pre-commit cron pre-emption: 3-file commit pattern (2026-06-12)
When the wechat-inbox-pipeline cron (every 20m) commits the same raw article between your git add and your commit, your commit is 3 files (entity + index + log), not 4. The raw is already in git from cron's commit.
Detection (run before git commit):
git ls-files --stage raw/articles/<slug>.md
# If output shows a hash → already in git → cron pre-committed
git log -1 --pretty=format:"%h %an %s" -- raw/articles/<slug>.md
# Check what commit added it
Acceptable resolution: don't try to "fix" this. The file is in git, content matches (both pipelines read the same URL), and cron is functioning. Verify your commit shows 3 files changed:
git show --stat <commit> | tail -3
# Should show "3 files changed" (entity, index, log)
Different from the documented cron-race protocol in memory: the memory note covers cron modifying OTHER FILES (concepts/, heartbeat/) that you don't want to sweep up. The new variant is the inverse: cron pre-empts your own raw article. The old rule is "don't git add -A"; the new rule is "verify with git ls-files --stage raw/... after add".
🚨 Source tags pollute semantic similarity analysis
Tags like newsletter, article, wechat are provenance/source tags, not semantic tags. They cause massive false-positive "similarity" pairs — two random newsletter clippings that both happen to have newsletter appear highly similar by Jaccard but share no real semantic relationship.
Meaningful semantic tags (use these for cross-link audits): agent, llm, memory, mcp, rag, harness, harness-engineering, ai-agent, multi-agent, anthropic, openai, claude, claude-code, context-management, skill, engineering, architecture, security, openclaw, bedrock, aws, memos.
When auditing wikilink density or finding unlinked similar pairs, always filter out source tags or the results are meaningless.
🚨 Provenance citations: schema requirement vs Obsidian rendering tradeoff
SCHEMA RULE (WORKFLOW.md line 314-319): Every prose paragraph in synthesized pages must end with a provenance citation:
- Single source:
^[raw/articles/slug.md]
- Multi-source:
^[raw/articles/slug-a.md, raw/articles/slug-b.md]
- Line-range claims:
^[raw/articles/slug.md:42-58]
- Inferred content (LLM synthesis): no citation — lint warns EXCESS INFERRED
Obsidian visual problem: Single-source entities get N identical ^[raw/articles/slug.md] citations (one per paragraph), rendering as N repeated outgoing links in reading mode. This is visually noisy but technically correct.
Decision recorded (2026-05-22): Single-source entities keep end-of-file citations only (文末单一回链). Multi-source entities keep paragraph-level citations. Rationale: provenance_state: extracted + frontmatter sources: array already declares single-source provenance; per-paragraph citation is redundant noise. If the schema changes, update WORKFLOW.md + lint rule + this pitfall.
Implementation: After Phase 2 synthesis, run cite-cleanup:
# Single-source: remove all ^[raw/articles/X] per-paragraph cites, keep frontmatter source
# Multi-source: retain paragraph-level cites as-is
cite_pat = re.compile(r'\^\[raw/articles/([^\]]+)\]')
unique = set(cite_pat.findall(content))
if len(unique) == 1:
content = cite_pat.sub('', content) # strip all per-paragraph cites
# Ensure frontmatter has: source: [[raw/articles/slug]]
Expected linter behavior after cite removal: After stripping per-paragraph citations from a single-source entity, the linter will still report EXCESS INFERRED: entities/<slug> has N/N uncited paragraphs (100%). This is expected and non-blocking — the linter counts prose paragraphs without ^[raw/articles/] markers as "uncited", even when frontmatter sources: array declares provenance. The warning is a style signal, not a structural error. Do not re-add per-paragraph citations to suppress it; the 2026-05-22 decision stands (frontmatter-only for single-source entities).