arkaos 2.25.0 → 2.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/bootstrap-agent/SKILL.md +76 -0
  3. package/arka/skills/design-ops/SKILL.md +68 -0
  4. package/arka/skills/design-ops/scripts/extract-colors.py +86 -0
  5. package/arka/skills/design-ops/scripts/shadcn-tokens.py +59 -0
  6. package/arka/skills/design-ops/scripts/wcag-contrast.py +92 -0
  7. package/arka/skills/dreams/SKILL.md +78 -0
  8. package/arka/skills/research/SKILL.md +117 -0
  9. package/config/cognition/schedules.yaml +6 -0
  10. package/config/constitution.yaml +4 -0
  11. package/config/hooks/post-tool-use.sh +17 -0
  12. package/config/hooks/user-prompt-submit.sh +16 -0
  13. package/core/agents/__pycache__/loader.cpython-313.pyc +0 -0
  14. package/core/agents/__pycache__/schema.cpython-313.pyc +0 -0
  15. package/core/agents/loader.py +4 -1
  16. package/core/agents/schema.py +29 -0
  17. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  18. package/core/cognition/__pycache__/dreams_reader.cpython-313.pyc +0 -0
  19. package/core/cognition/__pycache__/retrieval.cpython-313.pyc +0 -0
  20. package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
  21. package/core/cognition/dreaming.py +368 -0
  22. package/core/cognition/dreams_reader.py +141 -0
  23. package/core/cognition/retrieval.py +383 -0
  24. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  25. package/core/cognition/scheduler/daemon.py +23 -3
  26. package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  28. package/core/runtime/__pycache__/ollama_provider.cpython-313.pyc +0 -0
  29. package/core/runtime/__pycache__/path_resolver.cpython-313.pyc +0 -0
  30. package/core/runtime/llm_provider.py +8 -1
  31. package/core/runtime/ollama_provider.py +144 -0
  32. package/departments/brand/agents/design-ops/design-ops-lead.yaml +78 -0
  33. package/departments/brand/agents/design-ops/extraction-script-writer.yaml +74 -0
  34. package/departments/brand/agents/design-ops/shadcn-padronizer.yaml +76 -0
  35. package/departments/brand/agents/design-ops/wcag-auditor.yaml +76 -0
  36. package/departments/dev/skills/mcp/SKILL.md +16 -0
  37. package/installer/cli.js +8 -1
  38. package/installer/doctor.js +13 -1
  39. package/installer/index.js +6 -3
  40. package/installer/system-tools.js +79 -2
  41. package/package.json +1 -1
  42. package/pyproject.toml +1 -1
@@ -273,6 +273,35 @@ class Agent(BaseModel):
273
273
  description="Claude model override for dispatch. Falls back to tier default when None.",
274
274
  )
275
275
 
276
+ parent_squad: Optional[str] = Field(
277
+ default=None,
278
+ description=(
279
+ "Slug of the parent squad when this agent belongs to a sub-squad. "
280
+ "Sub-squads are operational specialisations inside a department "
281
+ "(e.g. brand → design-ops). Optional and backwards-compatible: "
282
+ "agents without a parent_squad belong directly to their department."
283
+ ),
284
+ )
285
+
286
+ sub_squad_role: Optional[str] = Field(
287
+ default=None,
288
+ description=(
289
+ "Role within the sub-squad when parent_squad is set "
290
+ "(e.g. 'lead', 'extraction-script-writer', 'wcag-auditor'). "
291
+ "Inspired by the AIOX Squad → Sub-Squad pattern (KB note "
292
+ "AIOX Squads, April 30 2026 live)."
293
+ ),
294
+ )
295
+
296
+ @model_validator(mode="after")
297
+ def validate_sub_squad_coherence(self) -> "Agent":
298
+ """sub_squad_role only makes sense when parent_squad is set."""
299
+ if self.sub_squad_role and not self.parent_squad:
300
+ raise ValueError(
301
+ "sub_squad_role requires parent_squad to be set"
302
+ )
303
+ return self
304
+
276
305
  @model_validator(mode="after")
277
306
  def auto_fill_memory_path(self) -> "Agent":
278
307
  if not self.memory_path:
@@ -0,0 +1,368 @@
1
+ """Dreaming v2 — nightly cognitive consolidation that surfaces insights.
2
+
3
+ Runs (manually or on a schedule) over the user's recent vault notes and
4
+ session digests, groups related content into clusters, asks the
5
+ configured LLM (Claude Code by default, Ollama / Anthropic / OpenAI on
6
+ opt-in) for one observation per cluster, and applies a second LLM pass
7
+ that filters noise. Accepted insights are written to the Obsidian vault
8
+ in a plugin-compat shape that a future mobile reader can consume.
9
+
10
+ Backend-agnostic by design — completion goes through
11
+ ``core.runtime.llm_provider.get_llm_provider()``. The same engine works
12
+ with any registered provider; the user picks in ``profile.json``.
13
+
14
+ See PR8 v2.30.0 and the 2026-05-13 Conclave Phase 4 correction (multi-
15
+ backend, not Ollama-only) for the architectural rationale.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import re
23
+ from dataclasses import asdict, dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ from core.runtime.llm_provider import LLMResponse, LLMUnavailable, get_llm_provider
28
+ from core.runtime.path_resolver import ProfileMissingError, load_profile
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _DEFAULT_VAULT_LOOKBACK_DAYS = 7
33
+ _DEFAULT_MIN_CLUSTER_SIZE = 3
34
+ _DEFAULT_MAX_CLUSTERS = 12
35
+ _DEFAULT_MAX_INSIGHTS = 5
36
+ _MIN_CHUNK_CHARS = 80
37
+ _MAX_CHUNK_CHARS = 1200
38
+ _CRITIC_PASS_TOKEN = "VALUABLE"
39
+
40
+
41
+ @dataclass
42
+ class Insight:
43
+ """One accepted insight ready for vault write."""
44
+
45
+ title: str
46
+ body: str
47
+ confidence: str # "high" | "medium" | "low"
48
+ sources: list[str] = field(default_factory=list)
49
+ tags: list[str] = field(default_factory=list)
50
+
51
+ def to_frontmatter(self, date_str: str) -> dict:
52
+ return {
53
+ "type": "arkaos-insight",
54
+ "date": date_str,
55
+ "status": "surfaced",
56
+ "confidence": self.confidence,
57
+ "sources": [f"[[{s}]]" for s in self.sources],
58
+ "tags": ["arkaos-dream", *self.tags],
59
+ "plugin_compat_version": "1.0",
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class Chunk:
65
+ """A piece of source text fed into clustering."""
66
+
67
+ source_path: str
68
+ text: str
69
+ kind: str # "vault" | "session-digest" | "capture"
70
+
71
+
72
+ @dataclass
73
+ class Cluster:
74
+ """A group of related chunks."""
75
+
76
+ topic: str
77
+ chunks: list[Chunk] = field(default_factory=list)
78
+
79
+
80
+ class Dreaming:
81
+ """Engine that produces nightly insights from recent activity."""
82
+
83
+ def __init__(
84
+ self,
85
+ vault_path: Path,
86
+ output_dir: Path,
87
+ digest_dir: Path | None = None,
88
+ lookback_days: int = _DEFAULT_VAULT_LOOKBACK_DAYS,
89
+ max_insights: int = _DEFAULT_MAX_INSIGHTS,
90
+ provider=None,
91
+ ) -> None:
92
+ self._vault = Path(vault_path)
93
+ self._output_dir = Path(output_dir)
94
+ self._digest_dir = Path(digest_dir) if digest_dir else None
95
+ self._lookback_days = lookback_days
96
+ self._max_insights = max_insights
97
+ self._provider = provider or get_llm_provider()
98
+
99
+ @classmethod
100
+ def from_profile(cls, output_subpath: str = "Projects/ArkaOS/Dreams") -> "Dreaming":
101
+ """Construct a Dreaming engine from the user's profile.json."""
102
+ profile = load_profile()
103
+ vault = Path(profile.vault_path)
104
+ output = vault / output_subpath
105
+ digests = Path.home() / ".arkaos" / "session-digests"
106
+ return cls(vault_path=vault, output_dir=output, digest_dir=digests)
107
+
108
+ def run(self, dry_run: bool = False) -> list[Insight]:
109
+ """Execute one dreaming pass. Returns accepted insights."""
110
+ chunks = self._collect_chunks()
111
+ if not chunks:
112
+ logger.info("Dreaming: no chunks to process — quiet night")
113
+ return []
114
+
115
+ clusters = self._cluster(chunks)
116
+ if not clusters:
117
+ logger.info("Dreaming: no clusters formed — quiet night")
118
+ return []
119
+
120
+ accepted: list[Insight] = []
121
+ for cluster in clusters[: _DEFAULT_MAX_CLUSTERS]:
122
+ insight = self._draft_insight(cluster)
123
+ if insight is None:
124
+ continue
125
+ if not self._critic_accepts(insight):
126
+ continue
127
+ accepted.append(insight)
128
+ if len(accepted) >= self._max_insights:
129
+ break
130
+
131
+ if not dry_run:
132
+ for insight in accepted:
133
+ self._write_insight(insight)
134
+ return accepted
135
+
136
+ def _collect_chunks(self) -> list[Chunk]:
137
+ chunks: list[Chunk] = []
138
+ chunks.extend(self._collect_vault_chunks())
139
+ chunks.extend(self._collect_digest_chunks())
140
+ return chunks
141
+
142
+ def _collect_vault_chunks(self) -> list[Chunk]:
143
+ if not self._vault.is_dir():
144
+ return []
145
+ cutoff = datetime.now(timezone.utc).timestamp() - self._lookback_days * 86400
146
+ out: list[Chunk] = []
147
+ for md in self._vault.rglob("*.md"):
148
+ try:
149
+ if md.stat().st_mtime < cutoff:
150
+ continue
151
+ text = md.read_text(encoding="utf-8", errors="ignore")
152
+ except OSError:
153
+ continue
154
+ relative = str(md.relative_to(self._vault))
155
+ for piece in _split_for_clustering(text):
156
+ out.append(Chunk(source_path=relative, text=piece, kind="vault"))
157
+ return out
158
+
159
+ def _collect_digest_chunks(self) -> list[Chunk]:
160
+ if not self._digest_dir or not self._digest_dir.is_dir():
161
+ return []
162
+ cutoff = datetime.now(timezone.utc).timestamp() - self._lookback_days * 86400
163
+ out: list[Chunk] = []
164
+ for f in self._digest_dir.glob("*.md"):
165
+ try:
166
+ if f.stat().st_mtime < cutoff:
167
+ continue
168
+ text = f.read_text(encoding="utf-8", errors="ignore")
169
+ except OSError:
170
+ continue
171
+ for piece in _split_for_clustering(text):
172
+ out.append(Chunk(source_path=f.name, text=piece, kind="session-digest"))
173
+ return out
174
+
175
+ def _cluster(self, chunks: list[Chunk]) -> list[Cluster]:
176
+ """Lightweight clustering by shared CamelCase / path tokens.
177
+
178
+ Embedding-based clustering is the obvious upgrade but requires
179
+ fastembed to be installed and warmed. The token-overlap baseline
180
+ ships value today and the embedding path is a follow-up.
181
+ """
182
+ buckets: dict[str, list[Chunk]] = {}
183
+ for chunk in chunks:
184
+ for token in _extract_topic_tokens(chunk.text):
185
+ buckets.setdefault(token, []).append(chunk)
186
+ clusters: list[Cluster] = []
187
+ seen_keys: set[str] = set()
188
+ for topic, items in sorted(buckets.items(), key=lambda kv: -len(kv[1])):
189
+ if len(items) < _DEFAULT_MIN_CLUSTER_SIZE:
190
+ continue
191
+ key = "|".join(sorted({c.source_path for c in items}))
192
+ if key in seen_keys:
193
+ continue
194
+ seen_keys.add(key)
195
+ clusters.append(Cluster(topic=topic, chunks=items))
196
+ return clusters
197
+
198
+ def _draft_insight(self, cluster: Cluster) -> Insight | None:
199
+ prompt = _build_insight_prompt(cluster)
200
+ try:
201
+ response = self._provider.complete(prompt, max_tokens=400)
202
+ except LLMUnavailable as exc:
203
+ logger.warning("Dreaming: provider unavailable, skipping cluster (%s)", exc)
204
+ return None
205
+ return _parse_insight(response, cluster)
206
+
207
+ def _critic_accepts(self, insight: Insight) -> bool:
208
+ prompt = _build_critic_prompt(insight)
209
+ try:
210
+ response = self._provider.complete(prompt, max_tokens=20)
211
+ except LLMUnavailable:
212
+ return False # safer to reject than to publish unchecked
213
+ return _CRITIC_PASS_TOKEN in response.text.upper()
214
+
215
+ def _write_insight(self, insight: Insight) -> Path:
216
+ self._output_dir.mkdir(parents=True, exist_ok=True)
217
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
218
+ slug = _slugify(insight.title) or "insight"
219
+ path = self._output_dir / f"{date_str}-{slug}.md"
220
+ frontmatter = insight.to_frontmatter(date_str)
221
+ path.write_text(_render_markdown(frontmatter, insight), encoding="utf-8")
222
+ return path
223
+
224
+
225
+ def _split_for_clustering(text: str) -> list[str]:
226
+ pieces = re.split(r"\n\s*\n", text)
227
+ out: list[str] = []
228
+ for p in pieces:
229
+ p = p.strip()
230
+ if len(p) < _MIN_CHUNK_CHARS:
231
+ continue
232
+ out.append(p[:_MAX_CHUNK_CHARS])
233
+ return out
234
+
235
+
236
+ def _extract_topic_tokens(text: str) -> list[str]:
237
+ tokens = re.findall(r"\b([A-Z][a-zA-Z0-9_-]{3,})\b", text)
238
+ return list({t for t in tokens if t not in _STOP_TOPIC_TOKENS})[:8]
239
+
240
+
241
+ _STOP_TOPIC_TOKENS = frozenset({
242
+ "The", "This", "That", "When", "Where", "Then", "Note", "TODO", "FIXME",
243
+ "README", "ArkaOS", "Claude", "Read", "Write", "Edit", "Run",
244
+ })
245
+
246
+
247
+ def _build_insight_prompt(cluster: Cluster) -> str:
248
+ excerpts = []
249
+ sources = []
250
+ for c in cluster.chunks[:6]:
251
+ sources.append(c.source_path)
252
+ excerpts.append(f"[{c.source_path}]\n{c.text[:400]}\n")
253
+ src_lines = "\n".join(f"- {s}" for s in sorted(set(sources)))
254
+ return (
255
+ "You are reviewing the user's recent work. Several notes share a topic.\n\n"
256
+ f"Topic anchor: {cluster.topic}\n\n"
257
+ f"Sources:\n{src_lines}\n\n"
258
+ f"Excerpts:\n{''.join(excerpts)}\n"
259
+ "Return ONE concrete observation, pattern, or action the user might "
260
+ "want to consider. Two sentences maximum. If nothing is genuinely "
261
+ "surprising or actionable, return literally: PASS\n\n"
262
+ "Format your reply as:\n"
263
+ "TITLE: <very short title>\n"
264
+ "BODY: <two sentences>\n"
265
+ "CONFIDENCE: high | medium | low\n"
266
+ )
267
+
268
+
269
+ def _build_critic_prompt(insight: Insight) -> str:
270
+ return (
271
+ "Judge this insight as if it were going to land on the user's desk "
272
+ "tomorrow morning. Is it specific, actionable, and non-generic? "
273
+ "Or is it noise that would erode trust over time?\n\n"
274
+ f"Title: {insight.title}\n"
275
+ f"Body: {insight.body}\n\n"
276
+ "Reply with exactly one word: VALUABLE or NOISE."
277
+ )
278
+
279
+
280
+ def _parse_insight(response: LLMResponse, cluster: Cluster) -> Insight | None:
281
+ text = response.text.strip()
282
+ if not text or text.upper().startswith("PASS"):
283
+ return None
284
+ title = _extract_field(text, "TITLE") or _first_line(text)
285
+ body = _extract_field(text, "BODY") or text
286
+ confidence = _extract_field(text, "CONFIDENCE") or "medium"
287
+ confidence = confidence.lower()
288
+ if confidence not in {"high", "medium", "low"}:
289
+ confidence = "medium"
290
+ sources = sorted({c.source_path for c in cluster.chunks})[:6]
291
+ tags = [cluster.topic.lower()] if cluster.topic else []
292
+ return Insight(title=title.strip(), body=body.strip(), confidence=confidence,
293
+ sources=sources, tags=tags)
294
+
295
+
296
+ def _extract_field(text: str, field_name: str) -> str | None:
297
+ pattern = re.compile(
298
+ rf"^{re.escape(field_name)}\s*:\s*(.+?)$", re.IGNORECASE | re.MULTILINE
299
+ )
300
+ match = pattern.search(text)
301
+ if match:
302
+ return match.group(1).strip()
303
+ return None
304
+
305
+
306
+ def _first_line(text: str) -> str:
307
+ for line in text.splitlines():
308
+ line = line.strip()
309
+ if line:
310
+ return line[:80]
311
+ return "Insight"
312
+
313
+
314
+ def _slugify(title: str) -> str:
315
+ slug = re.sub(r"[^a-zA-Z0-9-]+", "-", title.lower()).strip("-")
316
+ return slug[:60]
317
+
318
+
319
+ def _render_markdown(frontmatter: dict, insight: Insight) -> str:
320
+ lines = ["---"]
321
+ for key, value in frontmatter.items():
322
+ if isinstance(value, list):
323
+ lines.append(f"{key}:")
324
+ for v in value:
325
+ lines.append(f" - {v}")
326
+ else:
327
+ lines.append(f"{key}: {value}")
328
+ lines.append("---")
329
+ lines.append("")
330
+ lines.append(f"# {insight.title}")
331
+ lines.append("")
332
+ lines.append("## What I noticed")
333
+ lines.append(insight.body)
334
+ if insight.sources:
335
+ lines.append("")
336
+ lines.append("## Sources")
337
+ for s in insight.sources:
338
+ lines.append(f"- [[{s}]]")
339
+ return "\n".join(lines) + "\n"
340
+
341
+
342
+ # ─── CLI ──────────────────────────────────────────────────────────────
343
+
344
+
345
+ def main(argv: list[str]) -> int:
346
+ import argparse
347
+
348
+ parser = argparse.ArgumentParser(description=__doc__)
349
+ parser.add_argument("command", choices=["run"], help="action to perform")
350
+ parser.add_argument("--dry-run", action="store_true", help="cluster + draft without writing")
351
+ args = parser.parse_args(argv)
352
+
353
+ try:
354
+ engine = Dreaming.from_profile()
355
+ except ProfileMissingError as exc:
356
+ print(f"Cannot start Dreaming: {exc}")
357
+ return 2
358
+
359
+ insights = engine.run(dry_run=args.dry_run)
360
+ print(f"Dreaming produced {len(insights)} insight(s).")
361
+ for i, insight in enumerate(insights, start=1):
362
+ print(f"{i}. ({insight.confidence}) {insight.title}")
363
+ return 0
364
+
365
+
366
+ if __name__ == "__main__":
367
+ import sys
368
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,141 @@
1
+ """Read Dreaming v2 insights from the Obsidian vault.
2
+
3
+ Surface used by the ``/arka dreams`` skill. Parses the plugin-compat
4
+ frontmatter shape written by ``core.cognition.dreaming`` and groups
5
+ results by date so the CLI can show today, since-N-days, or all.
6
+
7
+ Read-only; never modifies vault content.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+
17
+
18
+ @dataclass
19
+ class StoredInsight:
20
+ """An insight surfaced by Dreaming v2 and parsed back from disk."""
21
+
22
+ path: Path
23
+ date: str # YYYY-MM-DD
24
+ title: str
25
+ confidence: str
26
+ sources: list[str] = field(default_factory=list)
27
+ tags: list[str] = field(default_factory=list)
28
+ body: str = ""
29
+
30
+
31
+ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)", re.DOTALL)
32
+
33
+
34
+ def list_insights(dreams_dir: Path, since_days: int = 1) -> list[StoredInsight]:
35
+ """List insights from *dreams_dir* whose date is within *since_days*.
36
+
37
+ ``since_days=1`` is "today only" (UTC-day boundary, generous tolerance
38
+ of one calendar day so a late-night dream surfaced after midnight
39
+ still counts). ``since_days=7`` returns last week, etc.
40
+ """
41
+ dreams_dir = Path(dreams_dir)
42
+ if not dreams_dir.is_dir():
43
+ return []
44
+ cutoff = datetime.now(timezone.utc).date() - timedelta(days=max(since_days - 1, 0))
45
+ out: list[StoredInsight] = []
46
+ for md in sorted(dreams_dir.glob("*.md"), reverse=True):
47
+ try:
48
+ text = md.read_text(encoding="utf-8")
49
+ except OSError:
50
+ continue
51
+ insight = parse_insight(md, text)
52
+ if insight is None:
53
+ continue
54
+ try:
55
+ insight_date = datetime.strptime(insight.date, "%Y-%m-%d").date()
56
+ except ValueError:
57
+ continue
58
+ if insight_date >= cutoff:
59
+ out.append(insight)
60
+ return out
61
+
62
+
63
+ def parse_insight(path: Path, text: str) -> StoredInsight | None:
64
+ """Parse one Dreaming v2 markdown file into a StoredInsight."""
65
+ match = _FRONTMATTER_RE.match(text)
66
+ if not match:
67
+ return None
68
+ fm_block, body = match.group(1), match.group(2)
69
+ fm = _parse_frontmatter(fm_block)
70
+ if fm.get("type") != "arkaos-insight":
71
+ return None
72
+ title = _extract_h1(body) or path.stem
73
+ return StoredInsight(
74
+ path=path,
75
+ date=str(fm.get("date", "")),
76
+ title=title,
77
+ confidence=str(fm.get("confidence", "medium")),
78
+ sources=_parse_list(fm.get("sources")),
79
+ tags=_parse_list(fm.get("tags")),
80
+ body=_extract_body(body),
81
+ )
82
+
83
+
84
+ def _parse_frontmatter(block: str) -> dict:
85
+ """Tiny YAML-ish parser. Frontmatter shape is constrained by Dreaming v2
86
+ output (`core/cognition/dreaming.py:_render_markdown`) so a full YAML
87
+ dependency is not warranted here.
88
+ """
89
+ out: dict[str, object] = {}
90
+ current_key: str | None = None
91
+ current_list: list[str] = []
92
+ for raw_line in block.splitlines():
93
+ if not raw_line.strip():
94
+ continue
95
+ if raw_line.startswith(" - "):
96
+ current_list.append(raw_line[4:].strip())
97
+ continue
98
+ if current_key is not None and current_list:
99
+ out[current_key] = list(current_list)
100
+ current_list = []
101
+ current_key = None
102
+ if ":" not in raw_line:
103
+ continue
104
+ key, _, value = raw_line.partition(":")
105
+ key = key.strip()
106
+ value = value.strip()
107
+ if value == "":
108
+ current_key = key
109
+ continue
110
+ out[key] = value
111
+ if current_key is not None and current_list:
112
+ out[current_key] = list(current_list)
113
+ return out
114
+
115
+
116
+ def _parse_list(value: object) -> list[str]:
117
+ if isinstance(value, list):
118
+ return [str(v) for v in value]
119
+ if isinstance(value, str) and value:
120
+ return [value]
121
+ return []
122
+
123
+
124
+ def _extract_h1(body: str) -> str | None:
125
+ for line in body.splitlines():
126
+ if line.startswith("# "):
127
+ return line[2:].strip()
128
+ return None
129
+
130
+
131
+ def _extract_body(body: str) -> str:
132
+ """Return the prose under '## What I noticed' if present, else the body."""
133
+ marker = "## What I noticed"
134
+ idx = body.find(marker)
135
+ if idx < 0:
136
+ return body.strip()
137
+ after = body[idx + len(marker):]
138
+ next_section = after.find("\n## ")
139
+ if next_section < 0:
140
+ return after.strip()
141
+ return after[:next_section].strip()