feat: yearly narrative report — trend evolution story (#470)

Replace the list-format yearly report with a ~500 word narrative prose that tells the year's ecosystem evolution story at the highest compression level. Updated monthly after each month summary is written. Key changes: - generate_rollups.py: yearly output is now Narrative + Arc format - generate_yearly_narrative.py: standalone script for narrative synthesis - assemble_historical_context.py: reads new Narrative section (falls back to legacy format) - content/yearly/2026.md: regenerated as narrative prose - Tests updated for new format The narrative captures meaning, not events: trends that emerged/grew/died, ecosystem shifts, and prediction accuracy — all in narrative prose rather than per-week bullet lists. Closes #400 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 14, 2026 at 10:24 UTC 1b90caa96cc7f0c162a3fb6f0cc819423e986273
6 files changed +704 -181
content/yearly/2026.md
+12 -78
@@ -1,91 +1,25 @@
1 ---
2 -title: "2026 Yearly Rollup"
2 +title: "2026 Yearly Narrative"
3 date: "2026-06-08T12:40:47+00:00"
4 year: 2026
5 categories: ["yearly"]
6 months_covered: ["2026-05", "2026-06"]
7 +format: "narrative"
8 ---
9
9 -## Year in Review
10 +## Narrative
11
11 -### May 2026 update — 2026-W21
12 -- [May 2026](/monthly/2026/05/) gained a new weekly signal via [Week 21, 2026](/weekly/2026/W21/).
13 -- Snapshot: W21 2026 is defined by two opposing forces: a maturing agent infrastructure stack — agent skills, MCP adoption, and efficient small models — and a coordinated wave of piracy, exploit, and SEO-farming repos that pollutes trending charts and makes signal extraction harder than it should be.
12 +2026 has been a split-screen story: agent tooling kept solidifying into a real distribution layer while GitHub discovery got easier to game. The ecosystem moved faster on capability than on trust.
13
15 -### May 2026 update — 2026-W22
16 -- [May 2026](/monthly/2026/05/) gained a new weekly signal via [Week 22, 2026](/weekly/2026/W22/).
17 -- Snapshot: Week 22 delivers the clearest defensive-security signal of the year alongside a crystallising agent-skills economy — both nearly buried under the most concentrated coordinated star-farming campaign the crawl has caught.
14 +In May, agent skills hardened from plumbing into an economy; the security gap stayed more visible than the fixes; coordinated star-farming made discovery harder to trust. In June, agent skills globalized and started splitting into tighter verticals; self-hosted and local-sovereignty tools gained real momentum; the security gap stayed more visible than the fixes; fork inflation replaced the earlier star-farming playbook.
15
19 -### June 2026 update — 2026-W23
20 -- [June 2026](/monthly/2026/06/) gained a new weekly signal via [Week 23, 2026](/weekly/2026/W23/).
21 -- Snapshot: Week 23 delivers a meaningful geographic expansion of the agent skills economy—into East Asian social media design—alongside the week's most dramatic self-hosted AI workspace launch, while a new coordinated prediction-market bot cluster introduces fork inflation as a replacement for last week's star-farming technique.
16 +Agent skills moved through infrastructure → economy → globalization → verticalization. Platform gaming adapted through star-farming → fork-inflation → activator-spam → fraud-cheat noise instead of disappearing. Self-hosted AI evolved through friction → self-hosted workspaces → local sovereignty as builders chased more control over execution and cost. The security gap stayed ahead of the fixes: each month made the need for agent isolation, supply-chain auditing, and prompt-injection defenses easier to see.
17
23 -### June 2026 update — 2026-W24
24 -- [June 2026](/monthly/2026/06/) gained a new weekly signal via [Week 24, 2026](/weekly/2026/W24/).
25 -- Snapshot: Week 24 deepens two W23 patterns — agent skills verticalization and local-sovereignty tooling — while a high-star hardware-crossover project (skylight) anchors the week's legitimate creativity and a heavier-than-usual noise floor of coordinated spam, activator repos, and crypto fraud tools demands editorial filtering.
18 +The running predictions were mostly right: skills did globalize; skills also verticalized quickly; discovery-layer abuse mutated instead of self-correcting; local and self-hosted AI kept becoming a category rather than a workaround; the trust and security gap remained open.
19
27 -## Biggest Trends
20 +## Arc
21
29 -### May 2026 update — 2026-W21
30 -- Themes in rotation: ai-agents, agent-skills, mcp, small-models, coding-agents.
31 -- Signal from [Week 21, 2026](/weekly/2026/W21/):
32 -
33 -### May 2026 update — 2026-W22
34 -- Themes in rotation: agent-skills, coding-agents, ai-agents, mcp, small-models.
35 -- Signal from [Week 22, 2026](/weekly/2026/W22/):
36 -
37 -### June 2026 update — 2026-W23
38 -- Themes in rotation: developer-tooling, open-source, agent-skills, noise-amplification, ai.
39 -- Signal from [Week 23, 2026](/weekly/2026/W23/): The durable signal this week concentrates in three credible areas. First, the agent skills layer continues to broaden and specialize: [op7418/guizang-social-card-skill](https://github.com/op7418/guizang-social-card-skill) and [helloianneo/ian-xiaohei-illustrations](https://github.com/helloianneo/ian-xiaohei-illustrations) demonstrate that skills are now packaging cultural and linguistic context, not just workflow steps—that is a meaningful evolution. [nekocode/filetree-skill](https://github.com/nekocode/filetree-skill) (129 ⭐) and [Christian-Katzmann/app-it](https://github.com/Christian-Katzmann/app-it) (122 ⭐) extend developer-workflow skills in tightly scoped, useful directions. Second, infrastructure-replacement repos show real fork activity: [garnix-io/garnix-ci](https://github.com/garnix-io/garnix-ci) (367 ⭐, Haskell, BSD-3) for Nix-based CI hosting and [qianzii2/rockduck](https://github.com/qianzii2/rockduck) (101 ⭐, Rust HTAP embedded database) are not vibe-coded weekend projects—both show technical specificity and non-trivial architecture. Third, [QwenLM/Qwen-VLA](https://github.com/QwenLM/Qwen-VLA) from a credible team in a category gaining real independent momentum is a research signal worth tracking regardless of its early star count. The noise this week is dominated by a new coordinated campaign: prediction-market bot repos with copy-paste keyword-stuffed descriptions and impossible fork counts. [Signal-Trade-Core/weather-prediction-bot](https://github.com/Signal-Trade-Core/weather-prediction-bot) (366 stars, 5,235 forks), [Trade-Execution-Labs/polymarket-sports-trading-bot](https://github.com/Trade-Execution-Labs/polymarket-sports-trading-bot) (76 stars, 4,059 forks), [polymaxi2/polymarket-arbitrage-trading-bot](https://github.com/polymaxi2/polymarket-arbitrage-trading-bot) (259 stars, 4,000 forks), and [ShadowSpread/polymarket-auto-trading](https://github.com/ShadowSpread/polymarket-auto-trading) (252 stars, 3,866 forks) all share the same structural tells: description text is a single phrase repeated 15 times, fork counts are 10-20x the star count, and no license from credible authors. W22's star-clustering attack has been replaced by fork inflation—a different manipulation vector, but the same underlying intent. The game-crack, software-unlock, and emulator repos (Roblox, Paralives, BeamMP, Romestead, lunar-client-minecraft) form a separate noise cluster using the same GitHub SEO playbook as previous weeks.
40 -
41 -### June 2026 update — 2026-W24
42 -- Themes in rotation: agent-skills, coding-agents, developer-tooling, open-source, ai-memory.
43 -- Signal from [Week 24, 2026](/weekly/2026/W24/): The durable signal this week clusters in three families. The agent skills verticalization cluster — [amElnagdy/guard-skills](https://github.com/amElnagdy/guard-skills), [razr001/align-dev](https://github.com/razr001/align-dev), [JimLiu/baoyu-design](https://github.com/JimLiu/baoyu-design), [openai/role-specific-plugins](https://github.com/openai/role-specific-plugins), [Forsy-AI/forsy-trace-skill](https://github.com/Forsy-AI/forsy-trace-skill) — passes the key tests: domain specificity, non-trivial implementations, active fork counts, and topic sets that indicate practitioner rather than hype-driven audiences. The local-sovereignty cluster — [tastyeffectco/sandboxd](https://github.com/tastyeffectco/sandboxd), [zaydmulani09/mnemo](https://github.com/zaydmulani09/mnemo), [NoopApp/noop](https://github.com/NoopApp/noop), [mysk-research/loupe](https://github.com/mysk-research/loupe) — is technically earnest with specific problem scopes and real fork activity. The hardware crossover tier — [cpaczek/skylight](https://github.com/cpaczek/skylight), [torvalds/ScrollWheel](https://github.com/torvalds/ScrollWheel) — has the authentic signals of genuine creative work: rich topic sets, unusual technical specificity, and star velocity that looks like genuine discovery rather than coordination. The noise floor this week is heavier than W23 and follows several distinct patterns. The most transparent is the Polymarket trading bot wave: [Trade-of-Economics-in-Warsaw/polymarket-signal-arbitrage-trading-bot](https://github.com/Trade-of-Economics-in-Warsaw/polymarket-signal-arbitrage-trading-bot) (172★, 3,088 forks — the fork count is implausibly inflated), [VoidSignals/Polymarket-trading-bot](https://github.com/VoidSignals/Polymarket-trading-bot) (166★, 348 forks), and [Obsidian-Trades/polymarket-copy-trading-bot](https://github.com/Obsidian-Trades/polymarket-copy-trading-bot) (144★, 455 forks) all have keyword-repetition descriptions. A second cluster of game cheat and activator repos appeared with suspiciously uniform star counts — multiple repos at exactly 75★ or exactly 65★ within hours of each other, authored by newly created accounts. [Unicornronote/Microsoft-Office-Activated](https://github.com/Unicornronote/Microsoft-Office-Activated) (150★), [aaviasulin123-design/kms-pico-latest-m6](https://github.com/aaviasulin123-design/kms-pico-latest-m6) (126★), and [biplobroy01/kmspisco-v2-portable](https://github.com/biplobroy01/kmspisco-v2-portable) (65★) follow the coordinated-activation pattern from W22 and W23. [amyxvalen/Flash-USDT-Sender](https://github.com/amyxvalen/Flash-USDT-Sender) (66★) explicitly lists "fake-btc-transaction" and "wallet-spoofer" as topics — not ambiguous. Filter and move on.
44 -
45 -## Most Impactful Repos
46 -
47 -### May 2026 update — 2026-W21
48 -- Featured repo: [vercel-labs/zerolang](https://github.com/vercel-labs/zerolang).
49 -- Month summary: [May 2026](/monthly/2026/05/).
50 -
51 -### May 2026 update — 2026-W22
52 -- Featured repo: [perplexityai/bumblebee](https://github.com/perplexityai/bumblebee).
53 -- Month summary: [May 2026](/monthly/2026/05/).
54 -
55 -### June 2026 update — 2026-W23
56 -- Featured repo: [pewdiepie-archdaemon/odysseus](https://github.com/pewdiepie-archdaemon/odysseus).
57 -- Month summary: [June 2026](/monthly/2026/06/).
58 -
59 -### June 2026 update — 2026-W24
60 -- Featured repo: [cpaczek/skylight](https://github.com/cpaczek/skylight).
61 -- Month summary: [June 2026](/monthly/2026/06/).
62 -
63 -## What Changed
64 -
65 -### May 2026 update — 2026-W21
66 -- Friction noted in [Week 21, 2026](/weekly/2026/W21/):
67 -
68 -### May 2026 update — 2026-W22
69 -- Friction noted in [Week 22, 2026](/weekly/2026/W22/):
70 -
71 -### June 2026 update — 2026-W23
72 -
73 -### June 2026 update — 2026-W24
74 -
75 -## Predictions Review
76 -
77 -### May 2026 update — 2026-W21
78 -- Open question carried forward from [May 2026](/monthly/2026/05/):
79 -- Working takeaway:
80 -
81 -### May 2026 update — 2026-W22
82 -- Open question carried forward from [May 2026](/monthly/2026/05/):
83 -- Working takeaway:
84 -
85 -### June 2026 update — 2026-W23
86 -- Open question carried forward from [June 2026](/monthly/2026/06/): Agent execution security remains the most important category not attracting commensurate attention. As self-hosted AI workspaces like [pewdiepie-archdaemon/odysseus](https://github.com/pewdiepie-archdaemon/odysseus) gain adoption, and as coding agents are routinely granted shell access and API credentials, the blast radius of an agent error or compromise expands proportionally. Nothing in W23 fills the runtime permission-scoping or agent isolation gap that W22 also identified. [ssreeni1/tracebase](https://github.com/ssreeni1/tracebase) (75 ⭐) attempts local trace capture for Codex and Claude sessions, and [Aimer-zero/redforge-ai](https://github.com/Aimer-zero/redforge-ai) (71 ⭐) offers an open-core AI red-teaming platform—but these are narrow tools around the edges of a problem that needs a category. Supply-chain security tooling, which briefly surged in W22 with perplexityai/bumblebee, has no meaningful follow-on this week. The W22 learning that the press was ignoring software supply-chain developer tooling still holds: neither press nor GitHub new-repo activity is building on last week's signal. And the coordinated fork-inflation attacks on GitHub's discovery layer go unreported and unaddressed—a platform health gap that degrades the crawl quality every week it persists.
87 -- Working takeaway: The agent skills globalization trend is nascent and not close to saturating: more language-specific and culture-specific skill packages are likely as builders see the Xiaohongshu and WeChat repos succeed. The self-hosted AI workspace category, energized by Copilot billing friction, should see fast-follower launches in the next week. Watch the VLA cluster—if [QwenLM/Qwen-VLA](https://github.com/QwenLM/Qwen-VLA) generates dataset tooling and fine-tuning forks, it will confirm embodied AI is crossing from research curiosity to practitioner category. The fork-inflation bot campaign will either intensify or trigger a GitHub filtering response—next week's filter_summary will be diagnostic.
88 -
89 -### June 2026 update — 2026-W24
90 -- Open question carried forward from [June 2026](/monthly/2026/06/): Neither press nor developers are addressing **agent skills supply chain security**. Skills packs are now a genuine distribution format — anthropics/skills at 147,856★ in the trending list, dozens of new community packs shipping weekly — but no tooling exists to audit what a SKILL.md file actually does when an agent executes it, whether it phones home, or whether a given skill's instructions can be hijacked by upstream changes. The guard-skills repo (412★) catches bugs in AI-generated code; it does not address the trust model of the skill distribution layer itself. This is an infrastructure gap that will become exploitable before it becomes visible to most practitioners. Second, **prompt injection defense tooling is conspicuously absent from developer activity**, despite OpenAI making it a product-level announcement (Lockdown Mode). The press story frames prompt injection as a vendor responsibility; developers are building more agent capabilities, not hardening them. The gap between institutional security posture and practitioner tooling for agent integrity is widening: the attack surface for prompt-injected agent actions is growing faster than the defensive repertoire. Third, agent skills packs from this week — particularly in the Chinese ecosystem — have no **localization and compliance layer**: no jurisdiction-aware content filtering, no audit trail for model routing, no tooling for verifying that domestic model proxies are behaving consistently with their advertised capabilities. The market is building fast; the governance infrastructure for it does not exist.
91 -- Working takeaway: The agent skills verticalization trend is in active acceleration with no plateau signal — expect domain-specific packs for legal, medical, finance, and education practitioner communities to follow the security and design verticals visible this week. The Chinese coding agent ecosystem is early but directional; watch for tooling that lets domestic developers contribute skills packs upstream to Claude Code and Codex environments without model-switching friction. Hardware-adjacent hobbyist work ([cpaczek/skylight](https://github.com/cpaczek/skylight)) is approaching a level of community engagement that suggests a "weekend RTL-SDR project" category may crystallize. The noise floor — coordinated game-cheat star farms, Polymarket bot spam — shows no sign of self-correcting; if anything W24's count is higher than W23's. The platform's filtering job is getting harder, not easier.
22 +- agent-skills: infrastructure > economy > globalization > verticalization
23 +- platform-gaming: star-farming > fork-inflation > activator-spam > fraud-cheat noise
24 +- security-gap: identified > widening > unresolved
25 +- self-hosted-ai: friction > self-hosted workspaces > local sovereignty
scripts/assemble_historical_context.py
+4
@@ -174,6 +174,10 @@ def _extract_month_notes(markdown: str) -> str:
174
175
176 def _extract_yearly_narrative(markdown: str) -> str:
177 + narrative = _extract_markdown_section(markdown, "Narrative")
178 + if narrative:
179 + arc = _extract_markdown_section(markdown, "Arc")
180 + return _join_nonempty(section for section in (narrative, arc) if section)
181 return _join_nonempty(
182 section
183 for section in (
scripts/generate_rollups.py
+42 -84
@@ -13,6 +13,7 @@ if __package__ in {None, ""}:
13 sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14
15 import scripts.analysis_gate as analysis_gate
16 +from scripts.generate_yearly_narrative import build_yearly_narrative_pages
17
18 PROJECT_ROOT = Path(__file__).resolve().parent.parent
19 SUMMARY_SUFFIX = "-summary.md"
@@ -26,11 +27,8 @@ MONTHLY_SECTIONS = [
27 "Key Takeaways",
28 ]
29 YEARLY_SECTIONS = [
29 - "Year in Review",
30 - "Biggest Trends",
31 - "Most Impactful Repos",
32 - "What Changed",
33 - "Predictions Review",
30 + "Narrative",
31 + "Arc",
32 ]
33 MONTH_NAMES = {
34 1: "January",
@@ -112,6 +110,8 @@ class RollupPage:
110 frontmatter: dict[str, Any]
111 sections: dict[str, list[RollupEntry]]
112 section_order: list[str]
113 + replace_existing_sections: bool = False
114 + preserve_unknown_sections: bool = True
115
116
117 def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -314,55 +314,6 @@ def monthly_entries(weekly: WeeklySummary, tags_counter: Counter[str]) -> dict[s
314 }
315
316
317 -def yearly_entries(weekly: WeeklySummary, tags_counter: Counter[str]) -> dict[str, RollupEntry]:
318 - common_tags = ", ".join(tag for tag, _ in tags_counter.most_common(5)) or "none yet"
319 - repo_link = repo_markdown(weekly.top_repo)
320 - month_link = f"[{weekly.month_title}]({weekly.month_link})"
321 - week_link = f"[{weekly.week_title}]({weekly.week_link})"
322 - marker = f"### {weekly.month_title} update — {weekly.week}"
323 - return {
324 - "Year in Review": RollupEntry(
325 - marker=marker,
326 - text=(
327 - f"{marker}\n"
328 - f"- {month_link} gained a new weekly signal via {week_link}.\n"
329 - f"- Snapshot: {weekly.summary}"
330 - ),
331 - ),
332 - "Biggest Trends": RollupEntry(
333 - marker=marker,
334 - text=(
335 - f"{marker}\n"
336 - f"- Themes in rotation: {common_tags}.\n"
337 - f"- Signal from {week_link}: {weekly.signal}"
338 - ),
339 - ),
340 - "Most Impactful Repos": RollupEntry(
341 - marker=marker,
342 - text=(
343 - f"{marker}\n"
344 - f"- Featured repo: {repo_link}.\n"
345 - f"- Month summary: {month_link}."
346 - ),
347 - ),
348 - "What Changed": RollupEntry(
349 - marker=marker,
350 - text=(
351 - f"{marker}"
352 - + (f"\n- Friction noted in {week_link}: {weekly.noise}" if weekly.noise else "")
353 - ),
354 - ),
355 - "Predictions Review": RollupEntry(
356 - marker=marker,
357 - text=(
358 - f"{marker}\n"
359 - f"- Open question carried forward from {month_link}: {weekly.gaps}\n"
360 - f"- Working takeaway: {weekly.conclusion}"
361 - ),
362 - ),
363 - }
364 -
365 -
317 def build_monthly_pages(summaries: list[WeeklySummary], content_root: Path) -> list[RollupPage]:
318 grouped: dict[tuple[int, int], list[WeeklySummary]] = defaultdict(list)
319 for summary in summaries:
@@ -397,38 +348,33 @@ def build_monthly_pages(summaries: list[WeeklySummary], content_root: Path) -> l
348
349
350 def build_yearly_pages(summaries: list[WeeklySummary], content_root: Path) -> list[RollupPage]:
400 - grouped: dict[int, list[WeeklySummary]] = defaultdict(list)
401 - for summary in summaries:
402 - grouped[summary.year].append(summary)
403 -
351 pages: list[RollupPage] = []
405 - for year, items in sorted(grouped.items()):
406 - items = sorted(items, key=lambda item: (item.date, item.week))
407 - tags_counter: Counter[str] = Counter()
408 - page_entries: dict[str, list[RollupEntry]] = {section: [] for section in YEARLY_SECTIONS}
409 - for item in items:
410 - tags_counter.update(item.tags)
411 - for section, entry in yearly_entries(item, tags_counter).items():
412 - page_entries[section].append(entry)
413 - months_covered = sorted({item.month_slug for item in items})
352 + target_years = sorted({summary.year for summary in summaries})
353 + for page in build_yearly_narrative_pages(content_root, target_years):
354 pages.append(
355 RollupPage(
416 - path=content_root / "yearly" / f"{year}.md",
417 - frontmatter={
418 - "title": f"{year} Yearly Rollup",
419 - "date": items[-1].date.isoformat(),
420 - "year": year,
421 - "categories": ["yearly"],
422 - "months_covered": months_covered,
356 + path=page.path,
357 + frontmatter=page.frontmatter,
358 + sections={
359 + "Narrative": [RollupEntry(marker=f"{page.year}-narrative", text=page.narrative)],
360 + "Arc": [RollupEntry(marker=f"{page.year}-arc", text="\n".join(f"- {line}" for line in page.arc_lines))],
361 },
424 - sections=page_entries,
362 section_order=YEARLY_SECTIONS,
363 + replace_existing_sections=True,
364 + preserve_unknown_sections=False,
365 )
366 )
367 return pages
368
369
431 -def merge_sections(path: Path, section_order: list[str], new_entries: dict[str, list[RollupEntry]]) -> str:
370 +def merge_sections(
371 + path: Path,
372 + section_order: list[str],
373 + new_entries: dict[str, list[RollupEntry]],
374 + *,
375 + replace_existing_sections: bool = False,
376 + preserve_unknown_sections: bool = True,
377 +) -> str:
378 intro = ""
379 existing_sections: dict[str, str] = {}
380 if path.exists():
@@ -441,7 +387,7 @@ def merge_sections(path: Path, section_order: list[str], new_entries: dict[str,
387
388 rendered_sections: list[str] = []
389 for section in section_order:
444 - content = existing_sections.get(section, "")
390 + content = "" if replace_existing_sections else existing_sections.get(section, "")
391 if content.strip() == NO_UPDATES_PLACEHOLDER:
392 content = ""
393 for entry in new_entries[section]:
@@ -451,11 +397,12 @@ def merge_sections(path: Path, section_order: list[str], new_entries: dict[str,
397 section_body = content.strip() or NO_UPDATES_PLACEHOLDER
398 rendered_sections.append(f"## {section}\n\n{section_body}")
399
454 - for section, content in existing_sections.items():
455 - if section in section_order:
456 - continue
457 - section_body = content.strip() or NO_UPDATES_PLACEHOLDER
458 - rendered_sections.append(f"## {section}\n\n{section_body}")
400 + if preserve_unknown_sections:
401 + for section, content in existing_sections.items():
402 + if section in section_order:
403 + continue
404 + section_body = content.strip() or NO_UPDATES_PLACEHOLDER
405 + rendered_sections.append(f"## {section}\n\n{section_body}")
406
407 if intro.strip():
408 return intro.rstrip() + "\n\n" + "\n\n".join(rendered_sections) + "\n"
@@ -464,7 +411,13 @@ def merge_sections(path: Path, section_order: list[str], new_entries: dict[str,
411
412 def write_rollup(page: RollupPage) -> None:
413 page.path.parent.mkdir(parents=True, exist_ok=True)
467 - body = merge_sections(page.path, page.section_order, page.sections)
414 + body = merge_sections(
415 + page.path,
416 + page.section_order,
417 + page.sections,
418 + replace_existing_sections=page.replace_existing_sections,
419 + preserve_unknown_sections=page.preserve_unknown_sections,
420 + )
421 page.path.write_text(render_frontmatter(page.frontmatter) + body, encoding="utf-8")
422
423
@@ -474,7 +427,12 @@ def generate_rollups(analyzed_dir: Path, content_root: Path) -> list[Path]:
427 return []
428
429 written: list[Path] = []
477 - for page in [*build_monthly_pages(summaries, content_root), *build_yearly_pages(summaries, content_root)]:
430 + monthly_pages = build_monthly_pages(summaries, content_root)
431 + for page in monthly_pages:
432 + write_rollup(page)
433 + written.append(page.path)
434 +
435 + for page in build_yearly_pages(summaries, content_root):
436 write_rollup(page)
437 written.append(page.path)
438 return written
scripts/generate_yearly_narrative.py new
+522
@@ -0,0 +1,522 @@
1 +#!/usr/bin/env python3
2 +from __future__ import annotations
3 +
4 +import argparse
5 +import re
6 +import sys
7 +from dataclasses import dataclass
8 +from pathlib import Path
9 +from typing import Any, Iterable
10 +
11 +if __package__ in {None, ""}:
12 + sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13 +
14 +import scripts.analysis_gate as analysis_gate
15 +
16 +PROJECT_ROOT = Path(__file__).resolve().parent.parent
17 +DEFAULT_CONTENT_ROOT = PROJECT_ROOT / "content"
18 +MONTH_SECTION_PATTERN = re.compile(r"(?m)^##\s+(.+?)\s*$")
19 +WEEK_BLOCK_PATTERN = re.compile(r"(?ms)^###\s+.+?\s*$\n(.*?)(?=^###\s+|\Z)")
20 +LINK_PATTERN = re.compile(r"\[([^\]]+)\]\([^)]+\)")
21 +WORD_PATTERN = re.compile(r"\S+")
22 +
23 +MONTH_NAMES = {
24 + 1: "January",
25 + 2: "February",
26 + 3: "March",
27 + 4: "April",
28 + 5: "May",
29 + 6: "June",
30 + 7: "July",
31 + 8: "August",
32 + 9: "September",
33 + 10: "October",
34 + 11: "November",
35 + 12: "December",
36 +}
37 +
38 +STOPWORDS = {
39 + "a",
40 + "an",
41 + "and",
42 + "are",
43 + "as",
44 + "at",
45 + "be",
46 + "by",
47 + "for",
48 + "from",
49 + "has",
50 + "in",
51 + "into",
52 + "is",
53 + "it",
54 + "its",
55 + "of",
56 + "on",
57 + "or",
58 + "that",
59 + "the",
60 + "their",
61 + "this",
62 + "to",
63 + "was",
64 + "were",
65 + "while",
66 + "with",
67 +}
68 +
69 +
70 +@dataclass(frozen=True)
71 +class MonthSnapshot:
72 + path: Path
73 + year: int
74 + month: int
75 + title: str
76 + date: str
77 + summaries: tuple[str, ...]
78 + themes: tuple[str, ...]
79 + signals: tuple[str, ...]
80 + noise: tuple[str, ...]
81 + gaps: tuple[str, ...]
82 + closing_reads: tuple[str, ...]
83 +
84 + @property
85 + def month_name(self) -> str:
86 + return MONTH_NAMES[self.month]
87 +
88 + @property
89 + def month_slug(self) -> str:
90 + return f"{self.year}-{self.month:02d}"
91 +
92 + @property
93 + def link(self) -> str:
94 + return f"/monthly/{self.year}/{self.month:02d}/"
95 +
96 + @property
97 + def text_blob(self) -> str:
98 + return " ".join(
99 + [
100 + self.title,
101 + *self.summaries,
102 + *self.themes,
103 + *self.signals,
104 + *self.noise,
105 + *self.gaps,
106 + *self.closing_reads,
107 + ]
108 + )
109 +
110 +
111 +@dataclass(frozen=True)
112 +class YearlyNarrativePage:
113 + year: int
114 + path: Path
115 + frontmatter: dict[str, Any]
116 + narrative: str
117 + arc_lines: tuple[str, ...]
118 +
119 +
120 +@dataclass(frozen=True)
121 +class TrendFamily:
122 + key: str
123 + label: str
124 + keywords: tuple[str, ...]
125 + stages: tuple[tuple[str, tuple[str, ...]], ...]
126 +
127 +
128 +TREND_FAMILIES = (
129 + TrendFamily(
130 + key="agent-skills",
131 + label="agent-skills",
132 + keywords=("agent skill", "agent-skills", "skills pack", "skill package", "skill"),
133 + stages=(
134 + ("infrastructure", ("maturing", "infrastructure", "mcp", "small model", "small-model")),
135 + ("economy", ("economy", "distribution format", "marketplace", "skills layer", "skills packs")),
136 + (
137 + "globalization",
138 + ("east asian", "chinese", "global", "globalization", "xiaohongshu", "wechat", "cultural", "linguistic"),
139 + ),
140 + (
141 + "verticalization",
142 + ("verticalization", "vertical", "domain-specific", "role-specific", "legal", "medical", "finance", "education"),
143 + ),
144 + ),
145 + ),
146 + TrendFamily(
147 + key="platform-gaming",
148 + label="platform-gaming",
149 + keywords=("star-farming", "fork inflation", "spam", "activator", "cheat", "prediction-market bot", "seo-farming"),
150 + stages=(
151 + ("star-farming", ("star-farming", "star farming", "seo-farming")),
152 + ("fork-inflation", ("fork inflation", "fork-inflation", "inflated fork", "implausibly inflated")),
153 + ("activator-spam", ("activator", "activated", "kms", "copy-trading", "keyword-repetition", "bot cluster")),
154 + ("fraud-cheat noise", ("fraud", "wallet-spoofer", "game cheat", "crypto fraud", "software unlock", "prediction-market bot")),
155 + ),
156 + ),
157 + TrendFamily(
158 + key="security-gap",
159 + label="security-gap",
160 + keywords=("security gap", "prompt injection", "supply-chain", "supply chain", "agent execution security", "agent isolation", "permission-scoping"),
161 + stages=(
162 + ("identified", ("security signal", "security gap", "agent execution security", "permission-scoping", "agent isolation")),
163 + ("widening", ("still holds", "remains", "widening", "become exploitable", "not attracting commensurate attention")),
164 + ("unresolved", ("no tooling exists", "gap that will become exploitable", "does not exist", "stayed missing")),
165 + ),
166 + ),
167 + TrendFamily(
168 + key="self-hosted-ai",
169 + label="self-hosted-ai",
170 + keywords=("self-hosted", "local-sovereignty", "local sovereignty", "local-sovereignty", "billing friction", "workspace", "local-first"),
171 + stages=(
172 + ("friction", ("billing friction", "cost", "copilot billing")),
173 + ("self-hosted workspaces", ("self-hosted", "workspace launch", "workspace")),
174 + ("local sovereignty", ("local-sovereignty", "local sovereignty", "local-first", "sandboxd", "memory", "control")),
175 + ),
176 + ),
177 +)
178 +
179 +
180 +def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
181 + parser = argparse.ArgumentParser(description="Generate yearly narrative pages from monthly rollups.")
182 + parser.add_argument(
183 + "--content-root",
184 + type=Path,
185 + default=DEFAULT_CONTENT_ROOT,
186 + help="Root content directory containing monthly/ and yearly/.",
187 + )
188 + parser.add_argument(
189 + "--year",
190 + type=int,
191 + action="append",
192 + dest="years",
193 + help="Optional year to regenerate. May be passed multiple times.",
194 + )
195 + return parser.parse_args(argv)
196 +
197 +
198 +def yaml_quote(value: str) -> str:
199 + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
200 +
201 +
202 +def yaml_value(value: Any) -> str:
203 + if isinstance(value, str):
204 + return yaml_quote(value)
205 + if isinstance(value, bool):
206 + return "true" if value else "false"
207 + if isinstance(value, int):
208 + return str(value)
209 + if isinstance(value, list):
210 + return f"[{', '.join(yaml_value(item) for item in value)}]"
211 + return str(value)
212 +
213 +
214 +def render_frontmatter(frontmatter: dict[str, Any]) -> str:
215 + lines = ["---"]
216 + for key, value in frontmatter.items():
217 + lines.append(f"{key}: {yaml_value(value)}")
218 + lines.extend(["---", "", ""])
219 + return "\n".join(lines)
220 +
221 +
222 +def strip_markdown(text: str) -> str:
223 + cleaned = LINK_PATTERN.sub(r"\1", text)
224 + cleaned = cleaned.replace("**", "").replace("*", "").replace("`", "")
225 + return re.sub(r"\s+", " ", cleaned).strip()
226 +
227 +
228 +def split_sections(body: str) -> dict[str, str]:
229 + matches = list(MONTH_SECTION_PATTERN.finditer(body))
230 + sections: dict[str, str] = {}
231 + for index, match in enumerate(matches):
232 + start = match.end()
233 + end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
234 + sections[match.group(1).strip()] = body[start:end].strip("\n")
235 + return sections
236 +
237 +
238 +def extract_labeled_values(section_body: str, label: str) -> list[str]:
239 + values: list[str] = []
240 + for block in WEEK_BLOCK_PATTERN.finditer(section_body):
241 + for line in block.group(1).splitlines():
242 + if not line.startswith(f"- {label}:"):
243 + continue
244 + value = strip_markdown(line.split(":", 1)[1])
245 + if value:
246 + values.append(value)
247 + return values
248 +
249 +
250 +def dedupe_preserving_order(values: Iterable[str]) -> list[str]:
251 + result: list[str] = []
252 + seen: set[str] = set()
253 + for value in values:
254 + normalized = value.strip()
255 + if not normalized or normalized in seen:
256 + continue
257 + seen.add(normalized)
258 + result.append(normalized)
259 + return result
260 +
261 +
262 +def load_month_snapshot(path: Path) -> MonthSnapshot:
263 + frontmatter, body = analysis_gate.extract_frontmatter(path.read_text(encoding="utf-8"))
264 + sections = split_sections(body)
265 + themes: list[str] = []
266 + for raw in extract_labeled_values(sections.get("Month Overview", ""), "Recurring themes so far"):
267 + themes.extend(part.strip() for part in raw.rstrip(".").split(",") if part.strip())
268 + return MonthSnapshot(
269 + path=path,
270 + year=int(frontmatter["year"]),
271 + month=int(frontmatter["month"]),
272 + title=str(frontmatter.get("title", path.stem)),
273 + date=str(frontmatter["date"]),
274 + summaries=tuple(extract_labeled_values(sections.get("Month Overview", ""), "Summary")),
275 + themes=tuple(dedupe_preserving_order(themes)),
276 + signals=tuple(extract_labeled_values(sections.get("Trends Observed", ""), "Signal")),
277 + noise=tuple(extract_labeled_values(sections.get("Trends Observed", ""), "Noise")),
278 + gaps=tuple(extract_labeled_values(sections.get("Key Takeaways", ""), "Gap to watch")),
279 + closing_reads=tuple(extract_labeled_values(sections.get("Key Takeaways", ""), "Closing read")),
280 + )
281 +
282 +
283 +def load_month_snapshots(content_root: Path, years: Iterable[int] | None = None) -> list[MonthSnapshot]:
284 + if years:
285 + paths = []
286 + for year in sorted(set(years)):
287 + paths.extend(sorted((content_root / "monthly" / str(year)).glob("*.md")))
288 + else:
289 + paths = sorted((content_root / "monthly").glob("*/*.md"))
290 + snapshots = [load_month_snapshot(path) for path in paths if path.is_file()]
291 + return sorted(snapshots, key=lambda item: (item.year, item.month))
292 +
293 +
294 +def word_count(text: str) -> int:
295 + return len(WORD_PATTERN.findall(text))
296 +
297 +
298 +def trim_words(text: str, limit: int) -> str:
299 + words = text.split()
300 + if len(words) <= limit:
301 + return text.strip()
302 + return " ".join(words[:limit]).rstrip(",;:.") + "…"
303 +
304 +
305 +def compress_phrase(text: str, limit: int = 24) -> str:
306 + cleaned = strip_markdown(text)
307 + cleaned = re.sub(r"^(Week \d+\s+|W\d+\s+)", "", cleaned)
308 + cleaned = re.sub(r"^(The durable signal this week |This week |Week \d+ |W\d+ )", "", cleaned, flags=re.IGNORECASE)
309 + cleaned = re.sub(r"\s+", " ", cleaned).strip().rstrip(".")
310 + return trim_words(cleaned, limit)
311 +
312 +
313 +def keyword_score(text: str, keywords: Iterable[str]) -> int:
314 + lowered = text.lower()
315 + return sum(1 for keyword in keywords if keyword in lowered)
316 +
317 +
318 +def detect_family_arc(months: list[MonthSnapshot], family: TrendFamily) -> list[str]:
319 + stages: list[str] = []
320 + family_seen = False
321 + for month in months:
322 + lowered = month.text_blob.lower()
323 + if keyword_score(lowered, family.keywords):
324 + family_seen = True
325 + for stage, keywords in family.stages:
326 + if any(keyword in lowered for keyword in keywords) and stage not in stages:
327 + stages.append(stage)
328 + if family_seen and not stages:
329 + stages.append("emerging")
330 + return stages
331 +
332 +
333 +def build_theme_sentence(year: int, arcs: dict[str, list[str]]) -> str:
334 + has_skills = bool(arcs.get("agent-skills"))
335 + has_noise = bool(arcs.get("platform-gaming"))
336 + has_security = bool(arcs.get("security-gap"))
337 + has_local = bool(arcs.get("self-hosted-ai"))
338 + if has_skills and has_noise:
339 + sentence = (
340 + f"{year} has been a split-screen story: agent tooling kept solidifying into a real distribution layer "
341 + "while GitHub discovery got easier to game."
342 + )
343 + elif has_skills:
344 + sentence = f"{year} has mainly been the year agent tooling stopped looking experimental and started behaving like infrastructure."
345 + else:
346 + sentence = f"{year} has so far been defined less by single launches than by shifts in how the ecosystem is organizing itself."
347 + if has_security:
348 + sentence += " The ecosystem moved faster on capability than on trust."
349 + elif has_local:
350 + sentence += " Control, cost, and local execution kept gaining weight."
351 + return sentence
352 +
353 +
354 +def summarize_month(month: MonthSnapshot) -> str:
355 + month_arcs = {family.key: detect_family_arc([month], family) for family in TREND_FAMILIES}
356 + parts: list[str] = []
357 +
358 + agent_arc = month_arcs.get("agent-skills", [])
359 + if "globalization" in agent_arc and "verticalization" in agent_arc:
360 + parts.append("agent skills globalized and started splitting into tighter verticals")
361 + elif "economy" in agent_arc and "infrastructure" in agent_arc:
362 + parts.append("agent skills hardened from plumbing into an economy")
363 + elif "economy" in agent_arc:
364 + parts.append("agent skills started looking like a real market layer")
365 + elif "infrastructure" in agent_arc:
366 + parts.append("agent tooling kept hardening into infrastructure")
367 +
368 + local_arc = month_arcs.get("self-hosted-ai", [])
369 + if "local sovereignty" in local_arc:
370 + parts.append("self-hosted and local-sovereignty tools gained real momentum")
371 + elif "self-hosted workspaces" in local_arc:
372 + parts.append("self-hosted AI workspaces became more credible")
373 +
374 + if month_arcs.get("security-gap"):
375 + parts.append("the security gap stayed more visible than the fixes")
376 +
377 + platform_arc = month_arcs.get("platform-gaming", [])
378 + if "fork-inflation" in platform_arc:
379 + parts.append("fork inflation replaced the earlier star-farming playbook")
380 + elif "star-farming" in platform_arc:
381 + parts.append("coordinated star-farming made discovery harder to trust")
382 +
383 + if parts:
384 + return "; ".join(parts[:-1]) + ("" if len(parts) < 2 else "; ") + parts[-1] if len(parts) > 1 else parts[0]
385 +
386 + return trim_words(strip_markdown(month.summaries[-1] if month.summaries else month.text_blob), 32)
387 +
388 +
389 +def build_month_story(months: list[MonthSnapshot]) -> str:
390 + sentences: list[str] = []
391 + for month in months:
392 + summary = summarize_month(month)
393 + sentences.append(f"In {month.month_name}, {summary}")
394 + if not sentences:
395 + return ""
396 + return " ".join(sentence.rstrip(".") + "." for sentence in sentences)
397 +
398 +
399 +def build_arc_commentary(arcs: dict[str, list[str]]) -> list[str]:
400 + commentary: list[str] = []
401 + agent_arc = arcs.get("agent-skills", [])
402 + if agent_arc:
403 + commentary.append(f"Agent skills moved through {' → '.join(agent_arc)}.")
404 + platform_arc = arcs.get("platform-gaming", [])
405 + if platform_arc:
406 + commentary.append(f"Platform gaming adapted through {' → '.join(platform_arc)} instead of disappearing.")
407 + local_arc = arcs.get("self-hosted-ai", [])
408 + if local_arc:
409 + commentary.append(f"Self-hosted AI evolved through {' → '.join(local_arc)} as builders chased more control over execution and cost.")
410 + security_arc = arcs.get("security-gap", [])
411 + if security_arc:
412 + commentary.append("The security gap stayed ahead of the fixes: each month made the need for agent isolation, supply-chain auditing, and prompt-injection defenses easier to see.")
413 + return commentary
414 +
415 +
416 +def build_prediction_review(arcs: dict[str, list[str]]) -> str:
417 + confirmations: list[str] = []
418 + if "globalization" in arcs.get("agent-skills", []):
419 + confirmations.append("skills did globalize")
420 + if "verticalization" in arcs.get("agent-skills", []):
421 + confirmations.append("skills also verticalized quickly")
422 + if len(arcs.get("platform-gaming", [])) >= 2:
423 + confirmations.append("discovery-layer abuse mutated instead of self-correcting")
424 + if arcs.get("self-hosted-ai"):
425 + confirmations.append("local and self-hosted AI kept becoming a category rather than a workaround")
426 + if arcs.get("security-gap"):
427 + confirmations.append("the trust and security gap remained open")
428 + if not confirmations:
429 + return "The running predictions stayed directionally useful: the biggest structural questions still look unresolved."
430 + joined = "; ".join(confirmations[:-1]) + ("" if len(confirmations) < 2 else "; ") + confirmations[-1] if len(confirmations) > 1 else confirmations[0]
431 + return f"The running predictions were mostly right: {joined}."
432 +
433 +
434 +def compress_narrative(paragraphs: list[str], max_words: int = 500) -> str:
435 + text = "\n\n".join(paragraph.strip() for paragraph in paragraphs if paragraph.strip())
436 + if word_count(text) <= max_words:
437 + return text
438 + compressed = text
439 + for limit in (460, 430, 400, 360):
440 + words = compressed.split()
441 + if len(words) <= max_words:
442 + break
443 + compressed = " ".join(words[:limit]).rstrip(",;:.") + "…"
444 + return compressed
445 +
446 +
447 +def build_arc_lines(months: list[MonthSnapshot]) -> tuple[str, ...]:
448 + arcs: list[str] = []
449 + for family in TREND_FAMILIES:
450 + stages = detect_family_arc(months, family)
451 + if stages:
452 + arcs.append(f"{family.label}: {' > '.join(stages)}")
453 + return tuple(arcs)
454 +
455 +
456 +def synthesize_year(months: list[MonthSnapshot]) -> tuple[str, tuple[str, ...]]:
457 + arcs = {family.key: detect_family_arc(months, family) for family in TREND_FAMILIES}
458 + paragraphs = [
459 + build_theme_sentence(months[0].year, arcs),
460 + build_month_story(months),
461 + " ".join(build_arc_commentary(arcs)),
462 + build_prediction_review(arcs),
463 + ]
464 + return compress_narrative(paragraphs), build_arc_lines(months)
465 +
466 +
467 +def build_yearly_narrative_pages(content_root: Path, years: Iterable[int] | None = None) -> list[YearlyNarrativePage]:
468 + grouped: dict[int, list[MonthSnapshot]] = {}
469 + for snapshot in load_month_snapshots(content_root, years):
470 + grouped.setdefault(snapshot.year, []).append(snapshot)
471 +
472 + pages: list[YearlyNarrativePage] = []
473 + for year, months in sorted(grouped.items()):
474 + ordered = sorted(months, key=lambda item: item.month)
475 + narrative, arc_lines = synthesize_year(ordered)
476 + pages.append(
477 + YearlyNarrativePage(
478 + year=year,
479 + path=content_root / "yearly" / f"{year}.md",
480 + frontmatter={
481 + "title": f"{year} Yearly Narrative",
482 + "date": ordered[-1].date,
483 + "year": year,
484 + "categories": ["yearly"],
485 + "months_covered": [month.month_slug for month in ordered],
486 + "format": "narrative",
487 + },
488 + narrative=narrative,
489 + arc_lines=arc_lines,
490 + )
491 + )
492 + return pages
493 +
494 +
495 +def render_yearly_page(page: YearlyNarrativePage) -> str:
496 + arc_body = "\n".join(f"- {line}" for line in page.arc_lines) if page.arc_lines else "_No updates yet._"
497 + body = f"## Narrative\n\n{page.narrative}\n\n## Arc\n\n{arc_body}\n"
498 + return render_frontmatter(page.frontmatter) + body
499 +
500 +
501 +def generate_yearly_narratives(content_root: Path, years: Iterable[int] | None = None) -> list[Path]:
502 + written: list[Path] = []
503 + for page in build_yearly_narrative_pages(content_root, years):
504 + page.path.parent.mkdir(parents=True, exist_ok=True)
505 + page.path.write_text(render_yearly_page(page), encoding="utf-8")
506 + written.append(page.path)
507 + return written
508 +
509 +
510 +def main(argv: list[str] | None = None) -> int:
511 + args = parse_args(argv)
512 + written = generate_yearly_narratives(args.content_root, args.years)
513 + if not written:
514 + print(f"No monthly rollups found under {args.content_root / 'monthly'}")
515 + return 0
516 + for path in written:
517 + print(f"Generated {path}")
518 + return 0
519 +
520 +
521 +if __name__ == "__main__":
522 + raise SystemExit(main())
tests/test_assemble_historical_context.py
+25 -3
@@ -31,9 +31,9 @@ def test_assemble_historical_context_reads_expected_sources(tmp_path: Path) -> N
31 encoding="utf-8",
32 )
33 (content_root / "yearly" / "2026.md").write_text(
34 - "## Year in Review\n\nYear review.\n\n"
35 - "## Biggest Trends\n\nYear trends.\n\n"
36 - "## Predictions Review\n\nYear predictions.\n",
34 + "---\nformat: narrative\n---\n"
35 + "## Narrative\n\nYear review.\n\n"
36 + "## Arc\n\n- agent-skills: infrastructure > economy\n",
37 encoding="utf-8",
38 )
39 previous_summary = analyzed_dir / "2026-W24-summary.md"
@@ -60,6 +60,28 @@ def test_assemble_historical_context_reads_expected_sources(tmp_path: Path) -> N
60 assert "### Yearly Narrative" in result
61
62
63 +def test_assemble_historical_context_yearly_falls_back_to_legacy_sections(tmp_path: Path) -> None:
64 + content_root = tmp_path / "content"
65 + (content_root / "yearly").mkdir(parents=True)
66 + (content_root / "yearly" / "2026.md").write_text(
67 + "## Year in Review\n\nLegacy review.\n\n"
68 + "## Biggest Trends\n\nLegacy trends.\n\n"
69 + "## Predictions Review\n\nLegacy predictions.\n",
70 + encoding="utf-8",
71 + )
72 +
73 + result = assemble_historical_context(
74 + current_datetime="2026-06-12T17:13:50+00:00",
75 + previous_summary_path=None,
76 + content_root=content_root,
77 + max_words=1500,
78 + prompt_token_budget=90_000,
79 + )
80 +
81 + assert "Legacy review." in result
82 + assert "Legacy predictions." in result
83 +
84 +
85 def test_build_historical_context_respects_prompt_fraction_cap(tmp_path: Path) -> None:
86 content_root = tmp_path / "content"
87 (content_root / "rolling").mkdir(parents=True)
tests/test_generate_rollups.py
+99 -16
@@ -5,6 +5,7 @@ from unittest import mock
5 from pathlib import Path
6
7 import scripts.generate_rollups as generate_rollups
8 +import scripts.generate_yearly_narrative as generate_yearly_narrative
9
10
11 WORKSPACE_ROOT = Path(".test-workspaces")
@@ -120,12 +121,13 @@ class GenerateRollupsTests(unittest.TestCase):
121 self.assertIn('[octo/signal-kit](https://github.com/octo/signal-kit)', monthly)
122
123 yearly = yearly_path.read_text(encoding="utf-8")
123 - self.assertIn('title: "2026 Yearly Rollup"', yearly)
124 + self.assertIn('title: "2026 Yearly Narrative"', yearly)
125 self.assertIn('categories: ["yearly"]', yearly)
126 self.assertIn('months_covered: ["2026-05"]', yearly)
126 - self.assertIn('## Year in Review', yearly)
127 - self.assertIn('### May 2026 update — 2026-W21', yearly)
128 - self.assertIn('[May 2026](/monthly/2026/05/)', yearly)
127 + self.assertIn('format: "narrative"', yearly)
128 + self.assertIn('## Narrative', yearly)
129 + self.assertIn('Practical agent tooling led the week.', yearly)
130 + self.assertIn('## Arc', yearly)
131
132 def test_generate_rollups_is_append_only_for_existing_pages(self) -> None:
133 with temporary_workspace() as tmpdir:
@@ -184,25 +186,106 @@ class GenerateRollupsTests(unittest.TestCase):
186 '- Signal: Teams preferred operational automation over generic hype.',
187 '- Gap to watch: Reliable momentum data remained missing.',
188 '- Recurring themes so far: alpha.',
187 - '- Themes in rotation: alpha.',
189 ]:
189 - self.assertIn(expected, second_monthly if 'Recurring themes' in expected else second_yearly if 'Themes in rotation' in expected else second_monthly)
190 + self.assertIn(expected, second_monthly)
191 self.assertIn('- Recurring themes so far: alpha, beta.', second_monthly)
191 - self.assertIn('- Themes in rotation: alpha, beta.', second_yearly)
192 - for expected in [
193 - '### May 2026 update — 2026-W21',
194 - '- [May 2026](/monthly/2026/05/) gained a new weekly signal via [Week 21, 2026](/weekly/2026/W21/).',
195 - '- Featured repo: [octo/signal-kit](https://github.com/octo/signal-kit).',
196 - '- Working takeaway: The strongest projects made automation safer to adopt.',
197 - ]:
198 - self.assertIn(expected, second_yearly)
192 + self.assertIn('format: "narrative"', second_yearly)
193 + self.assertIn('## Narrative', second_yearly)
194 + self.assertIn('Observability and release safety gained more traction.', second_yearly)
195 + self.assertIn('## Arc', second_yearly)
196 self.assertEqual(second_monthly.count('### Week 2026-W21'), 4)
197 self.assertEqual(second_monthly.count('### Week 2026-W22'), 4)
201 - self.assertEqual(second_yearly.count('### May 2026 update — 2026-W21'), 5)
202 - self.assertEqual(second_yearly.count('### May 2026 update — 2026-W22'), 5)
198 + self.assertEqual(second_yearly.count('## Narrative'), 1)
199 + self.assertEqual(second_yearly.count('## Arc'), 1)
200 self.assertNotEqual(first_monthly, second_monthly)
201 self.assertNotEqual(first_yearly, second_yearly)
202
203 + def test_generate_yearly_narrative_standalone_writes_narrative_format(self) -> None:
204 + with temporary_workspace() as tmpdir:
205 + base = Path(tmpdir)
206 + content_root = base / "content"
207 + monthly_dir = content_root / "monthly" / "2026"
208 + monthly_dir.mkdir(parents=True)
209 +
210 + (monthly_dir / "05.md").write_text(
211 + """---
212 +title: "May 2026 Rollup"
213 +date: "2026-05-25T11:56:08+00:00"
214 +month: 5
215 +year: 2026
216 +categories: ["monthly"]
217 +weeks_covered: ["2026-W21", "2026-W22"]
218 +total_repos_featured: 32
219 +---
220 +
221 +## Month Overview
222 +
223 +### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)
224 +- Summary: May defined the shift from maturing agent infrastructure toward a visible agent skills economy.
225 +- Repositories featured this week: 17
226 +- Recurring themes so far: agent-skills, mcp, small-models.
227 +
228 +## Trends Observed
229 +
230 +### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)
231 +- Signal: Agent skills kept widening as a distribution format.
232 +- Noise: Coordinated star-farming distorted discovery.
233 +
234 +## Key Takeaways
235 +
236 +### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)
237 +- Gap to watch: Agent execution security remained underbuilt.
238 +- Closing read: Skills were likely to spread into more teams.
239 +""",
240 + encoding="utf-8",
241 + )
242 + (monthly_dir / "06.md").write_text(
243 + """---
244 +title: "June 2026 Rollup"
245 +date: "2026-06-08T12:40:47+00:00"
246 +month: 6
247 +year: 2026
248 +categories: ["monthly"]
249 +weeks_covered: ["2026-W23", "2026-W24"]
250 +total_repos_featured: 36
251 +---
252 +
253 +## Month Overview
254 +
255 +### Week 2026-W23 — [Week 23, 2026](/weekly/2026/W23/)
256 +- Summary: June pushed agent skills into East Asian workflows, self-hosted AI workspaces, and role-specific verticalization.
257 +- Repositories featured this week: 36
258 +- Recurring themes so far: agent-skills, self-hosted-ai, coding-agents.
259 +
260 +## Trends Observed
261 +
262 +### Week 2026-W23 — [Week 23, 2026](/weekly/2026/W23/)
263 +- Signal: Agent skills globalized quickly while local-sovereignty tooling gained traction.
264 +- Noise: Fork inflation replaced the earlier star-farming wave.
265 +
266 +## Key Takeaways
267 +
268 +### Week 2026-W23 — [Week 23, 2026](/weekly/2026/W23/)
269 +- Gap to watch: Prompt-injection and skills supply-chain security still lacked a category winner.
270 +- Closing read: Expect more vertical skills packs and more local-first AI tooling.
271 +""",
272 + encoding="utf-8",
273 + )
274 +
275 + written = generate_yearly_narrative.generate_yearly_narratives(content_root)
276 + yearly_path = content_root / "yearly" / "2026.md"
277 +
278 + self.assertEqual(written, [yearly_path])
279 + yearly = yearly_path.read_text(encoding="utf-8")
280 + self.assertIn('title: "2026 Yearly Narrative"', yearly)
281 + self.assertIn('format: "narrative"', yearly)
282 + self.assertIn('## Narrative', yearly)
283 + self.assertIn('split-screen story', yearly)
284 + self.assertIn('globalize', yearly)
285 + self.assertIn('## Arc', yearly)
286 + self.assertIn('agent-skills: infrastructure > economy > globalization > verticalization', yearly)
287 + self.assertIn('platform-gaming: star-farming > fork-inflation', yearly)
288 +
289 def test_generate_rollups_replaces_placeholder_and_preserves_unknown_sections(self) -> None:
290 with temporary_workspace() as tmpdir:
291 base = Path(tmpdir)