Rework yearly report into a monthly year-in-review (#493)
* Rework yearly report synthesis Closes #485 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Copilot review comments for yearly rework Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: jmservera <jmservera@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 15, 2026 at 19:24 UTC
18ac19482a9f181b2a4924828e03bdbd8591a5c4
12 files changed
+427
-246
.github/workflows/podcaster-handoff-smoke.yml
+14
-6
@@ -71,11 +71,19 @@ jobs:
71
article = Path(article_path)
72
if not article.is_file():
73
raise SystemExit(f"article_path does not exist: {article_path}")
74
+ article_bytes = article.read_bytes()
75
+ actual_article_sha = hashlib.sha256(article_bytes).hexdigest()
76
if article_sha:
77
if len(article_sha) != 64 or article_sha.lower() != article_sha or any(ch not in "0123456789abcdef" for ch in article_sha):
78
raise SystemExit("article_sha256 must be lowercase 64-character hex when provided.")
79
+ if article_sha != actual_article_sha:
80
+ raise SystemExit("article_sha256 must match ARTICLE_PATH contents when provided.")
81
else:
78
- article_sha = hashlib.sha256(article.read_bytes()).hexdigest()
82
+ article_sha = actual_article_sha
83
+ raw_payload = {"week": week, "source": "github", "article_path": article_path}
84
+ raw_bytes = (json.dumps(raw_payload, sort_keys=True) + "\n").encode("utf-8")
85
+ raw_sha = hashlib.sha256(raw_bytes).hexdigest()
86
+ article_size = len(article_bytes)
87
88
def digest(label: str) -> str:
89
return hashlib.sha256(f"{week}:{label}".encode("utf-8")).hexdigest()
@@ -91,16 +99,16 @@ jobs:
99
"role": "raw_github",
100
"path": f"data/raw/{week}.json",
101
"name": f"{week}-raw-github",
94
- "sha256": digest("raw"),
102
+ "sha256": raw_sha,
103
"generated_at": "2026-06-08T10:15:00Z",
104
"crawled_at": "2026-06-08T10:12:00Z",
105
"source_status": "fresh",
106
"exists": True,
99
- "size_bytes": article.stat().st_size,
107
+ "size_bytes": len(raw_bytes),
108
"freshness": {"status": "fresh", "reasons": []},
109
"provenance": {
110
"path": f"data/raw/{week}.json",
103
- "sha256": digest("raw"),
111
+ "sha256": raw_sha,
112
},
113
"same_day_reuse": {"status": "reused", "source": "smoke"},
114
"sources_requested": ["github"],
@@ -111,10 +119,10 @@ jobs:
119
"role": "published_summary",
120
"path": article_path,
121
"href": f"https://example.com/{week}/source-index.json",
114
- "sha256": digest("published-summary"),
122
+ "sha256": article_sha,
123
"generated_at": "2026-06-08T11:20:00Z",
124
"exists": True,
117
- "size_bytes": article.stat().st_size,
125
+ "size_bytes": article_size,
126
"provenance": {
127
"path": article_path,
128
"sha256": article_sha,
content/yearly/2026.md
+4
-4
@@ -9,10 +9,10 @@ format: "narrative"
9
10
## Year in Review
11
12
-2026 has mainly been the year agent tooling stopped looking experimental and started behaving like infrastructure. The ecosystem moved faster on capability than on trust. From May through June, the important change was not a parade of isolated repositories but the way a few categories kept hardening: agent skills as a real distribution layer, local and self-hosted execution as a durable buyer priority, and agent security as the main unresolved infrastructure gap. The year so far reads less like a sequence of weekly surprises and more like an ecosystem choosing its operating model.
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. From May through June, the important change was not a parade of isolated repositories but the way a few categories kept hardening: agent skills as a real distribution layer, local and self-hosted execution as a durable buyer priority, and agent security as the main unresolved infrastructure gap. The year so far reads less like a sequence of weekly surprises and more like an ecosystem choosing its operating model.
13
14
-The monthly progression is clear: May set the initial tone when May 2026 reads less like three isolated weekly spikes and more like one continuous adjustment in priorities. The month opened with Week 21 shows real demand for agent infrastructure, but the trend data still lacks the baseline needed to separate…; June pushed the story further when June 2026 reads less like three isolated weekly spikes and more like one continuous adjustment in priorities. The month opened with Week 23 amplifies two W22 trends — agent memory infrastructure and skills verticalization — while a suspicious 56k-star self-hosted…. Taken together, those shifts show a market moving from experimentation toward packaging, distribution, and operating discipline. Even when the surface story changes from one month to the next, the deeper motion is cumulative rather than episodic.
14
+The monthly progression is clear: May set the initial tone when 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; June pushed the story further when 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. Taken together, those shifts show a market moving from experimentation toward packaging, distribution, and operating discipline. Even when the surface story changes from one month to the next, the deeper motion is cumulative rather than episodic.
15
16
-The category that hardened fastest was agent skills: what began as infrastructure and workflow plumbing started behaving like a market, then spread into more specific geographies, languages, and job-shaped use cases. Self-hosted and local-first tooling also matured from a cost or billing workaround into a control story about sovereignty, reliability, and execution on hardware teams already own. The prediction that capability would outrun trust was confirmed every month, because nothing in the visible tooling stack closed the gaps around agent isolation, prompt injection defense, or skills supply-chain auditing.
16
+The category that hardened fastest was agent skills: what began as infrastructure and workflow plumbing started behaving like a market, then spread into more specific geographies, languages, and job-shaped use cases. Self-hosted and local-first tooling also matured from a cost or billing workaround into a control story about sovereignty, reliability, and execution on hardware teams already own. The pattern that mutated instead of fading was platform gaming: the noise never really disappeared, it simply changed tactics from star-farming to fork inflation and then into more industrialized spam, fraud, and activator-style clutter. The prediction that capability would outrun trust was confirmed every month, because nothing in the visible tooling stack closed the gaps around agent isolation, prompt injection defense, or skills supply-chain auditing.
17
18
-What was confirmed: skills did globalize, skills also verticalized quickly, local and self-hosted AI kept becoming a category rather than a workaround, and the trust and security gap remained open. What weakened: the idea that trust tooling would catch up on its own and the simpler thesis that one general-purpose agent workflow would dominate everything. That leaves the main story of the year intact: builders are getting more serious about packaging and operating agents, while the trust, filtering, and governance layers remain conspicuously behind.
18
+What was confirmed: 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, and the trust and security gap remained open. What weakened: the hope that GitHub discovery noise would self-correct, the idea that trust tooling would catch up on its own, and the simpler thesis that one general-purpose agent workflow would dominate everything. That leaves the main story of the year intact: builders are getting more serious about packaging and operating agents, while the trust, filtering, and governance layers remain conspicuously behind.
scripts/analyze_fallback.py
+2
-13
@@ -16,10 +16,12 @@ from urllib import error, parse, request
16
17
try:
18
from scripts.assemble_historical_context import DEFAULT_CONTENT_ROOT, assemble_historical_context
19
+ from scripts.learned_context import render_continuity
20
from scripts.sanitize_repo_content import sanitize_repo_payload
21
except ModuleNotFoundError: # pragma: no cover - script execution path
22
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
23
from scripts.assemble_historical_context import DEFAULT_CONTENT_ROOT, assemble_historical_context
24
+ from scripts.learned_context import render_continuity
25
from scripts.sanitize_repo_content import sanitize_repo_payload
26
27
ROOT = Path(__file__).resolve().parent.parent
@@ -687,19 +689,6 @@ def render_skills(skills_dir: Path) -> str:
689
return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._"
690
691
690
-def render_continuity(continuity_file: Path) -> str:
691
- if not continuity_file.exists():
692
- return "_No learned continuity capsule has been recorded yet._"
693
-
694
- content = continuity_file.read_text(encoding="utf-8").strip()
695
- if not content:
696
- return "_No learned continuity capsule has been recorded yet._"
697
-
698
- from scripts.sanitize_repo_content import _escape_untrusted_boundaries
699
-
700
- return _escape_untrusted_boundaries(content)
701
-
702
-
692
def _sort_repos_for_compaction(repos: list[dict[str, Any]], score_key: str) -> list[dict[str, Any]]:
693
return sorted(
694
repos,
scripts/generate_rollups.py
+22
-74
@@ -13,7 +13,6 @@ 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
-import scripts.month_synthesis as month_synthesis
16
from scripts.generate_yearly_narrative import build_yearly_narrative_pages
17
18
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -21,19 +20,12 @@ SUMMARY_SUFFIX = "-summary.md"
20
WEEK_PATTERN = re.compile(r"^(?P<year>\d{4})-W(?P<week>\d{2})$")
21
REPO_LINK_PATTERN = re.compile(r"https://github\.com/(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)")
22
NO_UPDATES_PLACEHOLDER = "_No updates yet._"
24
-LEGACY_MONTHLY_SECTIONS = [
23
+MONTHLY_SECTIONS = [
24
"Month Overview",
25
"Top Repos This Month",
26
"Trends Observed",
27
"Key Takeaways",
28
]
30
-MONTHLY_SYNTHESIS_SECTIONS = [
31
- "Month Synthesis",
32
- "Trend Arc",
33
- "Prediction Review",
34
- "Weekly Reports",
35
-]
36
-MONTHLY_SECTIONS = LEGACY_MONTHLY_SECTIONS
29
YEARLY_SECTIONS = [
30
"Year in Review",
31
]
@@ -119,7 +111,6 @@ class RollupPage:
111
section_order: list[str]
112
replace_existing_sections: bool = False
113
preserve_unknown_sections: bool = True
122
- ignored_existing_sections: tuple[str, ...] = ()
114
115
116
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -322,27 +313,7 @@ def monthly_entries(weekly: WeeklySummary, tags_counter: Counter[str]) -> dict[s
313
}
314
315
325
-def build_legacy_monthly_sections(items: list[WeeklySummary]) -> dict[str, list[RollupEntry]]:
326
- tags_counter: Counter[str] = Counter()
327
- page_entries: dict[str, list[RollupEntry]] = {section: [] for section in LEGACY_MONTHLY_SECTIONS}
328
- for item in items:
329
- tags_counter.update(item.tags)
330
- for section, entry in monthly_entries(item, tags_counter).items():
331
- page_entries[section].append(entry)
332
- return page_entries
333
-
334
-
335
-def build_synthesized_monthly_sections(synthesis: month_synthesis.MonthSynthesis) -> dict[str, list[RollupEntry]]:
336
- marker = f"{synthesis.month_slug}-month-synthesis"
337
- return {
338
- "Month Synthesis": [RollupEntry(marker=marker, text=synthesis.narrative)],
339
- "Trend Arc": [RollupEntry(marker=f"{marker}-arc", text=synthesis.trend_arc)],
340
- "Prediction Review": [RollupEntry(marker=f"{marker}-prediction", text=synthesis.prediction_review)],
341
- "Weekly Reports": [RollupEntry(marker=f"{marker}-weekly", text="\n".join(synthesis.weekly_reports))],
342
- }
343
-
344
-
345
-def build_monthly_pages(summaries: list[WeeklySummary], analyzed_dir: Path, content_root: Path) -> list[RollupPage]:
316
+def build_monthly_pages(summaries: list[WeeklySummary], content_root: Path) -> list[RollupPage]:
317
grouped: dict[tuple[int, int], list[WeeklySummary]] = defaultdict(list)
318
for summary in summaries:
319
grouped[(summary.year, summary.month)].append(summary)
@@ -350,47 +321,26 @@ def build_monthly_pages(summaries: list[WeeklySummary], analyzed_dir: Path, cont
321
pages: list[RollupPage] = []
322
for (year, month), items in sorted(grouped.items()):
323
items = sorted(items, key=lambda item: (item.date, item.week))
353
- frontmatter = {
354
- "title": f"{MONTH_NAMES[month]} {year} Rollup",
355
- "date": items[-1].date.isoformat(),
356
- "month": month,
357
- "year": year,
358
- "categories": ["monthly"],
359
- "weeks_covered": [item.week for item in items],
360
- "total_repos_featured": len({repo for item in items for repo in item.featured_repos}),
361
- }
362
- try:
363
- synthesis = month_synthesis.ensure_month_synthesis(items, analyzed_dir)
364
- frontmatter.update(
365
- {
366
- "summary": synthesis.summary,
367
- "synthesis_status": synthesis.status,
368
- "synthesis_weeks": list(synthesis.weeks_covered),
369
- "themes": list(synthesis.themes),
370
- "persistent_themes": list(synthesis.persistent_themes),
371
- "accelerating_themes": list(synthesis.accelerating_themes),
372
- "weakening_themes": list(synthesis.weakening_themes),
373
- "key_gaps": list(synthesis.key_gaps),
374
- "top_repos": list(synthesis.top_repos),
375
- "source_checksum": synthesis.source_checksum,
376
- }
377
- )
378
- sections = build_synthesized_monthly_sections(synthesis)
379
- section_order = MONTHLY_SYNTHESIS_SECTIONS
380
- ignored_existing_sections = tuple(LEGACY_MONTHLY_SECTIONS)
381
- except Exception:
382
- frontmatter["synthesis_status"] = "fallback"
383
- sections = build_legacy_monthly_sections(items)
384
- section_order = LEGACY_MONTHLY_SECTIONS
385
- ignored_existing_sections = tuple(MONTHLY_SYNTHESIS_SECTIONS)
324
+ tags_counter: Counter[str] = Counter()
325
+ page_entries: dict[str, list[RollupEntry]] = {section: [] for section in MONTHLY_SECTIONS}
326
+ for item in items:
327
+ tags_counter.update(item.tags)
328
+ for section, entry in monthly_entries(item, tags_counter).items():
329
+ page_entries[section].append(entry)
330
pages.append(
331
RollupPage(
332
path=content_root / "monthly" / str(year) / f"{month:02d}.md",
389
- frontmatter=frontmatter,
390
- sections=sections,
391
- section_order=section_order,
392
- replace_existing_sections=True,
393
- ignored_existing_sections=ignored_existing_sections,
333
+ frontmatter={
334
+ "title": f"{MONTH_NAMES[month]} {year} Rollup",
335
+ "date": items[-1].date.isoformat(),
336
+ "month": month,
337
+ "year": year,
338
+ "categories": ["monthly"],
339
+ "weeks_covered": [item.week for item in items],
340
+ "total_repos_featured": len({repo for item in items for repo in item.featured_repos}),
341
+ },
342
+ sections=page_entries,
343
+ section_order=MONTHLY_SECTIONS,
344
)
345
)
346
return pages
@@ -405,7 +355,7 @@ def build_yearly_pages(summaries: list[WeeklySummary], content_root: Path) -> li
355
path=page.path,
356
frontmatter=page.frontmatter,
357
sections={
408
- "Year in Review": [RollupEntry(marker=f"{page.year}-review", text=page.narrative)],
358
+ "Year in Review": [RollupEntry(marker=f"{page.year}-year-in-review", text=page.narrative)],
359
},
360
section_order=YEARLY_SECTIONS,
361
replace_existing_sections=True,
@@ -422,7 +372,6 @@ def merge_sections(
372
*,
373
replace_existing_sections: bool = False,
374
preserve_unknown_sections: bool = True,
425
- ignored_existing_sections: tuple[str, ...] = (),
375
) -> str:
376
intro = ""
377
existing_sections: dict[str, str] = {}
@@ -448,7 +397,7 @@ def merge_sections(
397
398
if preserve_unknown_sections:
399
for section, content in existing_sections.items():
451
- if section in section_order or section in ignored_existing_sections:
400
+ if section in section_order:
401
continue
402
section_body = content.strip() or NO_UPDATES_PLACEHOLDER
403
rendered_sections.append(f"## {section}\n\n{section_body}")
@@ -466,7 +415,6 @@ def write_rollup(page: RollupPage) -> None:
415
page.sections,
416
replace_existing_sections=page.replace_existing_sections,
417
preserve_unknown_sections=page.preserve_unknown_sections,
469
- ignored_existing_sections=page.ignored_existing_sections,
418
)
419
page.path.write_text(render_frontmatter(page.frontmatter) + body, encoding="utf-8")
420
@@ -477,7 +425,7 @@ def generate_rollups(analyzed_dir: Path, content_root: Path) -> list[Path]:
425
return []
426
427
written: list[Path] = []
480
- monthly_pages = build_monthly_pages(summaries, analyzed_dir, content_root)
428
+ monthly_pages = build_monthly_pages(summaries, content_root)
429
for page in monthly_pages:
430
write_rollup(page)
431
written.append(page.path)
scripts/generate_yearly_narrative.py
+226
-90
@@ -19,6 +19,7 @@ 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
+HEADING_LINE_PATTERN = re.compile(r"(?m)^#+\s+")
23
24
MONTH_NAMES = {
25
1: "January",
@@ -80,7 +81,7 @@ class MonthSnapshot:
81
noise: tuple[str, ...]
82
gaps: tuple[str, ...]
83
closing_reads: tuple[str, ...]
83
- synthesis_narrative: str = ""
84
+ synthesis_paragraphs: tuple[str, ...] = ()
85
86
@property
87
def month_name(self) -> str:
@@ -99,16 +100,35 @@ class MonthSnapshot:
100
return " ".join(
101
[
102
self.title,
103
+ *self.synthesis_paragraphs,
104
*self.summaries,
105
*self.themes,
106
*self.signals,
107
*self.noise,
108
*self.gaps,
109
*self.closing_reads,
108
- self.synthesis_narrative,
110
]
111
)
112
113
+ @property
114
+ def yearly_source_paragraphs(self) -> tuple[str, ...]:
115
+ if self.synthesis_paragraphs:
116
+ return self.synthesis_paragraphs
117
+ paragraphs = [
118
+ sentence
119
+ for sentence in (
120
+ self.summaries[-1] if self.summaries else "",
121
+ self.signals[-1] if self.signals else "",
122
+ self.gaps[-1] if self.gaps else "",
123
+ self.closing_reads[-1] if self.closing_reads else "",
124
+ )
125
+ if sentence
126
+ ]
127
+ if paragraphs:
128
+ return tuple(paragraphs)
129
+ fallback = strip_markdown(self.text_blob)
130
+ return (fallback,) if fallback else ()
131
+
132
133
@dataclass(frozen=True)
134
class YearlyNarrativePage:
@@ -116,7 +136,6 @@ class YearlyNarrativePage:
136
path: Path
137
frontmatter: dict[str, Any]
138
narrative: str
119
- arc_lines: tuple[str, ...]
139
140
141
@dataclass(frozen=True)
@@ -237,6 +256,29 @@ def split_sections(body: str) -> dict[str, str]:
256
return sections
257
258
259
+def extract_prose_paragraphs(body: str) -> tuple[str, ...]:
260
+ paragraphs: list[str] = []
261
+ for block in re.split(r"\n\s*\n", body.strip()):
262
+ lines = [line.rstrip() for line in block.splitlines() if line.strip()]
263
+ if not lines:
264
+ continue
265
+ if all(line.lstrip().startswith("#") for line in lines):
266
+ continue
267
+ cleaned_lines: list[str] = []
268
+ for line in lines:
269
+ stripped = line.strip()
270
+ if stripped.startswith("#"):
271
+ stripped = HEADING_LINE_PATTERN.sub("", stripped)
272
+ stripped = re.sub(r"^[-*]\s+", "", stripped)
273
+ stripped = re.sub(r"^\d+\.\s+", "", stripped)
274
+ if stripped:
275
+ cleaned_lines.append(stripped)
276
+ paragraph = strip_markdown(" ".join(cleaned_lines))
277
+ if paragraph and paragraph != "_No updates yet._":
278
+ paragraphs.append(paragraph)
279
+ return tuple(paragraphs)
280
+
281
+
282
def extract_labeled_values(section_body: str, label: str) -> list[str]:
283
values: list[str] = []
284
for block in WEEK_BLOCK_PATTERN.finditer(section_body):
@@ -261,30 +303,63 @@ def dedupe_preserving_order(values: Iterable[str]) -> list[str]:
303
return result
304
305
264
-def month_synthesis_path(content_root: Path, year: int, month: int) -> Path:
265
- return content_root.parent / "data" / "analyzed" / f"{year}-{month:02d}-month-synthesis.md"
306
+def frontmatter_list(frontmatter: dict[str, Any], key: str) -> list[str]:
307
+ raw = frontmatter.get(key, [])
308
+ if isinstance(raw, list):
309
+ return [str(item) for item in raw if str(item).strip()]
310
+ return []
311
312
268
-def extract_month_synthesis_narrative(content_root: Path, year: int, month: int) -> str:
269
- synthesis_path = month_synthesis_path(content_root, year, month)
270
- if not synthesis_path.is_file():
271
- return ""
272
- _, body = analysis_gate.extract_frontmatter(synthesis_path.read_text(encoding="utf-8"))
273
- return split_sections(body).get("Month Synthesis", "").strip()
274
-
275
-
276
-def load_month_snapshot(path: Path, content_root: Path) -> MonthSnapshot:
313
+def load_month_snapshot(path: Path) -> MonthSnapshot:
314
frontmatter, body = analysis_gate.extract_frontmatter(path.read_text(encoding="utf-8"))
315
sections = split_sections(body)
316
+ month_synthesis = strip_markdown(sections.get("Month Synthesis", ""))
317
+ if month_synthesis:
318
+ summaries = dedupe_preserving_order(
319
+ [str(frontmatter.get("summary", "")).strip(), month_synthesis]
320
+ )
321
+ themes = dedupe_preserving_order(
322
+ frontmatter_list(frontmatter, "themes")
323
+ + frontmatter_list(frontmatter, "persistent_themes")
324
+ + frontmatter_list(frontmatter, "accelerating_themes")
325
+ + frontmatter_list(frontmatter, "weakening_themes")
326
+ )
327
+ signals = dedupe_preserving_order(
328
+ [strip_markdown(sections.get("Trend Arc", ""))]
329
+ + [theme.replace("-", " ") for theme in frontmatter_list(frontmatter, "accelerating_themes")]
330
+ + [theme.replace("-", " ") for theme in frontmatter_list(frontmatter, "persistent_themes")]
331
+ )
332
+ noise = tuple(theme.replace("-", " ") for theme in frontmatter_list(frontmatter, "weakening_themes"))
333
+ gaps = tuple(frontmatter_list(frontmatter, "key_gaps"))
334
+ closing_reads = tuple(
335
+ value
336
+ for value in [
337
+ strip_markdown(sections.get("Prediction Review", "")),
338
+ str(frontmatter.get("summary", "")).strip(),
339
+ ]
340
+ if value
341
+ )
342
+ return MonthSnapshot(
343
+ path=path,
344
+ year=int(frontmatter["year"]),
345
+ month=int(frontmatter["month"]),
346
+ title=str(frontmatter.get("title", path.stem)),
347
+ date=str(frontmatter["date"]),
348
+ summaries=tuple(summaries),
349
+ themes=tuple(themes),
350
+ signals=tuple(signals),
351
+ noise=noise,
352
+ gaps=gaps,
353
+ closing_reads=closing_reads,
354
+ )
355
+
356
themes: list[str] = []
357
for raw in extract_labeled_values(sections.get("Month Overview", ""), "Recurring themes so far"):
358
themes.extend(part.strip() for part in raw.rstrip(".").split(",") if part.strip())
282
- year = int(frontmatter["year"])
283
- month = int(frontmatter["month"])
359
return MonthSnapshot(
360
path=path,
286
- year=year,
287
- month=month,
361
+ year=int(frontmatter["year"]),
362
+ month=int(frontmatter["month"]),
363
title=str(frontmatter.get("title", path.stem)),
364
date=str(frontmatter["date"]),
365
summaries=tuple(extract_labeled_values(sections.get("Month Overview", ""), "Summary")),
@@ -293,7 +368,43 @@ def load_month_snapshot(path: Path, content_root: Path) -> MonthSnapshot:
368
noise=tuple(extract_labeled_values(sections.get("Trends Observed", ""), "Noise")),
369
gaps=tuple(extract_labeled_values(sections.get("Key Takeaways", ""), "Gap to watch")),
370
closing_reads=tuple(extract_labeled_values(sections.get("Key Takeaways", ""), "Closing read")),
296
- synthesis_narrative=extract_month_synthesis_narrative(content_root, year, month),
371
+ )
372
+
373
+
374
+def analyzed_dir_for(content_root: Path) -> Path:
375
+ return content_root.parent / "data" / "analyzed"
376
+
377
+
378
+def load_month_synthesis_paragraphs(path: Path) -> tuple[str, ...]:
379
+ text = path.read_text(encoding="utf-8")
380
+ try:
381
+ _, body = analysis_gate.extract_frontmatter(text)
382
+ except ValueError:
383
+ body = text
384
+ return extract_prose_paragraphs(body)
385
+
386
+
387
+def load_month_snapshot_with_preference(path: Path, content_root: Path) -> MonthSnapshot:
388
+ snapshot = load_month_snapshot(path)
389
+ synthesis_path = analyzed_dir_for(content_root) / f"{snapshot.year}-{snapshot.month:02d}-month-synthesis.md"
390
+ if not synthesis_path.is_file():
391
+ return snapshot
392
+ synthesis_paragraphs = load_month_synthesis_paragraphs(synthesis_path)
393
+ if not synthesis_paragraphs:
394
+ return snapshot
395
+ return MonthSnapshot(
396
+ path=snapshot.path,
397
+ year=snapshot.year,
398
+ month=snapshot.month,
399
+ title=snapshot.title,
400
+ date=snapshot.date,
401
+ summaries=snapshot.summaries,
402
+ themes=snapshot.themes,
403
+ signals=snapshot.signals,
404
+ noise=snapshot.noise,
405
+ gaps=snapshot.gaps,
406
+ closing_reads=snapshot.closing_reads,
407
+ synthesis_paragraphs=synthesis_paragraphs,
408
)
409
410
@@ -304,7 +415,7 @@ def load_month_snapshots(content_root: Path, years: Iterable[int] | None = None)
415
paths.extend(sorted((content_root / "monthly" / str(year)).glob("*.md")))
416
else:
417
paths = sorted((content_root / "monthly").glob("*/*.md"))
307
- snapshots = [load_month_snapshot(path, content_root) for path in paths if path.is_file()]
418
+ snapshots = [load_month_snapshot_with_preference(path, content_root) for path in paths if path.is_file()]
419
return sorted(snapshots, key=lambda item: (item.year, item.month))
420
421
@@ -327,6 +438,17 @@ def compress_phrase(text: str, limit: int = 24) -> str:
438
return trim_words(cleaned, limit)
439
440
441
+def join_phrases(parts: Iterable[str], *, conjunction: str = "and") -> str:
442
+ items = [part.strip() for part in parts if part and part.strip()]
443
+ if not items:
444
+ return ""
445
+ if len(items) == 1:
446
+ return items[0]
447
+ if len(items) == 2:
448
+ return f"{items[0]} {conjunction} {items[1]}"
449
+ return f"{', '.join(items[:-1])}, {conjunction} {items[-1]}"
450
+
451
+
452
def keyword_score(text: str, keywords: Iterable[str]) -> int:
453
lowered = text.lower()
454
return sum(1 for keyword in keywords if keyword in lowered)
@@ -369,6 +491,8 @@ def build_theme_sentence(year: int, arcs: dict[str, list[str]]) -> str:
491
492
493
def summarize_month(month: MonthSnapshot) -> str:
494
+ if month.synthesis_paragraphs:
495
+ return trim_words(strip_markdown(month.synthesis_paragraphs[0]), 40)
496
month_arcs = {family.key: detect_family_arc([month], family) for family in TREND_FAMILIES}
497
parts: list[str] = []
498
@@ -400,61 +524,89 @@ def summarize_month(month: MonthSnapshot) -> str:
524
if parts:
525
return "; ".join(parts[:-1]) + ("" if len(parts) < 2 else "; ") + parts[-1] if len(parts) > 1 else parts[0]
526
403
- return trim_words(strip_markdown(month.summaries[-1] if month.summaries else month.text_blob), 32)
527
+ source = month.yearly_source_paragraphs[0] if month.yearly_source_paragraphs else month.text_blob
528
+ return trim_words(strip_markdown(source), 32)
529
+
530
531
+def build_month_bridge(month: MonthSnapshot, position: int, total: int) -> str:
532
+ if total == 1:
533
+ return f"{month.month_name} supplied the year's opening evidence"
534
+ if position == 0:
535
+ return f"{month.month_name} set the initial tone"
536
+ if position == total - 1:
537
+ return f"{month.month_name} pushed the story further"
538
+ return f"in {month.month_name}"
539
406
-def month_yearly_excerpt(month: MonthSnapshot, limit: int = 48) -> str:
407
- if month.synthesis_narrative:
408
- return trim_words(strip_markdown(month.synthesis_narrative), limit).rstrip(".")
409
- fallback = month.summaries[-1] if month.summaries else month.text_blob
410
- return trim_words(strip_markdown(fallback), limit).rstrip(".")
540
+
541
+def build_opening_paragraph(months: list[MonthSnapshot], arcs: dict[str, list[str]]) -> str:
542
+ opening = build_theme_sentence(months[0].year, arcs)
543
+ span = months[0].month_name if len(months) == 1 else f"From {months[0].month_name} through {months[-1].month_name}"
544
+ durable_categories: list[str] = []
545
+ if arcs.get("agent-skills"):
546
+ durable_categories.append("agent skills as a real distribution layer")
547
+ if arcs.get("self-hosted-ai"):
548
+ durable_categories.append("local and self-hosted execution as a durable buyer priority")
549
+ if arcs.get("security-gap"):
550
+ durable_categories.append("agent security as the main unresolved infrastructure gap")
551
+ if not durable_categories:
552
+ durable_categories.append("workflow-level shifts rather than one-off launches")
553
+ return (
554
+ f"{opening} {span}, the important change was not a parade of isolated repositories but the way a few categories kept hardening: "
555
+ f"{join_phrases(durable_categories)}. The year so far reads less like a sequence of weekly surprises and more like an ecosystem choosing its operating model."
556
+ )
557
558
413
-def build_month_story(months: list[MonthSnapshot]) -> str:
559
+def build_evolution_paragraph(months: list[MonthSnapshot]) -> str:
560
if not months:
561
return ""
416
- if len(months) == 1:
417
- return f"The monthly progression is already visible in {months[0].month_name}: {month_yearly_excerpt(months[0])}."
418
-
419
- clauses: list[str] = []
420
- for index, month in enumerate(months):
421
- excerpt = month_yearly_excerpt(month)
422
- if index == 0:
423
- lead = "set the initial tone"
424
- elif index == len(months) - 1:
425
- lead = "pushed the story further"
426
- else:
427
- lead = "carried the story forward"
428
- clauses.append(f"{month.month_name} {lead} when {excerpt}")
562
+ fragments = [
563
+ f"{build_month_bridge(month, index, len(months))} when {summarize_month(month).rstrip('.')}"
564
+ for index, month in enumerate(months)
565
+ ]
566
+ if len(fragments) == 1:
567
+ body = fragments[0]
568
+ else:
569
+ body = "; ".join(fragments[:-1]) + f"; {fragments[-1]}"
570
return (
430
- "The monthly progression is clear: "
431
- + "; ".join(clauses)
432
- + ". Taken together, those shifts show a market moving from experimentation toward packaging, distribution, and operating discipline. "
433
- + "Even when the surface story changes from one month to the next, the deeper motion is cumulative rather than episodic."
571
+ f"The monthly progression is clear: {body}. Taken together, those shifts show a market moving from experimentation toward packaging, distribution, and operating discipline. "
572
+ "Even when the surface story changes from one month to the next, the deeper motion is cumulative rather than episodic."
573
)
574
575
437
-def build_arc_commentary(arcs: dict[str, list[str]]) -> list[str]:
438
- commentary: list[str] = []
576
+def build_pattern_paragraph(arcs: dict[str, list[str]]) -> str:
577
+ sentences: list[str] = []
578
agent_arc = arcs.get("agent-skills", [])
579
if agent_arc:
441
- commentary.append(f"Agent skills moved through {' → '.join(agent_arc)}.")
442
- platform_arc = arcs.get("platform-gaming", [])
443
- if platform_arc:
444
- commentary.append(f"Platform gaming adapted through {' → '.join(platform_arc)} instead of disappearing.")
580
+ if "verticalization" in agent_arc or "globalization" in agent_arc:
581
+ sentences.append(
582
+ "The category that hardened fastest was agent skills: what began as infrastructure and workflow plumbing started behaving like a market, then spread into more specific geographies, languages, and job-shaped use cases."
583
+ )
584
+ else:
585
+ sentences.append(
586
+ "The clearest durable category was agent skills, which stopped looking like a novelty and started looking like shared infrastructure."
587
+ )
588
local_arc = arcs.get("self-hosted-ai", [])
589
if local_arc:
447
- commentary.append(f"Self-hosted AI evolved through {' → '.join(local_arc)} as builders chased more control over execution and cost.")
590
+ sentences.append(
591
+ "Self-hosted and local-first tooling also matured from a cost or billing workaround into a control story about sovereignty, reliability, and execution on hardware teams already own."
592
+ )
593
+ platform_arc = arcs.get("platform-gaming", [])
594
+ if platform_arc:
595
+ sentences.append(
596
+ "The pattern that mutated instead of fading was platform gaming: the noise never really disappeared, it simply changed tactics from star-farming to fork inflation and then into more industrialized spam, fraud, and activator-style clutter."
597
+ )
598
security_arc = arcs.get("security-gap", [])
599
if security_arc:
450
- 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.")
451
- return commentary
600
+ sentences.append(
601
+ "The prediction that capability would outrun trust was confirmed every month, because nothing in the visible tooling stack closed the gaps around agent isolation, prompt injection defense, or skills supply-chain auditing."
602
+ )
603
+ return " ".join(sentences)
604
605
606
def build_prediction_review(arcs: dict[str, list[str]]) -> str:
607
confirmations: list[str] = []
608
if "globalization" in arcs.get("agent-skills", []):
457
- confirmations.append("skills globalized")
609
+ confirmations.append("skills did globalize")
610
if "verticalization" in arcs.get("agent-skills", []):
611
confirmations.append("skills also verticalized quickly")
612
if len(arcs.get("platform-gaming", [])) >= 2:
@@ -463,22 +615,22 @@ def build_prediction_review(arcs: dict[str, list[str]]) -> str:
615
confirmations.append("local and self-hosted AI kept becoming a category rather than a workaround")
616
if arcs.get("security-gap"):
617
confirmations.append("the trust and security gap remained open")
466
- if not confirmations:
467
- return "What was confirmed: the biggest structural questions still look unresolved."
468
- joined = "; ".join(confirmations[:-1]) + ("" if len(confirmations) < 2 else "; ") + confirmations[-1] if len(confirmations) > 1 else confirmations[0]
469
- return f"What was confirmed: {joined}."
470
-
471
-
472
-def build_weakened_review(arcs: dict[str, list[str]]) -> str:
618
weakened: list[str] = []
619
+ if arcs.get("platform-gaming"):
620
+ weakened.append("the hope that GitHub discovery noise would self-correct")
621
if arcs.get("security-gap"):
622
weakened.append("the idea that trust tooling would catch up on its own")
476
- if len(arcs.get("agent-skills", [])) >= 2:
623
+ if "verticalization" in arcs.get("agent-skills", []):
624
weakened.append("the simpler thesis that one general-purpose agent workflow would dominate everything")
478
- if not weakened:
479
- weakened.append("the hope that one short-term spike would settle the year's story")
480
- joined = ", ".join(weakened[:-1]) + ("" if len(weakened) < 2 else ", and ") + weakened[-1] if len(weakened) > 1 else weakened[0]
481
- return f"What weakened: {joined}."
625
+ if not confirmations and not weakened:
626
+ return "The running predictions stayed directionally useful: the biggest structural questions still look unresolved."
627
+ sentences: list[str] = []
628
+ if confirmations:
629
+ sentences.append(f"What was confirmed: {join_phrases(confirmations)}.")
630
+ if weakened:
631
+ sentences.append(f"What weakened: {join_phrases(weakened)}.")
632
+ sentences.append("That leaves the main story of the year intact: builders are getting more serious about packaging and operating agents, while the trust, filtering, and governance layers remain conspicuously behind.")
633
+ return " ".join(sentences)
634
635
636
def compress_narrative(paragraphs: list[str], max_words: int = 500) -> str:
@@ -486,7 +638,7 @@ def compress_narrative(paragraphs: list[str], max_words: int = 500) -> str:
638
if word_count(text) <= max_words:
639
return text
640
compressed = text
489
- for limit in (460, 430, 400, 360):
641
+ for limit in (480, 460, 430, 400, 360):
642
words = compressed.split()
643
if len(words) <= max_words:
644
break
@@ -494,30 +646,15 @@ def compress_narrative(paragraphs: list[str], max_words: int = 500) -> str:
646
return compressed
647
648
497
-def build_arc_lines(months: list[MonthSnapshot]) -> tuple[str, ...]:
498
- arcs: list[str] = []
499
- for family in TREND_FAMILIES:
500
- stages = detect_family_arc(months, family)
501
- if stages:
502
- arcs.append(f"{family.label}: {' > '.join(stages)}")
503
- return tuple(arcs)
504
-
505
-
506
-def synthesize_year(months: list[MonthSnapshot]) -> tuple[str, tuple[str, ...]]:
649
+def synthesize_year(months: list[MonthSnapshot]) -> str:
650
arcs = {family.key: detect_family_arc(months, family) for family in TREND_FAMILIES}
651
paragraphs = [
509
- build_theme_sentence(months[0].year, arcs),
510
- build_month_story(months),
511
- " ".join(build_arc_commentary(arcs)),
512
- " ".join(
513
- [
514
- build_prediction_review(arcs),
515
- build_weakened_review(arcs),
516
- "That leaves the main story of the year intact: builders are getting more serious about packaging and operating agents, while the trust, filtering, and governance layers remain conspicuously behind.",
517
- ]
518
- ),
652
+ build_opening_paragraph(months, arcs),
653
+ build_evolution_paragraph(months),
654
+ build_pattern_paragraph(arcs),
655
+ build_prediction_review(arcs),
656
]
520
- return compress_narrative(paragraphs), build_arc_lines(months)
657
+ return compress_narrative(paragraphs)
658
659
660
def build_yearly_narrative_pages(content_root: Path, years: Iterable[int] | None = None) -> list[YearlyNarrativePage]:
@@ -528,7 +665,7 @@ def build_yearly_narrative_pages(content_root: Path, years: Iterable[int] | None
665
pages: list[YearlyNarrativePage] = []
666
for year, months in sorted(grouped.items()):
667
ordered = sorted(months, key=lambda item: item.month)
531
- narrative, arc_lines = synthesize_year(ordered)
668
+ narrative = synthesize_year(ordered)
669
pages.append(
670
YearlyNarrativePage(
671
year=year,
@@ -542,7 +679,6 @@ def build_yearly_narrative_pages(content_root: Path, years: Iterable[int] | None
679
"format": "narrative",
680
},
681
narrative=narrative,
545
- arc_lines=arc_lines,
682
)
683
)
684
return pages
scripts/learned_context.py
new
+17
@@ -0,0 +1,17 @@
1
+from __future__ import annotations
2
+
3
+from pathlib import Path
4
+
5
+NO_CONTINUITY_MESSAGE = "_No learned continuity capsule has been recorded yet._"
6
+
7
+
8
+def render_continuity(continuity_file: Path) -> str:
9
+ from scripts.sanitize_repo_content import _escape_untrusted_boundaries
10
+
11
+ if not continuity_file.exists():
12
+ return NO_CONTINUITY_MESSAGE
13
+
14
+ content = continuity_file.read_text(encoding="utf-8").strip()
15
+ if not content:
16
+ return NO_CONTINUITY_MESSAGE
17
+ return _escape_untrusted_boundaries(content)
scripts/podcaster_handoff.py
+9
-3
@@ -107,6 +107,12 @@ def _load_podcast_config(path: Path | None) -> dict[str, Any]:
107
108
109
def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, Any]]:
110
+ def _string_list(value: Any) -> list[str] | None:
111
+ if not isinstance(value, list):
112
+ return None
113
+ filtered = [item for item in value if isinstance(item, str) and item]
114
+ return filtered or None
115
+
116
refs: list[dict[str, Any]] = []
117
for artifact in manifest.get("source_artifacts", []):
118
if not isinstance(artifact, dict):
@@ -156,9 +162,9 @@ def _source_artifact_refs(manifest: dict[str, Any]) -> list[dict[str, Any]]:
162
if isinstance(value, dict):
163
ref[key] = value
164
for key in ("sources_requested", "sources_succeeded", "sources_failed"):
159
- value = artifact.get(key)
160
- if isinstance(value, list):
161
- ref[key] = value
165
+ filtered = _string_list(artifact.get(key))
166
+ if filtered is not None:
167
+ ref[key] = filtered
168
if ref:
169
refs.append(ref)
170
return refs
scripts/reskill.py
+22
-27
@@ -18,11 +18,13 @@ from scripts import track_quality
18
from scripts.analyze_fallback import DEFAULT_CONTINUITY_FILE, resolve_analysis_context_paths
19
from scripts.assemble_historical_context import (
20
DEFAULT_CONTENT_ROOT,
21
+ compress_to_budget,
22
extract_month_notes,
23
extract_yearly_narrative,
24
resolve_latest_monthly_path,
25
resolve_latest_yearly_path,
26
)
27
+from scripts.learned_context import render_continuity
28
from scripts.load_scorecard import render_scorecard_section
29
30
DEFAULT_PROMPT_TEMPLATE = ROOT / "prompts" / "reskill.md"
@@ -35,6 +37,8 @@ DEFAULT_MODELS_ENDPOINT = "https://models.github.ai/inference/chat/completions"
37
DEFAULT_MODELS_MODEL = "openai/gpt-4o"
38
DEFAULT_MODELS_TIMEOUT = 30
39
ALLOWED_MODELS_HOSTS: frozenset[str] = frozenset({"models.github.ai"})
40
+ARCHIVE_MONTHLY_MAX_WORDS = 200
41
+ARCHIVE_YEARLY_MAX_WORDS = 500
42
43
44
def validate_https_url(url: str, *, label: str, allowed_hosts: frozenset[str] | None = None) -> None:
@@ -170,18 +174,6 @@ def render_skills(skills_dir: Path) -> str:
174
return "\n\n".join(blocks) if blocks else "_No learned skills have been extracted yet._"
175
176
173
-def render_continuity(continuity_file: Path) -> str:
174
- from scripts.sanitize_repo_content import _escape_untrusted_boundaries
175
-
176
- if not continuity_file.exists():
177
- return "_No learned continuity capsule has been recorded yet._"
178
-
179
- content = continuity_file.read_text(encoding="utf-8").strip()
180
- if not content:
181
- return "_No learned continuity capsule has been recorded yet._"
182
- return _escape_untrusted_boundaries(content)
183
-
184
-
177
def find_recent_summaries(analyzed_dir: Path, limit: int) -> list[Path]:
178
summaries = sorted(analyzed_dir.glob("*-summary.md")) if analyzed_dir.exists() else []
179
if limit <= 0:
@@ -254,7 +246,10 @@ def render_archive_context(current_datetime: str, content_root: Path) -> str:
246
if monthly_path and monthly_path.exists():
247
relative_path = monthly_path.relative_to(ROOT) if monthly_path.is_relative_to(ROOT) else monthly_path
248
monthly_raw = monthly_path.read_text(encoding="utf-8").strip()
257
- monthly_content = extract_month_notes(monthly_raw) or monthly_raw
249
+ monthly_content = compress_to_budget(
250
+ extract_month_notes(monthly_raw) or monthly_raw,
251
+ ARCHIVE_MONTHLY_MAX_WORDS,
252
+ )
253
blocks.append(
254
f"--- Monthly Rollup: {_escape_untrusted_boundaries(str(relative_path))} ---\n"
255
f"{_escape_untrusted_boundaries(monthly_content)}"
@@ -266,7 +261,10 @@ def render_archive_context(current_datetime: str, content_root: Path) -> str:
261
if yearly_path and yearly_path.exists():
262
relative_path = yearly_path.relative_to(ROOT) if yearly_path.is_relative_to(ROOT) else yearly_path
263
yearly_raw = yearly_path.read_text(encoding="utf-8").strip()
269
- yearly_content = extract_yearly_narrative(yearly_raw) or yearly_raw
264
+ yearly_content = compress_to_budget(
265
+ extract_yearly_narrative(yearly_raw) or yearly_raw,
266
+ ARCHIVE_YEARLY_MAX_WORDS,
267
+ )
268
blocks.append(
269
f"--- Yearly Narrative: {_escape_untrusted_boundaries(str(relative_path))} ---\n"
270
f"{_escape_untrusted_boundaries(yearly_content)}"
@@ -291,6 +289,13 @@ def render_prompt(
289
content_root: Path = DEFAULT_CONTENT_ROOT,
290
scorecard_section: str = "",
291
) -> str:
292
+ if (
293
+ wisdom_file == DEFAULT_WISDOM_FILE
294
+ and skills_dir == DEFAULT_SKILLS_DIR
295
+ and continuity_file == DEFAULT_CONTINUITY_FILE
296
+ ):
297
+ wisdom_file, skills_dir, continuity_file = resolve_analysis_context_paths()
298
+
299
prompt = prompt_template_path.read_text(encoding="utf-8")
300
replacements = {
301
"{{CURRENT_DATETIME}}": current_datetime,
@@ -398,25 +403,15 @@ def main(argv: list[str] | None = None) -> int:
403
if args.scorecard:
404
scorecard_section = render_scorecard_section(args.topic, args.scorecard_count)
405
401
- wisdom_file = args.wisdom_file
402
- skills_dir = args.skills_dir
403
- continuity_file = args.continuity_file
404
- if (
405
- wisdom_file == DEFAULT_WISDOM_FILE
406
- and skills_dir == DEFAULT_SKILLS_DIR
407
- and continuity_file == DEFAULT_CONTINUITY_FILE
408
- ):
409
- wisdom_file, skills_dir, continuity_file = resolve_analysis_context_paths()
410
-
406
prompt = render_prompt(
407
prompt_template_path=args.prompt_template,
408
current_datetime=args.current_datetime,
409
output_path=output_path,
410
analyzed_dir=args.analyzed_dir,
411
snapshots_dir=args.snapshots_dir,
417
- wisdom_file=wisdom_file,
418
- skills_dir=skills_dir,
419
- continuity_file=continuity_file,
412
+ wisdom_file=args.wisdom_file,
413
+ skills_dir=args.skills_dir,
414
+ continuity_file=args.continuity_file,
415
content_root=args.content_root,
416
limit=args.limit,
417
scorecard_section=scorecard_section,
tests/test_generate_rollups.py
+15
-28
@@ -114,20 +114,11 @@ class GenerateRollupsTests(unittest.TestCase):
114
self.assertIn('categories: ["monthly"]', monthly)
115
self.assertIn('weeks_covered: ["2026-W21"]', monthly)
116
self.assertIn('total_repos_featured: 1', monthly)
117
- self.assertIn('synthesis_status: "generated"', monthly)
118
- self.assertIn('## Month Synthesis', monthly)
119
- self.assertIn('## Trend Arc', monthly)
120
- self.assertIn('## Prediction Review', monthly)
121
- self.assertIn('## Weekly Reports', monthly)
122
- self.assertNotIn('### Week 2026-W21', monthly)
123
- self.assertIn('[Week 21, 2026](/weekly/2026/W21/) — Practical agent tooling led the week.', monthly)
124
-
125
- synthesis_artifact = analyzed_dir / "2026-05-month-synthesis.md"
126
- self.assertTrue(synthesis_artifact.exists())
127
- artifact = synthesis_artifact.read_text(encoding="utf-8")
128
- self.assertIn('title: "May 2026 Month Synthesis"', artifact)
129
- self.assertIn('source_checksum: "sha256:', artifact)
130
- self.assertIn('## Month Synthesis', artifact)
117
+ self.assertIn('---\n\n## Month Overview', monthly)
118
+ self.assertIn('## Month Overview', monthly)
119
+ self.assertIn('### Week 2026-W21', monthly)
120
+ self.assertIn('[Week 21, 2026](/weekly/2026/W21/)', monthly)
121
+ self.assertIn('[octo/signal-kit](https://github.com/octo/signal-kit)', monthly)
122
123
yearly = yearly_path.read_text(encoding="utf-8")
124
self.assertIn('title: "2026 Yearly Narrative"', yearly)
@@ -190,22 +181,20 @@ class GenerateRollupsTests(unittest.TestCase):
181
self.assertIn('total_repos_featured: 4', second_monthly)
182
self.assertIn('months_covered: ["2026-05"]', second_yearly)
183
for expected in [
193
- '## Month Synthesis',
194
- '## Trend Arc',
195
- '## Weekly Reports',
196
- '- [Week 21, 2026](/weekly/2026/W21/) — Practical agent tooling led the week.',
197
- '- [Week 22, 2026](/weekly/2026/W22/) — Observability and release safety gained more traction.',
198
- 'accelerating_themes: ["beta"]',
199
- 'weakening_themes: ["alpha"]',
184
+ '### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)',
185
+ '- [octo/signal-kit](https://github.com/octo/signal-kit) led the published weekly analysis for 2026-W21.',
186
+ '- Signal: Teams preferred operational automation over generic hype.',
187
+ '- Gap to watch: Reliable momentum data remained missing.',
188
+ '- Recurring themes so far: alpha.',
189
]:
190
self.assertIn(expected, second_monthly)
202
- self.assertIn('top_repos: ["octo/signal-kit", "octo/steady-watch"]', second_monthly)
191
+ self.assertIn('- Recurring themes so far: alpha, beta.', second_monthly)
192
self.assertIn('format: "narrative"', second_yearly)
193
self.assertIn('## Year in Review', second_yearly)
194
self.assertIn('Observability and release safety gained more traction.', second_yearly)
195
self.assertNotIn('## Arc', second_yearly)
207
- self.assertEqual(second_monthly.count('[Week 21, 2026](/weekly/2026/W21/)'), 1)
208
- self.assertEqual(second_monthly.count('[Week 22, 2026](/weekly/2026/W22/)'), 1)
196
+ self.assertEqual(second_monthly.count('### Week 2026-W21'), 4)
197
+ self.assertEqual(second_monthly.count('### Week 2026-W22'), 4)
198
self.assertEqual(second_yearly.count('## Year in Review'), 1)
199
self.assertNotEqual(first_monthly, second_monthly)
200
self.assertNotEqual(first_yearly, second_yearly)
@@ -381,10 +370,8 @@ The strongest thread was a shift from raw capability talk toward packaging, trus
370
generate_rollups.generate_rollups(analyzed_dir, content_root)
371
monthly = monthly_path.read_text(encoding="utf-8")
372
384
- self.assertNotIn("_No updates yet._\n\n## Month Synthesis", monthly)
385
- self.assertNotIn("## Month Overview", monthly)
386
- self.assertIn("## Weekly Reports", monthly)
387
- self.assertIn("[Week 21, 2026](/weekly/2026/W21/) — Practical agent tooling led the week.", monthly)
373
+ self.assertNotIn("_No updates yet._\n\n### Week 2026-W21", monthly)
374
+ self.assertIn("### Week 2026-W21", monthly)
375
self.assertIn("## Legacy Notes\n\nKeep this section.", monthly)
376
377
def test_generate_rollups_returns_empty_when_no_summaries_exist(self) -> None:
tests/test_pipeline.py
+5
-1
@@ -489,7 +489,11 @@ class WorkflowConfigTests(unittest.TestCase):
489
self.assertIsNotNone(smoke_step)
490
run_script = smoke_step["run"]
491
self.assertIn('if [ ! -f "$ARTICLE_PATH" ]', run_script)
492
- self.assertIn("hashlib.sha256(article.read_bytes()).hexdigest()", run_script)
492
+ self.assertIn("hashlib.sha256(article_bytes).hexdigest()", run_script)
493
+ self.assertIn("article_sha256 must match ARTICLE_PATH contents when provided.", run_script)
494
+ self.assertIn('raw_payload = {"week": week, "source": "github", "article_path": article_path}', run_script)
495
+ self.assertIn('"size_bytes": len(raw_bytes)', run_script)
496
+ self.assertIn('"sha256": article_sha', run_script)
497
self.assertIn('"source_artifacts": [', run_script)
498
self.assertIn('"same_day_reuse"', run_script)
499
self.assertIn("build_payload(", run_script)
tests/test_podcaster_handoff.py
+26
@@ -165,6 +165,32 @@ class PodcasterHandoffTests(unittest.TestCase):
165
self.assertEqual(payload["article_title"], "Week 23 Report")
166
self.assertEqual(payload["article_summary"], "Week 23 summary.")
167
168
+ def test_build_payload_filters_non_string_source_artifact_lists(self) -> None:
169
+ tests_root = Path(__file__).resolve().parent
170
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
171
+ manifest = self._write_manifest(Path(tmpdir))
172
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
173
+ payload["source_artifacts"][0]["sources_requested"] = ["github", "", None, 3]
174
+ payload["source_artifacts"][0]["sources_succeeded"] = ["github", False]
175
+ payload["source_artifacts"][0]["sources_failed"] = ["rss", "", {"bad": "entry"}]
176
+ payload["source_artifacts"][1]["sources_requested"] = [None, 0, ""]
177
+ manifest.write_text(json.dumps(payload), encoding="utf-8")
178
+
179
+ built = podcaster_handoff.build_payload(
180
+ week="2026-W23",
181
+ article_url="https://jmservera.github.io/SquadScope/weekly/2026/w23/",
182
+ article_path="content/weekly/2026/W23.md",
183
+ publish_run_id="123456789",
184
+ publish_mode="normal",
185
+ manifest_path=manifest,
186
+ repo_root=Path(tmpdir),
187
+ )
188
+
189
+ self.assertEqual(built["source_artifacts"][0]["sources_requested"], ["github"])
190
+ self.assertEqual(built["source_artifacts"][0]["sources_succeeded"], ["github"])
191
+ self.assertEqual(built["source_artifacts"][0]["sources_failed"], ["rss"])
192
+ self.assertNotIn("sources_requested", built["source_artifacts"][1])
193
+
194
def test_podcaster_dry_run_sets_payload_flag(self) -> None:
195
payload = podcaster_handoff.build_payload(
196
week="2026-W23",
tests/test_reskill.py
+65
@@ -80,6 +80,71 @@ class ReskillTests(unittest.TestCase):
80
self.assertIn("2026-W21-stars.json", prompt)
81
self.assertIn("No snapshot data available for hindsight validation.", prompt)
82
83
+ def test_render_prompt_resolves_topic_context_defaults(self) -> None:
84
+ tests_root = Path(__file__).resolve().parent
85
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
86
+ base = Path(tmpdir)
87
+ prompt_template = base / "reskill.md"
88
+ output_path = base / ".squad" / "reskill" / "2026-W21.md"
89
+ analyzed_dir = base / "data" / "analyzed"
90
+ snapshots_dir = base / "data" / "snapshots"
91
+ content_root = base / "content"
92
+ topic_wisdom = base / ".squad" / "topics" / "ai-ml" / "wisdom.md"
93
+ topic_skills = base / ".squad" / "topics" / "ai-ml" / "skills"
94
+ topic_continuity = base / ".squad" / "topics" / "ai-ml" / "continuity.md"
95
+ analyzed_dir.mkdir(parents=True)
96
+ snapshots_dir.mkdir(parents=True)
97
+ topic_wisdom.parent.mkdir(parents=True)
98
+ topic_skills.mkdir(parents=True)
99
+ prompt_template.write_text("w={{WISDOM}}\ns={{SKILLS}}\nc={{CONTINUITY}}", encoding="utf-8")
100
+ topic_wisdom.write_text("Topic wisdom", encoding="utf-8")
101
+ (topic_skills / "SKILL.md").write_text("Topic skill", encoding="utf-8")
102
+ topic_continuity.write_text("Topic continuity", encoding="utf-8")
103
+
104
+ with mock.patch.object(
105
+ reskill,
106
+ "resolve_analysis_context_paths",
107
+ return_value=(topic_wisdom, topic_skills, topic_continuity),
108
+ ) as resolver:
109
+ prompt = reskill.render_prompt(
110
+ prompt_template_path=prompt_template,
111
+ current_datetime="2026-05-18T15:22:25.067+02:00",
112
+ output_path=output_path,
113
+ analyzed_dir=analyzed_dir,
114
+ snapshots_dir=snapshots_dir,
115
+ wisdom_file=reskill.DEFAULT_WISDOM_FILE,
116
+ skills_dir=reskill.DEFAULT_SKILLS_DIR,
117
+ continuity_file=reskill.DEFAULT_CONTINUITY_FILE,
118
+ content_root=content_root,
119
+ limit=5,
120
+ )
121
+
122
+ resolver.assert_called_once_with()
123
+ self.assertIn("Topic wisdom", prompt)
124
+ self.assertIn("Topic skill", prompt)
125
+ self.assertIn("Topic continuity", prompt)
126
+
127
+ def test_render_archive_context_caps_monthly_and_yearly_sections(self) -> None:
128
+ tests_root = Path(__file__).resolve().parent
129
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
130
+ content_root = Path(tmpdir) / "content"
131
+ (content_root / "monthly" / "2026").mkdir(parents=True)
132
+ (content_root / "yearly").mkdir(parents=True)
133
+ (content_root / "monthly" / "2026" / "05.md").write_text(
134
+ "## Month Overview\n\n" + " ".join(["monthlytoken"] * 260),
135
+ encoding="utf-8",
136
+ )
137
+ (content_root / "yearly" / "2026.md").write_text(
138
+ "## Narrative\n\n" + " ".join(["yearlytoken"] * 620),
139
+ encoding="utf-8",
140
+ )
141
+
142
+ prompt = reskill.render_archive_context("2026-05-18T15:22:25.067+02:00", content_root)
143
+
144
+ self.assertLessEqual(prompt.count("monthlytoken"), reskill.ARCHIVE_MONTHLY_MAX_WORDS)
145
+ self.assertLessEqual(prompt.count("yearlytoken"), reskill.ARCHIVE_YEARLY_MAX_WORDS)
146
+ self.assertIn("…", prompt)
147
+
148
def test_main_writes_default_weekly_report(self) -> None:
149
tests_root = Path(__file__).resolve().parent
150
with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir: