agentic-planning 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ # Rust
2
+ target/
3
+ *.swp
4
+ *.swo
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.egg-info/
11
+ *.egg
12
+ dist/
13
+ build/
14
+ .eggs/
15
+ *.whl
16
+ .venv/
17
+ venv/
18
+ env/
19
+
20
+ # IDE
21
+ .vscode/
22
+ .idea/
23
+ *.iml
24
+ .fleet/
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
29
+ *.swp
30
+ *~
31
+
32
+ # Testing
33
+ .pytest_cache/
34
+ .coverage
35
+ htmlcov/
36
+ .mypy_cache/
37
+ .tox/
38
+
39
+ # LaTeX build artifacts
40
+ *.aux
41
+ *.bbl
42
+ *.blg
43
+ *.log
44
+ *.out
45
+ *.toc
46
+ *.fls
47
+ *.fdb_latexmk
48
+ *.synctex.gz
49
+
50
+ # Planning docs / AI prompts (internal only)
51
+ planning-docs/
52
+ CLAUDE-CODE-INSTRUCTIONS*.md
53
+ SPEC-*.md
54
+
55
+ # Internal vision / roadmap documents
56
+ docs/VISION-*.md
57
+
58
+ # Internal specs
59
+ specs/
60
+
61
+ # MCP crate internal docs / specs / AI prompts
62
+ crates/agentic-planning-mcp/docs/
63
+ crates/agentic-planning-mcp/scripts/
64
+
65
+ # Claude Code
66
+ .claude/
67
+ .agentra/
68
+ docs/REPO_HYGIENE.md
69
+
70
+ # Root-level paper files (canonical copies are in paper/)
71
+ /agenticplanning-paper.*
72
+ /references.bib
73
+
74
+ # FFI (build artifacts)
75
+ /ffi/
76
+
77
+ # Scripts (internal tooling)
78
+ /agent/scripts/
79
+ /installer/scripts/
80
+
81
+ # Internal docs (not for public repo)
82
+ docs/internal/
83
+ ECOSYSTEM-CONVENTIONS.md
84
+
85
+ # Local goals (internal only)
86
+ goals/
87
+
88
+ # Environment
89
+ .env
90
+ .env.local
91
+ *.pem
92
+ *.key
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentic-planning
3
+ Version: 0.1.0
4
+ Summary: Planning infrastructure for AI agents
5
+ Project-URL: Homepage, https://github.com/agentralabs/agentic-planning
6
+ Project-URL: Documentation, https://github.com/agentralabs/agentic-planning/tree/main/docs
7
+ Project-URL: Repository, https://github.com/agentralabs/agentic-planning
8
+ Author: Agentra Labs
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,mcp,planning
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.10; extra == 'dev'
22
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # AgenticPlanning Python SDK
27
+
28
+ Thin Python wrapper around the `aplan` FFI library via `ctypes`.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install agentic-planning
34
+ ```
35
+
36
+ Requires the `aplan` shared library (`.so`/`.dylib`/`.dll`) on your library path, or the `aplan` binary on PATH for CLI-mode fallback.
37
+
38
+ ## Quick Start
39
+
40
+ ```python
41
+ from agentic_planning import PlanningGraph
42
+
43
+ graph = PlanningGraph("project.aplan")
44
+ ```
45
+
46
+ ## Goals
47
+
48
+ ```python
49
+ # Create a goal
50
+ goal = graph.create_goal("Ship v1", intention="Persistent intention infrastructure")
51
+ print(goal["id"])
52
+
53
+ # List all goals
54
+ goals = graph.list_goals()
55
+ for g in goals:
56
+ print(f"{g['title']} — {g['status']} (momentum: {g['momentum']:.2f})")
57
+
58
+ # Update goal status
59
+ graph.activate_goal(goal["id"])
60
+ graph.complete_goal(goal["id"])
61
+
62
+ # Record progress
63
+ graph.record_progress(goal["id"], percentage=45, note="API endpoints done")
64
+
65
+ # Get goal with computed feelings
66
+ detail = graph.get_goal(goal["id"])
67
+ print(f"Urgency: {detail['feelings']['urgency']:.2f}")
68
+ print(f"Neglect: {detail['feelings']['neglect']:.2f}")
69
+ ```
70
+
71
+ ## Decisions
72
+
73
+ ```python
74
+ # Create a decision linked to a goal
75
+ decision = graph.create_decision(
76
+ title="Use PostgreSQL vs SQLite",
77
+ goal_id=goal["id"],
78
+ options=["PostgreSQL", "SQLite", "Both with abstraction layer"]
79
+ )
80
+
81
+ # Crystallize (choose an option)
82
+ graph.crystallize_decision(
83
+ decision["id"],
84
+ chosen="SQLite",
85
+ reasoning="Simpler deployment, sufficient for planning state sizes"
86
+ )
87
+
88
+ # Query past decisions
89
+ history = graph.list_decisions(goal_id=goal["id"])
90
+ for d in history:
91
+ print(f"{d['title']} — {d['status']}")
92
+ ```
93
+
94
+ ## Commitments
95
+
96
+ ```python
97
+ # Create a commitment
98
+ commitment = graph.create_commitment(
99
+ title="Deliver API docs by Friday",
100
+ stakeholder="team-lead",
101
+ stakeholder_weight=0.8,
102
+ due_date="2026-03-07T17:00:00Z"
103
+ )
104
+
105
+ # Check at-risk commitments
106
+ at_risk = graph.list_commitments(status="at_risk")
107
+ for c in at_risk:
108
+ print(f"AT RISK: {c['title']} (due: {c['due_date']})")
109
+
110
+ # Fulfill or break
111
+ graph.fulfill_commitment(commitment["id"])
112
+ # graph.break_commitment(commitment["id"], reason="Requirements changed")
113
+ ```
114
+
115
+ ## Singularity
116
+
117
+ ```python
118
+ # Get the unified field view of all goals
119
+ singularity = graph.get_singularity()
120
+ print(f"Center: {singularity['center']}")
121
+ print(f"Themes: {singularity['themes']}")
122
+ print(f"Golden path: {singularity['golden_path']}")
123
+
124
+ # Check for tensions between goals
125
+ for tension in singularity["tensions"]:
126
+ print(f"Tension: {tension['goal_a']} <-> {tension['goal_b']}")
127
+ ```
128
+
129
+ ## Blockers and Prophecy
130
+
131
+ ```python
132
+ # Scan for blocked goals
133
+ blockers = graph.scan_blockers()
134
+ for b in blockers:
135
+ print(f"{b['goal_title']} blocked by: {b['blocker_description']}")
136
+
137
+ # Listen for progress echoes
138
+ echoes = graph.listen_echoes()
139
+ for echo in echoes:
140
+ print(f"Echo: {echo['source_goal']} -> {echo['affected_goal']}: {echo['effect']}")
141
+ ```
142
+
143
+ ## File Persistence
144
+
145
+ ```python
146
+ # Save current state
147
+ graph.save()
148
+
149
+ # Load from file
150
+ graph = PlanningGraph("project.aplan")
151
+
152
+ # The .aplan file is portable — copy it anywhere
153
+ import shutil
154
+ shutil.copy("project.aplan", "/backup/project.aplan")
155
+ ```
156
+
157
+ ## Error Handling
158
+
159
+ All methods raise `PlanningError` on failure:
160
+
161
+ ```python
162
+ from agentic_planning import PlanningError
163
+
164
+ try:
165
+ graph.complete_goal("nonexistent-id")
166
+ except PlanningError as e:
167
+ print(f"Error code: {e.code}") # e.g., 4 (NotFound)
168
+ print(f"Message: {e.message}")
169
+ ```
170
+
171
+ Error codes match the FFI `AplanResult` enum:
172
+
173
+ | Code | Name | Meaning |
174
+ |------|------|---------|
175
+ | 0 | Ok | Success |
176
+ | 1 | NullPointer | Internal null pointer |
177
+ | 2 | InvalidUtf8 | Bad string encoding |
178
+ | 3 | EngineError | Engine-level failure |
179
+ | 4 | NotFound | Entity doesn't exist |
180
+ | 5 | ValidationError | Invalid input |
181
+ | 6 | IoError | File I/O failure |
182
+ | 7 | SerializationError | JSON error |
@@ -0,0 +1,157 @@
1
+ # AgenticPlanning Python SDK
2
+
3
+ Thin Python wrapper around the `aplan` FFI library via `ctypes`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install agentic-planning
9
+ ```
10
+
11
+ Requires the `aplan` shared library (`.so`/`.dylib`/`.dll`) on your library path, or the `aplan` binary on PATH for CLI-mode fallback.
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from agentic_planning import PlanningGraph
17
+
18
+ graph = PlanningGraph("project.aplan")
19
+ ```
20
+
21
+ ## Goals
22
+
23
+ ```python
24
+ # Create a goal
25
+ goal = graph.create_goal("Ship v1", intention="Persistent intention infrastructure")
26
+ print(goal["id"])
27
+
28
+ # List all goals
29
+ goals = graph.list_goals()
30
+ for g in goals:
31
+ print(f"{g['title']} — {g['status']} (momentum: {g['momentum']:.2f})")
32
+
33
+ # Update goal status
34
+ graph.activate_goal(goal["id"])
35
+ graph.complete_goal(goal["id"])
36
+
37
+ # Record progress
38
+ graph.record_progress(goal["id"], percentage=45, note="API endpoints done")
39
+
40
+ # Get goal with computed feelings
41
+ detail = graph.get_goal(goal["id"])
42
+ print(f"Urgency: {detail['feelings']['urgency']:.2f}")
43
+ print(f"Neglect: {detail['feelings']['neglect']:.2f}")
44
+ ```
45
+
46
+ ## Decisions
47
+
48
+ ```python
49
+ # Create a decision linked to a goal
50
+ decision = graph.create_decision(
51
+ title="Use PostgreSQL vs SQLite",
52
+ goal_id=goal["id"],
53
+ options=["PostgreSQL", "SQLite", "Both with abstraction layer"]
54
+ )
55
+
56
+ # Crystallize (choose an option)
57
+ graph.crystallize_decision(
58
+ decision["id"],
59
+ chosen="SQLite",
60
+ reasoning="Simpler deployment, sufficient for planning state sizes"
61
+ )
62
+
63
+ # Query past decisions
64
+ history = graph.list_decisions(goal_id=goal["id"])
65
+ for d in history:
66
+ print(f"{d['title']} — {d['status']}")
67
+ ```
68
+
69
+ ## Commitments
70
+
71
+ ```python
72
+ # Create a commitment
73
+ commitment = graph.create_commitment(
74
+ title="Deliver API docs by Friday",
75
+ stakeholder="team-lead",
76
+ stakeholder_weight=0.8,
77
+ due_date="2026-03-07T17:00:00Z"
78
+ )
79
+
80
+ # Check at-risk commitments
81
+ at_risk = graph.list_commitments(status="at_risk")
82
+ for c in at_risk:
83
+ print(f"AT RISK: {c['title']} (due: {c['due_date']})")
84
+
85
+ # Fulfill or break
86
+ graph.fulfill_commitment(commitment["id"])
87
+ # graph.break_commitment(commitment["id"], reason="Requirements changed")
88
+ ```
89
+
90
+ ## Singularity
91
+
92
+ ```python
93
+ # Get the unified field view of all goals
94
+ singularity = graph.get_singularity()
95
+ print(f"Center: {singularity['center']}")
96
+ print(f"Themes: {singularity['themes']}")
97
+ print(f"Golden path: {singularity['golden_path']}")
98
+
99
+ # Check for tensions between goals
100
+ for tension in singularity["tensions"]:
101
+ print(f"Tension: {tension['goal_a']} <-> {tension['goal_b']}")
102
+ ```
103
+
104
+ ## Blockers and Prophecy
105
+
106
+ ```python
107
+ # Scan for blocked goals
108
+ blockers = graph.scan_blockers()
109
+ for b in blockers:
110
+ print(f"{b['goal_title']} blocked by: {b['blocker_description']}")
111
+
112
+ # Listen for progress echoes
113
+ echoes = graph.listen_echoes()
114
+ for echo in echoes:
115
+ print(f"Echo: {echo['source_goal']} -> {echo['affected_goal']}: {echo['effect']}")
116
+ ```
117
+
118
+ ## File Persistence
119
+
120
+ ```python
121
+ # Save current state
122
+ graph.save()
123
+
124
+ # Load from file
125
+ graph = PlanningGraph("project.aplan")
126
+
127
+ # The .aplan file is portable — copy it anywhere
128
+ import shutil
129
+ shutil.copy("project.aplan", "/backup/project.aplan")
130
+ ```
131
+
132
+ ## Error Handling
133
+
134
+ All methods raise `PlanningError` on failure:
135
+
136
+ ```python
137
+ from agentic_planning import PlanningError
138
+
139
+ try:
140
+ graph.complete_goal("nonexistent-id")
141
+ except PlanningError as e:
142
+ print(f"Error code: {e.code}") # e.g., 4 (NotFound)
143
+ print(f"Message: {e.message}")
144
+ ```
145
+
146
+ Error codes match the FFI `AplanResult` enum:
147
+
148
+ | Code | Name | Meaning |
149
+ |------|------|---------|
150
+ | 0 | Ok | Success |
151
+ | 1 | NullPointer | Internal null pointer |
152
+ | 2 | InvalidUtf8 | Bad string encoding |
153
+ | 3 | EngineError | Engine-level failure |
154
+ | 4 | NotFound | Entity doesn't exist |
155
+ | 5 | ValidationError | Invalid input |
156
+ | 6 | IoError | File I/O failure |
157
+ | 7 | SerializationError | JSON error |
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentic-planning"
7
+ version = "0.1.0"
8
+ description = "Planning infrastructure for AI agents"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Agentra Labs" }]
13
+ keywords = ["ai", "agents", "planning", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence"
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8.0", "pytest-cov>=5.0", "mypy>=1.10"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/agentralabs/agentic-planning"
31
+ Documentation = "https://github.com/agentralabs/agentic-planning/tree/main/docs"
32
+ Repository = "https://github.com/agentralabs/agentic-planning"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/agentic_planning"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+
40
+ [tool.mypy]
41
+ python_version = "3.10"
42
+ strict = true
@@ -0,0 +1,54 @@
1
+ """AgenticPlanning Python SDK.
2
+
3
+ Wraps the `aplan` CLI for lightweight scripting.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ __version__ = "0.1.0"
16
+
17
+
18
+ class PlanningError(Exception):
19
+ """Raised when an `aplan` command fails."""
20
+
21
+
22
+ @dataclass
23
+ class PlanningGraph:
24
+ path: str | Path
25
+ binary: str = "aplan"
26
+
27
+ def __post_init__(self) -> None:
28
+ self.path = Path(self.path)
29
+
30
+ def _binary(self) -> str:
31
+ found = shutil.which(self.binary)
32
+ if not found:
33
+ raise PlanningError(
34
+ f"Cannot find '{self.binary}' on PATH. Install via https://agentralabs.tech/install/planning"
35
+ )
36
+ return found
37
+
38
+ def _run_json(self, *args: str) -> Any:
39
+ cmd = [self._binary(), "--file", str(self.path), *args]
40
+ result = subprocess.run(cmd, capture_output=True, text=True)
41
+ if result.returncode != 0:
42
+ raise PlanningError(result.stderr.strip() or "aplan command failed")
43
+ raw = result.stdout.strip()
44
+ return json.loads(raw) if raw else {}
45
+
46
+ def create_goal(self, title: str, intention: str) -> Any:
47
+ return self._run_json("goal", "create", title, "--intention", intention)
48
+
49
+ def list_goals(self) -> Any:
50
+ return self._run_json("goal", "list")
51
+
52
+ @property
53
+ def exists(self) -> bool:
54
+ return self.path.exists()
File without changes
@@ -0,0 +1,337 @@
1
+ """Comprehensive tests for AgenticPlanning Python SDK.
2
+
3
+ This file tests PlanningGraph and PlanningError.
4
+ Does NOT replace test_planning_graph.py -- this is an additional test file.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import subprocess
10
+ from pathlib import Path, PurePosixPath
11
+ from unittest.mock import patch, MagicMock
12
+
13
+ import pytest
14
+
15
+ from agentic_planning import PlanningGraph, PlanningError, __version__
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # 1. Package Metadata
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ class TestPackageMetadata:
24
+ def test_version_exists(self) -> None:
25
+ assert __version__ is not None
26
+ assert isinstance(__version__, str)
27
+ assert len(__version__) > 0
28
+
29
+ def test_version_semver(self) -> None:
30
+ parts = __version__.split(".")
31
+ assert len(parts) == 3
32
+ assert all(p.isdigit() for p in parts)
33
+
34
+ def test_version_is_010(self) -> None:
35
+ assert __version__ == "0.1.0"
36
+
37
+ def test_import_main_class(self) -> None:
38
+ assert PlanningGraph is not None
39
+
40
+ def test_import_error_class(self) -> None:
41
+ assert PlanningError is not None
42
+ assert issubclass(PlanningError, Exception)
43
+
44
+ def test_main_class_has_docstring(self) -> None:
45
+ # PlanningGraph is a dataclass; it may not have a long docstring
46
+ # but it should at least be importable and functional
47
+ assert PlanningGraph is not None
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # 2. Initialization
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ class TestInit:
56
+ def test_create_with_string_path(self, tmp_path: Path) -> None:
57
+ path = str(tmp_path / "test.aplan")
58
+ obj = PlanningGraph(path)
59
+ assert str(obj.path) == path
60
+
61
+ def test_create_with_path_object(self, tmp_path: Path) -> None:
62
+ path = tmp_path / "test.aplan"
63
+ obj = PlanningGraph(path)
64
+ assert obj.path == path
65
+
66
+ def test_create_with_pure_posix_path(self) -> None:
67
+ obj = PlanningGraph(PurePosixPath("/tmp/test.aplan"))
68
+ assert "test.aplan" in str(obj.path)
69
+
70
+ def test_path_converted_to_path_object(self, tmp_path: Path) -> None:
71
+ path = str(tmp_path / "test.aplan")
72
+ obj = PlanningGraph(path)
73
+ assert isinstance(obj.path, Path)
74
+
75
+ def test_custom_binary_name(self, tmp_path: Path) -> None:
76
+ obj = PlanningGraph(str(tmp_path / "test.aplan"), binary="custom-bin")
77
+ assert obj.binary == "custom-bin"
78
+
79
+ def test_default_binary_name(self, tmp_path: Path) -> None:
80
+ obj = PlanningGraph(str(tmp_path / "test.aplan"))
81
+ assert obj.binary == "aplan"
82
+
83
+ def test_exists_false_for_new(self, tmp_path: Path) -> None:
84
+ obj = PlanningGraph(str(tmp_path / "nonexistent.aplan"))
85
+ assert not obj.exists
86
+
87
+ def test_exists_true_when_file_present(self, tmp_path: Path) -> None:
88
+ path = tmp_path / "exists.aplan"
89
+ path.touch()
90
+ obj = PlanningGraph(str(path))
91
+ assert obj.exists
92
+
93
+ def test_repr_does_not_crash(self, tmp_path: Path) -> None:
94
+ obj = PlanningGraph(str(tmp_path / "test.aplan"))
95
+ r = repr(obj)
96
+ assert isinstance(r, str)
97
+
98
+ def test_path_name_preserved(self, tmp_path: Path) -> None:
99
+ obj = PlanningGraph(str(tmp_path / "test.aplan"))
100
+ assert obj.path.name == "test.aplan"
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # 3. Binary Resolution
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class TestBinaryResolution:
109
+ def test_missing_binary_raises(self, tmp_path: Path) -> None:
110
+ obj = PlanningGraph(str(tmp_path / "t.aplan"), binary="nonexistent-xyz-999")
111
+ with pytest.raises(PlanningError):
112
+ obj._binary()
113
+
114
+ def test_error_contains_binary_name(self, tmp_path: Path) -> None:
115
+ obj = PlanningGraph(str(tmp_path / "t.aplan"), binary="nonexistent-xyz-999")
116
+ with pytest.raises(PlanningError, match="nonexistent-xyz-999"):
117
+ obj._binary()
118
+
119
+ def test_error_contains_install_hint(self, tmp_path: Path) -> None:
120
+ obj = PlanningGraph(str(tmp_path / "t.aplan"), binary="nonexistent-xyz-999")
121
+ with pytest.raises(PlanningError, match="install"):
122
+ obj._binary()
123
+
124
+ def test_binary_returns_string(self, tmp_path: Path) -> None:
125
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
126
+ with patch("shutil.which", return_value="/usr/bin/aplan"):
127
+ result = obj._binary()
128
+ assert isinstance(result, str)
129
+ assert result == "/usr/bin/aplan"
130
+
131
+ def test_binary_uses_shutil_which(self, tmp_path: Path) -> None:
132
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
133
+ with patch("shutil.which", return_value="/opt/bin/aplan") as mock_which:
134
+ result = obj._binary()
135
+ mock_which.assert_called_with("aplan")
136
+ assert result == "/opt/bin/aplan"
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # 4. Subprocess Execution
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ class TestSubprocessExecution:
145
+ def test_run_json_calls_subprocess(self, tmp_path: Path) -> None:
146
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
147
+ with patch("shutil.which", return_value="/usr/bin/echo"), \
148
+ patch("subprocess.run") as mock_run:
149
+ mock_run.return_value = MagicMock(
150
+ returncode=0, stdout='{"key": "value"}\n', stderr=""
151
+ )
152
+ result = obj._run_json("test")
153
+ assert mock_run.called
154
+ cmd = mock_run.call_args[0][0]
155
+ assert cmd[0] == "/usr/bin/echo"
156
+
157
+ def test_run_json_includes_file_flag(self, tmp_path: Path) -> None:
158
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
159
+ with patch("shutil.which", return_value="/usr/bin/echo"), \
160
+ patch("subprocess.run") as mock_run:
161
+ mock_run.return_value = MagicMock(
162
+ returncode=0, stdout='{"k": 1}\n', stderr=""
163
+ )
164
+ obj._run_json("test")
165
+ cmd = mock_run.call_args[0][0]
166
+ assert "--file" in cmd
167
+ assert str(tmp_path / "t.aplan") in cmd
168
+
169
+ def test_run_json_raises_on_nonzero_exit(self, tmp_path: Path) -> None:
170
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
171
+ with patch("shutil.which", return_value="/bin/false"), \
172
+ patch("subprocess.run") as mock_run:
173
+ mock_run.return_value = MagicMock(
174
+ returncode=1, stdout="", stderr="error happened"
175
+ )
176
+ with pytest.raises(PlanningError, match="error happened"):
177
+ obj._run_json("fail")
178
+
179
+ def test_run_json_parses_output(self, tmp_path: Path) -> None:
180
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
181
+ with patch("shutil.which", return_value="/usr/bin/echo"), \
182
+ patch("subprocess.run") as mock_run:
183
+ mock_run.return_value = MagicMock(
184
+ returncode=0, stdout='{"key": "value"}\n', stderr=""
185
+ )
186
+ result = obj._run_json("test")
187
+ assert result == {"key": "value"}
188
+
189
+ def test_run_json_returns_empty_dict_on_empty_output(self, tmp_path: Path) -> None:
190
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
191
+ with patch("shutil.which", return_value="/usr/bin/echo"), \
192
+ patch("subprocess.run") as mock_run:
193
+ mock_run.return_value = MagicMock(
194
+ returncode=0, stdout="", stderr=""
195
+ )
196
+ result = obj._run_json("test")
197
+ assert result == {}
198
+
199
+ def test_run_json_raises_on_invalid_json(self, tmp_path: Path) -> None:
200
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
201
+ with patch("shutil.which", return_value="/usr/bin/echo"), \
202
+ patch("subprocess.run") as mock_run:
203
+ mock_run.return_value = MagicMock(
204
+ returncode=0, stdout="not json at all", stderr=""
205
+ )
206
+ with pytest.raises((json.JSONDecodeError, PlanningError)):
207
+ obj._run_json("test")
208
+
209
+ def test_run_json_error_with_empty_stderr_uses_fallback(self, tmp_path: Path) -> None:
210
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
211
+ with patch("shutil.which", return_value="/bin/false"), \
212
+ patch("subprocess.run") as mock_run:
213
+ mock_run.return_value = MagicMock(
214
+ returncode=1, stdout="", stderr=""
215
+ )
216
+ with pytest.raises(PlanningError, match="aplan command failed"):
217
+ obj._run_json("fail")
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # 5. Edge Cases
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ class TestEdgeCases:
226
+ def test_empty_path(self) -> None:
227
+ obj = PlanningGraph("")
228
+ assert isinstance(obj.path, Path)
229
+
230
+ def test_path_with_spaces(self, tmp_path: Path) -> None:
231
+ path = tmp_path / "path with spaces" / "test.aplan"
232
+ obj = PlanningGraph(str(path))
233
+ assert "spaces" in str(obj.path)
234
+
235
+ def test_path_with_unicode(self, tmp_path: Path) -> None:
236
+ path = tmp_path / "donnees" / "test.aplan"
237
+ obj = PlanningGraph(str(path))
238
+ assert "donnees" in str(obj.path)
239
+
240
+ def test_very_long_path(self, tmp_path: Path) -> None:
241
+ long_name = "a" * 200
242
+ path = tmp_path / long_name / "test.aplan"
243
+ obj = PlanningGraph(str(path))
244
+ assert len(str(obj.path)) > 200
245
+
246
+ def test_multiple_instances_independent(self, tmp_path: Path) -> None:
247
+ a = PlanningGraph(str(tmp_path / "a.aplan"))
248
+ b = PlanningGraph(str(tmp_path / "b.aplan"))
249
+ assert a.path != b.path
250
+
251
+ def test_dot_in_directory_name(self, tmp_path: Path) -> None:
252
+ path = tmp_path / "v1.0.0" / "test.aplan"
253
+ obj = PlanningGraph(str(path))
254
+ assert "v1.0.0" in str(obj.path)
255
+
256
+ def test_binary_not_cached_across_calls(self, tmp_path: Path) -> None:
257
+ """PlanningGraph._binary() re-calls shutil.which every time (no caching)."""
258
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
259
+ with patch("shutil.which", return_value="/usr/bin/aplan") as mock_which:
260
+ obj._binary()
261
+ obj._binary()
262
+ assert mock_which.call_count == 2
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # 6. Error Handling
267
+ # ---------------------------------------------------------------------------
268
+
269
+
270
+ class TestErrorHandling:
271
+ def test_error_is_exception(self) -> None:
272
+ assert issubclass(PlanningError, Exception)
273
+
274
+ def test_error_stores_message(self) -> None:
275
+ err = PlanningError("test message")
276
+ assert "test message" in str(err)
277
+
278
+ def test_error_caught_as_exception(self) -> None:
279
+ with pytest.raises(Exception):
280
+ raise PlanningError("boom")
281
+
282
+ def test_error_caught_specifically(self) -> None:
283
+ try:
284
+ raise PlanningError("specific")
285
+ except PlanningError as e:
286
+ assert "specific" in str(e)
287
+
288
+ def test_error_repr(self) -> None:
289
+ err = PlanningError("repr test")
290
+ assert repr(err) is not None
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # 7. High-Level API Methods
295
+ # ---------------------------------------------------------------------------
296
+
297
+
298
+ class TestAPIMethods:
299
+ def test_create_goal_calls_run_json(self, tmp_path: Path) -> None:
300
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
301
+ with patch.object(obj, "_run_json", return_value={"id": "g1"}) as mock:
302
+ result = obj.create_goal("Build MVP", "ship by Friday")
303
+ mock.assert_called_once_with("goal", "create", "Build MVP", "--intention", "ship by Friday")
304
+ assert result == {"id": "g1"}
305
+
306
+ def test_list_goals_calls_run_json(self, tmp_path: Path) -> None:
307
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
308
+ with patch.object(obj, "_run_json", return_value=[{"id": "g1"}]) as mock:
309
+ result = obj.list_goals()
310
+ mock.assert_called_once_with("goal", "list")
311
+ assert result == [{"id": "g1"}]
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # 8. Stress Tests
316
+ # ---------------------------------------------------------------------------
317
+
318
+
319
+ class TestStress:
320
+ def test_create_1000_instances(self, tmp_path: Path) -> None:
321
+ instances = [
322
+ PlanningGraph(str(tmp_path / f"test_{i}.aplan"))
323
+ for i in range(1000)
324
+ ]
325
+ assert len(instances) == 1000
326
+ assert instances[0].path != instances[999].path
327
+
328
+ def test_binary_lookup_1000_times(self, tmp_path: Path) -> None:
329
+ obj = PlanningGraph(str(tmp_path / "t.aplan"))
330
+ with patch("shutil.which", return_value="/usr/bin/aplan"):
331
+ for _ in range(1000):
332
+ assert obj._binary() == "/usr/bin/aplan"
333
+
334
+ def test_create_1000_errors(self) -> None:
335
+ errors = [PlanningError(f"err_{i}") for i in range(1000)]
336
+ assert len(errors) == 1000
337
+ assert "err_999" in str(errors[999])
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from agentic_planning import PlanningError, PlanningGraph, __version__
6
+
7
+
8
+ def test_version_semver() -> None:
9
+ parts = __version__.split(".")
10
+ assert len(parts) == 3
11
+
12
+
13
+ def test_graph_init(tmp_path: pytest.TempPathFactory) -> None:
14
+ graph = PlanningGraph(str(tmp_path / "test.aplan"))
15
+ assert graph.path.name == "test.aplan"
16
+
17
+
18
+ def test_missing_binary_raises(tmp_path: pytest.TempPathFactory) -> None:
19
+ graph = PlanningGraph(str(tmp_path / "test.aplan"), binary="nonexistent-aplan-binary")
20
+ with pytest.raises(PlanningError):
21
+ graph._binary()