diff --git a/docs/home/5-setup/project-organization.md b/docs/home/5-setup/project-organization.md
index c1c2f95b2..12ec2dd90 100644
--- a/docs/home/5-setup/project-organization.md
+++ b/docs/home/5-setup/project-organization.md
@@ -8,7 +8,8 @@ Pipelex automatically discovers `.mthds` pipeline files anywhere in your project
```bash
your_project/
-├── my_project/ # Your Python package
+├── METHODS.toml # Package manifest (optional)
+├── my_project/ # Your Python package
│ ├── finance/
│ │ ├── services.py
│ │ ├── invoices.mthds # Pipeline with finance code
@@ -23,6 +24,8 @@ your_project/
└── requirements.txt
```
+- **Package manifest**: `METHODS.toml` at your project root declares package identity and pipe visibility. See [Packages](../6-build-reliable-ai-workflows/packages.md) for details.
+
## Alternative: Centralize pipelines
```bash
diff --git a/docs/home/6-build-reliable-ai-workflows/domain.md b/docs/home/6-build-reliable-ai-workflows/domain.md
index 09733cca8..8482bd477 100644
--- a/docs/home/6-build-reliable-ai-workflows/domain.md
+++ b/docs/home/6-build-reliable-ai-workflows/domain.md
@@ -39,6 +39,37 @@ system_prompt = "You are an expert in financial document analysis and invoice pr
❌ domain = "invoiceProcessing" # camelCase not allowed
```
+## Hierarchical Domains
+
+Domains support **dotted paths** to express a hierarchy:
+
+```toml
+domain = "legal"
+domain = "legal.contracts"
+domain = "legal.contracts.shareholder"
+```
+
+Each segment must be `snake_case`. The hierarchy is organizational — there is no scope inheritance between parent and child domains. `legal.contracts` and `legal` are independent namespaces; defining concepts in one does not affect the other.
+
+**Valid hierarchical domains:**
+
+```toml
+✅ domain = "legal.contracts"
+✅ domain = "legal.contracts.shareholder"
+✅ domain = "finance.reporting"
+```
+
+**Invalid hierarchical domains:**
+
+```toml
+❌ domain = ".legal" # Cannot start with a dot
+❌ domain = "legal." # Cannot end with a dot
+❌ domain = "legal..contracts" # No consecutive dots
+❌ domain = "Legal.Contracts" # Segments must be snake_case
+```
+
+Hierarchical domains are used in the `[exports]` section of `METHODS.toml` to control pipe visibility across domains. See [Packages](./packages.md) for details.
+
## How Domains Work
### Concept Namespacing
@@ -170,6 +201,7 @@ Individual pipes can override the domain system prompt by defining their own `sy
## Related Documentation
+- [Packages](./packages.md) - Controlling pipe visibility with exports
- [Pipelex Bundle Specification](./pipelex-bundle-specification.md) - How domains are declared in bundles
- [Kick off a Pipelex Method Project](./kick-off-a-methods-project.md) - Getting started
- [Define Your Concepts](./concepts/define_your_concepts.md) - Creating concepts within domains
diff --git a/docs/home/6-build-reliable-ai-workflows/packages.md b/docs/home/6-build-reliable-ai-workflows/packages.md
new file mode 100644
index 000000000..daa1b70a9
--- /dev/null
+++ b/docs/home/6-build-reliable-ai-workflows/packages.md
@@ -0,0 +1,205 @@
+# Packages
+
+A **package** is a self-contained collection of `.mthds` bundles with a `METHODS.toml` manifest at the root. The manifest gives your project an identity, declares dependencies on other packages, and controls which pipes are visible to the outside world.
+
+## What is a Package?
+
+A package groups related bundles under a single manifest that provides:
+
+- **Identity** — a unique address and semantic version for your project
+- **Dependency declarations** — references to other packages your pipes rely on
+- **Visibility control** — fine-grained exports that determine which pipes other domains can reference
+
+!!! info "Backward Compatibility"
+ If your project has no `METHODS.toml`, everything works exactly as before — all pipes are treated as public. The manifest is entirely opt-in.
+
+## The Package Manifest: `METHODS.toml`
+
+Place a `METHODS.toml` file at the root of your project (next to your `.mthds` files or their parent directories). Here is a fully annotated example:
+
+```toml
+[package]
+address = "github.com/acme/legal-tools"
+version = "1.0.0"
+description = "Legal document analysis and contract review methods."
+authors = ["Acme Corp"]
+license = "MIT"
+mthds_version = ">=0.5.0"
+
+[dependencies]
+scoring_lib = { address = "github.com/acme/scoring-lib", version = "^2.0.0" }
+
+[exports.legal.contracts]
+pipes = ["extract_clause", "analyze_contract"]
+
+[exports.scoring]
+pipes = ["compute_weighted_score"]
+```
+
+### Field Reference
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `address` | Yes | Package address following a hostname/path pattern (e.g. `github.com/org/repo`) |
+| `version` | Yes | Semantic version (e.g. `1.0.0`, `2.1.3-beta.1`) |
+| `description` | Yes | Human-readable package description (must not be empty) |
+| `authors` | No | List of author names |
+| `license` | No | SPDX license identifier (e.g. `MIT`, `Apache-2.0`) |
+| `mthds_version` | No | Required MTHDS runtime version constraint |
+
+## Dependencies
+
+Dependencies are declared in the `[dependencies]` section using an alias-as-key format:
+
+```toml
+[dependencies]
+scoring_lib = { address = "github.com/acme/scoring-lib", version = "^2.0.0" }
+nlp_utils = { address = "github.com/acme/nlp-utils", version = ">=1.0.0, <3.0.0" }
+```
+
+- The **alias** (left-hand key) must be `snake_case`. It is used when making cross-package pipe references with the `->` syntax (e.g. `scoring_lib->scoring.compute_weighted_score`).
+- The **address** follows the same hostname/path pattern as the package address.
+- The **version** field accepts standard version constraint syntax:
+
+| Syntax | Meaning | Example |
+|--------|---------|---------|
+| `1.0.0` | Exact version | `1.0.0` |
+| `^1.0.0` | Compatible release (same major) | `^2.0.0` |
+| `~1.0.0` | Approximately compatible (same major.minor) | `~1.2.0` |
+| `>=`, `<=`, `>`, `<` | Comparison operators | `>=1.0.0` |
+| `==`, `!=` | Equality / inequality | `!=1.3.0` |
+| Comma-separated | Compound constraints | `>=1.0.0, <2.0.0` |
+| `*`, `1.*`, `1.0.*` | Wildcards | `2.*` |
+
+!!! note
+ Each dependency alias must be unique within the manifest.
+
+## Exports and Visibility
+
+The `[exports]` section controls which pipes are visible to other domains. This is the core access-control mechanism of the package system.
+
+### Default Behavior
+
+- **Without `METHODS.toml`**: all pipes are public. Any domain can reference any pipe.
+- **With `METHODS.toml`**: pipes are **private by default**. Only pipes listed in `[exports]` (and `main_pipe` entries) are accessible from other domains.
+
+### Declaring Exports
+
+Exports are organized by domain path. Each entry lists the pipes that domain exposes:
+
+```toml
+[exports.legal.contracts]
+pipes = ["extract_clause", "analyze_contract"]
+
+[exports.scoring]
+pipes = ["compute_weighted_score"]
+```
+
+In this example, the `legal.contracts` domain exports two pipes, and the `scoring` domain exports one.
+
+### Visibility Rules
+
+| Reference Type | Visibility Check |
+|----------------|-----------------|
+| Bare reference (no domain prefix) | Always allowed |
+| Same-domain reference | Always allowed |
+| Cross-domain to exported pipe | Allowed |
+| Cross-domain to `main_pipe` | Allowed (auto-exported) |
+| Cross-domain to non-exported pipe | **Blocked** |
+
+!!! important
+ A bundle's `main_pipe` is **automatically exported** — it is always accessible from other domains, even if it is not listed in the `[exports]` section.
+
+!!! note "Actionable Error Messages"
+ Visibility violations are detected at load time. When a pipe reference is blocked, the error message tells you exactly which pipe is inaccessible and suggests adding it to the appropriate `[exports]` section in `METHODS.toml`.
+
+### Example
+
+Given two bundles:
+
+```toml
+# contracts.mthds
+domain = "legal.contracts"
+main_pipe = "review_contract"
+
+[pipe.extract_clause]
+# ...
+
+[pipe.analyze_contract]
+# ...
+
+[pipe.internal_helper]
+# ...
+```
+
+```toml
+# scoring.mthds
+domain = "scoring"
+
+[pipe.compute_weighted_score]
+# ...
+```
+
+And this manifest:
+
+```toml
+[exports.legal.contracts]
+pipes = ["extract_clause", "analyze_contract"]
+```
+
+Then from a different domain (e.g. `reporting`):
+
+- `legal.contracts.extract_clause` — allowed (exported)
+- `legal.contracts.analyze_contract` — allowed (exported)
+- `legal.contracts.review_contract` — allowed (auto-exported as `main_pipe`)
+- `legal.contracts.internal_helper` — **blocked** (not exported)
+
+## Package Directory Structure
+
+A typical package layout:
+
+```
+your-project/
+├── METHODS.toml # Package manifest
+├── my_project/
+│ ├── finance/
+│ │ ├── services.py
+│ │ ├── invoices.mthds
+│ │ └── invoices_struct.py
+│ └── legal/
+│ ├── contracts.mthds
+│ ├── contracts_struct.py
+│ └── services.py
+├── .pipelex/
+│ └── pipelex.toml
+└── requirements.txt
+```
+
+The `METHODS.toml` sits at the project root. Pipelex discovers it by walking up from any `.mthds` file until it finds the manifest (stopping at a `.git` boundary or filesystem root).
+
+## Quick Start
+
+**Scaffold a manifest** from your existing bundles:
+
+```bash
+pipelex pkg init
+```
+
+This scans all `.mthds` files in the current directory, discovers domains and pipes, and generates a skeleton `METHODS.toml` with placeholder values. Edit the generated file to set the correct address and tune your exports.
+
+**Inspect the current manifest:**
+
+```bash
+pipelex pkg list
+```
+
+This displays the package metadata, dependencies, and exports in formatted tables.
+
+See the [Pkg CLI reference](../9-tools/cli/pkg.md) for full command details.
+
+## Related Documentation
+
+- [Domain](./domain.md) — How domains organize concepts and pipes
+- [Libraries](./libraries.md) — How libraries load and validate bundles
+- [Pipelex Bundle Specification](./pipelex-bundle-specification.md) — The `.mthds` file format
+- [Pkg CLI](../9-tools/cli/pkg.md) — CLI commands for package management
diff --git a/docs/home/9-tools/cli/index.md b/docs/home/9-tools/cli/index.md
index 9112a69b1..8221ffb8d 100644
--- a/docs/home/9-tools/cli/index.md
+++ b/docs/home/9-tools/cli/index.md
@@ -13,6 +13,7 @@ The Pipelex CLI is organized into several command groups:
| [**show**](show.md) | Inspect configuration, pipes, and AI models |
| [**run**](run.md) | Execute pipelines |
| [**build**](build/index.md) | Generate pipelines, runners, and structures |
+| [**pkg**](pkg.md) | Package management: initialize and inspect manifests |
## Usage Tips
diff --git a/docs/home/9-tools/cli/pkg.md b/docs/home/9-tools/cli/pkg.md
new file mode 100644
index 000000000..09486f9d6
--- /dev/null
+++ b/docs/home/9-tools/cli/pkg.md
@@ -0,0 +1,64 @@
+# Pkg Commands
+
+Manage package manifests for your Pipelex project.
+
+## Pkg Init
+
+```bash
+pipelex pkg init
+pipelex pkg init --force
+```
+
+Scans `.mthds` files in the current directory, discovers domains and pipes, and generates a skeleton `METHODS.toml` manifest.
+
+The generated manifest includes:
+
+- A placeholder `address` (edit this to your actual package address)
+- Version set to `0.1.0`
+- All discovered domains listed in the `[exports]` section with their pipes
+
+**Options:**
+
+| Option | Description |
+|--------|-------------|
+| `--force`, `-f` | Overwrite an existing `METHODS.toml` |
+
+**Examples:**
+
+```bash
+# Generate a manifest from .mthds files
+pipelex pkg init
+
+# Overwrite an existing manifest
+pipelex pkg init --force
+```
+
+!!! note
+ The command refuses to overwrite an existing `METHODS.toml` unless `--force` is specified. If no `.mthds` files are found in the current directory, the command exits with an error.
+
+## Pkg List
+
+```bash
+pipelex pkg list
+```
+
+Finds the nearest `METHODS.toml` by walking up from the current directory and displays its contents in Rich-formatted tables:
+
+- **Package** — address, version, description, authors, license, MTHDS version
+- **Dependencies** — alias, address, and version constraint for each dependency
+- **Exports** — domain path and exported pipe names
+
+**Examples:**
+
+```bash
+# Display the package manifest
+pipelex pkg list
+```
+
+!!! note
+ If no `METHODS.toml` is found in the current directory or any parent directory (up to the `.git` boundary), the command exits with an error and suggests running `pipelex pkg init`.
+
+## Related Documentation
+
+- [Packages](../../6-build-reliable-ai-workflows/packages.md) — Package system concepts and manifest reference
+- [Validate](validate.md) — Validating pipelines and configuration
diff --git a/mkdocs.yml b/mkdocs.yml
index c9f38de90..d43f62e57 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -134,6 +134,7 @@ nav:
- Design and Run Methods:
- Overview: home/6-build-reliable-ai-workflows/pipes/index.md
- Libraries: home/6-build-reliable-ai-workflows/libraries.md
+ - Packages: home/6-build-reliable-ai-workflows/packages.md
- Executing Pipelines: home/6-build-reliable-ai-workflows/pipes/executing-pipelines.md
- Providing Inputs to Pipelines: home/6-build-reliable-ai-workflows/pipes/provide-inputs.md
- Working Memory: home/6-build-reliable-ai-workflows/pipes/working-memory.md
@@ -179,6 +180,7 @@ nav:
- Validate: home/9-tools/cli/validate.md
- Run: home/9-tools/cli/run.md
- Show: home/9-tools/cli/show.md
+ - Pkg: home/9-tools/cli/pkg.md
- Build:
- Overview: home/9-tools/cli/build/index.md
- Pipe: home/9-tools/cli/build/pipe.md
diff --git a/pipelex/builder/builder_loop.py b/pipelex/builder/builder_loop.py
index 12854dd9e..b52eb9b02 100644
--- a/pipelex/builder/builder_loop.py
+++ b/pipelex/builder/builder_loop.py
@@ -19,6 +19,10 @@
from pipelex.client.protocol import PipelineInputs
from pipelex.config import get_config
from pipelex.core.concepts.native.concept_native import NativeConceptCode
+from pipelex.core.packages.bundle_scanner import build_domain_exports_from_scan, scan_bundles_for_domain_info
+from pipelex.core.packages.discovery import MANIFEST_FILENAME
+from pipelex.core.packages.manifest import MthdsPackageManifest
+from pipelex.core.packages.manifest_parser import serialize_manifest_to_toml
from pipelex.core.pipes.exceptions import PipeFactoryErrorType, PipeValidationErrorType
from pipelex.core.pipes.pipe_blueprint import PipeCategory
from pipelex.core.pipes.variable_multiplicity import format_concept_with_multiplicity, parse_concept_with_multiplicity
@@ -910,3 +914,47 @@ def _fix_concept_field_to_list(
field_spec.choices = None
return True
+
+
+def maybe_generate_manifest_for_output(output_dir: Path) -> Path | None:
+ """Generate a METHODS.toml if the output directory contains multiple domains.
+
+ Scans all .mthds files in the output directory, parses their headers to
+ extract domain and main_pipe information, and generates a METHODS.toml
+ if multiple distinct domains are found.
+
+ Args:
+ output_dir: Directory to scan for .mthds files
+
+ Returns:
+ Path to the generated METHODS.toml, or None if not generated
+ """
+ mthds_files = sorted(output_dir.rglob("*.mthds"))
+ if not mthds_files:
+ return None
+
+ # Parse each bundle to extract domain and pipe info
+ domain_pipes, domain_main_pipes, errors = scan_bundles_for_domain_info(mthds_files)
+ for error in errors:
+ log.warning(f"Could not parse {error}")
+
+ # Only generate manifest when multiple domains are present
+ if len(domain_pipes) < 2:
+ return None
+
+ # Build exports: include main_pipe and all pipes from each domain
+ exports = build_domain_exports_from_scan(domain_pipes, domain_main_pipes)
+
+ dir_name = output_dir.name.replace("-", "_").replace(" ", "_").lower()
+ manifest = MthdsPackageManifest(
+ address=f"example.com/yourorg/{dir_name}",
+ version="0.1.0",
+ description=f"Package generated from {len(mthds_files)} .mthds file(s)",
+ exports=exports,
+ )
+
+ manifest_path = output_dir / MANIFEST_FILENAME
+ toml_content = serialize_manifest_to_toml(manifest)
+ manifest_path.write_text(toml_content, encoding="utf-8")
+
+ return manifest_path
diff --git a/pipelex/cli/_cli.py b/pipelex/cli/_cli.py
index 22954c482..2d5baa8fa 100644
--- a/pipelex/cli/_cli.py
+++ b/pipelex/cli/_cli.py
@@ -11,6 +11,7 @@
from pipelex.cli.commands.graph_cmd import graph_app
from pipelex.cli.commands.init.command import init_cmd
from pipelex.cli.commands.init.ui.types import InitFocus
+from pipelex.cli.commands.pkg.app import pkg_app
from pipelex.cli.commands.run_cmd import run_cmd
from pipelex.cli.commands.show_cmd import show_app
from pipelex.cli.commands.validate_cmd import validate_cmd
@@ -26,7 +27,7 @@ class PipelexCLI(TyperGroup):
@override
def list_commands(self, ctx: Context) -> list[str]:
# List the commands in the proper order because natural ordering doesn't work between Typer groups and commands
- return ["init", "doctor", "build", "validate", "run", "graph", "show", "which"]
+ return ["init", "doctor", "build", "validate", "run", "graph", "show", "which", "pkg"]
@override
def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
@@ -152,3 +153,4 @@ def doctor_command(
app.add_typer(graph_app, name="graph", help="Generate and render execution graphs")
app.add_typer(show_app, name="show", help="Show configuration, pipes, and list AI models")
app.command(name="which", help="Locate where a pipe is defined, similar to 'which' for executables")(which_cmd)
+app.add_typer(pkg_app, name="pkg", help="Package management: initialize and inspect METHODS.toml manifests")
diff --git a/pipelex/cli/agent_cli/commands/build_core.py b/pipelex/cli/agent_cli/commands/build_core.py
index 707e5b078..9952aac52 100644
--- a/pipelex/cli/agent_cli/commands/build_core.py
+++ b/pipelex/cli/agent_cli/commands/build_core.py
@@ -7,7 +7,7 @@
from pipelex import log
from pipelex.builder.builder_errors import PipeBuilderError
-from pipelex.builder.builder_loop import BuilderLoop
+from pipelex.builder.builder_loop import BuilderLoop, maybe_generate_manifest_for_output
from pipelex.builder.conventions import DEFAULT_INPUTS_FILE_NAME
from pipelex.builder.exceptions import PipelexBundleSpecBlueprintError
from pipelex.config import get_config
@@ -148,6 +148,11 @@ async def build_pipe_core(
raise BuildPipeError(message=msg) from exc
save_text_to_path(text=mthds_content, path=str(mthds_file_path))
+ # Generate METHODS.toml if multiple domains exist in output dir
+ manifest_path = maybe_generate_manifest_for_output(output_dir=Path(extras_output_dir))
+ if manifest_path:
+ log.verbose(f"Package manifest generated: {manifest_path}")
+
main_pipe_code = pipelex_bundle_spec.main_pipe or ""
domain = pipelex_bundle_spec.domain or ""
diff --git a/pipelex/cli/commands/build/pipe_cmd.py b/pipelex/cli/commands/build/pipe_cmd.py
index d93bf0ad2..17fe44299 100644
--- a/pipelex/cli/commands/build/pipe_cmd.py
+++ b/pipelex/cli/commands/build/pipe_cmd.py
@@ -9,7 +9,7 @@
from pipelex import log
from pipelex.builder.builder_errors import PipeBuilderError
-from pipelex.builder.builder_loop import BuilderLoop
+from pipelex.builder.builder_loop import BuilderLoop, maybe_generate_manifest_for_output
from pipelex.builder.conventions import DEFAULT_INPUTS_FILE_NAME
from pipelex.builder.exceptions import PipelexBundleSpecBlueprintError
from pipelex.builder.runner_code import generate_runner_code
@@ -206,6 +206,11 @@ async def run_pipeline():
console.print(f" Output: {mthds_file_path}")
return
+ # Generate METHODS.toml if multiple domains exist in output dir
+ manifest_path = maybe_generate_manifest_for_output(output_dir=Path(extras_output_dir))
+ if manifest_path:
+ log.verbose(f"Package manifest generated: {manifest_path}")
+
# Generate extras (inputs and runner)
main_pipe_code = pipelex_bundle_spec.main_pipe
domain_code = pipelex_bundle_spec.domain
diff --git a/pipelex/cli/commands/pkg/__init__.py b/pipelex/cli/commands/pkg/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pipelex/cli/commands/pkg/app.py b/pipelex/cli/commands/pkg/app.py
new file mode 100644
index 000000000..6498a5435
--- /dev/null
+++ b/pipelex/cli/commands/pkg/app.py
@@ -0,0 +1,27 @@
+from typing import Annotated
+
+import typer
+
+from pipelex.cli.commands.pkg.init_cmd import do_pkg_init
+from pipelex.cli.commands.pkg.list_cmd import do_pkg_list
+
+pkg_app = typer.Typer(
+ no_args_is_help=True,
+)
+
+
+@pkg_app.command("init", help="Initialize a METHODS.toml package manifest from .mthds files in the current directory")
+def pkg_init_cmd(
+ force: Annotated[
+ bool,
+ typer.Option("--force", "-f", help="Overwrite existing METHODS.toml"),
+ ] = False,
+) -> None:
+ """Scan .mthds files and generate a skeleton METHODS.toml."""
+ do_pkg_init(force=force)
+
+
+@pkg_app.command("list", help="Display the package manifest (METHODS.toml) for the current directory")
+def pkg_list_cmd() -> None:
+ """Show the package manifest if one exists."""
+ do_pkg_list()
diff --git a/pipelex/cli/commands/pkg/init_cmd.py b/pipelex/cli/commands/pkg/init_cmd.py
new file mode 100644
index 000000000..210812854
--- /dev/null
+++ b/pipelex/cli/commands/pkg/init_cmd.py
@@ -0,0 +1,62 @@
+from pathlib import Path
+
+import typer
+
+from pipelex.core.packages.bundle_scanner import build_domain_exports_from_scan, scan_bundles_for_domain_info
+from pipelex.core.packages.discovery import MANIFEST_FILENAME
+from pipelex.core.packages.manifest import MthdsPackageManifest
+from pipelex.core.packages.manifest_parser import serialize_manifest_to_toml
+from pipelex.hub import get_console
+
+
+def do_pkg_init(force: bool = False) -> None:
+ """Scan .mthds files in the current directory and generate a METHODS.toml skeleton.
+
+ Args:
+ force: If True, overwrite an existing METHODS.toml
+ """
+ console = get_console()
+ cwd = Path.cwd()
+ manifest_path = cwd / MANIFEST_FILENAME
+
+ # Check if manifest already exists
+ if manifest_path.exists() and not force:
+ console.print(f"[red]METHODS.toml already exists at {manifest_path}[/red]")
+ console.print("Use --force to overwrite.")
+ raise typer.Exit(code=1)
+
+ # Scan for .mthds files
+ mthds_files = sorted(cwd.rglob("*.mthds"))
+ if not mthds_files:
+ console.print("[red]No .mthds files found in the current directory.[/red]")
+ raise typer.Exit(code=1)
+
+ # Parse each bundle header to extract domain and main_pipe
+ domain_pipes, domain_main_pipes, errors = scan_bundles_for_domain_info(mthds_files)
+
+ if errors:
+ console.print("[yellow]Some files could not be parsed:[/yellow]")
+ for error in errors:
+ console.print(f" {error}")
+
+ # Build exports from collected domain/pipe data, placing main_pipe first
+ exports = build_domain_exports_from_scan(domain_pipes, domain_main_pipes)
+
+ # Generate manifest with placeholder address
+ dir_name = cwd.name.replace("-", "_").replace(" ", "_").lower()
+ manifest = MthdsPackageManifest(
+ address=f"example.com/yourorg/{dir_name}",
+ version="0.1.0",
+ description=f"Package generated from {len(mthds_files)} .mthds file(s)",
+ exports=exports,
+ )
+
+ # Serialize and write
+ toml_content = serialize_manifest_to_toml(manifest)
+ manifest_path.write_text(toml_content, encoding="utf-8")
+
+ console.print(f"[green]Created {MANIFEST_FILENAME}[/green] with:")
+ console.print(f" Domains: {len(domain_pipes)}")
+ console.print(f" Total pipes: {sum(len(pipes) for pipes in domain_pipes.values())}")
+ console.print(f" Bundles scanned: {len(mthds_files)}")
+ console.print(f"\n[dim]Edit {MANIFEST_FILENAME} to set the correct address and configure exports.[/dim]")
diff --git a/pipelex/cli/commands/pkg/list_cmd.py b/pipelex/cli/commands/pkg/list_cmd.py
new file mode 100644
index 000000000..32066f30f
--- /dev/null
+++ b/pipelex/cli/commands/pkg/list_cmd.py
@@ -0,0 +1,75 @@
+from pathlib import Path
+
+import typer
+from rich import box
+from rich.table import Table
+
+from pipelex.core.packages.discovery import MANIFEST_FILENAME, find_package_manifest
+from pipelex.core.packages.exceptions import ManifestError
+from pipelex.hub import get_console
+
+
+def do_pkg_list() -> None:
+ """Display the package manifest information.
+
+ Walks up from the current directory to find a METHODS.toml and displays its contents.
+ """
+ console = get_console()
+ cwd = Path.cwd()
+
+ # Create a dummy bundle path to trigger the walk-up search from cwd
+ dummy_bundle_path = cwd / "dummy.mthds"
+ try:
+ manifest = find_package_manifest(dummy_bundle_path)
+ except ManifestError as exc:
+ console.print(f"[red]Error reading METHODS.toml: {exc.message}[/red]")
+ raise typer.Exit(code=1) from exc
+
+ if manifest is None:
+ console.print(f"[yellow]No {MANIFEST_FILENAME} found in current directory or parent directories.[/yellow]")
+ console.print("Run [bold]pipelex pkg init[/bold] to create one.")
+ raise typer.Exit(code=1)
+
+ # Display package info
+ console.print(f"\n[bold]{MANIFEST_FILENAME}[/bold]\n")
+
+ # Package table
+ pkg_table = Table(title="Package", box=box.ROUNDED, show_header=True)
+ pkg_table.add_column("Field", style="cyan")
+ pkg_table.add_column("Value")
+ pkg_table.add_row("Address", manifest.address)
+ pkg_table.add_row("Version", manifest.version)
+ pkg_table.add_row("Description", manifest.description)
+ if manifest.authors:
+ pkg_table.add_row("Authors", ", ".join(manifest.authors))
+ if manifest.license:
+ pkg_table.add_row("License", manifest.license)
+ if manifest.mthds_version:
+ pkg_table.add_row("MTHDS Version", manifest.mthds_version)
+ console.print(pkg_table)
+
+ # Dependencies table
+ if manifest.dependencies:
+ console.print()
+ deps_table = Table(title="Dependencies", box=box.ROUNDED, show_header=True)
+ deps_table.add_column("Alias", style="cyan")
+ deps_table.add_column("Address")
+ deps_table.add_column("Version")
+ for dep in manifest.dependencies:
+ deps_table.add_row(dep.alias, dep.address, dep.version)
+ console.print(deps_table)
+
+ # Exports table
+ if manifest.exports:
+ console.print()
+ exports_table = Table(title="Exports", box=box.ROUNDED, show_header=True)
+ exports_table.add_column("Domain", style="cyan")
+ exports_table.add_column("Pipes")
+ for domain_export in manifest.exports:
+ exports_table.add_row(
+ domain_export.domain_path,
+ ", ".join(domain_export.pipes),
+ )
+ console.print(exports_table)
+
+ console.print()
diff --git a/pipelex/core/bundles/pipelex_bundle_blueprint.py b/pipelex/core/bundles/pipelex_bundle_blueprint.py
index 8aa6b5abf..9f704563f 100644
--- a/pipelex/core/bundles/pipelex_bundle_blueprint.py
+++ b/pipelex/core/bundles/pipelex_bundle_blueprint.py
@@ -160,7 +160,7 @@ def validate_local_pipe_references(self) -> Self:
"""
declared_pipes: set[str] = set(self.pipe.keys()) if self.pipe else set()
special_outcomes = SpecialOutcome.value_list()
- all_pipe_refs = self._collect_pipe_references()
+ all_pipe_refs = self.collect_pipe_references()
invalid_refs: list[str] = []
for pipe_ref_str, context in all_pipe_refs:
@@ -196,7 +196,7 @@ def validate_local_pipe_references(self) -> Self:
raise ValueError(msg)
return self
- def _collect_pipe_references(self) -> list[tuple[str, str]]:
+ def collect_pipe_references(self) -> list[tuple[str, str]]:
"""Collect all pipe references from controller blueprints.
Returns:
diff --git a/pipelex/core/packages/__init__.py b/pipelex/core/packages/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/pipelex/core/packages/bundle_scanner.py b/pipelex/core/packages/bundle_scanner.py
new file mode 100644
index 000000000..4fa3aa3a8
--- /dev/null
+++ b/pipelex/core/packages/bundle_scanner.py
@@ -0,0 +1,82 @@
+from collections.abc import Iterable
+from pathlib import Path
+
+from pipelex.core.interpreter.interpreter import PipelexInterpreter
+from pipelex.core.packages.manifest import DomainExports
+
+
+def scan_bundles_for_domain_info(
+ mthds_files: Iterable[Path],
+) -> tuple[dict[str, list[str]], dict[str, str], list[str]]:
+ """Scan .mthds files and extract domain/pipe information from their headers.
+
+ Iterates over the given bundle files, parses each blueprint to collect
+ which pipes belong to which domains, and which domain has a main_pipe.
+
+ Args:
+ mthds_files: Paths to .mthds files to scan
+
+ Returns:
+ A tuple of (domain_pipes, domain_main_pipes, errors) where:
+ - domain_pipes maps domain codes to their list of pipe codes
+ - domain_main_pipes maps domain codes to their main_pipe code
+ - errors is a list of "{path}: {exc}" strings for files that failed parsing
+ """
+ domain_pipes: dict[str, list[str]] = {}
+ domain_main_pipes: dict[str, str] = {}
+ errors: list[str] = []
+
+ for mthds_file in mthds_files:
+ try:
+ blueprint = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=mthds_file)
+ except Exception as exc:
+ errors.append(f"{mthds_file}: {exc}")
+ continue
+
+ domain = blueprint.domain
+ if domain not in domain_pipes:
+ domain_pipes[domain] = []
+
+ if blueprint.pipe:
+ for pipe_code in blueprint.pipe:
+ domain_pipes[domain].append(pipe_code)
+
+ if blueprint.main_pipe:
+ existing = domain_main_pipes.get(domain)
+ if existing and existing != blueprint.main_pipe:
+ errors.append(f"Conflicting main_pipe for domain '{domain}': '{existing}' vs '{blueprint.main_pipe}' (from {mthds_file})")
+ else:
+ domain_main_pipes[domain] = blueprint.main_pipe
+
+ return domain_pipes, domain_main_pipes, errors
+
+
+def build_domain_exports_from_scan(
+ domain_pipes: dict[str, list[str]],
+ domain_main_pipes: dict[str, str],
+) -> list[DomainExports]:
+ """Build a list of DomainExports from scan results, placing main_pipe first.
+
+ For each domain (sorted alphabetically), creates a DomainExports entry with
+ the main_pipe listed first (if present), followed by remaining pipes sorted
+ alphabetically. Domains with zero exportable pipes are skipped.
+
+ Args:
+ domain_pipes: Mapping of domain codes to their pipe codes
+ domain_main_pipes: Mapping of domain codes to their main_pipe code
+
+ Returns:
+ List of DomainExports with deterministic ordering
+ """
+ exports: list[DomainExports] = []
+ for domain, pipe_codes in sorted(domain_pipes.items()):
+ exported: list[str] = []
+ main_pipe = domain_main_pipes.get(domain)
+ if main_pipe and main_pipe not in exported:
+ exported.append(main_pipe)
+ for pipe_code in sorted(pipe_codes):
+ if pipe_code not in exported:
+ exported.append(pipe_code)
+ if exported:
+ exports.append(DomainExports(domain_path=domain, pipes=exported))
+ return exports
diff --git a/pipelex/core/packages/discovery.py b/pipelex/core/packages/discovery.py
new file mode 100644
index 000000000..9d832c456
--- /dev/null
+++ b/pipelex/core/packages/discovery.py
@@ -0,0 +1,43 @@
+from pathlib import Path
+
+from pipelex.core.packages.manifest import MthdsPackageManifest
+from pipelex.core.packages.manifest_parser import parse_methods_toml
+
+MANIFEST_FILENAME = "METHODS.toml"
+
+
+def find_package_manifest(bundle_path: Path) -> MthdsPackageManifest | None:
+ """Walk up from a bundle file's directory to find the nearest METHODS.toml.
+
+ Stops at the first METHODS.toml found, or when a .git/ directory is
+ encountered, or at the filesystem root.
+
+ Args:
+ bundle_path: Path to a .mthds bundle file
+
+ Returns:
+ The parsed MthdsPackageManifest, or None if no manifest is found
+
+ Raises:
+ ManifestParseError: If a METHODS.toml is found but has invalid TOML syntax
+ ManifestValidationError: If a METHODS.toml is found but fails validation
+ """
+ current = bundle_path.parent.resolve()
+
+ while True:
+ manifest_path = current / MANIFEST_FILENAME
+ if manifest_path.is_file():
+ content = manifest_path.read_text(encoding="utf-8")
+ return parse_methods_toml(content)
+
+ # Stop at .git boundary
+ git_dir = current / ".git"
+ if git_dir.exists():
+ return None
+
+ # Stop at filesystem root
+ parent = current.parent
+ if parent == current:
+ return None
+
+ current = parent
diff --git a/pipelex/core/packages/exceptions.py b/pipelex/core/packages/exceptions.py
new file mode 100644
index 000000000..65cc2e1e9
--- /dev/null
+++ b/pipelex/core/packages/exceptions.py
@@ -0,0 +1,13 @@
+from pipelex.base_exceptions import PipelexError
+
+
+class ManifestError(PipelexError):
+ pass
+
+
+class ManifestParseError(ManifestError):
+ pass
+
+
+class ManifestValidationError(ManifestError):
+ pass
diff --git a/pipelex/core/packages/manifest.py b/pipelex/core/packages/manifest.py
new file mode 100644
index 000000000..bcc464a91
--- /dev/null
+++ b/pipelex/core/packages/manifest.py
@@ -0,0 +1,168 @@
+import re
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from pipelex.core.domains.validation import is_domain_code_valid
+from pipelex.core.pipes.validation import is_pipe_code_valid
+from pipelex.tools.misc.string_utils import is_snake_case
+from pipelex.tools.typing.pydantic_utils import empty_list_factory_of
+from pipelex.types import Self
+
+# Semver regex: MAJOR.MINOR.PATCH with optional pre-release and build metadata
+SEMVER_PATTERN = re.compile(
+ r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
+ r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
+ r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
+)
+
+# Version constraint pattern: supports standard range syntax used by Poetry/uv.
+# A single constraint is: optional operator + semver (with optional wildcard minor/patch).
+# Multiple constraints can be comma-separated (e.g., ">=1.0.0, <2.0.0").
+# Supported forms: "1.0.0", "^1.0.0", "~1.0.0", ">=1.0.0", "<=1.0.0", ">1.0.0", "<1.0.0",
+# "==1.0.0", "!=1.0.0", ">=1.0.0, <2.0.0", "*", "1.*", "1.0.*"
+_SINGLE_CONSTRAINT = (
+ r"(?:"
+ r"\*" # wildcard: *
+ r"|(?:(?:\^|~|>=?|<=?|==|!=)?(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*|\*))?(?:\.(?:0|[1-9]\d*|\*))?)" # [op]MAJOR[.MINOR[.PATCH]]
+ r"(?:-(?:(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?)" # optional prerelease
+)
+VERSION_CONSTRAINT_PATTERN = re.compile(rf"^{_SINGLE_CONSTRAINT}(?:\s*,\s*{_SINGLE_CONSTRAINT})*$")
+
+# Address pattern: must contain at least one dot before a slash (hostname pattern)
+# e.g. "github.com/org/repo", "example.io/pkg"
+ADDRESS_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+/[a-zA-Z0-9._/-]+$")
+
+
+def is_valid_semver(version: str) -> bool:
+ """Check if a version string is valid semver."""
+ return SEMVER_PATTERN.match(version) is not None
+
+
+def is_valid_version_constraint(constraint: str) -> bool:
+ """Check if a version constraint string is valid.
+
+ Supports standard range syntax used by Poetry/uv:
+ - Exact: "1.0.0"
+ - Caret: "^1.0.0" (compatible release)
+ - Tilde: "~1.0.0" (approximately compatible)
+ - Comparison: ">=1.0.0", "<=1.0.0", ">1.0.0", "<1.0.0", "==1.0.0", "!=1.0.0"
+ - Compound: ">=1.0.0, <2.0.0"
+ - Wildcard: "*", "1.*", "1.0.*"
+ """
+ return VERSION_CONSTRAINT_PATTERN.match(constraint.strip()) is not None
+
+
+def is_valid_address(address: str) -> bool:
+ """Check if an address contains at least one dot before a slash (hostname pattern)."""
+ return ADDRESS_PATTERN.match(address) is not None
+
+
+class PackageDependency(BaseModel):
+ """A dependency on another MTHDS package."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ address: str
+ version: str
+ alias: str
+
+ @field_validator("address")
+ @classmethod
+ def validate_address(cls, address: str) -> str:
+ if not is_valid_address(address):
+ msg = f"Invalid package address '{address}'. Address must follow hostname/path pattern (e.g. 'github.com/org/repo')."
+ raise ValueError(msg)
+ return address
+
+ @field_validator("version")
+ @classmethod
+ def validate_version(cls, version: str) -> str:
+ if not is_valid_version_constraint(version):
+ msg = f"Invalid version constraint '{version}'. Must be a valid version range (e.g. '1.0.0', '^1.0.0', '>=1.0.0, <2.0.0')."
+ raise ValueError(msg)
+ return version
+
+ @field_validator("alias")
+ @classmethod
+ def validate_alias(cls, alias: str) -> str:
+ if not is_snake_case(alias):
+ msg = f"Invalid dependency alias '{alias}'. Must be snake_case."
+ raise ValueError(msg)
+ return alias
+
+
+class DomainExports(BaseModel):
+ """Exports for a single domain within a package."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ domain_path: str
+ pipes: list[str] = Field(default_factory=list)
+
+ @field_validator("domain_path")
+ @classmethod
+ def validate_domain_path(cls, domain_path: str) -> str:
+ if not is_domain_code_valid(domain_path):
+ msg = f"Invalid domain path '{domain_path}' in [exports]. Domain paths must be dot-separated snake_case segments."
+ raise ValueError(msg)
+ return domain_path
+
+ @field_validator("pipes")
+ @classmethod
+ def validate_pipes(cls, pipes: list[str]) -> list[str]:
+ for pipe_name in pipes:
+ if not is_pipe_code_valid(pipe_name):
+ msg = f"Invalid pipe name '{pipe_name}' in [exports]. Pipe names must be in snake_case."
+ raise ValueError(msg)
+ return pipes
+
+
+class MthdsPackageManifest(BaseModel):
+ """The METHODS.toml package manifest model."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ address: str
+ version: str
+ description: str
+ authors: list[str] = Field(default_factory=list)
+ license: str | None = None
+ mthds_version: str | None = None
+
+ dependencies: list[PackageDependency] = Field(default_factory=empty_list_factory_of(PackageDependency))
+ exports: list[DomainExports] = Field(default_factory=empty_list_factory_of(DomainExports))
+
+ @field_validator("address")
+ @classmethod
+ def validate_address(cls, address: str) -> str:
+ if not is_valid_address(address):
+ msg = f"Invalid package address '{address}'. Address must follow hostname/path pattern (e.g. 'github.com/org/repo')."
+ raise ValueError(msg)
+ return address
+
+ @field_validator("version")
+ @classmethod
+ def validate_version(cls, version: str) -> str:
+ if not is_valid_semver(version):
+ msg = f"Invalid version '{version}'. Must be valid semver (e.g. '1.0.0', '2.1.3-beta.1')."
+ raise ValueError(msg)
+ return version
+
+ @field_validator("description")
+ @classmethod
+ def validate_description(cls, description: str) -> str:
+ if not description.strip():
+ msg = "Package description must not be empty."
+ raise ValueError(msg)
+ return description
+
+ @model_validator(mode="after")
+ def validate_unique_dependency_aliases(self) -> Self:
+ """Ensure all dependency aliases are unique."""
+ seen_aliases: set[str] = set()
+ for dep in self.dependencies:
+ if dep.alias in seen_aliases:
+ msg = f"Duplicate dependency alias '{dep.alias}'. Each dependency must have a unique alias."
+ raise ValueError(msg)
+ seen_aliases.add(dep.alias)
+ return self
diff --git a/pipelex/core/packages/manifest_parser.py b/pipelex/core/packages/manifest_parser.py
new file mode 100644
index 000000000..605ccedf1
--- /dev/null
+++ b/pipelex/core/packages/manifest_parser.py
@@ -0,0 +1,191 @@
+from typing import Any, cast
+
+import tomlkit
+from pydantic import ValidationError
+
+from pipelex.core.packages.exceptions import ManifestParseError, ManifestValidationError
+from pipelex.core.packages.manifest import DomainExports, MthdsPackageManifest, PackageDependency
+from pipelex.tools.misc.toml_utils import TomlError, load_toml_from_content
+
+
+def _walk_exports_table(table: dict[str, Any], prefix: str = "") -> list[DomainExports]:
+ """Recursively walk nested exports sub-tables to reconstruct dotted domain paths.
+
+ Given a TOML structure like:
+ [exports.legal.contracts]
+ pipes = ["extract_clause"]
+
+ This produces DomainExports(domain_path="legal.contracts", pipes=["extract_clause"]).
+
+ Args:
+ table: The current dict-level of the exports table
+ prefix: The dotted path prefix accumulated so far
+
+ Returns:
+ List of DomainExports built from nested sub-tables
+ """
+ result: list[DomainExports] = []
+
+ for key, value in table.items():
+ current_path = f"{prefix}.{key}" if prefix else str(key)
+
+ if isinstance(value, dict):
+ value_dict = cast("dict[str, Any]", value)
+ # Check if this level has a "pipes" key (leaf domain)
+ if "pipes" in value_dict:
+ pipes_value = value_dict["pipes"]
+ if isinstance(pipes_value, list):
+ pipes_list = cast("list[str]", pipes_value)
+ result.append(DomainExports(domain_path=current_path, pipes=pipes_list))
+
+ # Also recurse into remaining sub-tables (a domain can have both pipes and sub-domains)
+ for sub_key, sub_value in value_dict.items():
+ if sub_key != "pipes" and isinstance(sub_value, dict):
+ sub_dict = cast("dict[str, Any]", {sub_key: sub_value})
+ result.extend(_walk_exports_table(sub_dict, prefix=current_path))
+ else:
+ # No pipes at this level, just recurse deeper
+ result.extend(_walk_exports_table(value_dict, prefix=current_path))
+
+ return result
+
+
+def parse_methods_toml(content: str) -> MthdsPackageManifest:
+ """Parse METHODS.toml content into an MthdsPackageManifest model.
+
+ Args:
+ content: The raw TOML string
+
+ Returns:
+ A validated MthdsPackageManifest
+
+ Raises:
+ ManifestParseError: If the TOML syntax is invalid
+ ManifestValidationError: If the parsed data fails model validation
+ """
+ try:
+ raw = load_toml_from_content(content)
+ except TomlError as exc:
+ msg = f"Invalid TOML syntax in METHODS.toml: {exc.message}"
+ raise ManifestParseError(msg) from exc
+
+ # Extract [package] section
+ package_section = raw.get("package")
+ if not isinstance(package_section, dict):
+ msg = "METHODS.toml must contain a [package] section"
+ raise ManifestValidationError(msg)
+ pkg = cast("dict[str, Any]", package_section)
+
+ # Extract [dependencies] section
+ deps_section = raw.get("dependencies", {})
+ dependencies: list[PackageDependency] = []
+ if isinstance(deps_section, dict):
+ deps_dict = cast("dict[str, Any]", deps_section)
+ for alias, dep_data in deps_dict.items():
+ if isinstance(dep_data, dict):
+ dep_data_dict = cast("dict[str, Any]", dep_data)
+ dep_data_dict["alias"] = str(alias)
+ try:
+ dependencies.append(PackageDependency(**dep_data_dict))
+ except ValidationError as exc:
+ msg = f"Invalid dependency '{alias}' in METHODS.toml: {exc}"
+ raise ManifestValidationError(msg) from exc
+ else:
+ msg = (
+ f"Invalid dependency '{alias}' in METHODS.toml: expected a table with 'address' and 'version' keys, got {type(dep_data).__name__}"
+ )
+ raise ManifestValidationError(msg)
+
+ # Extract [exports] section with recursive walk
+ exports_section = raw.get("exports", {})
+ exports: list[DomainExports] = []
+ if isinstance(exports_section, dict):
+ exports_dict = cast("dict[str, Any]", exports_section)
+ try:
+ exports = _walk_exports_table(exports_dict)
+ except ValidationError as exc:
+ msg = f"Invalid exports in METHODS.toml: {exc}"
+ raise ManifestValidationError(msg) from exc
+
+ # Build the manifest
+ address: str = str(pkg.get("address", ""))
+ version: str = str(pkg.get("version", ""))
+ description: str = str(pkg.get("description", ""))
+ authors_val = pkg.get("authors", [])
+ authors: list[str] = cast("list[str]", authors_val) if isinstance(authors_val, list) else []
+ license_val = pkg.get("license")
+ license_str: str | None = str(license_val) if license_val is not None else None
+ mthds_version_val = pkg.get("mthds_version")
+ mthds_version: str | None = str(mthds_version_val) if mthds_version_val is not None else None
+
+ try:
+ manifest = MthdsPackageManifest(
+ address=address,
+ version=version,
+ description=description,
+ authors=authors,
+ license=license_str,
+ mthds_version=mthds_version,
+ dependencies=dependencies,
+ exports=exports,
+ )
+ except ValidationError as exc:
+ msg = f"METHODS.toml validation failed: {exc}"
+ raise ManifestValidationError(msg) from exc
+
+ return manifest
+
+
+def serialize_manifest_to_toml(manifest: MthdsPackageManifest) -> str:
+ """Serialize an MthdsPackageManifest to a human-readable TOML string.
+
+ Args:
+ manifest: The manifest model to serialize
+
+ Returns:
+ A TOML-formatted string
+ """
+ doc = tomlkit.document()
+
+ # [package] section
+ package_table = tomlkit.table()
+ package_table.add("address", manifest.address)
+ package_table.add("version", manifest.version)
+ package_table.add("description", manifest.description)
+ if manifest.authors:
+ package_table.add("authors", manifest.authors)
+ if manifest.license is not None:
+ package_table.add("license", manifest.license)
+ if manifest.mthds_version is not None:
+ package_table.add("mthds_version", manifest.mthds_version)
+ doc.add("package", package_table)
+
+ # [dependencies] section
+ if manifest.dependencies:
+ doc.add(tomlkit.nl())
+ deps_table = tomlkit.table()
+ for dep in manifest.dependencies:
+ dep_table = tomlkit.inline_table()
+ dep_table.append("address", dep.address)
+ dep_table.append("version", dep.version)
+ deps_table.add(dep.alias, dep_table)
+ doc.add("dependencies", deps_table)
+
+ # [exports] section — build nested tables from dotted domain paths
+ if manifest.exports:
+ doc.add(tomlkit.nl())
+ exports_table = tomlkit.table(is_super_table=True)
+
+ for domain_export in manifest.exports:
+ segments = domain_export.domain_path.split(".")
+ # Navigate/create nested tables
+ current: Any = exports_table
+ for segment in segments:
+ if segment not in current:
+ current.add(segment, tomlkit.table())
+ current = current[segment]
+ current.add("pipes", domain_export.pipes)
+
+ doc.add("exports", exports_table)
+
+ return tomlkit.dumps(doc) # type: ignore[arg-type]
diff --git a/pipelex/core/packages/visibility.py b/pipelex/core/packages/visibility.py
new file mode 100644
index 000000000..9b422c9a7
--- /dev/null
+++ b/pipelex/core/packages/visibility.py
@@ -0,0 +1,199 @@
+from pydantic import BaseModel, ConfigDict
+
+from pipelex import log
+from pipelex.core.bundles.pipelex_bundle_blueprint import PipelexBundleBlueprint
+from pipelex.core.packages.manifest import MthdsPackageManifest
+from pipelex.core.qualified_ref import QualifiedRef, QualifiedRefError
+from pipelex.pipe_controllers.condition.special_outcome import SpecialOutcome
+
+
+class VisibilityError(BaseModel):
+ """A single visibility violation."""
+
+ model_config = ConfigDict(frozen=True)
+
+ pipe_ref: str
+ source_domain: str
+ target_domain: str
+ context: str
+ message: str
+
+
+class PackageVisibilityChecker:
+ """Checks cross-domain pipe visibility against a manifest's exports.
+
+ If no manifest is provided, all pipes are considered public (backward compat).
+ """
+
+ def __init__(
+ self,
+ manifest: MthdsPackageManifest | None,
+ bundles: list[PipelexBundleBlueprint],
+ ):
+ self._manifest = manifest
+ self._bundles = bundles
+
+ # Build lookup: exported_pipes[domain_path] = set of pipe codes
+ self._exported_pipes: dict[str, set[str]] = {}
+ if manifest:
+ for domain_export in manifest.exports:
+ self._exported_pipes[domain_export.domain_path] = set(domain_export.pipes)
+
+ # Build lookup: main_pipes[domain_path] = main_pipe code (auto-exported)
+ self._main_pipes: dict[str, str] = {}
+ for bundle in bundles:
+ if bundle.main_pipe:
+ existing = self._main_pipes.get(bundle.domain)
+ if existing and existing != bundle.main_pipe:
+ log.warning(f"Conflicting main_pipe for domain '{bundle.domain}': '{existing}' vs '{bundle.main_pipe}' — keeping first value")
+ else:
+ self._main_pipes[bundle.domain] = bundle.main_pipe
+
+ def is_pipe_accessible_from(self, pipe_ref: QualifiedRef, source_domain: str) -> bool:
+ """Check if a domain-qualified pipe ref is accessible from source_domain.
+
+ Args:
+ pipe_ref: The parsed pipe reference
+ source_domain: The domain making the reference
+
+ Returns:
+ True if the pipe is accessible
+ """
+ # No manifest -> all pipes public
+ if self._manifest is None:
+ return True
+
+ # Bare ref -> always allowed (no domain check)
+ if not pipe_ref.is_qualified:
+ return True
+
+ # Same-domain ref -> always allowed
+ if pipe_ref.is_local_to(source_domain):
+ return True
+
+ target_domain = pipe_ref.domain_path
+ assert target_domain is not None
+ pipe_code = pipe_ref.local_code
+
+ # Check if it's in exports
+ exported = self._exported_pipes.get(target_domain, set())
+ if pipe_code in exported:
+ return True
+
+ # Check if it's a main_pipe (auto-exported)
+ main_pipe = self._main_pipes.get(target_domain)
+ return bool(main_pipe and pipe_code == main_pipe)
+
+ def validate_all_pipe_references(self) -> list[VisibilityError]:
+ """Validate all cross-domain pipe refs across all bundles.
+
+ Returns:
+ List of VisibilityError for each violation found
+ """
+ # No manifest -> no violations
+ if self._manifest is None:
+ return []
+
+ errors: list[VisibilityError] = []
+ special_outcomes = SpecialOutcome.value_list()
+
+ for bundle in self._bundles:
+ pipe_refs = bundle.collect_pipe_references()
+ for pipe_ref_str, context in pipe_refs:
+ # Skip special outcomes
+ if pipe_ref_str in special_outcomes:
+ continue
+
+ # Try to parse as pipe ref
+ try:
+ ref = QualifiedRef.parse_pipe_ref(pipe_ref_str)
+ except QualifiedRefError:
+ continue
+
+ if not self.is_pipe_accessible_from(ref, bundle.domain):
+ target_domain = ref.domain_path or ""
+ msg = (
+ f"Pipe '{pipe_ref_str}' referenced in {context} (domain '{bundle.domain}') "
+ f"is not exported by domain '{target_domain}'. "
+ f"Add it to [exports.{target_domain}] pipes in METHODS.toml."
+ )
+ errors.append(
+ VisibilityError(
+ pipe_ref=pipe_ref_str,
+ source_domain=bundle.domain,
+ target_domain=target_domain,
+ context=context,
+ message=msg,
+ )
+ )
+
+ return errors
+
+ def validate_cross_package_references(self) -> list[VisibilityError]:
+ """Validate cross-package references (using '->' syntax).
+
+ Checks that:
+ - If a ref contains '->' and the alias IS in dependencies -> emit warning (not error)
+ - If a ref contains '->' and the alias is NOT in dependencies -> error
+
+ Returns:
+ List of VisibilityError for unknown dependency aliases
+ """
+ if self._manifest is None:
+ return []
+
+ # Build alias lookup from manifest dependencies
+ known_aliases: set[str] = {dep.alias for dep in self._manifest.dependencies}
+
+ errors: list[VisibilityError] = []
+
+ for bundle in self._bundles:
+ pipe_refs = bundle.collect_pipe_references()
+ for pipe_ref_str, context in pipe_refs:
+ if not QualifiedRef.has_cross_package_prefix(pipe_ref_str):
+ continue
+
+ alias, _remainder = QualifiedRef.split_cross_package_ref(pipe_ref_str)
+
+ if alias in known_aliases:
+ # Known alias -> emit warning (cross-package resolution not yet implemented)
+ log.warning(
+ f"Cross-package reference '{pipe_ref_str}' in {context} "
+ f"(domain '{bundle.domain}'): alias '{alias}' is a known dependency. "
+ "Cross-package resolution is not yet implemented."
+ )
+ else:
+ # Unknown alias -> error
+ msg = (
+ f"Cross-package reference '{pipe_ref_str}' in {context} "
+ f"(domain '{bundle.domain}'): alias '{alias}' is not declared "
+ "in [dependencies] of METHODS.toml."
+ )
+ errors.append(
+ VisibilityError(
+ pipe_ref=pipe_ref_str,
+ source_domain=bundle.domain,
+ target_domain=alias,
+ context=context,
+ message=msg,
+ )
+ )
+
+ return errors
+
+
+def check_visibility_for_blueprints(
+ manifest: MthdsPackageManifest | None,
+ blueprints: list[PipelexBundleBlueprint],
+) -> list[VisibilityError]:
+ """Convenience function: check visibility for a set of blueprints.
+
+ Args:
+ manifest: The package manifest (None means all-public)
+ blueprints: The bundle blueprints to check
+
+ Returns:
+ List of visibility errors
+ """
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=blueprints)
+ return checker.validate_all_pipe_references()
diff --git a/pipelex/core/qualified_ref.py b/pipelex/core/qualified_ref.py
index a50e4b13d..746944f5d 100644
--- a/pipelex/core/qualified_ref.py
+++ b/pipelex/core/qualified_ref.py
@@ -152,3 +152,36 @@ def is_external_to(self, domain: str) -> bool:
if self.domain_path is None:
return False
return self.domain_path != domain
+
+ @staticmethod
+ def has_cross_package_prefix(raw: str) -> bool:
+ """Check if a raw reference string contains the cross-package '->' prefix.
+
+ Cross-package references look like: 'alias->domain.pipe_code'
+
+ Args:
+ raw: The raw reference string to check
+
+ Returns:
+ True if the string contains '->'
+ """
+ return "->" in raw
+
+ @staticmethod
+ def split_cross_package_ref(raw: str) -> tuple[str, str]:
+ """Split a cross-package reference into alias and remainder.
+
+ Args:
+ raw: The raw reference string like 'alias->domain.pipe_code'
+
+ Returns:
+ Tuple of (alias, remainder) where remainder is 'domain.pipe_code'
+
+ Raises:
+ QualifiedRefError: If the string does not contain '->'
+ """
+ if "->" not in raw:
+ msg = f"Reference '{raw}' is not a cross-package reference (no '->' found)"
+ raise QualifiedRefError(msg)
+ parts = raw.split("->", maxsplit=1)
+ return parts[0], parts[1]
diff --git a/pipelex/libraries/library_manager.py b/pipelex/libraries/library_manager.py
index 95f1f2653..9f76fd4f0 100644
--- a/pipelex/libraries/library_manager.py
+++ b/pipelex/libraries/library_manager.py
@@ -17,6 +17,9 @@
from pipelex.core.domains.domain_factory import DomainFactory
from pipelex.core.interpreter.exceptions import PipelexInterpreterError
from pipelex.core.interpreter.interpreter import PipelexInterpreter
+from pipelex.core.packages.discovery import find_package_manifest
+from pipelex.core.packages.exceptions import ManifestError
+from pipelex.core.packages.visibility import check_visibility_for_blueprints
from pipelex.core.pipes.pipe_abstract import PipeAbstract
from pipelex.core.pipes.pipe_factory import PipeFactory
from pipelex.core.stuffs.structured_content import StructuredContent
@@ -519,6 +522,9 @@ def _load_mthds_files_into_library(self, library_id: str, valid_mthds_paths: lis
) from interpreter_error
blueprints.append(blueprint)
+ # Run package visibility validation if a METHODS.toml manifest exists
+ self._check_package_visibility(blueprints=blueprints, mthds_paths=valid_mthds_paths)
+
# Store resolved absolute paths for duplicate detection in the library
library = self.get_library(library_id=library_id)
for mthds_file_path in valid_mthds_paths:
@@ -537,6 +543,40 @@ def _load_mthds_files_into_library(self, library_id: str, valid_mthds_paths: lis
message=msg,
) from validation_error
+ def _check_package_visibility(
+ self,
+ blueprints: list[PipelexBundleBlueprint],
+ mthds_paths: list[Path],
+ ) -> None:
+ """Check package visibility if a METHODS.toml manifest exists.
+
+ Walks up from the first bundle path to find a METHODS.toml manifest.
+ If found, validates all cross-domain pipe references against the exports.
+
+ Args:
+ blueprints: The parsed bundle blueprints
+ mthds_paths: The MTHDS file paths that were loaded
+ """
+ if not mthds_paths:
+ return
+
+ # Try to find a manifest from the first bundle path
+ try:
+ manifest = find_package_manifest(mthds_paths[0])
+ except ManifestError as exc:
+ log.warning(f"Could not parse METHODS.toml: {exc.message}")
+ return
+
+ if manifest is None:
+ return
+
+ visibility_errors = check_visibility_for_blueprints(manifest=manifest, blueprints=blueprints)
+ if visibility_errors:
+ error_messages = [err.message for err in visibility_errors]
+ joined_errors = "\n - ".join(error_messages)
+ msg = f"Package visibility violations found:\n - {joined_errors}"
+ raise LibraryLoadingError(msg)
+
def _remove_pipes_from_blueprint(self, blueprint: PipelexBundleBlueprint) -> None:
library = self.get_current_library()
if blueprint.pipe is not None:
diff --git a/refactoring/mthds-implementation-brief_v6.md b/refactoring/mthds-implementation-brief_v6.md
new file mode 100644
index 000000000..08c80359a
--- /dev/null
+++ b/refactoring/mthds-implementation-brief_v6.md
@@ -0,0 +1,103 @@
+# MTHDS Standard — Implementation Brief (v6)
+
+## Context
+
+Read these two design documents first:
+- Latest `pipelex-package-system-design_v*.md` — The MTHDS standard specification
+- Latest `pipelex-package-system-changes_v*.md` — The evolution plan from current Pipelex
+
+**MTHDS** is the new name for the open standard. **Pipelex** remains the reference implementation. Internal Pipelex class names (e.g., `PipelexBundleBlueprint`, `PipelexInterpreter`) do NOT rename — Pipelex is the implementation brand.
+
+---
+
+## Phase 0: Extension Rename — COMPLETED
+
+File extension renamed from `.plx` to `.mthds` across the entire codebase. User-facing terminology updated from "workflow" to "method". Hard switch, no backward-compatible `.plx` loading.
+
+---
+
+## Phase 1: Hierarchical Domains + Pipe Namespacing — COMPLETED
+
+Delivered:
+- **Hierarchical domain validation**: domain codes accept dotted paths (e.g., `legal.contracts.shareholder`). Updated domain validation in `pipelex/core/domains/`.
+- **Unified `QualifiedRef` model**: a single frozen Pydantic `BaseModel` in `pipelex/core/qualified_ref.py` that handles both concept and pipe references (fields: `domain_path: str | None`, `local_code: str`). This replaced the brief's suggestion of a separate `PipeReference` class in `pipelex/core/pipes/` — the unified model eliminates duplication since concept and pipe references share the same parsing logic (split-on-last-dot, casing disambiguates). The `package_alias` field is omitted since cross-package references are Phase 3; adding it later is trivial.
+- **Split-on-last-dot parsing**: unified parsing rule for both concept and pipe references — the last segment is the `local_code` (casing disambiguates pipe vs. concept), everything before it is the `domain_path`.
+- **Bundle blueprint validation**: domain-qualified pipe references are validated against known domains and pipes within the current package, mirroring the existing concept reference validation pattern.
+- **Builder bundles migrated**: cross-domain pipe references in the builder's internal bundles (`agentic_builder.mthds`, `builder.mthds`) now use `domain.pipe_code` syntax.
+- **New tests**: positive tests for domain-qualified pipe references in sequences, and negative tests for references to non-existent domains/pipes.
+
+---
+
+## Phase 2: Package Manifest + Exports / Visibility — COMPLETED
+
+Delivered:
+
+- **`MthdsPackageManifest` data model** (`pipelex/core/packages/manifest.py`): `PackageDependency`, `DomainExports`, and `MthdsPackageManifest` Pydantic models with field validators (address hostname pattern, semver for package version, version constraint ranges for dependency versions, non-empty description, snake_case aliases, unique aliases, valid domain paths, valid pipe codes).
+- **TOML parsing and serialization** (`pipelex/core/packages/manifest_parser.py`): `parse_methods_toml()` with recursive sub-table walk for `[exports]` domain path reconstruction; `serialize_manifest_to_toml()` using `tomlkit` for human-readable output.
+- **Custom exceptions** (`pipelex/core/packages/exceptions.py`): `ManifestError`, `ManifestParseError`, `ManifestValidationError`.
+- **Manifest discovery** (`pipelex/core/packages/discovery.py`): `find_package_manifest()` walks up from a bundle path, stopping at `METHODS.toml`, `.git/` boundary, or filesystem root. Returns `None` for standalone bundles.
+- **Visibility checker** (`pipelex/core/packages/visibility.py`): `PackageVisibilityChecker` enforces cross-domain pipe visibility against `[exports]`. Rules: no manifest = all public; bare ref = allowed; same-domain = allowed; cross-domain requires pipe to be in `[exports]` or be `main_pipe` (auto-exported). Error messages include `[exports]` hint.
+- **Cross-package `->` reference detection**: `QualifiedRef.has_cross_package_prefix()` and `split_cross_package_ref()` static methods. `PackageVisibilityChecker.validate_cross_package_references()` emits warnings for known aliases, errors for unknown aliases.
+- **Visibility wired into bundle loading** (`pipelex/libraries/library_manager.py`): `_check_package_visibility()` runs after blueprint parsing, before `load_from_blueprints`. Raises `LibraryLoadingError` on violations.
+- **CLI commands** (`pipelex/cli/commands/pkg/`): `pipelex pkg init` scans `.mthds` files, generates skeleton `METHODS.toml` with auto-discovered domains and all pipes exported. `pipelex pkg list` finds and displays the manifest with Rich tables (package info, dependencies, exports).
+- **Builder awareness** (`pipelex/builder/builder_loop.py`): `maybe_generate_manifest_for_output()` checks if an output directory contains multiple domains and generates a `METHODS.toml` if so. Hooked into both `pipe_cmd.py` and `build_core.py`.
+- **Physical test data** (`tests/data/packages/`): `legal_tools/` (full manifest + multi-domain bundles), `minimal_package/` (minimal manifest), `standalone_bundle/` (no manifest), `invalid_manifests/` (6 negative test files).
+- **Comprehensive tests**: 45+ new tests across 10 test files covering manifest model validation, TOML parsing, discovery, visibility, cross-package refs, CLI commands, and builder manifest generation. All domain/pipe names prefixed with `pkg_test_` to avoid collisions with the existing e2e test suite.
+
+### Adaptations from the original brief
+
+1. **Model name `MthdsPackageManifest`** (not `MethodsPackageManifest`): consistent with existing `MthdsFactory`, `MthdsDecodeError` naming.
+
+2. **Dependencies TOML format uses alias as key**: the brief shows `[dependencies]\n"github.com/..." = { version = "^1.0.0", alias = "docproc" }` (address as key, alias inline). The implementation uses `[dependencies]\nscoring_lib = { address = "...", version = "2.0.0" }` (alias as key, address inline). This is more natural for the `->` syntax since the alias is the lookup key when resolving cross-package references.
+
+3. **`collect_pipe_references()` made public**: renamed from `_collect_pipe_references()` on `PipelexBundleBlueprint` because the `PackageVisibilityChecker` (an external class) needs to call it. This is a minimal API change.
+
+4. **`pkg_app` in `app.py` not `__init__.py`**: Ruff RUF067 prohibits logic in `__init__.py` files. Followed the existing `build/app.py` pattern: `__init__.py` is empty, `app.py` defines the Typer sub-group.
+
+5. **Visibility check hooked into `library_manager.py` only**: the brief suggested hooking into both `library_manager.py` and `validate_bundle.py`. The library manager hook covers the main bundle loading path, which is sufficient. `validate_bundle.py` was left unchanged to keep the change surface minimal.
+
+6. **Cross-package `validate_cross_package_references()` defined but not wired into runtime**: the method exists and is unit-tested, but `check_visibility_for_blueprints()` (the convenience function called by the library manager) only invokes `validate_all_pipe_references()`. This is intentional: `->` refs would already fail at the per-bundle level (the pipe wouldn't be found locally), so the cross-package checker is a preparatory API for Phase 3 when it will produce better error messages.
+
+7. **Dependency version supports range syntax**: `PackageDependency.version` validates against Poetry/uv-style version constraint syntax (`^1.0.0`, `~1.0.0`, `>=1.0.0, <2.0.0`, wildcards). The package's own `MthdsPackageManifest.version` remains strict semver since it represents a concrete version, not a constraint.
+
+---
+
+## Phase 3: Cross-Package References + Local Dependency Resolution
+
+### Goal
+
+Implement the `alias->domain_path.name` syntax for cross-package references. Resolve dependencies locally (fetch from local paths or VCS). Wire `validate_cross_package_references()` into the runtime for better error messages.
+
+This phase does NOT implement remote registry browsing or the Know-How Graph.
+
+---
+
+## What NOT to Do
+
+- **Do NOT implement remote registry or Know-How Graph browsing.** That is Phase 5.
+- **Do NOT rename the manifest** to anything other than `METHODS.toml`. The design docs are explicit about this name.
+- **Do NOT rename Python classes or internal Pipelex types.** The standard is MTHDS; the implementation is Pipelex. Keep existing class names.
+
+---
+
+## Note on Client Project Brief
+
+`mthds-client-project-update-brief.md` exists in the `implementation/` directory for propagating changes to cookbooks, tutorials, and client-facing documentation. After Phase 2 lands, that brief should be updated to reflect:
+- The existence of `METHODS.toml` and what it means for project setup.
+- The new `pipelex pkg init` and `pipelex pkg list` commands.
+- The visibility model and its impact on how bundles are organized.
+- Any changes to the builder output format.
+
+---
+
+## Source Documents
+
+| Section | Source document | Relevant sections |
+|---------|----------------|-------------------|
+| Manifest format | `pipelex-package-system-design_v*.md` | §3 Package Structure, §4 Package Manifest |
+| Visibility model | `pipelex-package-system-design_v*.md` | §4 `[exports]` rules, §5 Namespace Resolution |
+| Manifest data model | `pipelex-package-system-changes_v*.md` | §4.1 Package Manifest |
+| CLI commands | `pipelex-package-system-changes_v*.md` | §5.6 CLI |
+| Builder impact | `pipelex-package-system-changes_v*.md` | §5.5 Builder |
+| Roadmap position | `pipelex-package-system-changes_v*.md` | §6 Roadmap table |
+| Design rationale | `Proposal -The Pipelex Package System.md` | §2, §4 |
diff --git a/refactoring/pipelex-package-system-changes_v6.md b/refactoring/pipelex-package-system-changes_v6.md
new file mode 100644
index 000000000..d77e7b37a
--- /dev/null
+++ b/refactoring/pipelex-package-system-changes_v6.md
@@ -0,0 +1,356 @@
+# MTHDS Package System — Evolution from Current Pipelex Architecture
+
+This document maps the proposed MTHDS package system back to the current Pipelex codebase, identifying what changes, what's new, and the implementation roadmap.
+
+**Context**: MTHDS is the open standard (language, file format, packaging). Pipelex is the reference implementation (runtime, CLI, builder). This document describes the changes needed in Pipelex to implement the MTHDS standard.
+
+**Operational detail** for the current phases lives in the latest `mthds-implementation-brief_v*.md`.
+
+---
+
+## 1. Summary of Changes
+
+| Category | Nature | Description |
+|----------|--------|-------------|
+| File extension | **Done** | `.mthds` (renamed from `.plx` in Phase 0) |
+| Terminology | **Done** | "method" terminology throughout docs and UI (renamed from "workflow" in Phase 0) |
+| Hierarchical domains | **Done** | Domains support `.`-separated hierarchy (e.g., `legal.contracts`) |
+| Pipe namespacing | **Done** | Pipes gain `domain_path.pipe_code` references, symmetric with concepts |
+| Package manifest | **Done** | `METHODS.toml` — identity, dependencies (parsed only), exports |
+| Visibility model | **Done** | Pipes are private by default when manifest exists, exported via `[exports]` |
+| CLI `pipelex pkg` | **Done** | `pipelex pkg init` (scaffold manifest), `pipelex pkg list` (display manifest) |
+| Lock file | **New artifact** | `methods.lock` — resolved dependency versions and checksums |
+| Dependency resolver | **New subsystem** | Fetches, caches, and version-resolves packages |
+| Cross-package references | **New syntax** | `alias->domain_path.pipe_code` and `alias->domain_path.ConceptCode` |
+| Bundle loading | **Major rework** | Package-aware resolver replaces flat `library_dirs` scanning |
+
+---
+
+## 2. The Standard/Implementation Split
+
+The MTHDS standard defines:
+
+- The `.mthds` file format (TOML-based bundle definition)
+- The `METHODS.toml` manifest format
+- The `methods.lock` lock file format
+- Namespace resolution rules (bare, domain-qualified, package-qualified with `->`)
+- The package addressing scheme
+- The distribution model
+
+Pipelex implements:
+
+- The runtime that loads, validates, and executes `.mthds` bundles
+- The CLI (`pipelex`) that exposes standard operations
+- The builder that generates `.mthds` files
+- The agent CLI (`pipelex-agent`) for machine-driven building
+
+The standard docs should never reference Pipelex. The implementation docs reference both.
+
+---
+
+## 3. What Changes in the File Format
+
+### 3.1 Extension Rename — COMPLETED (Phase 0)
+
+All bundle files now use the `.mthds` extension. The TOML structure inside is unchanged.
+
+### 3.2 Hierarchical Domains
+
+**Current state**: Domain names are single `snake_case` identifiers (e.g., `recruitment`, `scoring`).
+
+**New state**: Domains support `.`-separated hierarchies using `snake_case` segments.
+
+```toml
+# Current (still valid)
+domain = "legal"
+
+# New (hierarchical)
+domain = "legal.contracts"
+domain = "legal.contracts.shareholder"
+```
+
+The hierarchy is purely organizational — no implicit scope or inheritance between parent and child domains. `legal.contracts` does not automatically have access to concepts from `legal`.
+
+**Impact**: Domain validation must accept dotted paths. Domain storage and lookup must handle multi-segment keys.
+
+### 3.3 Pipe References Gain Domain Namespacing
+
+**Current state**: Pipes are referenced by bare `snake_case` names everywhere.
+
+```toml
+# Current
+steps = [
+ { pipe = "extract_documents", result = "extracted_documents" },
+ { pipe = "analyze_cv", result = "cv_analysis" },
+]
+branch_pipe_code = "process_single_cv"
+outcomes = { "high" = "deep_analysis", "low" = "quick_analysis" }
+```
+
+**New state**: Pipe references support three forms — bare (local), domain-qualified, and package-qualified. With hierarchical domains, the domain path can be multi-segment.
+
+```toml
+# Within same bundle (unchanged)
+steps = [
+ { pipe = "extract_documents", result = "extracted_documents" },
+]
+
+# Cross-bundle, same package (single-segment domain)
+steps = [
+ { pipe = "scoring.compute_weighted_score", result = "score" },
+]
+
+# Cross-bundle, same package (hierarchical domain)
+steps = [
+ { pipe = "legal.contracts.extract_clause", result = "clause" },
+]
+
+# Cross-package
+steps = [
+ { pipe = "docproc->extraction.extract_text", result = "pages" },
+]
+```
+
+**Parsing rule**: Split on the **last `.`** to separate the domain path from the name. Casing of the last segment disambiguates: `snake_case` = pipe code, `PascalCase` = concept code.
+
+**All pipe reference locations affected:**
+
+| Field | Example |
+|-------|---------|
+| `steps[].pipe` (PipeSequence) | `"legal.contracts.extract_clause"` |
+| `parallels[].pipe` (PipeParallel) | `"docproc->extraction.extract_text"` |
+| `branch_pipe_code` (PipeBatch) | `"legal.contracts.process_nda"` |
+| `outcomes` values (PipeCondition) | `"scoring.deep_analysis"` |
+| `default_outcome` (PipeCondition) | `"scoring.fallback"` |
+
+**Not affected**: `main_pipe` (always local), pipe definition keys (`[pipe.my_pipe]` — always local).
+
+### 3.4 Concept References Gain Package Qualification
+
+**Current state**: Concepts support bare names and `domain.ConceptCode`.
+
+```toml
+# Current — both forms already work
+inputs = { profile = "CandidateProfile" }
+inputs = { profile = "recruitment.CandidateProfile" }
+refines = "base_domain.Person"
+```
+
+**New state**: Adds package-qualified form and supports hierarchical domain paths.
+
+```toml
+# Hierarchical domain concept reference (same package)
+inputs = { clause = "legal.contracts.NonCompeteClause" }
+
+# Cross-package concept reference
+inputs = { profile = "acme_hr->recruitment.CandidateProfile" }
+refines = "acme_legal->legal.contracts.NonDisclosureAgreement"
+```
+
+### 3.5 The Bundle Header — Domain Now Supports Hierarchy
+
+The top-level bundle fields remain structurally the same, but `domain` now accepts dotted paths:
+
+```toml
+domain = "legal.contracts"
+description = "Contract analysis and clause extraction"
+main_pipe = "extract_clause"
+```
+
+No new required fields in the `.mthds` file itself. The package relationship is established by the manifest, not by the bundle.
+
+---
+
+## 4. New Artifacts
+
+### 4.1 Package Manifest: `METHODS.toml` — IMPLEMENTED (Phase 2)
+
+Parsed and validated. Declares package identity, dependencies (stored but not resolved), and exports.
+
+Exports use TOML sub-tables, one per domain. The domain path maps directly to the TOML table path — `legal.contracts` becomes `[exports.legal.contracts]`.
+
+```toml
+[package]
+address = "github.com/acme/legal-tools"
+version = "0.3.0"
+description = "Legal document analysis and contract review methods."
+mthds_version = ">=0.2.0"
+
+[dependencies]
+docproc = { address = "github.com/mthds/document-processing", version = "1.0.0" }
+scoring_lib = { address = "github.com/mthds/scoring-lib", version = "0.5.0" }
+
+[exports.legal]
+pipes = ["classify_document"]
+
+[exports.legal.contracts]
+pipes = ["extract_clause", "analyze_nda", "compare_contracts"]
+
+[exports.scoring]
+pipes = ["compute_weighted_score"]
+```
+
+**Implementation note**: The `[dependencies]` format uses the alias as the TOML key and the address as an inline field (see §4.1 note in `mthds-implementation-brief_v6.md`). Dependency versions support Poetry/uv-style range syntax (`^1.0.0`, `~1.0.0`, `>=1.0.0, <2.0.0`, wildcards) — validated at parse time, resolution deferred to Phase 3+. The `description` field is required and must be non-empty.
+
+**Impact**: New parser (`manifest_parser.py`), new model class (`MthdsPackageManifest`), new validation rules, new discovery function, new visibility checker. See `pipelex/core/packages/`.
+
+### 4.2 Lock File: `methods.lock`
+
+Auto-generated by the dependency resolver. Committed to version control.
+
+```toml
+["github.com/mthds/document-processing"]
+version = "1.2.3"
+hash = "sha256:a1b2c3d4..."
+source = "https://github.com/mthds/document-processing"
+
+["github.com/mthds/scoring-lib"]
+version = "0.5.1"
+hash = "sha256:e5f6g7h8..."
+source = "https://github.com/mthds/scoring-lib"
+```
+
+**Impact**: New generation/verification code, new CLI commands.
+
+### 4.3 Package Cache Directory
+
+`~/.mthds/packages/` (global) or `.mthds/packages/` (project-local). Stores fetched package contents, organized by address and version.
+
+---
+
+## 5. Impact on Existing Pipelex Subsystems
+
+### 5.1 Pipe Code Validation (`pipelex/core/pipes/`)
+
+**Current**: `is_pipe_code_valid()` accepts only `snake_case` identifiers.
+
+**Change**: Must distinguish between pipe *definitions* (always bare `snake_case`) and pipe *references* (three forms: bare, `domain_path.pipe_code`, `alias->domain_path.pipe_code`). **Done in Phase 1**: implemented as the unified `QualifiedRef` model in `pipelex/core/qualified_ref.py`, handling both concept and pipe references with the "split on last dot" rule. **Extended in Phase 2**: `has_cross_package_prefix()` and `split_cross_package_ref()` static methods added for `->` syntax detection.
+
+### 5.2 Bundle Blueprint (`pipelex/core/bundles/`)
+
+**Current**: Validates pipe keys and concept references in isolation.
+
+**Changes**:
+- `validate_pipe_keys()`: unchanged (definitions are still bare names)
+- `validate_local_concept_references()`: must understand the `alias->domain_path.ConceptCode` form and skip validation for external references (already partially done for domain-qualified refs)
+- `collect_pipe_references()`: **Done in Phase 2** — made public (was `_collect_pipe_references`) so the `PackageVisibilityChecker` can call it
+- Both concept and pipe reference collectors need to understand the `->` syntax
+
+### 5.3 Interpreter (`pipelex/core/interpreter/`)
+
+**Current**: Loads `.mthds` files.
+
+**Change**: No structural change to the interpreter itself, but it needs to be called within the context of a package-aware loader that reads the manifest, resolves dependencies, and loads bundles in order.
+
+### 5.4 Domain Validation (`pipelex/core/domains/`)
+
+**Current**: Validates domain code syntax (single `snake_case` segment).
+
+**Change**: Must accept `.`-separated hierarchical domain paths where each segment is `snake_case`. Must also handle package-qualified domain references (`alias->domain_path`).
+
+### 5.5 Builder (`pipelex/builder/`)
+
+**Current**: Generates `.mthds` bundles.
+
+**Changes — Done in Phase 2**:
+- `maybe_generate_manifest_for_output()` in `builder_loop.py` generates `METHODS.toml` alongside `.mthds` files when the output directory contains multiple domains
+- Hooked into `pipe_cmd.py` (CLI build) and `build_core.py` (agent CLI build)
+
+**Still pending (Phase 3+)**:
+- When building a method that depends on external packages, the builder needs awareness of available packages and their exported pipes/concepts
+- Pipe signature design needs to account for cross-package pipe references
+
+### 5.6 CLI (`pipelex/cli/`)
+
+**New command group — Done in Phase 2**: `pipelex pkg` with `init` and `list` subcommands.
+
+| Command | Status | Does |
+|---------|--------|------|
+| `pipelex pkg init` | **Done** | Create a `METHODS.toml` in the current directory |
+| `pipelex pkg list` | **Done** | Show package info, dependencies, and exported pipes from the manifest |
+| `pipelex pkg add
` | Phase 3+ | Add a dependency to the manifest |
+| `pipelex pkg install` | Phase 4 | Fetch and cache all dependencies from lock file |
+| `pipelex pkg update` | Phase 4 | Update dependencies to latest compatible versions |
+| `pipelex pkg lock` | Phase 4 | Regenerate the lock file |
+| `pipelex pkg publish` | Phase 5 | Validate and prepare a package for distribution |
+
+**Existing commands impacted (Phase 3+)**:
+- `pipelex validate`: must resolve packages before validating cross-package references
+- `pipelex run`: must load dependency packages into the runtime
+- `pipelex-agent build`: should be package-aware for cross-package pipe references
+
+### 5.7 Pipe Blueprints (All Pipe Types)
+
+Every pipe type that holds references to other pipes needs its validation/resolution updated:
+
+| Pipe Type | Fields Holding Pipe References |
+|-----------|-------------------------------|
+| `PipeSequenceBlueprint` | `steps[].pipe` |
+| `PipeParallelBlueprint` | `parallels[].pipe` |
+| `PipeBatchBlueprint` | `branch_pipe_code` |
+| `PipeConditionBlueprint` | `outcomes` values, `default_outcome` |
+
+Each of these must accept and parse the three-scope pipe reference format. Look in `pipelex/pipe_controllers/`.
+
+### 5.8 Library Manager (`pipelex/libraries/`) — NEW (Phase 2)
+
+**Change**: `_check_package_visibility()` added to `library_manager.py`. After parsing all blueprints from `.mthds` files, it:
+1. Finds the nearest `METHODS.toml` manifest via walk-up discovery
+2. If found, runs the `PackageVisibilityChecker` against all blueprints
+3. Raises `LibraryLoadingError` if cross-domain pipe references violate visibility
+
+---
+
+## 6. Implementation Roadmap
+
+Each phase gets its own implementation brief with decisions, grammar, acceptance criteria, and codebase pointers. See the latest `mthds-implementation-brief_v*.md` for the current phases.
+
+| Phase | Goal | Depends on |
+|-------|------|-----------|
+| **0** | ~~Extension rename + terminology update~~ | **COMPLETED** |
+| **1** | ~~Hierarchical domains + pipe namespacing: `domain_path.pipe_code` references, split-on-last-dot parsing for concepts and pipes~~ | **COMPLETED** |
+| **2** | ~~Package manifest (`METHODS.toml`) + exports / visibility model~~ | **COMPLETED** |
+| **3** | Cross-package references (`alias->domain_path.name`) + local dependency resolution | Phase 2 |
+| **4** | Remote dependency resolution, lock file (`methods.lock`), package cache | Phase 3 |
+| **5** | Registry, type-aware search, Know-How Graph browsing | Phase 4 |
+
+---
+
+## 7. Migration Guide for Existing Bundles
+
+### What Stays the Same
+
+- Bundle file format is still TOML
+- `domain`, `description`, `main_pipe` fields unchanged
+- `[concept]` and `[pipe]` sections unchanged
+- Bare pipe references (`"extract_documents"`) still work within a bundle
+- Concept `domain.ConceptCode` references unchanged
+- Native concepts (`Text`, `Image`, etc.) unchanged
+
+### What Changes
+
+- ~~File extension is now `.mthds`~~ (done in Phase 0)
+- ~~Terminology is now "method"~~ (done in Phase 0)
+- Domains can now be hierarchical: `legal.contracts.shareholder` (optional, for organization)
+- Pipe references can now be `domain_path.pipe_code` (optional, for cross-bundle clarity)
+- Packages with a `METHODS.toml` get dependency management and export controls
+- Cross-package references use `alias->domain_path.name` syntax
+
+### Migration Steps for an Existing Project
+
+1. **To adopt packages**: run `pipelex pkg init` in your project directory. This creates a `METHODS.toml` with your bundles auto-discovered.
+2. **To use cross-bundle pipes**: change bare pipe references to `domain_path.pipe_code` where you reference pipes from a different bundle in the same project.
+3. **To depend on external packages**: add `[dependencies]` to your `METHODS.toml`, use `alias->domain_path.name` in your `.mthds` files.
+
+### Breaking Changes
+
+| Change | Impact | Migration |
+|--------|--------|-----------|
+| `.mthds` extension | Done (Phase 0) | — |
+| Pipe reference parser accepts `.` and `->` | Low — new syntax, old syntax still works | None needed |
+| `main_pipe` auto-exported | Low — only affects packages with manifest | Intentional; remove from `[exports]` if you want to override |
+| Pipes private by default with manifest | Medium — only affects packages with `METHODS.toml` | Run `pipelex pkg init` to auto-export all pipes, then trim |
+
+---
+
+*This document tracks the delta between current Pipelex and the MTHDS standard implementation. It will be updated as phases are implemented.*
diff --git a/refactoring/pipelex-package-system-design_v6.md b/refactoring/pipelex-package-system-design_v6.md
new file mode 100644
index 000000000..16e40458c
--- /dev/null
+++ b/refactoring/pipelex-package-system-design_v6.md
@@ -0,0 +1,441 @@
+# The MTHDS Package System — Design Specification
+
+## 1. Vision
+
+Methods are designed to be composable, shareable, and reusable. Today, bundles can reference concepts across domains, but the standard lacks the infrastructure for web-scale distribution: there are no globally unique addresses, no explicit dependencies, no visibility controls, and pipes lack the namespacing that concepts already have.
+
+The MTHDS Package System introduces the structures needed to turn individual bundles into nodes of the **Know-How Graph**: a federated network of reusable, discoverable, type-safe AI methods.
+
+### Design Principles
+
+These principles are drawn from what works in existing ecosystems (Go modules, Rust crates, Agent Skills) and what's unique to MTHDS:
+
+- **Filesystem as interface.** Packages are directories of text files. Git-native, human-readable, agent-readable. No proprietary formats, no binary blobs.
+- **Progressive enhancement.** A single `.mthds` file still works. Packaging is opt-in complexity added only when you need distribution.
+- **Type-driven composability.** Unlike Agent Skills (discovered by text description), pipes have typed signatures. The concept system enables semantic discovery: "I have X, I need Y."
+- **Federated distribution.** Decentralized storage (Git), centralized discovery (registries). No single point of ownership.
+- **Packages own namespaces, domains carry meaning.** The package is the ownership/isolation boundary. The domain is a semantic label and an intra-package namespace, but it never merges across packages.
+
+---
+
+## 2. Core Concepts
+
+### Three Layers
+
+| Layer | What it is | Role |
+|-------|-----------|------|
+| **Domain** | A semantic namespace for concepts and pipes within a package. E.g., `recruitment`, `legal.contracts`, `scoring`. | Intra-package organization. Semantic label for discovery. Carries meaning about what the bundle is about. |
+| **Bundle** | A single `.mthds` file. Declares exactly one domain. Contains concept definitions and pipe definitions. | The authoring unit. Where concepts and pipes are defined. |
+| **Package** | A directory with a manifest (`METHODS.toml`) and one or more bundles. Has a globally unique address. | The distribution unit. Owns a namespace. Declares dependencies and exports. |
+
+### Hierarchical Domains
+
+Domains can be hierarchical, using `.` as the hierarchy separator:
+
+```
+legal
+legal.contracts
+legal.contracts.shareholder_agreements
+```
+
+This enables natural organization of complex knowledge areas. A large package covering legal methods can structure its domains as a tree rather than a flat list.
+
+**The hierarchy is purely organizational.** There is no implicit scope or inheritance between parent and child domains. `legal.contracts` does not automatically have access to concepts defined in `legal`. If a bundle in `legal.contracts` needs concepts from `legal`, it uses explicit domain-qualified references — the same as any other cross-domain reference. This keeps the system predictable: you can read a bundle and know exactly where its references come from.
+
+### Key Rule: Packages Isolate Namespaces
+
+Two packages can both declare `domain = "recruitment"`. Their concepts and pipes are completely independent — there is no merging. The domain name is semantic (it tells you what the bundle is about) and serves as a namespace within its package, but across packages, the package address is the true isolation boundary.
+
+This means:
+
+- `recruitment.CandidateProfile` from Package A and `recruitment.CandidateProfile` from Package B are **different things**.
+- To reference something from another package, you must qualify it with the package identity.
+- Within a single package, bundles sharing the same domain DO merge their namespace (same behavior as today's multi-file loading). Conflicts within the same package + same domain are errors.
+
+### Why Not Merge Domains?
+
+Merging domains across packages would create fragile implicit coupling: any package declaring `domain = "recruitment"` could inject concepts into your namespace. Instead, cross-package composition is explicit — through dependencies, concept refinement, and pipe invocation. This is how Go modules, Rust crates, and every robust package system works: you build on top of other packages, you don't extend their namespace.
+
+The domain remains valuable for **discovery**: searching the Know-How Graph for "all packages in the recruitment domain" is powerful. But discovery is not namespace merging.
+
+### Domain Naming Rules
+
+- Domain names must be lowercase `snake_case` segments, optionally separated by `.` for hierarchy.
+- Each segment follows `snake_case` rules: `[a-z][a-z0-9_]*`.
+- Recommended depth: 1-3 levels. Recommended segment length: 1-4 words.
+- Reserved domains that cannot be used by packages: `native`, `mthds`, `pipelex`. (Note: currently not enforced by domain validation — the manifest parser is the right place to check this.)
+
+---
+
+## 3. Package Structure
+
+A package is a directory following progressive enhancement — start minimal, add structure as needed:
+
+```
+legal-tools/
+├── METHODS.toml # Package manifest (required for distribution)
+├── general_legal.mthds # Bundle: domain = "legal"
+├── contract_analysis.mthds # Bundle: domain = "legal.contracts"
+├── shareholder_agreements.mthds # Bundle: domain = "legal.contracts.shareholder"
+├── scoring.mthds # Bundle: domain = "scoring"
+├── README.md # Optional: human-facing documentation
+├── test_data/ # Optional: example inputs
+│ └── inputs.json
+└── LICENSE # Optional: licensing terms
+```
+
+### Minimal Package
+
+The absolute minimum for a distributable package:
+
+```
+my-tool/
+├── METHODS.toml
+└── method.mthds
+```
+
+### Standalone Bundle (No Package)
+
+A `.mthds` file without a manifest still works. It behaves as an implicit local package with no dependencies (beyond native concepts) and all pipes public. This preserves the "single file = working method" experience for learning, prototyping, and simple projects.
+
+---
+
+## 4. The Package Manifest
+
+`METHODS.toml` — the identity card and dependency declaration for a package.
+
+```toml
+[package]
+address = "github.com/acme/legal-tools"
+version = "0.3.0"
+description = "Legal document analysis and contract review methods."
+authors = ["ACME Legal Tech "]
+license = "MIT"
+mthds_version = ">=0.2.0"
+
+[dependencies]
+"github.com/mthds/document-processing" = { version = "^1.0.0", alias = "docproc" }
+"github.com/mthds/scoring-lib" = { version = "^0.5.0", alias = "scoring_lib" }
+
+[exports.legal]
+pipes = ["classify_document"]
+
+[exports.legal.contracts]
+pipes = ["extract_clause", "analyze_nda", "compare_contracts"]
+
+[exports.scoring]
+pipes = ["compute_weighted_score"]
+```
+
+### Fields
+
+**`[package]`**
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `address` | Yes | Globally unique identifier. Must start with a hostname. URL-style, self-describing. The address IS the fetch location (modulo protocol). |
+| `version` | Yes | Semantic version. |
+| `description` | Yes | Human-readable summary of the package's purpose. Written at the package level (not duplicating pipe signatures). |
+| `authors` | No | List of author identifiers. |
+| `license` | No | SPDX license identifier. |
+| `mthds_version` | No | Minimum MTHDS standard version required. |
+
+**`[dependencies]`**
+
+Each key is a package address (must start with a hostname). Values:
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `version` | Yes | Version constraint (semver range). |
+| `alias` | Yes | Short `snake_case` name for use in `.mthds` cross-package references. Must be valid `snake_case`. No auto-defaulting — explicit aliases keep references readable and intentional. |
+
+**`[exports]`**
+
+Uses TOML sub-tables, one per domain. The domain path maps directly to the TOML table path — `legal.contracts` becomes `[exports.legal.contracts]`. Each sub-table contains:
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `pipes` | Yes | List of pipe codes that are public from this domain. |
+
+Rules:
+
+- **Concepts are always public.** They are vocabulary — the whole point of domains is shared meaning.
+- **Pipes are private by default.** A non-exported pipe is only accessible from within its own domain. Pipes listed in `[exports]` are callable from any domain within the package and by external packages.
+- **`main_pipe` is auto-exported.** If a bundle declares a `main_pipe`, it is automatically part of the public API.
+- Pipes not listed in exports are implementation details — invisible to consumers.
+
+---
+
+## 5. Namespace Resolution
+
+References to concepts and pipes resolve through three scopes, from most local to most global.
+
+### Parsing Rule
+
+A reference is parsed by splitting on the **last `.`** to separate the domain path from the name:
+
+- `extract_clause` → bare name (no dot, local)
+- `legal.contracts.extract_clause` → domain `legal.contracts`, pipe `extract_clause`
+- `legal.contracts.NonCompeteClause` → domain `legal.contracts`, concept `NonCompeteClause`
+- `scoring.compute_score` → domain `scoring`, pipe `compute_score`
+
+The casing of the last segment disambiguates: `snake_case` = pipe code, `PascalCase` = concept code. This is unambiguous because pipe codes and concept codes follow different casing conventions.
+
+For package-qualified references, `->` is split first:
+
+- `docproc->legal.contracts.extract_clause` → package `docproc`, domain `legal.contracts`, pipe `extract_clause`
+
+### Scope 1: Bundle-Local (Bare Names)
+
+Within a `.mthds` file, bare names resolve to the current bundle and its domain. This is how things work today.
+
+```toml
+# In contract_analysis.mthds (domain = "legal.contracts")
+[pipe.extract_clause]
+inputs = { contract = "ContractDocument" } # concept from this bundle
+output = "NonCompeteClause" # concept from this bundle
+steps = [
+ { pipe = "parse_sections", result = "sections" } # pipe from this bundle
+]
+```
+
+### Scope 2: Domain-Qualified (Cross-Bundle, Same Package)
+
+When referencing something from another bundle within the same package (or for explicitness), use `domain_path.name`:
+
+```toml
+# Concepts — single-segment domain (already supported today)
+inputs = { doc = "legal.ClassifiedDocument" }
+output = "scoring.WeightedScore"
+
+# Concepts — hierarchical domain (NEW)
+inputs = { clause = "legal.contracts.NonCompeteClause" }
+
+# Pipes (NEW — same syntax as concepts)
+steps = [
+ { pipe = "legal.classify_document", result = "classified" },
+ { pipe = "legal.contracts.extract_clause", result = "clause" },
+ { pipe = "scoring.compute_weighted_score", result = "score" }
+]
+```
+
+This is the main change for pipe namespacing: pipes get domain-qualified references, symmetric with concepts.
+
+### Scope 3: Package-Qualified (Cross-Package)
+
+When referencing something from another package, prefix with the package alias and `->`:
+
+```toml
+# Using dependency alias from METHODS.toml
+inputs = { pages = "docproc->extraction.Page" }
+steps = [
+ { pipe = "docproc->extraction.extract_text", result = "pages" }
+]
+```
+
+The `->` (arrow) separator was chosen for **readability by non-technical audiences**. MTHDS is a language that business people and domain experts read and contribute to — the separator must feel natural, not "geeky."
+
+- Reads as natural language: "from docproc, get extraction.extract_text"
+- Directional — conveys "reaching into another package" intuitively
+- Visually distinctive from `.` — the package boundary is immediately visible at a glance
+- Universally understood (arrows are not a programming concept)
+
+**Alias naming rule**: Package aliases must be `snake_case` (consistent with domain names). This ensures clean readability — e.g., `acme_hr->recruitment.extract_cv`.
+
+### Resolution Order
+
+When resolving a bare reference like `NonCompeteClause`:
+
+1. Check native concepts (`Text`, `Image`, `Document`, etc.) — native always takes priority
+2. Look in the current bundle's declared concepts
+3. Look in other bundles of the same domain within the same package
+4. If not found: error
+
+When resolving `legal.contracts.NonCompeteClause`:
+
+1. Look in the `legal.contracts` domain within the current package
+2. If not found: error (domain-qualified refs don't fall through to dependencies)
+
+When resolving `acme->legal.contracts.NonCompeteClause`:
+
+1. Look in the `legal.contracts` domain of the package aliased as `acme`
+2. If not found: error
+
+### Special Namespace: `native`
+
+Built-in concepts remain accessible as `native.Image`, `native.Text`, etc. — or by bare name (`Image`, `Text`) since they're always in scope. The `native` prefix is a reserved namespace that no package can claim.
+
+---
+
+## 6. Pipe Namespacing — All Reference Points
+
+Every place in the `.mthds` format that references a pipe must support the three-scope syntax:
+
+| Location | Current | With Namespacing |
+|----------|---------|-----------------|
+| `main_pipe` | `"extract_clause"` | `"extract_clause"` (always local) |
+| `steps[].pipe` | `"extract_documents"` | `"extract_documents"` or `"legal.contracts.extract_clause"` or `"pkg->legal.contracts.extract_clause"` |
+| `parallels[].pipe` | `"analyze_cv"` | Same three-scope options |
+| `branch_pipe_code` | `"process_single_cv"` | Same three-scope options |
+| `outcomes` values | `"deep_analysis"` | Same three-scope options |
+| `default_outcome` | `"fallback_analysis"` | Same three-scope options |
+
+**Rule**: Pipe *definitions* (the `[pipe.my_pipe]` keys) are always local bare names. Namespacing applies only to pipe *references*.
+
+---
+
+## 7. Dependency Management
+
+### Addressing
+
+Package addresses are URL-style identifiers that must start with a hostname. They double as fetch locations:
+
+```
+github.com/mthds/document-processing
+github.com/acme/legal-tools
+gitlab.com/company/internal-methods
+```
+
+The canonical form is always the full hostname-based address.
+
+### Fetching
+
+Resolution chain:
+
+1. **Local cache**: `~/.mthds/packages/` (global) or `.mthds/packages/` (project-local)
+2. **VCS fetch**: The address IS the fetch URL — `github.com/acme/...` maps to `https://github.com/acme/...`
+3. **Proxy/mirror**: Optional, configurable proxy for speed, reliability, or air-gapped environments (like Go's `GOPROXY`)
+
+### Lock File
+
+`methods.lock` — auto-generated, committed to version control:
+
+```toml
+["github.com/mthds/document-processing"]
+version = "1.2.3"
+hash = "sha256:a1b2c3d4..."
+source = "https://github.com/mthds/document-processing"
+
+["github.com/mthds/scoring-lib"]
+version = "0.5.1"
+hash = "sha256:e5f6g7h8..."
+source = "https://github.com/mthds/scoring-lib"
+```
+
+### Integrity
+
+- **SHA-256 checksums** in the lock file for every resolved package.
+- **Optional signed manifests** for enterprise use (verifiable authorship).
+- Checksum verification on every install/update.
+
+### Version Resolution Strategy
+
+Minimum version selection (Go's approach): deterministic, reproducible, simple. If Package A requires `>=1.0.0` of B and Package C requires `>=1.2.0` of B, resolve to `1.2.0` — the minimum version that satisfies all constraints.
+
+### Cross-Package Concept Refinement Validation
+
+When concept A in Package X `refines` concept B in Package Y, compatibility is validated at **both install time and load time**:
+
+- **Install time**: verify that the referenced concept exists in the declared dependency version. Detect breaking changes early (e.g., if Package Y removes concept B in a new version).
+- **Load time**: verify structural compatibility when bundles are actually loaded into the runtime.
+
+---
+
+## 8. Distribution Architecture
+
+Following the federated model: decentralized storage, centralized discovery.
+
+### Storage: Git Repositories
+
+Packages live in Git repositories. The repository IS the package. No upload step, no proprietary hosting. Authors retain full control.
+
+A repository can contain one package (at the root) or multiple packages (in subdirectories, with distinct addresses).
+
+### Discovery: Registry Indexes
+
+One or more registry services index packages without owning them. A registry provides:
+
+- **Search**: by domain, by concept, by pipe signature, by description
+- **Type-compatible search** (unique to MTHDS): "find pipes that accept `Document` and produce something refining `Text`"
+- **Metadata**: versions, descriptions, licenses, dependency graphs
+- **Social signals**: install counts, stars, community endorsements
+- **Concept/pipe browsing**: navigate the refinement hierarchy, explore pipe signatures
+
+Registries build their index by:
+
+1. Crawling known package addresses
+2. Parsing `METHODS.toml` for metadata
+3. Parsing `.mthds` files for concept definitions and pipe signatures
+4. No duplication — all data derived from the source files
+
+### Installation
+
+CLI-driven, inspired by `go get` and `npx skills add`:
+
+```bash
+mthds pkg add github.com/mthds/document-processing
+mthds pkg add github.com/acme/legal-tools@0.3.0
+mthds pkg install # install all dependencies from lock file
+mthds pkg update # update to latest compatible versions
+```
+
+### Multi-Tier Deployment
+
+Inspired by Agent Skills' enterprise tiers:
+
+| Tier | Scope | Typical Use |
+|------|-------|-------------|
+| **Local** | Single `.mthds` file, no manifest | Learning, prototyping, one-off methods |
+| **Project** | Package in a project repo | Team methods, versioned with the codebase |
+| **Organization** | Internal registry/proxy | Company-wide approved methods, governance |
+| **Community** | Public Git repos + public registries | Open-source Know-How Graph |
+
+---
+
+## 9. The Know-How Graph Integration
+
+The package system is the infrastructure layer that enables the Know-How Graph to operate at web scale.
+
+### Pipes as Typed Nodes
+
+Every exported pipe has a typed signature:
+
+```
+extract_clause: (ContractDocument) → NonCompeteClause
+classify_document: (Document) → ClassifiedDocument
+compute_weighted_score: (CandidateProfile, JobRequirements) → WeightedScore
+```
+
+These signatures, combined with concept refinement hierarchies, form a directed graph where:
+
+- **Nodes** are pipe signatures (typed transformations)
+- **Edges** are data flow connections (output of one pipe type-matches input of another)
+- **Refinement edges** connect concept hierarchies (`NonCompeteClause refines ContractClause refines Text`)
+
+### Discovery Capabilities
+
+The type system enables queries that text-based discovery (like Agent Skills) cannot support:
+
+| Query Type | Example |
+|-----------|---------|
+| "I have X, I need Y" | "I have a `Document`, I need a `NonCompeteClause`" → finds all pipes/chains that produce it |
+| "What can I do with X?" | "What pipes accept `ContractDocument` as input?" → shows downstream possibilities |
+| Auto-composition | No single pipe goes from X to Y? Find a chain through the graph. |
+| Compatibility check | Before installing a package, verify its pipes are type-compatible with yours. |
+
+### Concept Refinement Across Packages
+
+Cross-package concept refinement enables building on others' vocabulary:
+
+```toml
+# In your package, depending on acme_legal
+[concept.EmploymentNDA]
+description = "A non-disclosure agreement specific to employment contexts"
+refines = "acme_legal->legal.contracts.NonDisclosureAgreement"
+```
+
+This extends the refinement hierarchy across package boundaries, enriching the Know-How Graph without merging namespaces.
+
+---
+
+*This is a living design document. It will evolve as we implement and discover edge cases.*
diff --git a/refactoring/testing-package-system.md b/refactoring/testing-package-system.md
new file mode 100644
index 000000000..25c15e3fa
--- /dev/null
+++ b/refactoring/testing-package-system.md
@@ -0,0 +1,502 @@
+# Package System — Testing Guide
+
+This guide covers testing the package system (METHODS.toml, exports/visibility, `pkg` CLI, cross-package references) using a layered strategy that maximizes coverage while minimizing external dependencies.
+
+## Testing Strategy Overview
+
+Cross-package references are the hardest part to test because they involve two independent packages — a **provider** (exports pipes) and a **consumer** (references them via `alias->domain.pipe`). The naive approach — creating multiple GitHub accounts — is fragile, slow, and unnecessary.
+
+Instead, we use four testing layers, each building on the previous one:
+
+| Layer | What it tests | I/O | Runs in CI |
+|-------|--------------|-----|------------|
+| **1. Unit tests** | `->` syntax parsing, alias validation, manifest models | None | Yes |
+| **2. Local path deps** | Full resolution pipeline with two directories on disk | Filesystem only | Yes |
+| **3. Local git repos** | VCS fetch path using `file://` protocol URLs | Local git, no network | Yes |
+| **4. Manual smoke test** | Real GitHub fetch + export validation | Network (GitHub) | No — manual only |
+
+Layers 1-3 are automated and form the test suite. Layer 4 is a one-time confidence check before shipping.
+
+**Why not two GitHub accounts?**
+
+- GitHub ToS discourages multiple personal accounts per person.
+- Credential management in CI is painful (two sets of secrets, token rotation).
+- Tests become fragile: network outages, rate limits, and GitHub API changes break them.
+- Slow feedback loop — every test run hits the network.
+- You don't need two *accounts*, you need two *repositories*. A single account or org can own both.
+- And for automated tests, you don't need GitHub at all — local git repos and local path deps cover the logic.
+
+## Prerequisites
+
+- A working Pipelex install with the virtual environment activated
+- The test fixtures in `tests/data/packages/` (automated tests) and optionally `refactoring/test-package-fixtures/` (manual tests)
+- All commands below assume you are in the **project root** (where `.pipelex/` lives)
+
+**Important**: `pipelex validate --all` requires a full Pipelex setup (the `.pipelex/` config directory). Use `--library-dir` to point it at fixture files while running from the project root. The `pkg list` and `pkg init` commands only need a `METHODS.toml` in the current directory, so for those you `cd` into the fixtures.
+
+---
+
+## Layer 1: Unit Tests (parsing, validation, models)
+
+These tests verify the low-level building blocks with no I/O at all. They already exist from Phase 2.
+
+### 1.1 Cross-package ref parsing
+
+The `->` syntax is validated by unit tests in `tests/unit/pipelex/core/packages/test_cross_package_refs.py`:
+
+```bash
+make tp TEST=TestCrossPackageRefs
+```
+
+**Expected**: All 4 tests pass:
+
+- `test_has_cross_package_prefix` — detects `->` in ref strings
+- `test_split_cross_package_ref` — splits `alias->domain.pipe` correctly
+- `test_known_alias_emits_warning_not_error` — known alias produces no error (warning via log)
+- `test_unknown_alias_produces_error` — unknown alias produces a `VisibilityError`
+
+### 1.2 Manifest model validation
+
+Manifest parsing, field validation, and serialization are covered by tests in `tests/unit/pipelex/core/packages/`. Run the full package unit test suite:
+
+```bash
+make tp TEST=tests/unit/pipelex/core/packages
+```
+
+### 1.3 What the `->` syntax looks like in practice
+
+In a `.mthds` file, a cross-package reference uses the alias from `[dependencies]`:
+
+```toml
+[pipe.call_remote_scoring]
+type = "PipeSequence"
+description = "Call a pipe from the shared_scoring remote package"
+inputs = { data = "Text" }
+output = "Text"
+steps = [
+ { pipe = "shared_scoring->scoring.compute_score", result = "remote_score" },
+]
+```
+
+Where `shared_scoring` matches the dependency declared in METHODS.toml:
+
+```toml
+[dependencies]
+shared_scoring = { address = "github.com/acme/scoring-methods", version = "^2.0.0" }
+```
+
+---
+
+## Layer 2: Integration Tests with Local Path Dependencies
+
+This is where 90% of the cross-package test coverage should live. Two directories on disk, each with its own `METHODS.toml`, the consumer declaring the provider as a local path dependency. This tests the full resolution pipeline — discover manifest, read exports, validate visibility — with zero network I/O.
+
+### 2.1 Fixture layout
+
+The test fixtures live under `tests/data/packages/` and follow this structure:
+
+```
+tests/data/packages/
+├── provider_package/
+│ ├── METHODS.toml # declares [exports.scoring]
+│ └── scoring/
+│ └── scoring.mthds # defines compute_weighted_score (public) + internal_score_normalizer (private)
+│
+├── consumer_valid/
+│ ├── METHODS.toml # [dependencies] scoring_lib = { path = "../provider_package" }
+│ └── analysis/
+│ └── analysis.mthds # uses scoring_lib->scoring.compute_weighted_score (valid)
+│
+├── consumer_invalid/
+│ ├── METHODS.toml # same dependency declaration
+│ └── analysis/
+│ └── analysis.mthds # uses scoring_lib->scoring.internal_score_normalizer (blocked — not exported)
+│
+└── consumer_unknown_alias/
+ ├── METHODS.toml # no [dependencies] section
+ └── analysis/
+ └── analysis.mthds # uses nonexistent_lib->scoring.compute_weighted_score (unknown alias)
+```
+
+### 2.2 What the local path dependency looks like
+
+The consumer's `METHODS.toml` uses a `path` field instead of (or alongside) an `address`:
+
+```toml
+[package]
+name = "contract-analysis"
+version = "1.0.0"
+description = "Analyzes contracts using external scoring"
+
+[dependencies]
+scoring_lib = { path = "../provider_package", version = "^1.0.0" }
+```
+
+The `path` field is resolved relative to the `METHODS.toml` file's location. This is the same pattern used by Cargo (`path = "..."`), Go (`replace` directive), and Poetry (`path` dependencies).
+
+### 2.3 Test cases
+
+These are automated tests (pytest), not manual steps:
+
+| Test case | Consumer fixture | Expected result |
+|-----------|-----------------|-----------------|
+| Valid cross-package ref | `consumer_valid/` | Passes — pipe is exported by provider |
+| Private pipe ref | `consumer_invalid/` | Fails — `internal_score_normalizer` not in provider's `[exports]` |
+| Unknown alias | `consumer_unknown_alias/` | Fails — alias not declared in `[dependencies]` |
+| Provider has no manifest | (provider without METHODS.toml) | Passes — no manifest means all public |
+| Provider `main_pipe` auto-export | (consumer refs provider's main_pipe not in exports) | Passes — main_pipe is auto-exported |
+
+### 2.4 Running the tests
+
+```bash
+make tp TEST=TestCrossPackageLocalPath
+```
+
+### 2.5 Why this layer matters
+
+Local path dependencies test the **exact same resolution logic** that remote dependencies will use — the only difference is *how* the provider package is located on disk. Once the provider's directory is found:
+
+1. Read its `METHODS.toml`
+2. Build a `PackageVisibilityChecker` from its exports
+3. Validate the consumer's `->` references against the provider's exports
+
+Steps 1-3 are identical regardless of whether the provider came from a local path, a local git clone, or a GitHub fetch. This is why local path tests give high confidence.
+
+---
+
+## Layer 3: Integration Tests with Local Git Repos
+
+This layer tests the VCS fetch path — cloning a repo, checking out a version, reading its manifest — without touching the network. It uses bare git repos on the local filesystem with `file://` protocol URLs.
+
+### 3.1 How it works
+
+The test setup creates temporary git repos using `git init --bare`, pushes fixture content to them, and tags releases. The consumer's dependency uses a `file://` URL instead of a `github.com/...` address:
+
+```toml
+[dependencies]
+scoring_lib = { address = "file:///tmp/test-repos/scoring-methods.git", version = "^1.0.0" }
+```
+
+### 3.2 Test setup (pytest fixture)
+
+A pytest fixture handles the lifecycle:
+
+1. Create a temp directory
+2. Initialize a bare git repo: `git init --bare /tmp/test-repos/scoring-methods.git`
+3. Clone it to a working copy, add the provider package files (METHODS.toml + .mthds bundles)
+4. Commit and tag: `git tag v1.0.0`
+5. Push to the bare repo
+6. Yield the `file://` URL to the test
+7. Clean up on teardown
+
+This mirrors exactly what happens with a real GitHub repo, but runs entirely on the local filesystem.
+
+### 3.3 Test cases
+
+| Test case | Setup | Expected result |
+|-----------|-------|-----------------|
+| Clone + resolve valid ref | Provider tagged `v1.0.0`, consumer requires `^1.0.0` | Passes — version matches, pipe is exported |
+| Version mismatch | Provider tagged `v1.0.0`, consumer requires `^2.0.0` | Fails — no matching version |
+| Clone + visibility violation | Provider exports only `compute_weighted_score`, consumer refs private pipe | Fails — visibility error with helpful message |
+| Multiple tags | Provider has `v1.0.0` and `v1.1.0`, consumer requires `^1.0.0` | Resolves to `v1.1.0` (latest matching) |
+
+### 3.4 Running the tests
+
+```bash
+make tp TEST=TestCrossPackageGitLocal
+```
+
+### 3.5 What this adds over Layer 2
+
+Layer 2 tests the resolution logic assuming the provider is already on disk. Layer 3 tests the **fetch** logic:
+
+- Can we clone from a URL?
+- Can we resolve version constraints against git tags?
+- Can we read the manifest from the cloned repo?
+- Does caching work (second resolve doesn't re-clone)?
+
+These are the moving parts that break when the VCS integration has bugs.
+
+---
+
+## Layer 4: Manual Smoke Test (GitHub)
+
+This is a one-time manual test to confirm end-to-end behavior with real GitHub repos. It is **not** part of the automated test suite. You need a single GitHub account (or org) with two public repos.
+
+### 4.1 Setup
+
+1. Create a GitHub repo `yourorg/scoring-methods` containing:
+
+ ```
+ METHODS.toml
+ scoring/
+ scoring.mthds
+ ```
+
+ Where `METHODS.toml` declares:
+
+ ```toml
+ [package]
+ name = "scoring-methods"
+ version = "1.0.0"
+ description = "Shared scoring methods"
+ address = "github.com/yourorg/scoring-methods"
+
+ [exports.scoring]
+ pipes = ["compute_weighted_score"]
+ ```
+
+ Tag a release: `git tag v1.0.0 && git push --tags`
+
+2. Create a GitHub repo `yourorg/contract-analysis` containing:
+
+ ```
+ METHODS.toml
+ analysis/
+ analysis.mthds
+ ```
+
+ Where `METHODS.toml` declares:
+
+ ```toml
+ [package]
+ name = "contract-analysis"
+ version = "1.0.0"
+ description = "Contract analysis pipeline"
+ address = "github.com/yourorg/contract-analysis"
+
+ [dependencies]
+ scoring_lib = { address = "github.com/yourorg/scoring-methods", version = "^1.0.0" }
+
+ [exports.analysis]
+ pipes = ["analyze_contract"]
+ ```
+
+ And `analysis.mthds` references the remote pipe:
+
+ ```toml
+ [pipe.analyze_contract]
+ type = "PipeSequence"
+ description = "Analyze a contract using remote scoring"
+ inputs = { data = "Text" }
+ output = "Text"
+ steps = [
+ { pipe = "scoring_lib->scoring.compute_weighted_score", result = "score" },
+ ]
+ ```
+
+### 4.2 Test it
+
+Clone the consumer repo and run:
+
+```bash
+pipelex validate --all --library-dir .
+```
+
+**Expected**: Passes — the scoring pipe is exported and the version matches.
+
+### 4.3 Test a visibility violation
+
+Update `analysis.mthds` to reference a private pipe:
+
+```toml
+steps = [
+ { pipe = "scoring_lib->scoring.internal_score_normalizer", result = "score" },
+]
+```
+
+Re-run validation. **Expected**: Fails with a visibility error naming the pipe and suggesting to add it to `[exports.scoring]`.
+
+### 4.4 When to run this
+
+Run the smoke test once after implementing the GitHub fetch path, and again before releasing. It does not need to be part of CI.
+
+---
+
+## A. Local Testing (single package, visibility enforcement)
+
+These are manual tests for Phase 2 functionality (single-package visibility). They remain useful for quickly verifying the visibility model without running the full pytest suite.
+
+### 1. Verify the fixture structure
+
+```
+refactoring/test-package-fixtures/
+├── METHODS.toml
+├── legal/
+│ └── contracts.mthds
+├── scoring/
+│ └── scoring.mthds
+└── reporting/
+ └── summary.mthds
+```
+
+### 2. Inspect the manifest with `pkg list`
+
+```bash
+cd refactoring/test-package-fixtures
+pipelex pkg list
+cd ../..
+```
+
+**Expected**: Three Rich tables showing:
+
+- **Package** table — address `github.com/acme/contract-analysis`, version `1.0.0`
+- **Dependencies** table — alias `shared_scoring`, address `github.com/acme/scoring-methods`, version `^2.0.0`
+- **Exports** table — two rows:
+ - `legal.contracts` → `extract_clause, analyze_contract`
+ - `scoring` → `compute_weighted_score`
+
+### 3. Run validate — expect visibility failure
+
+From the project root:
+
+```bash
+pipelex validate --all --library-dir refactoring/test-package-fixtures
+```
+
+**Expected**: A `LibraryLoadingError` with a visibility violation:
+
+```
+Pipe 'scoring.internal_score_normalizer' referenced in
+pipe.generate_report.steps[2].pipe (domain 'reporting') is not exported by
+domain 'scoring'. Add it to [exports.scoring] pipes in METHODS.toml.
+```
+
+This is because `reporting/summary.mthds` references `scoring.internal_score_normalizer`, which is **not** listed in `[exports.scoring]`.
+
+### 4. Fix the violation and re-validate
+
+Edit `refactoring/test-package-fixtures/reporting/summary.mthds` — remove the offending step:
+
+```toml
+steps = [
+ { pipe = "legal.contracts.extract_clause", result = "clause" },
+ { pipe = "scoring.compute_weighted_score", result = "score" },
+]
+```
+
+Re-run:
+
+```bash
+pipelex validate --all --library-dir refactoring/test-package-fixtures
+```
+
+**Expected**: Validation passes (no visibility errors).
+
+After testing, restore the original step so the fixture remains useful for future tests:
+
+```toml
+steps = [
+ { pipe = "legal.contracts.extract_clause", result = "clause" },
+ { pipe = "scoring.compute_weighted_score", result = "score" },
+ { pipe = "scoring.internal_score_normalizer", result = "normalized" },
+]
+```
+
+### 5. Alternative fix — export the pipe
+
+Instead of removing the reference, you can export the pipe. Edit `refactoring/test-package-fixtures/METHODS.toml`:
+
+```toml
+[exports.scoring]
+pipes = ["compute_weighted_score", "internal_score_normalizer"]
+```
+
+Re-run `pipelex validate --all --library-dir refactoring/test-package-fixtures`. **Expected**: passes. Remember to restore the original exports afterward.
+
+### 6. Test `pkg init` scaffolding
+
+Copy just the `.mthds` files (no METHODS.toml) to a temp directory:
+
+```bash
+mkdir -p /tmp/pkg-init-test
+cp -r refactoring/test-package-fixtures/legal /tmp/pkg-init-test/
+cp -r refactoring/test-package-fixtures/scoring /tmp/pkg-init-test/
+cd /tmp/pkg-init-test
+pipelex pkg init
+```
+
+**Expected**: A new `METHODS.toml` is created with:
+
+- A placeholder address derived from the directory name
+- `[exports]` sections for all discovered domains and pipes
+- Version `0.1.0`
+
+Inspect it:
+
+```bash
+pipelex pkg list
+```
+
+Return to the project root when done:
+
+```bash
+cd /path/to/project
+```
+
+### 7. Test backward compatibility — no METHODS.toml
+
+Copy fixtures without the manifest:
+
+```bash
+cp -r refactoring/test-package-fixtures /tmp/pkg-no-manifest
+rm /tmp/pkg-no-manifest/METHODS.toml
+pipelex validate --all --library-dir /tmp/pkg-no-manifest
+```
+
+**Expected**: Validation passes. Without a manifest, all pipes are treated as public (backward-compatible behavior).
+
+### 8. Test `main_pipe` auto-export
+
+In the fixture files, `legal/contracts.mthds` declares `main_pipe = "extract_clause"`. This pipe is automatically exported even if you remove it from `[exports.legal.contracts]`.
+
+Copy the fixtures and edit the copy:
+
+```bash
+cp -r refactoring/test-package-fixtures /tmp/pkg-main-pipe-test
+```
+
+Edit `/tmp/pkg-main-pipe-test/METHODS.toml` to remove `extract_clause` from the exports:
+
+```toml
+[exports.legal.contracts]
+pipes = ["analyze_contract"]
+```
+
+Also edit `/tmp/pkg-main-pipe-test/reporting/summary.mthds` to remove the blocked step (`internal_score_normalizer`), then run:
+
+```bash
+pipelex validate --all --library-dir /tmp/pkg-main-pipe-test
+```
+
+**Expected**: Passes. The reference to `legal.contracts.extract_clause` is still valid because it is the `main_pipe` of its domain.
+
+---
+
+## Fixture File Reference
+
+| File | Domain | Exports | Private pipes |
+|------|--------|---------|---------------|
+| `legal/contracts.mthds` | `legal.contracts` | `extract_clause` (also main_pipe), `analyze_contract` | `internal_clause_helper` |
+| `scoring/scoring.mthds` | `scoring` | `compute_weighted_score` (also main_pipe) | `internal_score_normalizer` |
+| `reporting/summary.mthds` | `reporting` | (none declared) | `generate_report` |
+
+The `reporting/summary.mthds` bundle is the key testing tool — its `generate_report` pipe references:
+
+- `legal.contracts.extract_clause` — **valid** (exported)
+- `scoring.compute_weighted_score` — **valid** (exported)
+- `scoring.internal_score_normalizer` — **blocked** (not exported) — toggle this line to test pass/fail
+
+---
+
+## Current Implementation State
+
+Cross-package reference **parsing and alias validation** are implemented in `PackageVisibilityChecker.validate_cross_package_references()` (`pipelex/core/packages/visibility.py:128`). However, this method is **not yet wired** into the `pipelex validate --all` pipeline — `check_visibility_for_blueprints()` only calls `validate_all_pipe_references()`, not `validate_cross_package_references()`. This means `->` references are currently validated only by unit tests, not at CLI level.
+
+Full cross-package **resolution** (fetching and loading remote packages) is also not yet implemented. The test layers described above (2, 3, 4) serve as the specification for what Phase 3 must deliver:
+
+- **Layer 2 defines** the local path dependency format and resolution behavior.
+- **Layer 3 defines** the VCS fetch, version resolution, and caching behavior.
+- **Layer 4 defines** the end-user experience with real GitHub repos.
+
+Phase 3 implementation should make these test cases pass, in order.
diff --git a/tests/data/packages/invalid_manifests/bad_address.toml b/tests/data/packages/invalid_manifests/bad_address.toml
new file mode 100644
index 000000000..2fcc316b2
--- /dev/null
+++ b/tests/data/packages/invalid_manifests/bad_address.toml
@@ -0,0 +1,4 @@
+[package]
+address = "no-dots-or-slashes"
+version = "1.0.0"
+description = "Test package with invalid address"
diff --git a/tests/data/packages/invalid_manifests/bad_exports_domain.toml b/tests/data/packages/invalid_manifests/bad_exports_domain.toml
new file mode 100644
index 000000000..ce5ef588e
--- /dev/null
+++ b/tests/data/packages/invalid_manifests/bad_exports_domain.toml
@@ -0,0 +1,7 @@
+[package]
+address = "github.com/org/repo"
+version = "1.0.0"
+description = "Test package with invalid exports domain"
+
+[exports.InvalidDomain]
+pipes = ["my_pipe"]
diff --git a/tests/data/packages/invalid_manifests/bad_exports_pipe.toml b/tests/data/packages/invalid_manifests/bad_exports_pipe.toml
new file mode 100644
index 000000000..da90b2ce8
--- /dev/null
+++ b/tests/data/packages/invalid_manifests/bad_exports_pipe.toml
@@ -0,0 +1,7 @@
+[package]
+address = "github.com/org/repo"
+version = "1.0.0"
+description = "Test package with invalid exports pipe name"
+
+[exports.valid_domain]
+pipes = ["InvalidPipeName"]
diff --git a/tests/data/packages/invalid_manifests/bad_version.toml b/tests/data/packages/invalid_manifests/bad_version.toml
new file mode 100644
index 000000000..c39e71739
--- /dev/null
+++ b/tests/data/packages/invalid_manifests/bad_version.toml
@@ -0,0 +1,4 @@
+[package]
+address = "github.com/org/repo"
+version = "not-a-version"
+description = "Test package with invalid version"
diff --git a/tests/data/packages/invalid_manifests/duplicate_aliases.toml b/tests/data/packages/invalid_manifests/duplicate_aliases.toml
new file mode 100644
index 000000000..82891027c
--- /dev/null
+++ b/tests/data/packages/invalid_manifests/duplicate_aliases.toml
@@ -0,0 +1,7 @@
+[package]
+address = "github.com/org/repo"
+version = "1.0.0"
+description = "Test package with duplicate aliases"
+
+[dependencies]
+my_dep = { address = "github.com/org/dep1", version = "1.0.0" }
diff --git a/tests/data/packages/invalid_manifests/missing_required_fields.toml b/tests/data/packages/invalid_manifests/missing_required_fields.toml
new file mode 100644
index 000000000..9b09112bc
--- /dev/null
+++ b/tests/data/packages/invalid_manifests/missing_required_fields.toml
@@ -0,0 +1,2 @@
+[package]
+description = "Missing address and version"
diff --git a/tests/data/packages/legal_tools/METHODS.toml b/tests/data/packages/legal_tools/METHODS.toml
new file mode 100644
index 000000000..65d6ba02f
--- /dev/null
+++ b/tests/data/packages/legal_tools/METHODS.toml
@@ -0,0 +1,16 @@
+[package]
+address = "github.com/pipelexlab/legal-tools"
+version = "1.0.0"
+description = "Legal document analysis tools"
+authors = ["PipelexLab"]
+license = "MIT"
+mthds_version = "0.5.0"
+
+[dependencies]
+scoring_lib = { address = "github.com/pipelexlab/scoring-lib", version = "2.0.0" }
+
+[exports.pkg_test_legal.contracts]
+pipes = ["pkg_test_extract_clause", "pkg_test_analyze_contract"]
+
+[exports.pkg_test_scoring]
+pipes = ["pkg_test_compute_weighted_score"]
diff --git a/tests/data/packages/legal_tools/legal/contracts.mthds b/tests/data/packages/legal_tools/legal/contracts.mthds
new file mode 100644
index 000000000..e3108983e
--- /dev/null
+++ b/tests/data/packages/legal_tools/legal/contracts.mthds
@@ -0,0 +1,23 @@
+domain = "pkg_test_legal.contracts"
+main_pipe = "pkg_test_extract_clause"
+
+[concept.PkgTestContractClause]
+description = "A clause extracted from a contract"
+
+[pipe.pkg_test_extract_clause]
+type = "PipeLLM"
+description = "Extract the main clause from a contract"
+output = "PkgTestContractClause"
+prompt = "Extract the main clause from the following contract text: {{ text }}"
+
+[pipe.pkg_test_extract_clause.inputs]
+text = "Text"
+
+[pipe.pkg_test_analyze_contract]
+type = "PipeLLM"
+description = "Full contract analysis"
+output = "PkgTestContractClause"
+prompt = "Analyze the following contract: {{ text }}"
+
+[pipe.pkg_test_analyze_contract.inputs]
+text = "Text"
diff --git a/tests/data/packages/legal_tools/scoring/scoring.mthds b/tests/data/packages/legal_tools/scoring/scoring.mthds
new file mode 100644
index 000000000..b1627f837
--- /dev/null
+++ b/tests/data/packages/legal_tools/scoring/scoring.mthds
@@ -0,0 +1,23 @@
+domain = "pkg_test_scoring"
+main_pipe = "pkg_test_compute_weighted_score"
+
+[concept.PkgTestScoreResult]
+description = "A weighted score result"
+
+[pipe.pkg_test_compute_weighted_score]
+type = "PipeLLM"
+description = "Compute a weighted score for an item"
+output = "PkgTestScoreResult"
+prompt = "Compute a weighted score for: {{ item }}"
+
+[pipe.pkg_test_compute_weighted_score.inputs]
+item = "Text"
+
+[pipe.pkg_test_private_helper]
+type = "PipeLLM"
+description = "Helper pipe for internal scoring"
+output = "Text"
+prompt = "Helper pipe for internal scoring: {{ data }}"
+
+[pipe.pkg_test_private_helper.inputs]
+data = "Text"
diff --git a/tests/data/packages/minimal_package/METHODS.toml b/tests/data/packages/minimal_package/METHODS.toml
new file mode 100644
index 000000000..36bf23154
--- /dev/null
+++ b/tests/data/packages/minimal_package/METHODS.toml
@@ -0,0 +1,4 @@
+[package]
+address = "github.com/pipelexlab/minimal"
+version = "0.1.0"
+description = "A minimal MTHDS package"
diff --git a/tests/data/packages/minimal_package/core.mthds b/tests/data/packages/minimal_package/core.mthds
new file mode 100644
index 000000000..f39a10b12
--- /dev/null
+++ b/tests/data/packages/minimal_package/core.mthds
@@ -0,0 +1,7 @@
+domain = "pkg_test_minimal_core"
+
+[pipe.pkg_test_hello]
+type = "PipeLLM"
+description = "Say hello"
+output = "Text"
+prompt = "Say hello"
diff --git a/tests/data/packages/standalone_bundle/my_pipe.mthds b/tests/data/packages/standalone_bundle/my_pipe.mthds
new file mode 100644
index 000000000..b69c98044
--- /dev/null
+++ b/tests/data/packages/standalone_bundle/my_pipe.mthds
@@ -0,0 +1,7 @@
+domain = "pkg_test_standalone"
+
+[pipe.pkg_test_do_something]
+type = "PipeLLM"
+description = "Do something useful"
+output = "Text"
+prompt = "Do something useful"
diff --git a/tests/integration/pipelex/core/packages/test_visibility_integration.py b/tests/integration/pipelex/core/packages/test_visibility_integration.py
new file mode 100644
index 000000000..74ccee524
--- /dev/null
+++ b/tests/integration/pipelex/core/packages/test_visibility_integration.py
@@ -0,0 +1,92 @@
+import shutil
+from pathlib import Path
+
+from pipelex.core.interpreter.interpreter import PipelexInterpreter
+from pipelex.core.packages.discovery import find_package_manifest
+from pipelex.core.packages.visibility import check_visibility_for_blueprints
+
+# Path to the physical test data
+PACKAGES_DATA_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent / "data" / "packages"
+
+
+class TestVisibilityIntegration:
+ """Integration tests using physical METHODS.toml and .mthds files on disk."""
+
+ def test_legal_tools_package_valid_refs(self):
+ """Legal tools package: all cross-domain refs are to exported pipes -> no errors."""
+ contracts_path = PACKAGES_DATA_DIR / "legal_tools" / "legal" / "contracts.mthds"
+ scoring_path = PACKAGES_DATA_DIR / "legal_tools" / "scoring" / "scoring.mthds"
+
+ manifest = find_package_manifest(contracts_path)
+ assert manifest is not None
+
+ contracts_bp = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=contracts_path)
+ scoring_bp = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=scoring_path)
+
+ errors = check_visibility_for_blueprints(manifest=manifest, blueprints=[contracts_bp, scoring_bp])
+ assert errors == []
+
+ def test_standalone_bundle_all_public(self):
+ """Standalone bundle (no METHODS.toml) -> all pipes public, no errors."""
+ bundle_path = PACKAGES_DATA_DIR / "standalone_bundle" / "my_pipe.mthds"
+
+ manifest = find_package_manifest(bundle_path)
+ assert manifest is None
+
+ bundle_bp = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=bundle_path)
+ errors = check_visibility_for_blueprints(manifest=None, blueprints=[bundle_bp])
+ assert errors == []
+
+ def test_modified_bundle_references_private_pipe(self, tmp_path: Path):
+ """Modified bundle that references a private pipe -> visibility error."""
+ # Copy the legal_tools package to tmp_path
+ src_dir = PACKAGES_DATA_DIR / "legal_tools"
+ dst_dir = tmp_path / "legal_tools"
+ shutil.copytree(src_dir, dst_dir)
+
+ # Modify contracts.mthds to reference the private helper pipe
+ contracts_path = dst_dir / "legal" / "contracts.mthds"
+ contracts_content = contracts_path.read_text(encoding="utf-8")
+ contracts_content = contracts_content.replace(
+ "pkg_test_scoring.pkg_test_compute_weighted_score",
+ "pkg_test_scoring.pkg_test_private_helper",
+ )
+ # Add the pipe reference as a sequence step
+ modified_content = """\
+domain = "pkg_test_legal.contracts"
+main_pipe = "pkg_test_extract_clause"
+
+[concept.PkgTestContractClause]
+description = "A clause extracted from a contract"
+
+[pipe.pkg_test_extract_clause]
+type = "PipeLLM"
+description = "Extract the main clause from a contract"
+output = "PkgTestContractClause"
+prompt = "Extract the main clause from the following contract text: {{ text }}"
+
+[pipe.pkg_test_extract_clause.inputs]
+text = "Text"
+
+[pipe.pkg_test_call_private]
+type = "PipeSequence"
+description = "Call a private pipe from another domain"
+output = "Text"
+
+[[pipe.pkg_test_call_private.steps]]
+pipe = "pkg_test_scoring.pkg_test_private_helper"
+"""
+ contracts_path.write_text(modified_content, encoding="utf-8")
+
+ scoring_path = dst_dir / "scoring" / "scoring.mthds"
+
+ manifest = find_package_manifest(contracts_path)
+ assert manifest is not None
+
+ contracts_bp = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=contracts_path)
+ scoring_bp = PipelexInterpreter.make_pipelex_bundle_blueprint(bundle_path=scoring_path)
+
+ errors = check_visibility_for_blueprints(manifest=manifest, blueprints=[contracts_bp, scoring_bp])
+ assert len(errors) == 1
+ assert "pkg_test_private_helper" in errors[0].pipe_ref
+ assert "[exports" in errors[0].message
diff --git a/tests/unit/pipelex/builder/test_builder_manifest_generation.py b/tests/unit/pipelex/builder/test_builder_manifest_generation.py
new file mode 100644
index 000000000..9f8cf6438
--- /dev/null
+++ b/tests/unit/pipelex/builder/test_builder_manifest_generation.py
@@ -0,0 +1,68 @@
+import shutil
+from pathlib import Path
+
+from pipelex.builder.builder_loop import maybe_generate_manifest_for_output
+from pipelex.core.packages.discovery import MANIFEST_FILENAME
+from pipelex.core.packages.manifest_parser import parse_methods_toml
+
+# Path to the physical test data
+PACKAGES_DATA_DIR = Path(__file__).resolve().parent.parent.parent.parent / "data" / "packages"
+
+
+class TestBuilderManifestGeneration:
+ """Tests for post-build METHODS.toml generation."""
+
+ def test_multiple_domains_generates_manifest(self, tmp_path: Path) -> None:
+ """Output dir with multiple domains -> METHODS.toml generated."""
+ # Copy two .mthds files with different domains
+ shutil.copy(PACKAGES_DATA_DIR / "legal_tools" / "legal" / "contracts.mthds", tmp_path / "contracts.mthds")
+ shutil.copy(PACKAGES_DATA_DIR / "legal_tools" / "scoring" / "scoring.mthds", tmp_path / "scoring.mthds")
+
+ result = maybe_generate_manifest_for_output(output_dir=tmp_path)
+
+ assert result is not None
+ manifest_path = tmp_path / MANIFEST_FILENAME
+ assert manifest_path.exists()
+
+ content = manifest_path.read_text(encoding="utf-8")
+ manifest = parse_methods_toml(content)
+ assert manifest.version == "0.1.0"
+ assert len(manifest.exports) >= 2
+
+ # Check that main_pipe entries are exported
+ exported_pipes: list[str] = []
+ for domain_export in manifest.exports:
+ exported_pipes.extend(domain_export.pipes)
+ assert "pkg_test_extract_clause" in exported_pipes
+ assert "pkg_test_compute_weighted_score" in exported_pipes
+
+ def test_single_domain_no_manifest(self, tmp_path: Path) -> None:
+ """Output dir with single domain -> no METHODS.toml generated."""
+ shutil.copy(PACKAGES_DATA_DIR / "minimal_package" / "core.mthds", tmp_path / "core.mthds")
+
+ result = maybe_generate_manifest_for_output(output_dir=tmp_path)
+
+ assert result is None
+ manifest_path = tmp_path / MANIFEST_FILENAME
+ assert not manifest_path.exists()
+
+ def test_exported_pipes_include_main_pipe(self, tmp_path: Path) -> None:
+ """Exported pipes include main_pipe entries from each bundle."""
+ shutil.copy(PACKAGES_DATA_DIR / "legal_tools" / "legal" / "contracts.mthds", tmp_path / "contracts.mthds")
+ shutil.copy(PACKAGES_DATA_DIR / "legal_tools" / "scoring" / "scoring.mthds", tmp_path / "scoring.mthds")
+
+ maybe_generate_manifest_for_output(output_dir=tmp_path)
+
+ manifest_path = tmp_path / MANIFEST_FILENAME
+ content = manifest_path.read_text(encoding="utf-8")
+ manifest = parse_methods_toml(content)
+
+ # Build a lookup of domain -> pipes
+ domain_pipes: dict[str, list[str]] = {}
+ for domain_export in manifest.exports:
+ domain_pipes[domain_export.domain_path] = domain_export.pipes
+
+ # contracts.mthds has main_pipe = "pkg_test_extract_clause"
+ assert "pkg_test_extract_clause" in domain_pipes.get("pkg_test_legal.contracts", [])
+ # scoring.mthds has main_pipe = "pkg_test_compute_weighted_score"
+ assert "pkg_test_compute_weighted_score" in domain_pipes.get("pkg_test_scoring", [])
diff --git a/tests/unit/pipelex/cli/test_pkg_init.py b/tests/unit/pipelex/cli/test_pkg_init.py
new file mode 100644
index 000000000..10eb0a5d6
--- /dev/null
+++ b/tests/unit/pipelex/cli/test_pkg_init.py
@@ -0,0 +1,90 @@
+import shutil
+from pathlib import Path
+
+import pytest
+from click.exceptions import Exit
+
+from pipelex.cli.commands.pkg.init_cmd import do_pkg_init
+from pipelex.core.packages.discovery import MANIFEST_FILENAME
+from pipelex.core.packages.manifest_parser import parse_methods_toml
+
+# Path to the physical test data
+PACKAGES_DATA_DIR = Path(__file__).resolve().parent.parent.parent.parent / "data" / "packages"
+
+
+class TestPkgInit:
+ """Tests for pipelex pkg init command logic."""
+
+ def test_generate_manifest_from_mthds_files(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """With .mthds files in tmp dir -> generates valid METHODS.toml."""
+ src = PACKAGES_DATA_DIR / "minimal_package" / "core.mthds"
+ shutil.copy(src, tmp_path / "core.mthds")
+
+ monkeypatch.chdir(tmp_path)
+
+ do_pkg_init(force=False)
+
+ manifest_path = tmp_path / MANIFEST_FILENAME
+ assert manifest_path.exists()
+
+ content = manifest_path.read_text(encoding="utf-8")
+ manifest = parse_methods_toml(content)
+ assert manifest.version == "0.1.0"
+ assert len(manifest.exports) >= 1
+
+ def test_existing_manifest_without_force_refuses(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Existing METHODS.toml without --force -> refuses."""
+ src = PACKAGES_DATA_DIR / "minimal_package" / "core.mthds"
+ shutil.copy(src, tmp_path / "core.mthds")
+ (tmp_path / MANIFEST_FILENAME).write_text("[package]\n", encoding="utf-8")
+
+ monkeypatch.chdir(tmp_path)
+
+ with pytest.raises(Exit):
+ do_pkg_init(force=False)
+
+ def test_existing_manifest_with_force_overwrites(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """With --force -> overwrites existing METHODS.toml."""
+ src = PACKAGES_DATA_DIR / "minimal_package" / "core.mthds"
+ shutil.copy(src, tmp_path / "core.mthds")
+ (tmp_path / MANIFEST_FILENAME).write_text("[package]\nold = true\n", encoding="utf-8")
+
+ monkeypatch.chdir(tmp_path)
+
+ do_pkg_init(force=True)
+
+ content = (tmp_path / MANIFEST_FILENAME).read_text(encoding="utf-8")
+ assert "old" not in content
+ manifest = parse_methods_toml(content)
+ assert manifest.version == "0.1.0"
+
+ def test_main_pipe_appears_first_in_exports(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """main_pipe should appear first in domain exports, not buried alphabetically."""
+ legal_tools_dir = PACKAGES_DATA_DIR / "legal_tools"
+ # Copy both .mthds files preserving subdirectory structure
+ for mthds_file in legal_tools_dir.rglob("*.mthds"):
+ rel = mthds_file.relative_to(legal_tools_dir)
+ dest = tmp_path / rel
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(mthds_file, dest)
+
+ monkeypatch.chdir(tmp_path)
+ do_pkg_init(force=False)
+
+ manifest_path = tmp_path / MANIFEST_FILENAME
+ manifest = parse_methods_toml(manifest_path.read_text(encoding="utf-8"))
+
+ # Find the contracts domain
+ contracts_export = next(
+ (exp for exp in manifest.exports if exp.domain_path == "pkg_test_legal.contracts"),
+ None,
+ )
+ assert contracts_export is not None, "Expected pkg_test_legal.contracts domain in exports"
+ assert contracts_export.pipes[0] == "pkg_test_extract_clause", f"main_pipe should be first in exports, got: {contracts_export.pipes}"
+
+ def test_no_mthds_files_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """No .mthds files -> error message."""
+ monkeypatch.chdir(tmp_path)
+
+ with pytest.raises(Exit):
+ do_pkg_init(force=False)
diff --git a/tests/unit/pipelex/cli/test_pkg_list.py b/tests/unit/pipelex/cli/test_pkg_list.py
new file mode 100644
index 000000000..ffc2e7952
--- /dev/null
+++ b/tests/unit/pipelex/cli/test_pkg_list.py
@@ -0,0 +1,42 @@
+import shutil
+from pathlib import Path
+
+import pytest
+from click.exceptions import Exit
+
+from pipelex.cli.commands.pkg.list_cmd import do_pkg_list
+from pipelex.core.packages.discovery import MANIFEST_FILENAME
+
+# Path to the physical test data
+PACKAGES_DATA_DIR = Path(__file__).resolve().parent.parent.parent.parent / "data" / "packages"
+
+
+class TestPkgList:
+ """Tests for pipelex pkg list command logic."""
+
+ def test_display_manifest_info(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """With valid METHODS.toml -> displays info without error."""
+ src_manifest = PACKAGES_DATA_DIR / "minimal_package" / MANIFEST_FILENAME
+ shutil.copy(src_manifest, tmp_path / MANIFEST_FILENAME)
+
+ monkeypatch.chdir(tmp_path)
+
+ # Should not raise — it prints to console but doesn't return anything
+ do_pkg_list()
+
+ def test_no_manifest_found_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """No METHODS.toml found -> error exit."""
+ monkeypatch.chdir(tmp_path)
+
+ with pytest.raises(Exit):
+ do_pkg_list()
+
+ def test_display_manifest_with_exports(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ """With full METHODS.toml including exports -> displays all sections."""
+ src_dir = PACKAGES_DATA_DIR / "legal_tools"
+ shutil.copytree(src_dir, tmp_path / "legal_tools")
+
+ monkeypatch.chdir(tmp_path / "legal_tools")
+
+ # Should not raise — it prints tables including exports
+ do_pkg_list()
diff --git a/tests/unit/pipelex/core/packages/test_bundle_scanner.py b/tests/unit/pipelex/core/packages/test_bundle_scanner.py
new file mode 100644
index 000000000..c76f61876
--- /dev/null
+++ b/tests/unit/pipelex/core/packages/test_bundle_scanner.py
@@ -0,0 +1,197 @@
+from pathlib import Path
+
+import pytest
+
+from pipelex.core.packages.bundle_scanner import build_domain_exports_from_scan, scan_bundles_for_domain_info
+
+# Path to the physical test data
+PACKAGES_DATA_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent / "data" / "packages"
+
+
+class TestBundleScanner:
+ """Tests for the shared bundle scanning and domain-exports-building functions."""
+
+ def test_scan_bundles_extracts_domains_and_pipes(self):
+ """Scanning multi-domain .mthds files returns correct domain/pipe mappings."""
+ mthds_files = sorted(PACKAGES_DATA_DIR.joinpath("legal_tools").rglob("*.mthds"))
+ assert len(mthds_files) >= 2, "Expected at least two .mthds fixtures"
+
+ domain_pipes, domain_main_pipes, errors = scan_bundles_for_domain_info(mthds_files)
+
+ assert not errors
+ assert "pkg_test_legal.contracts" in domain_pipes
+ assert "pkg_test_scoring" in domain_pipes
+ assert "pkg_test_extract_clause" in domain_pipes["pkg_test_legal.contracts"]
+ assert "pkg_test_analyze_contract" in domain_pipes["pkg_test_legal.contracts"]
+ assert "pkg_test_compute_weighted_score" in domain_pipes["pkg_test_scoring"]
+ assert domain_main_pipes["pkg_test_legal.contracts"] == "pkg_test_extract_clause"
+ assert domain_main_pipes["pkg_test_scoring"] == "pkg_test_compute_weighted_score"
+
+ def test_scan_bundles_collects_parse_errors(self, tmp_path: Path):
+ """Files that cannot be parsed are collected as error strings."""
+ bad_file = tmp_path / "broken.mthds"
+ bad_file.write_text("[broken\n", encoding="utf-8")
+
+ _domain_pipes, _domain_main_pipes, errors = scan_bundles_for_domain_info([bad_file])
+
+ assert len(errors) == 1
+ assert str(bad_file) in errors[0]
+
+ def test_scan_bundles_handles_empty_input(self):
+ """Passing no files returns empty results."""
+ domain_pipes, domain_main_pipes, errors = scan_bundles_for_domain_info([])
+
+ assert domain_pipes == {}
+ assert domain_main_pipes == {}
+ assert errors == []
+
+ def test_build_exports_main_pipe_first(self):
+ """Main pipe appears first in the exports pipe list, remaining sorted."""
+ domain_pipes = {
+ "alpha": ["zebra_pipe", "alpha_pipe", "main_alpha"],
+ }
+ domain_main_pipes = {
+ "alpha": "main_alpha",
+ }
+
+ exports = build_domain_exports_from_scan(domain_pipes, domain_main_pipes)
+
+ assert len(exports) == 1
+ assert exports[0].domain_path == "alpha"
+ assert exports[0].pipes[0] == "main_alpha"
+ assert exports[0].pipes == ["main_alpha", "alpha_pipe", "zebra_pipe"]
+
+ def test_build_exports_skips_empty_domains(self):
+ """Domains with no pipes produce no exports entry."""
+ domain_pipes = {
+ "has_pipes": ["some_pipe"],
+ "empty_domain": [],
+ }
+ domain_main_pipes: dict[str, str] = {}
+
+ exports = build_domain_exports_from_scan(domain_pipes, domain_main_pipes)
+
+ assert len(exports) == 1
+ assert exports[0].domain_path == "has_pipes"
+
+ def test_build_exports_sorts_domains(self):
+ """Domains appear in sorted order in the exports list."""
+ domain_pipes = {
+ "zebra_domain": ["pipe_z"],
+ "alpha_domain": ["pipe_a"],
+ }
+ domain_main_pipes: dict[str, str] = {}
+
+ exports = build_domain_exports_from_scan(domain_pipes, domain_main_pipes)
+
+ assert len(exports) == 2
+ assert exports[0].domain_path == "alpha_domain"
+ assert exports[1].domain_path == "zebra_domain"
+
+ def test_scan_bundles_detects_main_pipe_conflict(self, tmp_path: Path):
+ """Two bundles sharing a domain but declaring different main_pipe produce an error."""
+ bundle_a = tmp_path / "bundle_a.mthds"
+ bundle_a.write_text(
+ 'domain = "shared_domain"\n'
+ 'main_pipe = "pipe_alpha"\n'
+ "\n"
+ "[pipe.pipe_alpha]\n"
+ 'type = "PipeLLM"\n'
+ 'description = "Alpha"\n'
+ 'output = "Text"\n'
+ 'prompt = "alpha"\n',
+ encoding="utf-8",
+ )
+ bundle_b = tmp_path / "bundle_b.mthds"
+ bundle_b.write_text(
+ 'domain = "shared_domain"\n'
+ 'main_pipe = "pipe_beta"\n'
+ "\n"
+ "[pipe.pipe_beta]\n"
+ 'type = "PipeLLM"\n'
+ 'description = "Beta"\n'
+ 'output = "Text"\n'
+ 'prompt = "beta"\n',
+ encoding="utf-8",
+ )
+
+ _domain_pipes, domain_main_pipes, errors = scan_bundles_for_domain_info(
+ sorted([bundle_a, bundle_b]),
+ )
+
+ assert len(errors) == 1
+ assert "shared_domain" in errors[0]
+ assert "pipe_alpha" in errors[0]
+ assert "pipe_beta" in errors[0]
+ assert str(bundle_b) in errors[0]
+ # First value kept, conflict reported but not overwritten
+ assert domain_main_pipes["shared_domain"] == "pipe_alpha"
+
+ def test_scan_bundles_allows_identical_main_pipe(self, tmp_path: Path):
+ """Two bundles declaring the same main_pipe for a domain is not an error."""
+ bundle_a = tmp_path / "bundle_a.mthds"
+ bundle_a.write_text(
+ 'domain = "shared_domain"\n'
+ 'main_pipe = "same_pipe"\n'
+ "\n"
+ "[pipe.same_pipe]\n"
+ 'type = "PipeLLM"\n'
+ 'description = "A"\n'
+ 'output = "Text"\n'
+ 'prompt = "a"\n',
+ encoding="utf-8",
+ )
+ bundle_b = tmp_path / "bundle_b.mthds"
+ bundle_b.write_text(
+ 'domain = "shared_domain"\n'
+ 'main_pipe = "same_pipe"\n'
+ "\n"
+ "[pipe.same_pipe]\n"
+ 'type = "PipeLLM"\n'
+ 'description = "B copy"\n'
+ 'output = "Text"\n'
+ 'prompt = "b"\n',
+ encoding="utf-8",
+ )
+
+ _domain_pipes, domain_main_pipes, errors = scan_bundles_for_domain_info(
+ sorted([bundle_a, bundle_b]),
+ )
+
+ assert not errors
+ assert domain_main_pipes["shared_domain"] == "same_pipe"
+
+ @pytest.mark.parametrize(
+ ("topic", "domain_pipes", "domain_main_pipes", "expected_first_pipe"),
+ [
+ (
+ "main_pipe present and also in pipe list",
+ {"dom": ["other", "main_p"]},
+ {"dom": "main_p"},
+ "main_p",
+ ),
+ (
+ "main_pipe not in pipe list",
+ {"dom": ["other"]},
+ {"dom": "main_p"},
+ "main_p",
+ ),
+ (
+ "no main_pipe",
+ {"dom": ["beta", "alpha"]},
+ {},
+ "alpha",
+ ),
+ ],
+ )
+ def test_build_exports_main_pipe_ordering(
+ self,
+ topic: str,
+ domain_pipes: dict[str, list[str]],
+ domain_main_pipes: dict[str, str],
+ expected_first_pipe: str,
+ ):
+ """Main pipe ordering scenarios."""
+ _ = topic # Used for test identification
+ exports = build_domain_exports_from_scan(domain_pipes, domain_main_pipes)
+ assert exports[0].pipes[0] == expected_first_pipe
diff --git a/tests/unit/pipelex/core/packages/test_cross_package_refs.py b/tests/unit/pipelex/core/packages/test_cross_package_refs.py
new file mode 100644
index 000000000..b9adeb006
--- /dev/null
+++ b/tests/unit/pipelex/core/packages/test_cross_package_refs.py
@@ -0,0 +1,104 @@
+from pipelex.core.bundles.pipelex_bundle_blueprint import PipelexBundleBlueprint
+from pipelex.core.packages.manifest import MthdsPackageManifest, PackageDependency
+from pipelex.core.packages.visibility import PackageVisibilityChecker
+from pipelex.core.qualified_ref import QualifiedRef
+from pipelex.pipe_controllers.sequence.pipe_sequence_blueprint import PipeSequenceBlueprint
+from pipelex.pipe_controllers.sub_pipe_blueprint import SubPipeBlueprint
+
+
+class TestCrossPackageRefs:
+ """Tests for cross-package '->' reference detection."""
+
+ def test_has_cross_package_prefix(self):
+ """Detect '->' in raw reference strings."""
+ assert QualifiedRef.has_cross_package_prefix("my_lib->scoring.compute") is True
+ assert QualifiedRef.has_cross_package_prefix("scoring.compute") is False
+ assert QualifiedRef.has_cross_package_prefix("compute") is False
+
+ def test_split_cross_package_ref(self):
+ """Split 'alias->domain.pipe' correctly."""
+ alias, remainder = QualifiedRef.split_cross_package_ref("my_lib->scoring.compute")
+ assert alias == "my_lib"
+ assert remainder == "scoring.compute"
+
+ def test_known_alias_emits_warning_not_error(self):
+ """Cross-package ref with alias in dependencies -> warning emitted, no error."""
+ manifest = MthdsPackageManifest(
+ address="github.com/org/test",
+ version="1.0.0",
+ description="Test package",
+ dependencies=[
+ PackageDependency(
+ address="github.com/org/scoring-lib",
+ version="1.0.0",
+ alias="scoring_lib",
+ ),
+ ],
+ )
+ bundle = PipelexBundleBlueprint(
+ domain="my_domain",
+ pipe={
+ "my_pipe": PipeSequenceBlueprint(
+ type="PipeSequence",
+ description="Test",
+ output="Text",
+ steps=[
+ SubPipeBlueprint(pipe="scoring_lib->scoring.compute_score"),
+ ],
+ ),
+ },
+ )
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[bundle])
+ errors = checker.validate_cross_package_references()
+ # Known alias -> no error (only warning emitted via log)
+ assert errors == []
+
+ def test_unknown_alias_produces_error(self):
+ """Cross-package ref with alias NOT in dependencies -> error."""
+ manifest = MthdsPackageManifest(
+ address="github.com/org/test",
+ version="1.0.0",
+ description="Test package",
+ )
+ bundle = PipelexBundleBlueprint(
+ domain="my_domain",
+ pipe={
+ "my_pipe": PipeSequenceBlueprint(
+ type="PipeSequence",
+ description="Test",
+ output="Text",
+ steps=[
+ SubPipeBlueprint(pipe="unknown_lib->scoring.compute_score"),
+ ],
+ ),
+ },
+ )
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[bundle])
+ errors = checker.validate_cross_package_references()
+ assert len(errors) == 1
+ assert "unknown_lib" in errors[0].message
+ assert "[dependencies]" in errors[0].message
+
+ def test_no_cross_package_refs_no_warnings(self):
+ """No '->' refs at all -> no warnings or errors."""
+ manifest = MthdsPackageManifest(
+ address="github.com/org/test",
+ version="1.0.0",
+ description="Test package",
+ )
+ bundle = PipelexBundleBlueprint(
+ domain="my_domain",
+ pipe={
+ "my_pipe": PipeSequenceBlueprint(
+ type="PipeSequence",
+ description="Test",
+ output="Text",
+ steps=[
+ SubPipeBlueprint(pipe="scoring.compute_score"),
+ ],
+ ),
+ },
+ )
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[bundle])
+ errors = checker.validate_cross_package_references()
+ assert errors == []
diff --git a/tests/unit/pipelex/core/packages/test_data.py b/tests/unit/pipelex/core/packages/test_data.py
new file mode 100644
index 000000000..973123f43
--- /dev/null
+++ b/tests/unit/pipelex/core/packages/test_data.py
@@ -0,0 +1,139 @@
+from typing import ClassVar
+
+from pipelex.core.packages.manifest import DomainExports, MthdsPackageManifest, PackageDependency
+
+# ============================================================
+# TOML strings for parser tests
+# ============================================================
+
+FULL_MANIFEST_TOML = """\
+[package]
+address = "github.com/pipelexlab/legal-tools"
+version = "1.0.0"
+description = "Legal document analysis tools"
+authors = ["PipelexLab"]
+license = "MIT"
+mthds_version = "0.5.0"
+
+[dependencies]
+scoring_lib = { address = "github.com/pipelexlab/scoring-lib", version = "2.0.0" }
+
+[exports.legal.contracts]
+pipes = ["extract_clause", "analyze_contract"]
+
+[exports.scoring]
+pipes = ["compute_weighted_score"]
+"""
+
+MINIMAL_MANIFEST_TOML = """\
+[package]
+address = "github.com/pipelexlab/minimal"
+version = "0.1.0"
+description = "A minimal MTHDS package"
+"""
+
+EMPTY_EXPORTS_DEPS_TOML = """\
+[package]
+address = "github.com/pipelexlab/empty"
+version = "1.0.0"
+description = "Package with empty exports and dependencies"
+
+[dependencies]
+
+[exports]
+"""
+
+MULTI_LEVEL_EXPORTS_TOML = """\
+[package]
+address = "github.com/pipelexlab/deep"
+version = "1.0.0"
+description = "Deep nested exports package"
+
+[exports.legal.contracts.shareholder]
+pipes = ["extract_shareholder_clause"]
+
+[exports.legal.contracts]
+pipes = ["extract_clause"]
+
+[exports.scoring]
+pipes = ["compute_score"]
+"""
+
+INVALID_TOML_SYNTAX = """\
+[package
+address = "broken
+"""
+
+MISSING_PACKAGE_SECTION_TOML = """\
+[something_else]
+foo = "bar"
+"""
+
+MISSING_REQUIRED_FIELDS_TOML = """\
+[package]
+description = "Missing address and version"
+"""
+
+NON_TABLE_DEPENDENCY_TOML = """\
+[package]
+address = "github.com/pipelexlab/bad-deps"
+version = "1.0.0"
+description = "Package with a non-table dependency entry"
+
+[dependencies]
+foo = "1.0.0"
+"""
+
+INVALID_DOMAIN_PATH_EXPORTS_TOML = """\
+[package]
+address = "github.com/pipelexlab/bad-exports"
+version = "1.0.0"
+description = "Package with an invalid domain path in exports"
+
+[exports.InvalidDomain]
+pipes = ["extract_clause"]
+"""
+
+INVALID_PIPE_NAME_EXPORTS_TOML = """\
+[package]
+address = "github.com/pipelexlab/bad-pipes"
+version = "1.0.0"
+description = "Package with an invalid pipe name in exports"
+
+[exports.legal]
+pipes = ["BadPipe"]
+"""
+
+# ============================================================
+# Expected model instances
+# ============================================================
+
+
+class ManifestTestData:
+ """Reusable expected manifest instances for test assertions."""
+
+ FULL_MANIFEST: ClassVar[MthdsPackageManifest] = MthdsPackageManifest(
+ address="github.com/pipelexlab/legal-tools",
+ version="1.0.0",
+ description="Legal document analysis tools",
+ authors=["PipelexLab"],
+ license="MIT",
+ mthds_version="0.5.0",
+ dependencies=[
+ PackageDependency(
+ address="github.com/pipelexlab/scoring-lib",
+ version="2.0.0",
+ alias="scoring_lib",
+ ),
+ ],
+ exports=[
+ DomainExports(domain_path="legal.contracts", pipes=["extract_clause", "analyze_contract"]),
+ DomainExports(domain_path="scoring", pipes=["compute_weighted_score"]),
+ ],
+ )
+
+ MINIMAL_MANIFEST: ClassVar[MthdsPackageManifest] = MthdsPackageManifest(
+ address="github.com/pipelexlab/minimal",
+ version="0.1.0",
+ description="A minimal MTHDS package",
+ )
diff --git a/tests/unit/pipelex/core/packages/test_discovery.py b/tests/unit/pipelex/core/packages/test_discovery.py
new file mode 100644
index 000000000..562874781
--- /dev/null
+++ b/tests/unit/pipelex/core/packages/test_discovery.py
@@ -0,0 +1,78 @@
+from pathlib import Path
+
+import pytest
+
+from pipelex.core.packages.discovery import MANIFEST_FILENAME, find_package_manifest
+from pipelex.core.packages.exceptions import ManifestParseError
+
+# Path to the physical test data
+PACKAGES_DATA_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent / "data" / "packages"
+
+
+class TestManifestDiscovery:
+ """Tests for METHODS.toml walk-up discovery."""
+
+ def test_find_manifest_from_bundle_in_subdir(self):
+ """Find METHODS.toml from a bundle path like legal/contracts.mthds."""
+ bundle_path = PACKAGES_DATA_DIR / "legal_tools" / "legal" / "contracts.mthds"
+ manifest = find_package_manifest(bundle_path)
+ assert manifest is not None
+ assert manifest.address == "github.com/pipelexlab/legal-tools"
+ assert manifest.version == "1.0.0"
+
+ def test_find_manifest_from_bundle_in_same_dir(self):
+ """Find METHODS.toml when bundle is in the same directory as manifest."""
+ bundle_path = PACKAGES_DATA_DIR / "minimal_package" / "core.mthds"
+ manifest = find_package_manifest(bundle_path)
+ assert manifest is not None
+ assert manifest.address == "github.com/pipelexlab/minimal"
+
+ def test_standalone_bundle_no_manifest(self):
+ """Standalone bundle with no METHODS.toml returns None."""
+ bundle_path = PACKAGES_DATA_DIR / "standalone_bundle" / "my_pipe.mthds"
+ # This will walk up until it finds the repo's .git directory
+ manifest = find_package_manifest(bundle_path)
+ assert manifest is None
+
+ def test_git_boundary_stops_search(self, tmp_path: Path):
+ """Discovery stops at .git/ directory boundary."""
+ # Create structure: tmp_path/METHODS.toml (above git boundary)
+ # tmp_path/project/.git/
+ # tmp_path/project/bundle.mthds
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / ".git").mkdir()
+ bundle_path = project_dir / "bundle.mthds"
+ bundle_path.touch()
+
+ # Put a METHODS.toml above the .git boundary (should NOT be found)
+ manifest_content = '[package]\naddress = "github.com/org/above-git"\nversion = "1.0.0"\ndescription = "Above git"\n'
+ (tmp_path / MANIFEST_FILENAME).write_text(manifest_content)
+
+ result = find_package_manifest(bundle_path)
+ assert result is None
+
+ def test_manifest_in_parent_found(self, tmp_path: Path):
+ """METHODS.toml two levels up from bundle is found."""
+ # tmp_path/METHODS.toml
+ # tmp_path/sub/deep/bundle.mthds
+ manifest_content = '[package]\naddress = "github.com/org/deep"\nversion = "2.0.0"\ndescription = "Deep package"\n'
+ (tmp_path / MANIFEST_FILENAME).write_text(manifest_content)
+ deep_dir = tmp_path / "sub" / "deep"
+ deep_dir.mkdir(parents=True)
+ bundle_path = deep_dir / "bundle.mthds"
+ bundle_path.touch()
+
+ result = find_package_manifest(bundle_path)
+ assert result is not None
+ assert result.address == "github.com/org/deep"
+ assert result.version == "2.0.0"
+
+ def test_malformed_manifest_raises(self, tmp_path: Path):
+ """Malformed METHODS.toml raises ManifestParseError."""
+ (tmp_path / MANIFEST_FILENAME).write_text("[broken\n")
+ bundle_path = tmp_path / "bundle.mthds"
+ bundle_path.touch()
+
+ with pytest.raises(ManifestParseError):
+ find_package_manifest(bundle_path)
diff --git a/tests/unit/pipelex/core/packages/test_manifest.py b/tests/unit/pipelex/core/packages/test_manifest.py
new file mode 100644
index 000000000..b5b9a2a0b
--- /dev/null
+++ b/tests/unit/pipelex/core/packages/test_manifest.py
@@ -0,0 +1,212 @@
+import pytest
+from pydantic import ValidationError
+
+from pipelex.core.packages.manifest import DomainExports, MthdsPackageManifest, PackageDependency
+
+
+class TestMthdsPackageManifest:
+ """Tests for manifest model validation."""
+
+ def test_valid_full_manifest(self):
+ """Valid manifest with all fields populated."""
+ manifest = MthdsPackageManifest(
+ address="github.com/pipelexlab/legal-tools",
+ version="1.0.0",
+ description="Legal analysis",
+ authors=["Alice", "Bob"],
+ license="MIT",
+ mthds_version="0.5.0",
+ dependencies=[
+ PackageDependency(address="github.com/org/dep", version="2.0.0", alias="my_dep"),
+ ],
+ exports=[
+ DomainExports(domain_path="legal.contracts", pipes=["extract_clause"]),
+ ],
+ )
+ assert manifest.address == "github.com/pipelexlab/legal-tools"
+ assert manifest.version == "1.0.0"
+ assert len(manifest.dependencies) == 1
+ assert manifest.dependencies[0].alias == "my_dep"
+ assert len(manifest.exports) == 1
+ assert manifest.exports[0].domain_path == "legal.contracts"
+
+ def test_valid_minimal_manifest(self):
+ """Minimal manifest with only required fields."""
+ manifest = MthdsPackageManifest(
+ address="github.com/org/pkg",
+ version="0.1.0",
+ description="Minimal test package",
+ )
+ assert manifest.address == "github.com/org/pkg"
+ assert manifest.version == "0.1.0"
+ assert manifest.description == "Minimal test package"
+ assert manifest.authors == []
+ assert manifest.dependencies == []
+ assert manifest.exports == []
+
+ def test_missing_description_fails(self):
+ """Missing description should fail validation."""
+ with pytest.raises(ValidationError):
+ MthdsPackageManifest(
+ address="github.com/org/repo",
+ version="1.0.0",
+ ) # type: ignore[call-arg]
+
+ def test_empty_description_fails(self):
+ """Empty description should fail validation."""
+ with pytest.raises(ValidationError, match="must not be empty"):
+ MthdsPackageManifest(
+ address="github.com/org/repo",
+ version="1.0.0",
+ description=" ",
+ )
+
+ def test_invalid_address_no_hostname(self):
+ """Address without hostname pattern should fail."""
+ with pytest.raises(ValidationError, match="Invalid package address"):
+ MthdsPackageManifest(
+ address="no-dots-or-slashes",
+ version="1.0.0",
+ description="Test",
+ )
+
+ def test_invalid_address_no_slash(self):
+ """Address with dots but no slash should fail."""
+ with pytest.raises(ValidationError, match="Invalid package address"):
+ MthdsPackageManifest(
+ address="github.com",
+ version="1.0.0",
+ description="Test",
+ )
+
+ def test_invalid_version_not_semver(self):
+ """Non-semver version should fail."""
+ with pytest.raises(ValidationError, match="Invalid version"):
+ MthdsPackageManifest(
+ address="github.com/org/repo",
+ version="not-a-version",
+ description="Test",
+ )
+
+ def test_invalid_version_partial(self):
+ """Partial semver should fail."""
+ with pytest.raises(ValidationError, match="Invalid version"):
+ MthdsPackageManifest(
+ address="github.com/org/repo",
+ version="1.0",
+ description="Test",
+ )
+
+ def test_valid_semver_with_prerelease(self):
+ """Semver with prerelease tag should pass."""
+ manifest = MthdsPackageManifest(
+ address="github.com/org/repo",
+ version="1.0.0-beta.1",
+ description="Test",
+ )
+ assert manifest.version == "1.0.0-beta.1"
+
+ def test_duplicate_dependency_aliases(self):
+ """Duplicate aliases should fail validation."""
+ with pytest.raises(ValidationError, match="Duplicate dependency alias"):
+ MthdsPackageManifest(
+ address="github.com/org/repo",
+ version="1.0.0",
+ description="Test",
+ dependencies=[
+ PackageDependency(address="github.com/org/dep1", version="1.0.0", alias="same_alias"),
+ PackageDependency(address="github.com/org/dep2", version="2.0.0", alias="same_alias"),
+ ],
+ )
+
+ def test_invalid_dependency_alias_not_snake_case(self):
+ """Dependency alias that is not snake_case should fail."""
+ with pytest.raises(ValidationError, match="Invalid dependency alias"):
+ PackageDependency(
+ address="github.com/org/dep",
+ version="1.0.0",
+ alias="NotSnakeCase",
+ )
+
+ def test_invalid_domain_path_in_exports(self):
+ """Invalid domain path in exports should fail."""
+ with pytest.raises(ValidationError, match="Invalid domain path"):
+ DomainExports(
+ domain_path="InvalidDomain",
+ pipes=["my_pipe"],
+ )
+
+ def test_invalid_pipe_name_in_exports(self):
+ """Invalid pipe name in exports should fail."""
+ with pytest.raises(ValidationError, match="Invalid pipe name"):
+ DomainExports(
+ domain_path="valid_domain",
+ pipes=["InvalidPipeName"],
+ )
+
+ def test_valid_hierarchical_domain_in_exports(self):
+ """Hierarchical domain path in exports should pass."""
+ export = DomainExports(
+ domain_path="legal.contracts.shareholder",
+ pipes=["extract_clause"],
+ )
+ assert export.domain_path == "legal.contracts.shareholder"
+
+ def test_empty_dependencies_and_exports(self):
+ """Empty lists for dependencies and exports should pass."""
+ manifest = MthdsPackageManifest(
+ address="github.com/org/repo",
+ version="1.0.0",
+ description="Test",
+ dependencies=[],
+ exports=[],
+ )
+ assert manifest.dependencies == []
+ assert manifest.exports == []
+
+ @pytest.mark.parametrize(
+ "version_str",
+ [
+ "^1.0.0",
+ "~1.0.0",
+ ">=1.0.0",
+ "<=2.0.0",
+ ">1.0.0",
+ "<2.0.0",
+ "==1.0.0",
+ "!=1.0.0",
+ ">=1.0.0, <2.0.0",
+ "*",
+ "1.*",
+ "1.0.*",
+ "1.0.0",
+ "2.1.3-beta.1",
+ ],
+ )
+ def test_valid_dependency_version_constraints(self, version_str: str):
+ """Version constraints using Poetry/uv range syntax should pass."""
+ dep = PackageDependency(
+ address="github.com/org/dep",
+ version=version_str,
+ alias="my_dep",
+ )
+ assert dep.version == version_str
+
+ @pytest.mark.parametrize(
+ "version_str",
+ [
+ "not-a-version",
+ "abc",
+ "1.0.0.0",
+ ">>1.0.0",
+ "~=1.0.0",
+ ],
+ )
+ def test_invalid_dependency_version_constraints(self, version_str: str):
+ """Invalid version constraint strings should fail."""
+ with pytest.raises(ValidationError, match="Invalid version constraint"):
+ PackageDependency(
+ address="github.com/org/dep",
+ version=version_str,
+ alias="my_dep",
+ )
diff --git a/tests/unit/pipelex/core/packages/test_manifest_parser.py b/tests/unit/pipelex/core/packages/test_manifest_parser.py
new file mode 100644
index 000000000..c0cbd2c33
--- /dev/null
+++ b/tests/unit/pipelex/core/packages/test_manifest_parser.py
@@ -0,0 +1,120 @@
+import pytest
+
+from pipelex.core.packages.exceptions import ManifestParseError, ManifestValidationError
+from pipelex.core.packages.manifest_parser import parse_methods_toml, serialize_manifest_to_toml
+from tests.unit.pipelex.core.packages.test_data import (
+ EMPTY_EXPORTS_DEPS_TOML,
+ FULL_MANIFEST_TOML,
+ INVALID_DOMAIN_PATH_EXPORTS_TOML,
+ INVALID_PIPE_NAME_EXPORTS_TOML,
+ INVALID_TOML_SYNTAX,
+ MINIMAL_MANIFEST_TOML,
+ MISSING_PACKAGE_SECTION_TOML,
+ MISSING_REQUIRED_FIELDS_TOML,
+ MULTI_LEVEL_EXPORTS_TOML,
+ NON_TABLE_DEPENDENCY_TOML,
+ ManifestTestData,
+)
+
+
+class TestManifestParser:
+ """Tests for METHODS.toml parsing and serialization."""
+
+ def test_parse_full_manifest(self):
+ """Parse a well-formed TOML with nested exports sub-tables."""
+ manifest = parse_methods_toml(FULL_MANIFEST_TOML)
+ assert manifest.address == ManifestTestData.FULL_MANIFEST.address
+ assert manifest.version == ManifestTestData.FULL_MANIFEST.version
+ assert manifest.description == ManifestTestData.FULL_MANIFEST.description
+ assert manifest.authors == ManifestTestData.FULL_MANIFEST.authors
+ assert manifest.license == ManifestTestData.FULL_MANIFEST.license
+ assert manifest.mthds_version == ManifestTestData.FULL_MANIFEST.mthds_version
+ assert len(manifest.dependencies) == 1
+ assert manifest.dependencies[0].alias == "scoring_lib"
+ assert manifest.dependencies[0].address == "github.com/pipelexlab/scoring-lib"
+ assert len(manifest.exports) == 2
+ domain_paths = {exp.domain_path for exp in manifest.exports}
+ assert "legal.contracts" in domain_paths
+ assert "scoring" in domain_paths
+
+ def test_parse_minimal_manifest(self):
+ """Parse a manifest with only required fields."""
+ manifest = parse_methods_toml(MINIMAL_MANIFEST_TOML)
+ assert manifest.address == ManifestTestData.MINIMAL_MANIFEST.address
+ assert manifest.version == ManifestTestData.MINIMAL_MANIFEST.version
+ assert manifest.dependencies == []
+ assert manifest.exports == []
+
+ def test_parse_empty_exports_and_deps(self):
+ """Parse a manifest with empty exports and dependencies sections."""
+ manifest = parse_methods_toml(EMPTY_EXPORTS_DEPS_TOML)
+ assert manifest.dependencies == []
+ assert manifest.exports == []
+
+ def test_parse_multi_level_nested_exports(self):
+ """Parse manifest with multi-level nested exports like [exports.legal.contracts.shareholder]."""
+ manifest = parse_methods_toml(MULTI_LEVEL_EXPORTS_TOML)
+ domain_paths = {exp.domain_path for exp in manifest.exports}
+ assert "legal.contracts.shareholder" in domain_paths
+ assert "legal.contracts" in domain_paths
+ assert "scoring" in domain_paths
+
+ # Check pipes for each domain
+ shareholder_exports = next(exp for exp in manifest.exports if exp.domain_path == "legal.contracts.shareholder")
+ assert shareholder_exports.pipes == ["extract_shareholder_clause"]
+
+ contracts_exports = next(exp for exp in manifest.exports if exp.domain_path == "legal.contracts")
+ assert contracts_exports.pipes == ["extract_clause"]
+
+ def test_parse_invalid_toml_syntax(self):
+ """TOML syntax error should raise ManifestParseError."""
+ with pytest.raises(ManifestParseError, match="Invalid TOML syntax"):
+ parse_methods_toml(INVALID_TOML_SYNTAX)
+
+ def test_parse_missing_package_section(self):
+ """Missing [package] section should raise ManifestValidationError."""
+ with pytest.raises(ManifestValidationError, match="must contain a \\[package\\] section"):
+ parse_methods_toml(MISSING_PACKAGE_SECTION_TOML)
+
+ def test_parse_missing_required_fields(self):
+ """Missing required fields in [package] should raise ManifestValidationError."""
+ with pytest.raises(ManifestValidationError, match="validation failed"):
+ parse_methods_toml(MISSING_REQUIRED_FIELDS_TOML)
+
+ def test_parse_non_table_dependency_raises(self):
+ """A dependency whose value is not a table should raise ManifestValidationError."""
+ with pytest.raises(ManifestValidationError, match="expected a table"):
+ parse_methods_toml(NON_TABLE_DEPENDENCY_TOML)
+
+ @pytest.mark.parametrize(
+ ("topic", "toml_content"),
+ [
+ ("invalid domain path", INVALID_DOMAIN_PATH_EXPORTS_TOML),
+ ("invalid pipe name", INVALID_PIPE_NAME_EXPORTS_TOML),
+ ],
+ )
+ def test_parse_invalid_exports_raises(self, topic: str, toml_content: str):
+ """Invalid domain paths or pipe names in [exports] should raise ManifestValidationError."""
+ _ = topic # Used for test identification
+ with pytest.raises(ManifestValidationError, match="Invalid exports"):
+ parse_methods_toml(toml_content)
+
+ def test_serialize_roundtrip(self):
+ """Serialize a manifest to TOML and parse it back — roundtrip check."""
+ original = ManifestTestData.FULL_MANIFEST
+ toml_str = serialize_manifest_to_toml(original)
+ parsed = parse_methods_toml(toml_str)
+ assert parsed.address == original.address
+ assert parsed.version == original.version
+ assert parsed.description == original.description
+ assert len(parsed.dependencies) == len(original.dependencies)
+ assert len(parsed.exports) == len(original.exports)
+
+ def test_serialize_minimal_manifest(self):
+ """Serialize a minimal manifest with no deps/exports."""
+ manifest = ManifestTestData.MINIMAL_MANIFEST
+ toml_str = serialize_manifest_to_toml(manifest)
+ assert "[package]" in toml_str
+ assert 'address = "github.com/pipelexlab/minimal"' in toml_str
+ assert "[dependencies]" not in toml_str
+ assert "[exports" not in toml_str
diff --git a/tests/unit/pipelex/core/packages/test_visibility.py b/tests/unit/pipelex/core/packages/test_visibility.py
new file mode 100644
index 000000000..f2a138236
--- /dev/null
+++ b/tests/unit/pipelex/core/packages/test_visibility.py
@@ -0,0 +1,157 @@
+from pipelex.core.bundles.pipelex_bundle_blueprint import PipelexBundleBlueprint
+from pipelex.core.packages.manifest import DomainExports, MthdsPackageManifest
+from pipelex.core.packages.visibility import PackageVisibilityChecker
+from pipelex.core.qualified_ref import QualifiedRef
+from pipelex.pipe_controllers.sequence.pipe_sequence_blueprint import PipeSequenceBlueprint
+from pipelex.pipe_controllers.sub_pipe_blueprint import SubPipeBlueprint
+from pipelex.pipe_operators.llm.pipe_llm_blueprint import PipeLLMBlueprint
+
+
+def _make_llm_pipe(description: str = "test", output: str = "Text", prompt: str = "test") -> PipeLLMBlueprint:
+ return PipeLLMBlueprint(
+ type="PipeLLM",
+ description=description,
+ output=output,
+ prompt=prompt,
+ )
+
+
+def _make_manifest_with_exports(exports: list[DomainExports]) -> MthdsPackageManifest:
+ return MthdsPackageManifest(
+ address="github.com/org/test",
+ version="1.0.0",
+ description="Test package",
+ exports=exports,
+ )
+
+
+class TestPackageVisibilityChecker:
+ """Tests for cross-domain pipe visibility enforcement."""
+
+ def test_no_manifest_no_violations(self):
+ """No manifest -> all pipes public, no violations."""
+ bundle = PipelexBundleBlueprint(
+ domain="alpha",
+ pipe={"my_pipe": _make_llm_pipe()},
+ )
+ checker = PackageVisibilityChecker(manifest=None, bundles=[bundle])
+ errors = checker.validate_all_pipe_references()
+ assert errors == []
+
+ def test_cross_domain_ref_to_exported_pipe_passes(self):
+ """Cross-domain ref to an exported pipe should pass."""
+ manifest = _make_manifest_with_exports(
+ [
+ DomainExports(domain_path="beta", pipes=["do_beta"]),
+ ]
+ )
+ ref = QualifiedRef.parse_pipe_ref("beta.do_beta")
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[])
+ assert checker.is_pipe_accessible_from(ref, "alpha") is True
+
+ def test_cross_domain_ref_to_main_pipe_passes(self):
+ """Cross-domain ref to a main_pipe (not in exports) should pass (auto-export)."""
+ manifest = _make_manifest_with_exports([]) # No explicit exports
+ bundle_beta = PipelexBundleBlueprint(
+ domain="beta",
+ main_pipe="beta_main",
+ pipe={"beta_main": _make_llm_pipe()},
+ )
+ ref = QualifiedRef.parse_pipe_ref("beta.beta_main")
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[bundle_beta])
+ assert checker.is_pipe_accessible_from(ref, "alpha") is True
+
+ def test_cross_domain_ref_to_non_exported_pipe_fails(self):
+ """Cross-domain ref to a non-exported pipe should produce a VisibilityError."""
+ manifest = _make_manifest_with_exports(
+ [
+ DomainExports(domain_path="beta", pipes=["public_pipe"]),
+ ]
+ )
+ bundle_beta = PipelexBundleBlueprint(
+ domain="beta",
+ pipe={
+ "public_pipe": _make_llm_pipe(),
+ "private_pipe": _make_llm_pipe(),
+ },
+ )
+ ref = QualifiedRef.parse_pipe_ref("beta.private_pipe")
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[bundle_beta])
+ assert checker.is_pipe_accessible_from(ref, "alpha") is False
+
+ def test_same_domain_ref_to_non_exported_pipe_passes(self):
+ """Same-domain ref to a non-exported pipe should always pass."""
+ manifest = _make_manifest_with_exports(
+ [
+ DomainExports(domain_path="alpha", pipes=["exported_only"]),
+ ]
+ )
+ ref = QualifiedRef.parse_pipe_ref("alpha.internal_pipe")
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[])
+ assert checker.is_pipe_accessible_from(ref, "alpha") is True
+
+ def test_bare_ref_passes(self):
+ """Bare ref (no domain qualifier) should always pass."""
+ manifest = _make_manifest_with_exports([])
+ ref = QualifiedRef(domain_path=None, local_code="some_pipe")
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[])
+ assert checker.is_pipe_accessible_from(ref, "alpha") is True
+
+ def test_validate_all_detects_violations(self):
+ """validate_all_pipe_references finds cross-domain violations in bundles."""
+ manifest = _make_manifest_with_exports(
+ [
+ DomainExports(domain_path="pkg_test_scoring", pipes=["pkg_test_compute_weighted_score"]),
+ ]
+ )
+ # Bundle in legal.contracts that references a non-exported scoring pipe
+ bundle_legal = PipelexBundleBlueprint(
+ domain="pkg_test_legal.contracts",
+ pipe={
+ "pkg_test_orchestrate": PipeSequenceBlueprint(
+ type="PipeSequence",
+ description="Orchestrate",
+ output="Text",
+ steps=[
+ SubPipeBlueprint(pipe="pkg_test_scoring.pkg_test_private_helper"),
+ ],
+ ),
+ },
+ )
+ bundle_scoring = PipelexBundleBlueprint(
+ domain="pkg_test_scoring",
+ main_pipe="pkg_test_compute_weighted_score",
+ pipe={
+ "pkg_test_compute_weighted_score": _make_llm_pipe(),
+ "pkg_test_private_helper": _make_llm_pipe(),
+ },
+ )
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[bundle_legal, bundle_scoring])
+ errors = checker.validate_all_pipe_references()
+ assert len(errors) == 1
+ assert errors[0].pipe_ref == "pkg_test_scoring.pkg_test_private_helper"
+ assert "[exports" in errors[0].message
+
+ def test_validate_all_no_violations_when_all_exported(self):
+ """validate_all_pipe_references returns empty when all refs are exported."""
+ manifest = _make_manifest_with_exports(
+ [
+ DomainExports(domain_path="pkg_test_scoring", pipes=["pkg_test_compute_weighted_score"]),
+ ]
+ )
+ bundle_legal = PipelexBundleBlueprint(
+ domain="pkg_test_legal.contracts",
+ pipe={
+ "pkg_test_orchestrate": PipeSequenceBlueprint(
+ type="PipeSequence",
+ description="Orchestrate",
+ output="Text",
+ steps=[
+ SubPipeBlueprint(pipe="pkg_test_scoring.pkg_test_compute_weighted_score"),
+ ],
+ ),
+ },
+ )
+ checker = PackageVisibilityChecker(manifest=manifest, bundles=[bundle_legal])
+ errors = checker.validate_all_pipe_references()
+ assert errors == []