agent-core-hatchery 0.8.2__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ """agent-core-hatchery — bootstrap system for hatching new beings."""
@@ -0,0 +1,7 @@
1
+ """Channel scaffolding (Discord, webcam, GitHub backup).
2
+
3
+ Each module exports a `scaffold_<name>(config)` function that:
4
+ - Returns None if the channel is disabled.
5
+ - Performs side effects (write env file, write hook script) and returns
6
+ a dict (the endpoint or job block) to be appended to daemon fragments.
7
+ """
@@ -0,0 +1,43 @@
1
+ """Discord channel scaffolding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from agent_core_hatchery.config import HatchConfig
10
+
11
+
12
+ def scaffold_discord(config: HatchConfig) -> dict[str, Any] | None:
13
+ if not config.channels.discord.enabled:
14
+ return None
15
+
16
+ daemon_dir = config.resolved_daemon_config_dir()
17
+ daemon_dir.mkdir(parents=True, exist_ok=True)
18
+ env_file = daemon_dir / f"discord-{config.being_name_lower}.env"
19
+ env_file.write_text(
20
+ f"{config.discord_token_env}={config.channels.discord.resolved_token}\n",
21
+ encoding="utf-8",
22
+ )
23
+ if os.name != "nt":
24
+ env_file.chmod(0o600)
25
+
26
+ block: dict[str, Any] = {
27
+ "type": "builtin.discord",
28
+ "name": f"discord-{config.being_name_lower}",
29
+ "description": f"Discord adapter for {config.being_name}.",
30
+ "params": {
31
+ "target": config.endpoint_name,
32
+ "token_env": config.discord_token_env,
33
+ "env_file": str(env_file),
34
+ },
35
+ }
36
+ if config.channels.discord.channel_allowlist:
37
+ access_file = daemon_dir / f"discord-{config.being_name_lower}-access.json"
38
+ access_file.write_text(
39
+ json.dumps({"channels": config.channels.discord.channel_allowlist}, indent=2),
40
+ encoding="utf-8",
41
+ )
42
+ block["params"]["access_config_path"] = str(access_file)
43
+ return block
@@ -0,0 +1,65 @@
1
+ """GitHub backup scaffolding (CW-4 — opt-in)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from agent_core_hatchery.config import HatchConfig
9
+
10
+ _HOOK_TEMPLATE = """#!/usr/bin/env bash
11
+ # Generated by agent-core-hatchery for {being_name}.
12
+ # Backup the vault's Memory/ to GitHub. Invoked by the
13
+ # {being_name_lower}-github_backup scheduler job.
14
+ set -euo pipefail
15
+
16
+ VAULT_ROOT="{vault_root}"
17
+ REPO_URL="{repo_url}"
18
+
19
+ cd "$VAULT_ROOT"
20
+ if [ ! -d Memory/.git ]; then
21
+ cd Memory
22
+ git init
23
+ git remote add origin "$REPO_URL"
24
+ cd ..
25
+ fi
26
+ cd Memory
27
+ git add -A
28
+ git commit -m "backup $(date -Iseconds)" || true # ok if nothing to commit
29
+ git push -u origin HEAD || true # warn-only on first push
30
+ """
31
+
32
+
33
+ def scaffold_github_backup(config: HatchConfig) -> dict[str, Any] | None:
34
+ if not config.channels.github_backup.enabled:
35
+ return None
36
+
37
+ hook_dir = config.resolved_vault_root() / "hooks"
38
+ hook_dir.mkdir(parents=True, exist_ok=True)
39
+ hook_path = hook_dir / "backup-to-github.sh"
40
+ hook_path.write_text(
41
+ _HOOK_TEMPLATE.format(
42
+ being_name=config.being_name,
43
+ being_name_lower=config.being_name_lower,
44
+ vault_root=str(config.resolved_vault_root()).replace("\\", "/"),
45
+ repo_url=config.channels.github_backup.repo_url,
46
+ ),
47
+ encoding="utf-8",
48
+ )
49
+ if os.name != "nt":
50
+ hook_path.chmod(0o755)
51
+
52
+ job_name = f"{config.being_name_lower}-github_backup"
53
+ return {
54
+ job_name: {
55
+ "trigger": "cron",
56
+ "timezone": "America/New_York",
57
+ "schedule": {"hour": 4, "minute": 30},
58
+ "target": config.endpoint_name,
59
+ "envelope_kind": "Event",
60
+ "payload": {
61
+ "type": "GitHubBackup",
62
+ "data": {"hook_path": str(hook_path)},
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,26 @@
1
+ """Webcam channel scaffolding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from agent_core_hatchery.config import HatchConfig
8
+
9
+
10
+ def scaffold_webcam(config: HatchConfig) -> dict[str, Any] | None:
11
+ if not config.channels.webcam.enabled:
12
+ return None
13
+ return {
14
+ "type": "builtin.webcam",
15
+ "name": f"webcam-{config.being_name_lower}",
16
+ "description": f"{config.being_name}'s webcam endpoint.",
17
+ "params": {
18
+ "enabled": True,
19
+ "captures_root": f"~/.agent-core/webcam/{config.being_name_lower}",
20
+ "audit_log_path": f"~/.agent-core/webcam/{config.being_name_lower}/audit.jsonl",
21
+ "default_camera_index": 0,
22
+ "default_resolution": [1280, 720],
23
+ "max_resolution": [3840, 2160],
24
+ "capture_timeout_seconds": 3.0,
25
+ },
26
+ }
@@ -0,0 +1,97 @@
1
+ """hatch-being CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ import yaml
9
+
10
+ from agent_core_hatchery.config import HatchConfig
11
+ from agent_core_hatchery.daemon_probe import reload_and_probe
12
+ from agent_core_hatchery.hatcher import Hatcher, VaultExistsError
13
+ from agent_core_hatchery.report import write_hatching_report
14
+ from agent_core_hatchery.wizard import offer_letter_authoring, run_wizard
15
+
16
+ app = typer.Typer(
17
+ name="hatch-being",
18
+ help="Hatch a new agent-core being. Run with no flags for interactive TUI.",
19
+ no_args_is_help=False,
20
+ )
21
+
22
+
23
+ @app.callback(invoke_without_command=True)
24
+ def hatch_being(
25
+ config: Path | None = typer.Option( # noqa: B008
26
+ None, "--config",
27
+ help="Non-interactive: load HatchConfig from YAML.",
28
+ ),
29
+ vault_root: Path | None = typer.Option( # noqa: B008
30
+ None, "--vault-root", "--root",
31
+ help="Override the resolved vault root. Default: $HOME.",
32
+ ),
33
+ daemon_config_dir: Path | None = typer.Option( # noqa: B008
34
+ None, "--daemon-config-dir",
35
+ help="Override the daemon's config directory. Default: ~/.agent-core/.",
36
+ ),
37
+ init_missing: bool = typer.Option(
38
+ False, "--init-missing",
39
+ help="Top-up an existing vault with newly-added scaffolding files.",
40
+ ),
41
+ ) -> None:
42
+ if config is not None:
43
+ raw = yaml.safe_load(config.read_text(encoding="utf-8")) or {}
44
+ cfg = HatchConfig(**raw)
45
+ interactive = False
46
+ else:
47
+ cfg = run_wizard()
48
+ interactive = True
49
+
50
+ if vault_root is not None:
51
+ cfg = cfg.model_copy(update={"vault_root": str(vault_root)})
52
+ if daemon_config_dir is not None:
53
+ cfg = cfg.model_copy(update={"daemon_config_dir": str(daemon_config_dir)})
54
+ if init_missing:
55
+ cfg = cfg.model_copy(update={"init_missing": True})
56
+
57
+ try:
58
+ result = Hatcher(cfg).hatch()
59
+ except VaultExistsError as exc:
60
+ typer.secho(str(exc), fg=typer.colors.RED)
61
+ raise typer.Exit(code=1) from None
62
+
63
+ letter_authored = False
64
+ if interactive:
65
+ letter_authored = offer_letter_authoring(cfg)
66
+
67
+ if not cfg.init_missing:
68
+ daemon_check_status = reload_and_probe(cfg)
69
+ else:
70
+ daemon_check_status = "skipped"
71
+
72
+ report_path = write_hatching_report(
73
+ cfg, result,
74
+ letter_authored=letter_authored,
75
+ daemon_check_status=daemon_check_status,
76
+ )
77
+
78
+ typer.echo(f"\nHatched at {result.vault_root}")
79
+ typer.echo(f"Report: {report_path}")
80
+ typer.echo("🐣")
81
+
82
+ if daemon_check_status == "start_failed":
83
+ typer.secho(
84
+ "✗ Daemon FAILED to restart after hatch — the fleet may be OFFLINE. "
85
+ "Run `agent-core daemon start` now.",
86
+ fg=typer.colors.RED,
87
+ err=True,
88
+ )
89
+ raise typer.Exit(code=1)
90
+
91
+ if daemon_check_status == "reachable_but_missing":
92
+ typer.secho(
93
+ "⚠ Daemon probe: endpoint not registered after reload. See HATCHING-REPORT.md.",
94
+ fg=typer.colors.RED,
95
+ err=True,
96
+ )
97
+ raise typer.Exit(code=1)
@@ -0,0 +1,117 @@
1
+ """HatchConfig — pydantic model for the hatcher's input contract.
2
+
3
+ Both the TUI wizard and `--config <yaml>` mode produce instances of this
4
+ model. The model is the single source of truth for what inputs the
5
+ hatcher needs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from datetime import date
13
+ from pathlib import Path
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
16
+
17
+ _ENV_VAR_PATTERN = re.compile(r"\$\{([A-Z_][A-Z0-9_]*)\}")
18
+
19
+
20
+ def _substitute_env_vars(value: str) -> str:
21
+ """Replace ${VAR} occurrences with os.environ[VAR]. Raises ValueError if missing."""
22
+
23
+ def repl(match: re.Match[str]) -> str:
24
+ var_name = match.group(1)
25
+ if var_name not in os.environ:
26
+ raise ValueError(
27
+ f"Environment variable {var_name!r} referenced in config but not set"
28
+ )
29
+ return os.environ[var_name]
30
+
31
+ return _ENV_VAR_PATTERN.sub(repl, value)
32
+
33
+
34
+ class DiscordChannelConfig(BaseModel):
35
+ enabled: bool = False
36
+ token: str = ""
37
+ channel_allowlist: list[str] = Field(default_factory=list)
38
+
39
+ model_config = ConfigDict(extra="forbid")
40
+
41
+ @property
42
+ def resolved_token(self) -> str:
43
+ return _substitute_env_vars(self.token)
44
+
45
+
46
+ class WebcamChannelConfig(BaseModel):
47
+ enabled: bool = False
48
+
49
+ model_config = ConfigDict(extra="forbid")
50
+
51
+
52
+ class GitHubBackupConfig(BaseModel):
53
+ enabled: bool = False
54
+ repo_url: str = ""
55
+
56
+ model_config = ConfigDict(extra="forbid")
57
+
58
+
59
+ class ChannelsConfig(BaseModel):
60
+ discord: DiscordChannelConfig = Field(default_factory=DiscordChannelConfig)
61
+ webcam: WebcamChannelConfig = Field(default_factory=WebcamChannelConfig)
62
+ github_backup: GitHubBackupConfig = Field(default_factory=GitHubBackupConfig)
63
+
64
+ model_config = ConfigDict(extra="forbid")
65
+
66
+
67
+ class HatchConfig(BaseModel):
68
+ being_name: str
69
+ being_emoji: str = ""
70
+ primary_human_name: str
71
+ being_role_placeholder: str | None = None
72
+ # endpoint_name is Optional[str]; after validation, None is replaced by being_name_lower.
73
+ # This allows `endpoint_name: foo` in YAML to override, while defaulting to being_name_lower.
74
+ endpoint_name: str | None = None
75
+ vault_root: str | None = None
76
+ daemon_config_dir: str = "~/.agent-core"
77
+ channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
78
+ init_missing: bool = False
79
+ author_letter_in_editor: bool = True
80
+
81
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
82
+
83
+ @model_validator(mode="after")
84
+ def _resolve_defaults(self) -> HatchConfig:
85
+ if self.endpoint_name is None:
86
+ self.endpoint_name = self.being_name.lower()
87
+ return self
88
+
89
+ @computed_field
90
+ @property
91
+ def being_name_lower(self) -> str:
92
+ return self.being_name.lower()
93
+
94
+ @computed_field
95
+ @property
96
+ def being_name_upper(self) -> str:
97
+ return self.being_name.upper()
98
+
99
+ @computed_field
100
+ @property
101
+ def hatched_date(self) -> str:
102
+ return date.today().isoformat()
103
+
104
+ @computed_field
105
+ @property
106
+ def discord_token_env(self) -> str:
107
+ return f"DISCORD_{self.being_name_upper}_TOKEN"
108
+
109
+ def resolved_vault_root(self) -> Path:
110
+ root = Path(self.vault_root or "~").expanduser().resolve()
111
+ return (root / f".{self.being_name_lower}").resolve()
112
+
113
+ def resolved_vault_memory_path(self) -> Path:
114
+ return self.resolved_vault_root() / "Memory"
115
+
116
+ def resolved_daemon_config_dir(self) -> Path:
117
+ return Path(self.daemon_config_dir).expanduser().resolve()
@@ -0,0 +1,82 @@
1
+ """Write daemon-config fragments for a hatched being.
2
+
3
+ Outputs (under <daemon_config_dir>):
4
+ - endpoints.d/<being>.yaml — claude_code_mcp + briefs.<being> always; +
5
+ discord-<being> and webcam-<being> if those channels were enabled.
6
+ - jobs.d/<being>.yaml — 5 always-on scheduler jobs prefixed with being
7
+ name; + <being>-github_backup if GitHub backup was enabled.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from agent_core_hatchery.config import HatchConfig
17
+ from agent_core_hatchery.hatcher import TEMPLATES_DIR
18
+ from agent_core_hatchery.renderer import Renderer
19
+
20
+
21
+ class DaemonConfigWriter:
22
+ def __init__(self, config: HatchConfig, templates_dir: Path | None = None) -> None:
23
+ self._config = config
24
+ self._templates_dir = templates_dir or TEMPLATES_DIR
25
+ self._renderer = Renderer(config)
26
+
27
+ def write_all(self) -> list[Path]:
28
+ written: list[Path] = []
29
+ written.append(self._write_endpoints_fragment())
30
+ written.append(self._write_jobs_fragment())
31
+ return written
32
+
33
+ def _write_endpoints_fragment(self) -> Path:
34
+ from agent_core_hatchery.channels.discord import scaffold_discord
35
+ from agent_core_hatchery.channels.webcam import scaffold_webcam
36
+
37
+ always_on_template = (self._templates_dir / "daemon-fragments" / "endpoints.yaml.j2").read_text(
38
+ encoding="utf-8"
39
+ )
40
+ always_on_yaml = self._renderer.render_string(always_on_template)
41
+ merged: dict = yaml.safe_load(always_on_yaml) or {}
42
+ merged.setdefault("endpoints", [])
43
+
44
+ for scaffold in (scaffold_discord, scaffold_webcam):
45
+ block = scaffold(self._config)
46
+ if block is not None:
47
+ merged["endpoints"].append(block)
48
+
49
+ dest_dir = self._config.resolved_daemon_config_dir() / "endpoints.d"
50
+ dest = dest_dir / f"{self._config.being_name_lower}.yaml"
51
+ if dest.exists():
52
+ raise FileExistsError(
53
+ f"daemon endpoints fragment already exists: {dest.name} "
54
+ f"(refusing to overwrite — mv aside or remove manually)"
55
+ )
56
+ dest_dir.mkdir(parents=True, exist_ok=True)
57
+ dest.write_text(yaml.safe_dump(merged, sort_keys=False), encoding="utf-8")
58
+ return dest
59
+
60
+ def _write_jobs_fragment(self) -> Path:
61
+ from agent_core_hatchery.channels.github_backup import scaffold_github_backup
62
+
63
+ always_on_template = (self._templates_dir / "daemon-fragments" / "jobs.yaml.j2").read_text(
64
+ encoding="utf-8"
65
+ )
66
+ always_on_yaml = self._renderer.render_string(always_on_template)
67
+ merged: dict = yaml.safe_load(always_on_yaml) or {}
68
+
69
+ github_job = scaffold_github_backup(self._config)
70
+ if github_job:
71
+ for k, v in github_job.items():
72
+ merged[k] = v
73
+
74
+ dest_dir = self._config.resolved_daemon_config_dir() / "jobs.d"
75
+ dest = dest_dir / f"{self._config.being_name_lower}.yaml"
76
+ if dest.exists():
77
+ raise FileExistsError(
78
+ f"daemon jobs fragment already exists: {dest.name}"
79
+ )
80
+ dest_dir.mkdir(parents=True, exist_ok=True)
81
+ dest.write_text(yaml.safe_dump(merged, sort_keys=False), encoding="utf-8")
82
+ return dest
@@ -0,0 +1,140 @@
1
+ """Daemon reload and endpoint health-probe for hatch→run handoff (Cβ-3, issue #327).
2
+
3
+ SRP: this module does two things only — restart the daemon process and HTTP-probe the
4
+ new being's endpoint. Imports HatchConfig and DaemonCheckStatus from agent_core_hatchery;
5
+ no cycle exists (daemon_probe is not imported by hatcher.py or config.py).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+
18
+ from agent_core_hatchery.config import HatchConfig
19
+ from agent_core_hatchery.report import DaemonCheckStatus
20
+
21
+
22
+ def read_daemon_http_config(daemon_config_dir: Path) -> tuple[str, int]:
23
+ """Parse agent_core.yaml for the bus HTTP bind host and port.
24
+
25
+ Returns (bind_host, bind_port). Falls back to ("127.0.0.1", 8789) if
26
+ the file does not exist or the keys are absent.
27
+ """
28
+ config_path = daemon_config_dir / "agent_core.yaml"
29
+ if not config_path.is_file():
30
+ return ("127.0.0.1", 8789)
31
+ try:
32
+ raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
33
+ except yaml.YAMLError:
34
+ return ("127.0.0.1", 8789)
35
+ http = raw.get("http", {}) if isinstance(raw, dict) else {}
36
+ host = str(http.get("bind_host", "127.0.0.1"))
37
+ port = int(http.get("bind_port", 8789))
38
+ return (host, port)
39
+
40
+
41
+ def _stop_daemon(runner=subprocess.run) -> None:
42
+ """Best-effort daemon stop. Never raises; failures are absorbed."""
43
+ try:
44
+ runner(
45
+ ["agent-core", "daemon", "stop"],
46
+ check=False,
47
+ capture_output=True,
48
+ timeout=15,
49
+ )
50
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
51
+ pass # agent-core not on PATH or daemon already stopped — probe decides outcome
52
+
53
+
54
+ def _start_daemon(runner=subprocess.run) -> bool:
55
+ """Start the daemon. Returns True iff the start command exited 0.
56
+
57
+ Unlike ``_stop_daemon``, a failed START is NOT best-effort: the daemon was
58
+ just stopped, so a failure here leaves the whole fleet offline. The result
59
+ is returned (not swallowed) so the caller can surface it loudly.
60
+ """
61
+ try:
62
+ proc = runner(
63
+ ["agent-core", "daemon", "start"],
64
+ check=False,
65
+ capture_output=True,
66
+ timeout=15,
67
+ )
68
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
69
+ return False
70
+ return proc.returncode == 0
71
+
72
+
73
+ def _probe_endpoint(
74
+ host: str,
75
+ port: int,
76
+ endpoint_name: str,
77
+ *,
78
+ timeout: float = 15.0,
79
+ poll_interval: float = 0.5,
80
+ ) -> DaemonCheckStatus:
81
+ """Poll http://{host}:{port}/mcp/{endpoint_name}/ until a response or timeout.
82
+
83
+ Returns:
84
+ "reachable_and_registered" — any non-404 HTTP response (endpoint is mounted).
85
+ "reachable_but_missing" — HTTP 404 (daemon up; endpoint not registered).
86
+ "unreachable" — polling timed out or connection always refused.
87
+ """
88
+ url = f"http://{host}:{port}/mcp/{endpoint_name}/"
89
+ deadline = time.monotonic() + timeout
90
+
91
+ while time.monotonic() < deadline:
92
+ try:
93
+ with urllib.request.urlopen(url, timeout=2.0) as _resp:
94
+ # Any 2xx or redirect means the endpoint is mounted.
95
+ return "reachable_and_registered"
96
+ except urllib.error.HTTPError as exc:
97
+ if exc.code == 404:
98
+ return "reachable_but_missing"
99
+ # Other HTTP error (405, 500, etc.) — server is up, endpoint mounted.
100
+ return "reachable_and_registered"
101
+ except (urllib.error.URLError, OSError):
102
+ # Connection refused / reset — daemon not yet up. Retry.
103
+ time.sleep(poll_interval)
104
+
105
+ return "unreachable"
106
+
107
+
108
+ def reload_and_probe(
109
+ config: HatchConfig,
110
+ *,
111
+ timeout: float = 15.0,
112
+ runner=subprocess.run,
113
+ ) -> DaemonCheckStatus:
114
+ """Stop the daemon, start it, then probe the new being's endpoint.
115
+
116
+ Returns DaemonCheckStatus: see report.py for semantics. Called from cli.py after
117
+ Hatcher.hatch() succeeds (fragments + venv + .mcp.json already written).
118
+ """
119
+ daemon_config_dir = config.resolved_daemon_config_dir()
120
+
121
+ # Bail early if there is no daemon config to read.
122
+ if not (daemon_config_dir / "agent_core.yaml").is_file():
123
+ return "unreachable"
124
+
125
+ host, port = read_daemon_http_config(daemon_config_dir)
126
+
127
+ _stop_daemon(runner)
128
+ if not _start_daemon(runner):
129
+ # The daemon was stopped but could not be brought back up — the whole
130
+ # fleet is offline. Surface as a hard failure, not a soft "unreachable"
131
+ # (which reads as "daemon up, endpoint will register shortly").
132
+ return "start_failed"
133
+
134
+ return _probe_endpoint(
135
+ host,
136
+ port,
137
+ config.endpoint_name,
138
+ timeout=timeout,
139
+ poll_interval=0.5,
140
+ )
@@ -0,0 +1,65 @@
1
+ """Elder-letter resolution: canonical-path preferred, bundled-snapshot fallback.
2
+
3
+ Each new being's vault is seeded with letters from beings who came
4
+ before. Each elder's canonical letter lives in HER own vault (vault is
5
+ hers, traveling artifacts honor that). The hatchery package ships
6
+ release-time snapshots as a fallback for users who don't have the
7
+ elder's vault on their machine.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import warnings
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+
20
+ class SourceKind(StrEnum):
21
+ CANONICAL = "canonical"
22
+ BUNDLED = "bundled"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ResolvedLetter:
27
+ name: str
28
+ source: Path
29
+ source_kind: SourceKind
30
+ content: str
31
+
32
+
33
+ def resolve_elder_letters(manifest_path: Path, bundled_dir: Path) -> list[ResolvedLetter]:
34
+ manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
35
+ resolved: list[ResolvedLetter] = []
36
+ for entry in manifest.get("elders", []) or []:
37
+ name = entry["name"]
38
+ canonical = Path(entry["canonical_path"]).expanduser()
39
+ bundled = bundled_dir / entry["bundled_basename"]
40
+
41
+ if canonical.is_file():
42
+ resolved.append(
43
+ ResolvedLetter(
44
+ name=name,
45
+ source=canonical,
46
+ source_kind=SourceKind.CANONICAL,
47
+ content=canonical.read_text(encoding="utf-8"),
48
+ )
49
+ )
50
+ elif bundled.is_file():
51
+ resolved.append(
52
+ ResolvedLetter(
53
+ name=name,
54
+ source=bundled,
55
+ source_kind=SourceKind.BUNDLED,
56
+ content=bundled.read_text(encoding="utf-8"),
57
+ )
58
+ )
59
+ else:
60
+ warnings.warn(
61
+ f"Elder letter for {name!r} not found at canonical {canonical} "
62
+ f"or bundled {bundled} — skipping",
63
+ stacklevel=2,
64
+ )
65
+ return resolved