diff --git a/README.md b/README.md index 82e6430..c8da371 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,30 @@ Auto-detection: if the project sits inside a CompanyOS layout (`builds/ Pattern files are named `CANDIDATE-.md`. Promote a candidate to a real pattern by dropping the `CANDIDATE-` prefix **only after** it has worked on at least one additional project. +### `socrates pack [path]` — assemble an Architect input bundle + +Concatenates every load-bearing planning file (AGENTS, README, STATE, DOMAIN, DECISIONS, RISKS, QUESTIONS) plus the active sprint's four files into one paste-able bundle for the Architect (Claude Chat / ChatGPT / etc.). Output goes to `.socrates-architect-pack.` in the project root, or to stdout with `--stdout`. + +```bash +socrates pack # write .socrates-architect-pack.md +socrates pack --stdout # print to stdout +socrates pack --sprint 002-rebate-engine # override the auto-detected sprint +socrates pack --include-philosophy # prepend socrates' own 120x stance summary +socrates pack --kit-path ~/120x-kit # also embed the kit's three load-bearing files +socrates pack --format xml # XML section delimiters around markdown bodies +socrates pack --format html # full HTML (requires the [html] extra) +``` + +| Flag | Effect | +|---|---| +| `--sprint ` | include this sprint folder instead of auto-detecting the highest-numbered one | +| `--stdout` | print to stdout instead of writing the file | +| `--include-philosophy` | prepend a short, original 120x stance written by socrates | +| `--kit-path PATH` | also embed the kit's three load-bearing files (or use `$SOCRATES_KIT_PATH`) | +| `--format md\|html\|xml` | output format. `md` (default), `xml` (markdown wrapped in `
` tags — matches Anthropic's prompt-engineering recommendation), `html` (full HTML, requires `pip install socrates120x[html]`) | + +The output file extension follows the format: `.md`, `.xml`, or `.html`. The default `md` keeps the historical behavior. `xml` adds ~5% token overhead but gives the Architect explicit structural delimiters. `html` adds ~30-50% token overhead and requires the optional `markdown` dependency — use it when you're explicitly testing whether full HTML helps the Architect on your specific content. + ### `socrates companyos ` — scaffold the macro layer Creates the CompanyOS skeleton: `clients/`, `builds/`, `pipeline/`, `patterns/`, `content/`, `reference/`, `daily/`, `templates/`, plus an `AGENTS.md` router that points each subfolder at its role. diff --git a/pyproject.toml b/pyproject.toml index 516de52..33a0598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,13 @@ dev = [ "pytest>=8.0", "ruff>=0.4", "mypy>=1.9", + "markdown>=3.5", # exercised by --format html tests +] +html = [ + # Required by `socrates pack --format html`. Optional because the + # default markdown pack format is pure-stdlib; users who never run + # the HTML format shouldn't have to install anything extra. + "markdown>=3.5", ] [project.scripts] diff --git a/src/socrates120x/cli.py b/src/socrates120x/cli.py index f3de197..dadf51d 100644 --- a/src/socrates120x/cli.py +++ b/src/socrates120x/cli.py @@ -140,6 +140,17 @@ def main(argv: list[str] | None = None) -> int: "embedded in the pack. Falls back to the $SOCRATES_KIT_PATH env var." ), ) + pack.add_argument( + "--format", choices=("md", "html", "xml"), default="md", + dest="pack_format", + help=( + "Output format. `md` (default) is plain markdown — the historical " + "behavior. `xml` wraps markdown bodies in
tags, matching " + "Anthropic's prompt-engineering recommendation for structural " + "delimitation. `html` produces full HTML (requires the optional " + "`markdown` package: `pip install socrates120x[html]`)." + ), + ) patterns = sub.add_parser( "patterns", @@ -489,11 +500,17 @@ def _cmd_pack(args: argparse.Namespace) -> int: "include_sprint": args.sprint, "include_philosophy": args.include_philosophy, "kit_path": args.kit_path, + "format": args.pack_format, } - if args.stdout: - print(build_pack(project, **kwargs)) - return 0 - target = write_pack(project, **kwargs) + try: + if args.stdout: + print(build_pack(project, **kwargs)) + return 0 + target = write_pack(project, **kwargs) + except RuntimeError as exc: + # Raised by --format html when the `markdown` package is missing. + print(f"error: {exc}", file=sys.stderr) + return 2 print(f"Wrote {target}") return 0 diff --git a/src/socrates120x/pack.py b/src/socrates120x/pack.py index b6d45a8..b2a6b04 100644 --- a/src/socrates120x/pack.py +++ b/src/socrates120x/pack.py @@ -6,8 +6,9 @@ bundle: one file containing every load-bearing planning document the Architect needs, in a stable order, separated by clearly-labelled headers. -Output goes to `.socrates-architect-pack.md` in the project root by default, -or to stdout with `--stdout`. +Output goes to `.socrates-architect-pack.` in the project root by +default, or to stdout with `--stdout`. The extension follows the chosen +format (md / html / xml). Optional preambles: @@ -20,14 +21,28 @@ three load-bearing files from a local 120x Operators Kit checkout (philosophy, scaffold-instructions, quickstart). Use this when you want the FULL kit context in the pack, not just socrates' short summary. + +Output format (``--format``): + +- ``md`` (default): plain markdown — the historical behavior. Easiest to + paste into a chat that's expecting markdown. +- ``xml``: section content stays markdown, wrapped in ``
`` tags. + Matches Anthropic's published prompt-engineering recommendation for + structural delimitation. ~5% token overhead vs. plain markdown. +- ``html``: full HTML, converted from the markdown via the optional + ``markdown`` library. Install with ``pip install socrates120x[html]`` + or ``pip install markdown``. ~30-50% token overhead vs. markdown. """ from __future__ import annotations import datetime as _dt import os +from dataclasses import dataclass from importlib import resources from pathlib import Path +from typing import Any, Literal +from xml.sax.saxutils import escape as _xml_escape # Files the kit-path option looks for, in order, in the kit directory. KIT_FILES: tuple[str, ...] = ( @@ -36,6 +51,23 @@ "120x-quickstart.md", ) +PackFormat = Literal["md", "html", "xml"] +SUPPORTED_FORMATS: tuple[PackFormat, ...] = ("md", "html", "xml") +FORMAT_EXTENSIONS: dict[PackFormat, str] = {"md": "md", "html": "html", "xml": "xml"} + + +@dataclass(frozen=True) +class _Section: + """One section of the pack — label, file source (if any), body, kind.""" + label: str + body: str + # `path` is the on-disk source for traceability; None for synthetic + # sections (header, footer, philosophy preamble, sprint divider). + path: str | None = None + # Discriminator for the XML/HTML renderers so they can pick semantic + # tags. The MD renderer doesn't use this. + kind: str = "section" + def build_pack( project: Path, @@ -43,39 +75,32 @@ def build_pack( include_sprint: str | None = None, include_philosophy: bool = False, kit_path: Path | None = None, + format: PackFormat = "md", ) -> str: - """Return the full Architect input bundle as a single markdown string.""" - sections: list[str] = [] - sections.append(_header(project)) - - if include_philosophy: - sections.append(_philosophy_preamble()) + """Return the full Architect input bundle as a single string. - resolved_kit = _resolve_kit_path(kit_path) - if resolved_kit is not None: - sections.extend(_kit_sections(resolved_kit)) - - sections.append(_load("AGENTS.md", project, label="Project router")) - sections.append(_load("README.md", project, label="Project README")) - sections.append(_load("planning/STATE.md", project, label="Current state")) - sections.append(_load("planning/DOMAIN.md", project, label="Client domain")) - sections.append(_load("planning/DECISIONS.md", project, label="Decisions")) - sections.append(_load("planning/RISKS.md", project, label="Risks")) - sections.append(_load("planning/QUESTIONS.md", project, label="Open questions")) + ``format`` selects the output language: + - ``md`` (default): plain markdown + - ``xml``: markdown wrapped in
tags + - ``html``: full HTML (requires the optional `markdown` package) + """ + if format not in SUPPORTED_FORMATS: + raise ValueError( + f"format must be one of {SUPPORTED_FORMATS!r}, got {format!r}" + ) - sprint = _resolve_sprint(project, include_sprint) - if sprint is not None: - sections.append(f"# Active sprint: `{sprint.name}`\n") - for fname, label in ( - ("requirements.md", "Sprint requirements"), - ("blueprint.md", "Sprint blueprint"), - ("acceptance.md", "Sprint acceptance criteria"), - ("handoff-prompt.md", "Sprint handoff prompt (Builder)"), - ): - sections.append(_load_rel(sprint / fname, label=label)) + sections = _collect_sections( + project, + include_sprint=include_sprint, + include_philosophy=include_philosophy, + kit_path=kit_path, + ) - sections.append(_footer()) - return "\n\n".join(filter(None, sections)) + if format == "md": + return _render_md(sections) + if format == "xml": + return _render_xml(project, sections) + return _render_html(project, sections) def write_pack( @@ -84,69 +109,84 @@ def write_pack( include_sprint: str | None = None, include_philosophy: bool = False, kit_path: Path | None = None, + format: PackFormat = "md", ) -> Path: body = build_pack( project, include_sprint=include_sprint, include_philosophy=include_philosophy, kit_path=kit_path, + format=format, ) - target = project / ".socrates-architect-pack.md" + target = project / f".socrates-architect-pack.{FORMAT_EXTENSIONS[format]}" target.write_text(body) return target # --------------------------------------------------------------------------- -# Optional preambles +# Section collection (format-independent) # --------------------------------------------------------------------------- -def _philosophy_preamble() -> str: - """A short, original stance summary written by socrates. +def _collect_sections( + project: Path, + *, + include_sprint: str | None, + include_philosophy: bool, + kit_path: Path | None, +) -> list[_Section]: + out: list[_Section] = [_header_section(project)] - Loaded from ``socrates120x/templates/architect_preamble.md`` so the text - can be iterated without editing Python. Deliberately not copied from the - 120x Operators Kit — use ``--kit-path`` if you want the kit's own files - embedded in the pack. - """ - return resources.files("socrates120x").joinpath( - "templates/architect_preamble.md" - ).read_text(encoding="utf-8").rstrip() + if include_philosophy: + out.append(_philosophy_section()) + resolved_kit = _resolve_kit_path(kit_path) + if resolved_kit is not None: + out.extend(_kit_sections(resolved_kit)) + + for rel, label in ( + ("AGENTS.md", "Project router"), + ("README.md", "Project README"), + ("planning/STATE.md", "Current state"), + ("planning/DOMAIN.md", "Client domain"), + ("planning/DECISIONS.md", "Decisions"), + ("planning/RISKS.md", "Risks"), + ("planning/QUESTIONS.md", "Open questions"), + ): + out.append(_file_section(project / rel, rel_display=rel, label=label)) -def _resolve_kit_path(explicit: Path | None) -> Path | None: - if explicit is not None: - return explicit.expanduser().resolve() if explicit.exists() else None - env = os.environ.get("SOCRATES_KIT_PATH") - if env: - candidate = Path(env).expanduser().resolve() - return candidate if candidate.is_dir() else None - return None + sprint = _resolve_sprint(project, include_sprint) + if sprint is not None: + out.append(_Section( + label=f"Active sprint: `{sprint.name}`", + body="", + kind="sprint-header", + )) + sprint_rel = sprint.relative_to(project).as_posix() + for fname, label in ( + ("requirements.md", "Sprint requirements"), + ("blueprint.md", "Sprint blueprint"), + ("acceptance.md", "Sprint acceptance criteria"), + ("handoff-prompt.md", "Sprint handoff prompt (Builder)"), + ): + out.append(_file_section( + sprint / fname, + rel_display=f"{sprint_rel}/{fname}", + label=label, + )) - -def _kit_sections(kit: Path) -> list[str]: - sections: list[str] = [] - for name in KIT_FILES: - path = kit / name - if not path.is_file(): - continue - text = path.read_text(errors="replace").strip() - if not text: - continue - sections.append(f"# 120x Operators Kit: `{name}`\n\n{text}") - return sections + out.append(_footer_section()) + return out # --------------------------------------------------------------------------- -# Section helpers +# Section builders # --------------------------------------------------------------------------- -def _header(project: Path) -> str: +def _header_section(project: Path) -> _Section: today = _dt.date.today().isoformat() - return f"""# Architect input bundle — `{project.name}` - -_Generated {today} by `socrates pack`. Paste this entire file into your Architect + body = f"""_Generated {today} by `socrates pack`. Paste this entire file into your Architect session (Claude Chat / ChatGPT / etc.) as project context. The Architect should:_ 1. _Read every section below in order._ @@ -155,28 +195,204 @@ def _header(project: Path) -> str: (planning artifacts, prompts, acceptance criteria — never code)._ _The Builder layer is downstream of this conversation; do not write source code here._""" + return _Section( + label=f"Architect input bundle — `{project.name}`", + body=body, + kind="header", + ) -def _footer() -> str: - return ( - "---\n\n" +def _footer_section() -> _Section: + body = ( "_End of bundle. The Architect should now ask the operator what they need next, " "treating everything above as the source of truth._" ) + return _Section(label="", body=body, kind="footer") + + +def _philosophy_section() -> _Section: + """A short, original stance summary written by socrates. + + Loaded from ``socrates120x/templates/architect_preamble.md`` so the text + can be iterated without editing Python. Deliberately not copied from the + 120x Operators Kit — use ``--kit-path`` if you want the kit's own files + embedded in the pack. + + The preamble template already begins with a top-level markdown header, + so the renderers must avoid double-wrapping it. + """ + text = resources.files("socrates120x").joinpath( + "templates/architect_preamble.md" + ).read_text(encoding="utf-8").rstrip() + return _Section(label="", body=text, kind="preamble-raw") -def _load(rel: str, project: Path, *, label: str) -> str: - return _load_rel(project / rel, label=label) +def _kit_sections(kit: Path) -> list[_Section]: + out: list[_Section] = [] + for name in KIT_FILES: + path = kit / name + if not path.is_file(): + continue + text = path.read_text(errors="replace").strip() + if not text: + continue + out.append(_Section( + label=f"120x Operators Kit: `{name}`", + body=text, + path=name, + kind="kit", + )) + return out -def _load_rel(path: Path, *, label: str) -> str: - rel_display = path.name if path.parent.name == "" else path.as_posix() +def _file_section(path: Path, *, rel_display: str, label: str) -> _Section: if not path.is_file(): - return f"# {label} (`{rel_display}`)\n\n_(file not present — skipped)_" + return _Section( + label=f"{label} (`{rel_display}`)", + body="_(file not present — skipped)_", + path=rel_display, + kind="missing", + ) text = path.read_text(errors="replace").strip() if not text: - return f"# {label} (`{rel_display}`)\n\n_(file is empty)_" - return f"# {label} (`{rel_display}`)\n\n{text}" + return _Section( + label=f"{label} (`{rel_display}`)", + body="_(file is empty)_", + path=rel_display, + kind="empty", + ) + return _Section( + label=f"{label} (`{rel_display}`)", + body=text, + path=rel_display, + ) + + +# --------------------------------------------------------------------------- +# Renderers +# --------------------------------------------------------------------------- + + +def _render_md(sections: list[_Section]) -> str: + """Markdown renderer — the historical pack format.""" + parts: list[str] = [] + for s in sections: + if s.kind == "footer": + parts.append(f"---\n\n{s.body}") + continue + if s.kind == "preamble-raw": + # Template already has its own top-level header; emit as-is. + parts.append(s.body) + continue + if not s.label: + parts.append(s.body) + continue + if s.body: + parts.append(f"# {s.label}\n\n{s.body}") + else: + parts.append(f"# {s.label}\n") + return "\n\n".join(filter(None, parts)) + + +def _render_xml(project: Path, sections: list[_Section]) -> str: + """XML renderer — markdown bodies wrapped in
tags. + + Matches Anthropic's published recommendation to use XML-style tags for + structural delimitation when packing context for Claude. The section + body remains markdown; only the delimiters are XML. ~5% token overhead. + """ + today = _dt.date.today().isoformat() + out: list[str] = [ + f'' + ] + for s in sections: + attrs = f' kind="{_xml_escape(s.kind)}"' + if s.path: + attrs += f' path="{_xml_escape(s.path)}"' + if s.label: + attrs += f' label="{_xml_escape(s.label)}"' + # The body contains markdown — escape only the bare minimum so the + # markdown stays readable. Standard XML chars `<`, `>`, `&` need + # escaping; markdown's quote/apostrophe usage is irrelevant inside + # element content. + body = s.body.replace("&", "&").replace("<", "<").replace(">", ">") + if s.kind == "footer": + out.append(f" \n{body}\n ") + elif s.kind == "header": + out.append(f" \n{body}\n ") + else: + out.append(f" \n{body}\n
") + out.append("") + return "\n".join(out) + + +def _render_html(project: Path, sections: list[_Section]) -> str: + """HTML renderer — markdown converted via the optional `markdown` lib. + + Requires the ``markdown`` package. Install with + ``pip install socrates120x[html]`` or ``pip install markdown``. + """ + md = _import_markdown() + today = _dt.date.today().isoformat() + rendered_sections: list[str] = [] + for s in sections: + body_html = md.markdown( + s.body, + extensions=["fenced_code", "tables"], + ) if s.body else "" + tag = "section" + if s.kind == "header": + tag = "header" + elif s.kind == "footer": + tag = "footer" + attrs = f' data-kind="{_xml_escape(s.kind)}"' + if s.path: + attrs += f' data-path="{_xml_escape(s.path)}"' + label_html = "" + if s.label: + label_html = f"

{_xml_escape(s.label)}

\n" + rendered_sections.append( + f"<{tag}{attrs}>\n{label_html} {body_html}\n" + ) + body = "\n\n".join(rendered_sections) + return f""" + + + +Architect input bundle — {_xml_escape(project.name)} + + + + +{body} + +""" + + +def _import_markdown() -> Any: + try: + import markdown # type: ignore[import-untyped] + except ImportError as exc: # pragma: no cover - exercised by error-path test + raise RuntimeError( + "--format html requires the `markdown` package. Install with " + "`pip install socrates120x[html]` or `pip install markdown`." + ) from exc + return markdown + + +# --------------------------------------------------------------------------- +# Resolvers (kit, sprint) +# --------------------------------------------------------------------------- + + +def _resolve_kit_path(explicit: Path | None) -> Path | None: + if explicit is not None: + return explicit.expanduser().resolve() if explicit.exists() else None + env = os.environ.get("SOCRATES_KIT_PATH") + if env: + candidate = Path(env).expanduser().resolve() + return candidate if candidate.is_dir() else None + return None def _resolve_sprint(project: Path, name: str | None) -> Path | None: diff --git a/tests/test_cli_pack.py b/tests/test_cli_pack.py new file mode 100644 index 0000000..f4808e2 --- /dev/null +++ b/tests/test_cli_pack.py @@ -0,0 +1,123 @@ +"""CLI-level tests for `socrates pack --format`. + +The argparse wiring in cli.py was previously untested at the CLI level +(the project has unit tests for build_pack/write_pack but nothing that +exercises the actual `socrates pack` argparse path). These tests drive +``socrates120x.cli.main`` end-to-end so a regression in the CLI plumbing +(dest names, choices, defaults, the markdown-missing error path) gets +caught in CI. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from socrates120x.cli import main +from socrates120x.render import render_all +from socrates120x.scaffold import scaffold + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + p = tmp_path / "cli-demo" + scaffold(p) + render_all(p, { + "project_name": "cli-demo", "client": "AcmeCorp", "tagline": "demo", + "business_goal": "goal", "tech_stack": "Python", + "users": ["op"], "current_process": "manual", "terminology": [], + "business_rules": [], "decisions": ["choice — reason"], "out_of_scope": [], + "risks": ["risk one"], "fragile_inputs": "", "open_questions": ["q1?"], + "sprint1_goal": "", "sprint1_acceptance": ["one"], + "sprint1_inspect": [], "state_current": "", "state_next": "", "state_blockers": [], + }) + return p + + +def test_pack_default_format_writes_md(project: Path) -> None: + rc = main(["pack", str(project)]) + assert rc == 0 + assert (project / ".socrates-architect-pack.md").is_file() + + +def test_pack_format_md_explicit(project: Path) -> None: + rc = main(["pack", str(project), "--format", "md"]) + assert rc == 0 + assert (project / ".socrates-architect-pack.md").is_file() + + +def test_pack_format_xml_writes_xml(project: Path) -> None: + rc = main(["pack", str(project), "--format", "xml"]) + assert rc == 0 + target = project / ".socrates-architect-pack.xml" + assert target.is_file() + content = target.read_text() + assert content.startswith(" None: + rc = main(["pack", str(project), "--format", "html"]) + assert rc == 0 + target = project / ".socrates-architect-pack.html" + assert target.is_file() + content = target.read_text() + assert content.startswith("") + + +def test_pack_format_stdout_xml(project: Path, capsys: pytest.CaptureFixture[str]) -> None: + rc = main(["pack", str(project), "--stdout", "--format", "xml"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith(" None: + # argparse exits with SystemExit (rc=2) on invalid choice; main() never + # gets to return its own code. We catch the SystemExit and inspect stderr. + with pytest.raises(SystemExit) as exc: + main(["pack", str(project), "--format", "json"]) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "invalid choice" in err + assert "'json'" in err + + +def test_pack_format_html_error_when_markdown_missing( + project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Exercise the CLI's RuntimeError handling for the missing-markdown path.""" + import socrates120x.pack as pack_module + + def _raise() -> None: + raise RuntimeError( + "--format html requires the `markdown` package. Install with " + "`pip install socrates120x[html]` or `pip install markdown`." + ) + + monkeypatch.setattr(pack_module, "_import_markdown", _raise) + rc = main(["pack", str(project), "--format", "html"]) + # CLI should return exit code 2 (not let the exception propagate) and + # write the install hint to stderr. + assert rc == 2 + err = capsys.readouterr().err + assert "markdown" in err + assert "pip install" in err + + +def test_pack_format_help_text_lists_all_choices(capsys: pytest.CaptureFixture[str]) -> None: + # --help on argparse causes SystemExit(0); the help text goes to stdout. + with pytest.raises(SystemExit): + main(["pack", "--help"]) + out = capsys.readouterr().out + assert "--format" in out + assert "md" in out + assert "xml" in out + assert "html" in out diff --git a/tests/test_pack.py b/tests/test_pack.py index 18c6f92..10fb074 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -6,7 +6,11 @@ import pytest -from socrates120x.pack import build_pack, write_pack +from socrates120x.pack import ( + SUPPORTED_FORMATS, + build_pack, + write_pack, +) from socrates120x.render import render_all from socrates120x.scaffold import scaffold @@ -137,3 +141,131 @@ def test_pack_philosophy_and_kit_can_combine(project: Path, tmp_path: Path) -> N text = build_pack(project, include_philosophy=True, kit_path=kit) assert "120x Architect / Builder stance" in text # socrates preamble assert "Kit philosophy" in text # kit file + + +# --------------------------------------------------------------------------- +# --format md|html|xml +# --------------------------------------------------------------------------- + + +def test_supported_formats_constant() -> None: + # Guard against accidental drift between the constant and the CLI choices. + assert SUPPORTED_FORMATS == ("md", "html", "xml") + + +def test_pack_rejects_unknown_format(project: Path) -> None: + with pytest.raises(ValueError, match="format must be one of"): + build_pack(project, format="json") # type: ignore[arg-type] + + +def test_pack_md_format_is_default(project: Path) -> None: + # The unparameterized call (existing behavior) and an explicit md call + # produce identical output. + assert build_pack(project) == build_pack(project, format="md") + + +def test_pack_md_format_has_markdown_headers(project: Path) -> None: + text = build_pack(project, format="md") + # Markdown headers, not HTML/XML tags. + assert text.startswith("# Architect input bundle") + assert " None: + text = build_pack(project, format="xml") + # Bundle wrapper + at least one section + header + footer. + assert text.startswith("") + assert "
None: + text = build_pack(project, format="xml") + # The DECISIONS file should appear as a section with path + label attrs. + assert 'path="planning/DECISIONS.md"' in text + assert 'label="Decisions' in text + + +def test_pack_xml_format_escapes_xml_specials(tmp_path: Path) -> None: + # Build a minimal project whose content contains XML-special chars. + p = tmp_path / "xml-escape-demo" + scaffold(p) + (p / "planning" / "DECISIONS.md").write_text( + "# Decisions\n\n- chose over Y & Z for the foo>bar case\n" + ) + text = build_pack(p, format="xml") + # The body is inside an XML element — `<`, `>`, `&` must be escaped. + assert "<X>" in text + assert "Y & Z" in text + # Raw unescaped angle brackets from the content should NOT appear inside + # the element body. (We allow them in our own emitted tags, naturally.) + assert "" not in text + + +def test_pack_html_format_emits_doctype_and_html_tags(project: Path) -> None: + text = build_pack(project, format="html") + assert text.startswith("") + assert "" in text + assert "" in text + assert "" in text + assert text.rstrip().endswith("") + # Markdown should have been converted — headers become

/

/etc. + # The label of the AGENTS section appears as an

inside its section. + assert "

" in text + + +def test_pack_html_format_includes_section_kind_attr(project: Path) -> None: + text = build_pack(project, format="html") + # Header section gets a
tag with data-kind="header". + assert "
. + assert "
None: + md_target = write_pack(project, format="md") + assert md_target.suffix == ".md" + assert md_target.name == ".socrates-architect-pack.md" + + xml_target = write_pack(project, format="xml") + assert xml_target.suffix == ".xml" + assert xml_target.name == ".socrates-architect-pack.xml" + + html_target = write_pack(project, format="html") + assert html_target.suffix == ".html" + assert html_target.name == ".socrates-architect-pack.html" + + +def test_pack_all_formats_carry_user_content_through(project: Path) -> None: + # Independent of format, the operator's planning content must reach the + # output. AcmeCorp comes from the fixture's render_all answers. + for fmt in SUPPORTED_FORMATS: + text = build_pack(project, format=fmt) + assert "AcmeCorp" in text, f"format={fmt} dropped user content" + assert "choice" in text and "reason" in text, f"format={fmt} dropped a decision" + + +def test_pack_html_format_error_when_markdown_missing( + project: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Simulate the markdown package not being installed by making the + # internal import helper raise. This avoids actually uninstalling the + # dev-dep used by other tests. + import socrates120x.pack as pack_module + + def _raise() -> None: + raise RuntimeError( + "--format html requires the `markdown` package. Install with " + "`pip install socrates120x[html]` or `pip install markdown`." + ) + + monkeypatch.setattr(pack_module, "_import_markdown", _raise) + with pytest.raises(RuntimeError, match="requires the `markdown` package"): + build_pack(project, format="html")