|
| 1 | +"""Generator for the bundled ``src/openarmature/AGENTS.md`` agent docs. |
| 2 | +
|
| 3 | +Pulls from canonical sources (pinned spec submodule, patterns docs, |
| 4 | +hand-curated agent docs, example program docstrings) and concatenates |
| 5 | +into a single agent-discoverable file shipped in the wheel. |
| 6 | +
|
| 7 | +Sources, in order of bundle layout: |
| 8 | +
|
| 9 | +1. Self-reference header — version-stamped, pointers out to the docs |
| 10 | + site and the spec capabilities page. |
| 11 | +2. ``docs/agent/tldr.md`` — hand-written 3-5 sentence orientation. |
| 12 | +3. Capability summaries — §1 (Purpose) + §2 (Concepts) of each |
| 13 | + capability spec, read from the pinned ``openarmature-spec`` |
| 14 | + submodule via ``git show <sha>:spec/...`` rather than the |
| 15 | + working tree. |
| 16 | +4. ``docs/patterns/*.md`` — verbatim concatenation of the patterns |
| 17 | + docs (excluding ``index.md``). |
| 18 | +5. ``docs/agent/non-obvious-shapes.md`` — hand-written opinionated |
| 19 | + recipes. |
| 20 | +6. Example index — one-line description + path for each |
| 21 | + ``examples/*/main.py`` program. |
| 22 | +7. Discovery footer — pointer back out to docs / spec / host |
| 23 | + project conventions. |
| 24 | +
|
| 25 | +Build-time invariants (matches proposal-0028 follow-on review's |
| 26 | +submodule-pin discipline): |
| 27 | +
|
| 28 | +- Submodule HEAD MUST be reachable from a ``v*`` tag. The build |
| 29 | + refuses to read draft (untagged) spec text into a release bundle. |
| 30 | +- Spec text is read from the pinned commit via ``git show``, NOT |
| 31 | + from the submodule working tree. Closes the "submodule HEAD |
| 32 | + moved but bundle still reads stale tree" failure mode. |
| 33 | +
|
| 34 | +Drift between the committed bundle and the regenerated output is |
| 35 | +caught by ``tests/test_agents_md_drift.py``. |
| 36 | +""" |
| 37 | + |
| 38 | +from __future__ import annotations |
| 39 | + |
| 40 | +import subprocess |
| 41 | +import sys |
| 42 | +from pathlib import Path |
| 43 | + |
| 44 | +# Make ``openarmature`` importable without requiring an editable install |
| 45 | +# pass through ``uv`` — the build script runs locally and on CI. |
| 46 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 47 | +sys.path.insert(0, str(REPO_ROOT / "src")) |
| 48 | + |
| 49 | +import openarmature # noqa: E402 |
| 50 | + |
| 51 | +SPEC_ROOT = REPO_ROOT / "openarmature-spec" |
| 52 | +DOCS = REPO_ROOT / "docs" |
| 53 | +EXAMPLES = REPO_ROOT / "examples" |
| 54 | +OUTPUT = REPO_ROOT / "src" / "openarmature" / "AGENTS.md" |
| 55 | + |
| 56 | +# Spec capability directory names under ``openarmature-spec/spec/``, |
| 57 | +# in the order they appear in the bundle's "Capability contracts" |
| 58 | +# section. The order matches the order capabilities were introduced |
| 59 | +# (graph-engine first, prompt-management most recent) so an agent |
| 60 | +# reading top-down sees the foundational layer before the layers |
| 61 | +# built on top. |
| 62 | +CAPABILITIES = ( |
| 63 | + "graph-engine", |
| 64 | + "pipeline-utilities", |
| 65 | + "llm-provider", |
| 66 | + "observability", |
| 67 | + "prompt-management", |
| 68 | +) |
| 69 | + |
| 70 | + |
| 71 | +def _git_in_spec(*args: str) -> str: |
| 72 | + """Run ``git -C openarmature-spec <args>`` and return stdout stripped.""" |
| 73 | + return subprocess.run( |
| 74 | + ["git", "-C", str(SPEC_ROOT), *args], |
| 75 | + capture_output=True, |
| 76 | + text=True, |
| 77 | + check=True, |
| 78 | + ).stdout.strip() |
| 79 | + |
| 80 | + |
| 81 | +def _assert_pin_at_tag() -> str: |
| 82 | + """Confirm the submodule HEAD is at a ``v*`` tag. |
| 83 | +
|
| 84 | + Returns the tag name (e.g., ``v0.22.1``). Raises ``RuntimeError`` |
| 85 | + on a non-tag pin so a release can't accidentally ship a bundle |
| 86 | + pinned to a draft spec commit. |
| 87 | + """ |
| 88 | + sha = _git_in_spec("rev-parse", "HEAD") |
| 89 | + tags_out = _git_in_spec("tag", "--points-at", sha, "--list", "v*") |
| 90 | + if not tags_out: |
| 91 | + raise RuntimeError( |
| 92 | + f"submodule HEAD {sha[:8]} is not at a v* tag; " |
| 93 | + f"bundle build refuses to read draft (untagged) spec text. " |
| 94 | + f"Pin the submodule to a published tag before regenerating." |
| 95 | + ) |
| 96 | + # Prefer the highest version tag if multiple point at the same SHA |
| 97 | + # (e.g., during a re-tag) — sort by version-string descending. |
| 98 | + tags = sorted(tags_out.splitlines(), reverse=True) |
| 99 | + return tags[0] |
| 100 | + |
| 101 | + |
| 102 | +def _read_pinned_spec(path_in_spec: str) -> str: |
| 103 | + """Read a file from the pinned spec commit via ``git show``. |
| 104 | +
|
| 105 | + Distinct from reading the working tree: a stale checkout would |
| 106 | + silently produce stale bundle content. ``git show HEAD:<path>`` |
| 107 | + always reads from the recorded commit. |
| 108 | + """ |
| 109 | + return subprocess.run( |
| 110 | + ["git", "-C", str(SPEC_ROOT), "show", f"HEAD:{path_in_spec}"], |
| 111 | + capture_output=True, |
| 112 | + text=True, |
| 113 | + check=True, |
| 114 | + ).stdout |
| 115 | + |
| 116 | + |
| 117 | +def _header(version: str, spec_tag: str) -> str: |
| 118 | + return ( |
| 119 | + f"# OpenArmature — Agent documentation\n" |
| 120 | + f"\n" |
| 121 | + f"*This is the agent guide bundled with the openarmature Python package, " |
| 122 | + f"version {version} (spec {spec_tag}). For the full docs site see " |
| 123 | + f"[openarmature.ai](https://openarmature.ai). For the canonical spec text see " |
| 124 | + f"[openarmature.ai/capabilities](https://openarmature.ai/capabilities/). " |
| 125 | + f"For project-specific conventions for the code you're editing, see the host " |
| 126 | + f"project's `AGENTS.md` or `CLAUDE.md`.*" |
| 127 | + ) |
| 128 | + |
| 129 | + |
| 130 | +def _tldr() -> str: |
| 131 | + body = (DOCS / "agent" / "tldr.md").read_text().strip() |
| 132 | + return f"## TL;DR\n\n{body}" |
| 133 | + |
| 134 | + |
| 135 | +def _extract_sections_1_2(spec_text: str) -> str: |
| 136 | + """Extract content between ``## 1.`` and ``## 3.`` (inclusive of §1+§2).""" |
| 137 | + out: list[str] = [] |
| 138 | + in_target = False |
| 139 | + for line in spec_text.splitlines(): |
| 140 | + if line.startswith("## 1."): |
| 141 | + in_target = True |
| 142 | + elif line.startswith("## 3."): |
| 143 | + break |
| 144 | + if in_target: |
| 145 | + out.append(line) |
| 146 | + if not out: |
| 147 | + raise RuntimeError( |
| 148 | + "spec heading-extraction failed: no `## 1.` heading found. " |
| 149 | + "Spec capability may have renumbered; revisit the build script." |
| 150 | + ) |
| 151 | + return "\n".join(out).rstrip() |
| 152 | + |
| 153 | + |
| 154 | +def _capability_summaries(spec_tag: str) -> str: |
| 155 | + sections = [ |
| 156 | + "## Capability contracts", |
| 157 | + "", |
| 158 | + f"_Sourced from openarmature-spec {spec_tag}. Each entry below " |
| 159 | + f"reproduces §1 (Purpose) and §2 (Concepts) of the capability's " |
| 160 | + f"`spec.md`. For the full spec text (execution model, error semantics, " |
| 161 | + f"determinism, observer hooks, etc.) see the linked docs site._", |
| 162 | + ] |
| 163 | + for cap in CAPABILITIES: |
| 164 | + text = _read_pinned_spec(f"spec/{cap}/spec.md") |
| 165 | + sections.append("") |
| 166 | + sections.append(f"### Capability: `{cap}`") |
| 167 | + sections.append("") |
| 168 | + sections.append(_extract_sections_1_2(text)) |
| 169 | + return "\n".join(sections) |
| 170 | + |
| 171 | + |
| 172 | +def _patterns() -> str: |
| 173 | + sections = [ |
| 174 | + "## Patterns", |
| 175 | + "", |
| 176 | + "_Recipes that compose the primitives. Not framework contracts — " |
| 177 | + "these are how to do common things idiomatically._", |
| 178 | + ] |
| 179 | + pattern_files = sorted(p for p in (DOCS / "patterns").glob("*.md") if p.name != "index.md") |
| 180 | + for pf in pattern_files: |
| 181 | + sections.append("") |
| 182 | + sections.append(pf.read_text().rstrip()) |
| 183 | + return "\n".join(sections) |
| 184 | + |
| 185 | + |
| 186 | +def _non_obvious_shapes() -> str: |
| 187 | + # The file's own top-level heading is `## Non-obvious shapes`; |
| 188 | + # inlined verbatim with the heading intact. |
| 189 | + return (DOCS / "agent" / "non-obvious-shapes.md").read_text().rstrip() |
| 190 | + |
| 191 | + |
| 192 | +def _extract_first_docstring_paragraph(source: str) -> str: |
| 193 | + """Extract the first paragraph of a Python module docstring. |
| 194 | +
|
| 195 | + Module docstrings open with a triple-quoted string at line 0. |
| 196 | + The first "paragraph" is the text from the opening quotes to |
| 197 | + the first blank line within the docstring (or to the closing |
| 198 | + quotes if the docstring is one paragraph). |
| 199 | + """ |
| 200 | + lines = source.splitlines() |
| 201 | + if not lines or not lines[0].startswith('"""'): |
| 202 | + return "" |
| 203 | + # First line after the opening triple-quote |
| 204 | + first_text = lines[0][3:].rstrip() |
| 205 | + if first_text.endswith('"""'): |
| 206 | + return first_text[:-3].rstrip() |
| 207 | + para = [first_text] if first_text else [] |
| 208 | + for line in lines[1:]: |
| 209 | + stripped = line.strip() |
| 210 | + if stripped == "" or stripped.startswith('"""') or stripped.endswith('"""'): |
| 211 | + break |
| 212 | + para.append(stripped) |
| 213 | + return " ".join(p for p in para if p) |
| 214 | + |
| 215 | + |
| 216 | +def _example_index() -> str: |
| 217 | + sections = [ |
| 218 | + "## Example index", |
| 219 | + "", |
| 220 | + "_Runnable example programs shipped in the source tree at `examples/`. " |
| 221 | + "The full code is not bundled here (each example is 300+ lines); read " |
| 222 | + "the file at the listed path to see the canonical shape for that use case._", |
| 223 | + "", |
| 224 | + ] |
| 225 | + for ex in sorted(EXAMPLES.glob("*/main.py")): |
| 226 | + first_paragraph = _extract_first_docstring_paragraph(ex.read_text()) |
| 227 | + rel = ex.relative_to(REPO_ROOT) |
| 228 | + sections.append(f"- **`{rel}`** — {first_paragraph}") |
| 229 | + return "\n".join(sections) |
| 230 | + |
| 231 | + |
| 232 | +def _discovery_footer() -> str: |
| 233 | + return ( |
| 234 | + "## Discovery cross-references\n" |
| 235 | + "\n" |
| 236 | + "If your question isn't covered above, look here:\n" |
| 237 | + "\n" |
| 238 | + "- **Full docs site:** [openarmature.ai](https://openarmature.ai)\n" |
| 239 | + "- **Spec text:** [openarmature.ai/capabilities](https://openarmature.ai/capabilities/)\n" |
| 240 | + "- **API reference:** [openarmature.ai/reference](https://openarmature.ai/reference/)\n" |
| 241 | + "- **Host project conventions:** the project's own `AGENTS.md` / `CLAUDE.md`\n" |
| 242 | + ) |
| 243 | + |
| 244 | + |
| 245 | +def build() -> str: |
| 246 | + spec_tag = _assert_pin_at_tag() |
| 247 | + version = openarmature.__version__ |
| 248 | + sections = [ |
| 249 | + _header(version, spec_tag), |
| 250 | + _tldr(), |
| 251 | + _capability_summaries(spec_tag), |
| 252 | + _patterns(), |
| 253 | + _non_obvious_shapes(), |
| 254 | + _example_index(), |
| 255 | + _discovery_footer(), |
| 256 | + ] |
| 257 | + return "\n\n".join(sections) + "\n" |
| 258 | + |
| 259 | + |
| 260 | +def main() -> None: |
| 261 | + content = build() |
| 262 | + OUTPUT.write_text(content) |
| 263 | + line_count = content.count("\n") |
| 264 | + byte_count = len(content.encode("utf-8")) |
| 265 | + print(f"wrote {OUTPUT.relative_to(REPO_ROOT)}: {line_count} lines, {byte_count:,} bytes") |
| 266 | + |
| 267 | + |
| 268 | +if __name__ == "__main__": |
| 269 | + main() |
0 commit comments