agentic-development-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentic_development_framework-0.1.0.dist-info/METADATA +175 -0
- agentic_development_framework-0.1.0.dist-info/RECORD +27 -0
- agentic_development_framework-0.1.0.dist-info/WHEEL +4 -0
- agentic_development_framework-0.1.0.dist-info/entry_points.txt +2 -0
- agentic_development_framework-0.1.0.dist-info/licenses/LICENSE +22 -0
- agentic_framework/__init__.py +6 -0
- agentic_framework/__main__.py +7 -0
- agentic_framework/adapters/__init__.py +5 -0
- agentic_framework/adapters/generator.py +139 -0
- agentic_framework/cli.py +264 -0
- agentic_framework/core/__init__.py +2 -0
- agentic_framework/core/command_registry.py +16 -0
- agentic_framework/core/config.py +146 -0
- agentic_framework/core/initialization.py +241 -0
- agentic_framework/core/project.py +93 -0
- agentic_framework/core/workflow.py +94 -0
- agentic_framework/exceptions.py +22 -0
- agentic_framework/logging.py +70 -0
- agentic_framework/requirements_manager/__init__.py +8 -0
- agentic_framework/requirements_manager/comparison.py +129 -0
- agentic_framework/requirements_manager/extraction.py +43 -0
- agentic_framework/requirements_manager/ingestion.py +367 -0
- agentic_framework/requirements_manager/traceability.py +65 -0
- agentic_framework/state/__init__.py +5 -0
- agentic_framework/state/database.py +231 -0
- agentic_framework/templates/__init__.py +1 -0
- agentic_framework/templates/skills/agentic-commands/SKILL.md +22 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Validated YAML configuration for the framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path, PurePath
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
11
|
+
|
|
12
|
+
from agentic_framework.exceptions import ConfigurationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StrictModel(BaseModel):
|
|
16
|
+
"""Base model that rejects misspelled and unsupported configuration keys."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProjectMode(StrEnum):
|
|
22
|
+
"""How project discovery should treat the target directory."""
|
|
23
|
+
|
|
24
|
+
AUTO = "auto"
|
|
25
|
+
NEW = "new"
|
|
26
|
+
EXISTING = "existing"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IdeKind(StrEnum):
|
|
30
|
+
"""IDE harnesses known to the initial product specification."""
|
|
31
|
+
|
|
32
|
+
CODEX = "codex"
|
|
33
|
+
ANTIGRAVITY = "antigravity"
|
|
34
|
+
VSCODE = "vscode"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LogLevel(StrEnum):
|
|
38
|
+
"""Supported Python logging levels."""
|
|
39
|
+
|
|
40
|
+
DEBUG = "DEBUG"
|
|
41
|
+
INFO = "INFO"
|
|
42
|
+
WARNING = "WARNING"
|
|
43
|
+
ERROR = "ERROR"
|
|
44
|
+
CRITICAL = "CRITICAL"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ProjectConfig(StrictModel):
|
|
48
|
+
"""Project identification and discovery preferences."""
|
|
49
|
+
|
|
50
|
+
name: str | None = Field(default=None, min_length=1, max_length=100)
|
|
51
|
+
mode: ProjectMode = ProjectMode.AUTO
|
|
52
|
+
|
|
53
|
+
@field_validator("name")
|
|
54
|
+
@classmethod
|
|
55
|
+
def normalize_name(cls, value: str | None) -> str | None:
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
normalized = value.strip()
|
|
59
|
+
if not normalized:
|
|
60
|
+
raise ValueError("project name cannot be blank")
|
|
61
|
+
return normalized
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RuntimeConfig(StrictModel):
|
|
65
|
+
"""Local runtime storage and diagnostic preferences."""
|
|
66
|
+
|
|
67
|
+
directory: Path = Path(".agentic")
|
|
68
|
+
log_level: LogLevel = LogLevel.INFO
|
|
69
|
+
|
|
70
|
+
@field_validator("directory")
|
|
71
|
+
@classmethod
|
|
72
|
+
def require_safe_relative_path(cls, value: Path) -> Path:
|
|
73
|
+
path = PurePath(value)
|
|
74
|
+
if path.is_absolute():
|
|
75
|
+
raise ValueError("runtime directory must be project-relative")
|
|
76
|
+
if not path.parts or any(part == ".." for part in path.parts):
|
|
77
|
+
raise ValueError("runtime directory cannot traverse outside the project")
|
|
78
|
+
return Path(path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class IdeConfig(StrictModel):
|
|
82
|
+
"""IDE adapters requested for a project."""
|
|
83
|
+
|
|
84
|
+
enabled: list[IdeKind] = Field(default_factory=lambda: [IdeKind.CODEX])
|
|
85
|
+
|
|
86
|
+
@field_validator("enabled")
|
|
87
|
+
@classmethod
|
|
88
|
+
def require_unique_adapters(cls, value: list[IdeKind]) -> list[IdeKind]:
|
|
89
|
+
if len(value) != len(set(value)):
|
|
90
|
+
raise ValueError("IDE adapters must not be duplicated")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PolicyConfig(StrictModel):
|
|
95
|
+
"""Conservative capability defaults for future execution phases."""
|
|
96
|
+
|
|
97
|
+
allow_network: bool = False
|
|
98
|
+
allow_destructive_operations: bool = False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class FrameworkConfig(StrictModel):
|
|
102
|
+
"""Root configuration document."""
|
|
103
|
+
|
|
104
|
+
schema_version: int = Field(default=1, ge=1, le=1)
|
|
105
|
+
project: ProjectConfig = Field(default_factory=ProjectConfig)
|
|
106
|
+
runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
|
|
107
|
+
ide: IdeConfig = Field(default_factory=IdeConfig)
|
|
108
|
+
policies: PolicyConfig = Field(default_factory=PolicyConfig)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_config(data: Any) -> FrameworkConfig:
|
|
112
|
+
"""Validate decoded configuration data and present failures consistently."""
|
|
113
|
+
|
|
114
|
+
if data is None:
|
|
115
|
+
data = {}
|
|
116
|
+
if not isinstance(data, dict):
|
|
117
|
+
raise ConfigurationError("configuration root must be a YAML mapping")
|
|
118
|
+
try:
|
|
119
|
+
return FrameworkConfig.model_validate(data)
|
|
120
|
+
except ValidationError as exc:
|
|
121
|
+
raise ConfigurationError(f"configuration validation failed:\n{exc}") from exc
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_config(path: Path) -> FrameworkConfig:
|
|
125
|
+
"""Read and validate a UTF-8 YAML configuration file."""
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
content = path.read_text(encoding="utf-8")
|
|
129
|
+
except FileNotFoundError as exc:
|
|
130
|
+
raise ConfigurationError(f"configuration file does not exist: {path}") from exc
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
raise ConfigurationError(f"cannot read configuration file {path}: {exc}") from exc
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data = yaml.safe_load(content)
|
|
136
|
+
except yaml.YAMLError as exc:
|
|
137
|
+
raise ConfigurationError(f"invalid YAML in {path}: {exc}") from exc
|
|
138
|
+
return parse_config(data)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def dump_config(config: FrameworkConfig) -> str:
|
|
142
|
+
"""Serialize a normalized configuration document as stable YAML."""
|
|
143
|
+
|
|
144
|
+
data = config.model_dump(mode="json")
|
|
145
|
+
return yaml.safe_dump(data, sort_keys=False)
|
|
146
|
+
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Safe, deterministic project initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agentic_framework.adapters import generated_project_files
|
|
12
|
+
from agentic_framework.core.config import (
|
|
13
|
+
FrameworkConfig,
|
|
14
|
+
ProjectConfig,
|
|
15
|
+
ProjectMode,
|
|
16
|
+
dump_config,
|
|
17
|
+
load_config,
|
|
18
|
+
)
|
|
19
|
+
from agentic_framework.core.project import ProjectInspection, inspect_project
|
|
20
|
+
from agentic_framework.exceptions import ConfigurationError, InitializationError
|
|
21
|
+
from agentic_framework.state import RuntimeStore
|
|
22
|
+
|
|
23
|
+
_DIRECTORIES = (
|
|
24
|
+
".agentic/database",
|
|
25
|
+
".agentic/state",
|
|
26
|
+
".agentic/checkpoints",
|
|
27
|
+
".agentic/logs",
|
|
28
|
+
".agentic/reports",
|
|
29
|
+
".agentic/proposals",
|
|
30
|
+
".agents/rules",
|
|
31
|
+
".agents/skills",
|
|
32
|
+
".codex/agents",
|
|
33
|
+
".github/agents",
|
|
34
|
+
".github/instructions",
|
|
35
|
+
".github/workflows",
|
|
36
|
+
"requirements/inbox",
|
|
37
|
+
"requirements/versions",
|
|
38
|
+
"requirements/reports",
|
|
39
|
+
"docs/architecture",
|
|
40
|
+
"docs/design",
|
|
41
|
+
"docs/decisions",
|
|
42
|
+
"docs/revisions",
|
|
43
|
+
"docs/reports",
|
|
44
|
+
"design-prototype",
|
|
45
|
+
"tests",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_AGENTS = """# Project agent instructions
|
|
49
|
+
|
|
50
|
+
This project uses the Agentic Development Framework (ADF).
|
|
51
|
+
|
|
52
|
+
- Read `.agentic/config.yaml` before framework operations.
|
|
53
|
+
- Preserve existing project files and unrelated working-tree changes.
|
|
54
|
+
- Do not expose secrets or perform destructive operations without explicit approval.
|
|
55
|
+
- Treat framework slash commands as workflow requests even when the IDE has no native
|
|
56
|
+
slash-command support.
|
|
57
|
+
- Use the `agentic-commands` skill for `/start`, `/status`, `/pause`, `/resume`, `/continue`,
|
|
58
|
+
`/stop`, `/review`, `/approve`, `/revise`, `/requirements`, and `/tasks`.
|
|
59
|
+
- Do not claim a task or phase is complete until its required checks pass.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
_REQUIREMENTS_README = """# Project requirements
|
|
63
|
+
|
|
64
|
+
Place source requirement files in `inbox/`, then run `adt requirements ingest`. ADF copies every
|
|
65
|
+
source byte-for-byte into an immutable directory under `versions/`, detects duplicates using
|
|
66
|
+
SHA-256 hashes, and compares each new version with its predecessor. Files in `inbox/` are never
|
|
67
|
+
silently overwritten or removed.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
_GITIGNORE = """# Agentic Development Framework local runtime data
|
|
71
|
+
.agentic/database/
|
|
72
|
+
.agentic/state/
|
|
73
|
+
.agentic/checkpoints/
|
|
74
|
+
.agentic/logs/
|
|
75
|
+
.agentic/proposals/
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FileStatus(StrEnum):
|
|
80
|
+
"""Outcome of safely installing one generated file."""
|
|
81
|
+
|
|
82
|
+
CREATED = "created"
|
|
83
|
+
UNCHANGED = "unchanged"
|
|
84
|
+
CONFLICT = "conflict"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class FileResult:
|
|
89
|
+
"""Outcome and optional proposal for one generated file."""
|
|
90
|
+
|
|
91
|
+
path: Path
|
|
92
|
+
status: FileStatus
|
|
93
|
+
proposal: Path | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(slots=True)
|
|
97
|
+
class InitializationResult:
|
|
98
|
+
"""Complete result returned to CLI and tests."""
|
|
99
|
+
|
|
100
|
+
inspection: ProjectInspection
|
|
101
|
+
mode: ProjectMode
|
|
102
|
+
files: list[FileResult] = field(default_factory=list)
|
|
103
|
+
git_initialized: bool = False
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def conflicts(self) -> tuple[FileResult, ...]:
|
|
107
|
+
return tuple(item for item in self.files if item.status is FileStatus.CONFLICT)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _proposal_path(root: Path, relative: Path) -> Path:
|
|
111
|
+
proposal_root = root / ".agentic" / "proposals"
|
|
112
|
+
return proposal_root / relative.parent / f"{relative.name}.proposed"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _safe_install(root: Path, relative: str, content: str) -> FileResult:
|
|
116
|
+
relative_path = Path(relative)
|
|
117
|
+
destination = root / relative_path
|
|
118
|
+
if not destination.exists():
|
|
119
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
destination.write_text(content, encoding="utf-8")
|
|
121
|
+
return FileResult(path=relative_path, status=FileStatus.CREATED)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
existing = destination.read_text(encoding="utf-8")
|
|
125
|
+
except (OSError, UnicodeError) as exc:
|
|
126
|
+
raise InitializationError(f"cannot compare existing file {destination}: {exc}") from exc
|
|
127
|
+
if existing == content:
|
|
128
|
+
return FileResult(path=relative_path, status=FileStatus.UNCHANGED)
|
|
129
|
+
|
|
130
|
+
proposal = _proposal_path(root, relative_path)
|
|
131
|
+
proposal.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
if not proposal.exists() or proposal.read_text(encoding="utf-8") != content:
|
|
133
|
+
proposal.write_text(content, encoding="utf-8")
|
|
134
|
+
return FileResult(
|
|
135
|
+
path=relative_path,
|
|
136
|
+
status=FileStatus.CONFLICT,
|
|
137
|
+
proposal=proposal.relative_to(root),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _configuration(root: Path, inspection: ProjectInspection) -> tuple[FrameworkConfig, bool]:
|
|
142
|
+
existing = root / ".agentic" / "config.yaml"
|
|
143
|
+
if existing.is_file():
|
|
144
|
+
try:
|
|
145
|
+
return load_config(existing), True
|
|
146
|
+
except ConfigurationError:
|
|
147
|
+
pass
|
|
148
|
+
mode = ProjectMode.NEW if inspection.is_empty else ProjectMode.EXISTING
|
|
149
|
+
return FrameworkConfig(project=ProjectConfig(name=root.name, mode=mode)), False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run_git_init(root: Path) -> None:
|
|
153
|
+
try:
|
|
154
|
+
subprocess.run(
|
|
155
|
+
["git", "init", "--quiet"],
|
|
156
|
+
cwd=root,
|
|
157
|
+
check=True,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
)
|
|
161
|
+
except FileNotFoundError as exc:
|
|
162
|
+
raise InitializationError("Git is not installed or is not available on PATH") from exc
|
|
163
|
+
except subprocess.CalledProcessError as exc:
|
|
164
|
+
detail = exc.stderr.strip() or "unknown Git error"
|
|
165
|
+
raise InitializationError(f"Git initialization failed: {detail}") from exc
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _report(result: InitializationResult) -> str:
|
|
169
|
+
inspection = result.inspection
|
|
170
|
+
technologies = ", ".join(inspection.technologies) or "none detected"
|
|
171
|
+
managers = ", ".join(inspection.package_managers) or "none detected"
|
|
172
|
+
if result.git_initialized:
|
|
173
|
+
git_state = "initialized"
|
|
174
|
+
elif inspection.has_git:
|
|
175
|
+
git_state = "detected"
|
|
176
|
+
else:
|
|
177
|
+
git_state = "skipped"
|
|
178
|
+
lines = [
|
|
179
|
+
"# ADF initialization report",
|
|
180
|
+
"",
|
|
181
|
+
f"- Project root: `{inspection.root}`",
|
|
182
|
+
f"- Project mode: `{result.mode.value}`",
|
|
183
|
+
f"- Git: {git_state}",
|
|
184
|
+
f"- Technologies: {technologies}",
|
|
185
|
+
f"- Package managers: {managers}",
|
|
186
|
+
f"- Conflicts: {len(result.conflicts)}",
|
|
187
|
+
]
|
|
188
|
+
if result.conflicts:
|
|
189
|
+
lines.extend(["", "## Conflicts", ""])
|
|
190
|
+
lines.extend(
|
|
191
|
+
f"- `{item.path}` preserved; proposed content written to `{item.proposal}`"
|
|
192
|
+
for item in result.conflicts
|
|
193
|
+
)
|
|
194
|
+
return "\n".join(lines) + "\n"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def initialize_project(
|
|
198
|
+
target: Path,
|
|
199
|
+
*,
|
|
200
|
+
initialize_git: bool = True,
|
|
201
|
+
git_initializer: Callable[[Path], None] = _run_git_init,
|
|
202
|
+
) -> InitializationResult:
|
|
203
|
+
"""Initialize a target without overwriting existing project files."""
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
inspection = inspect_project(target)
|
|
207
|
+
except (OSError, NotADirectoryError) as exc:
|
|
208
|
+
raise InitializationError(str(exc)) from exc
|
|
209
|
+
|
|
210
|
+
root = inspection.root
|
|
211
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
config, existing_config_is_valid = _configuration(root, inspection)
|
|
213
|
+
result = InitializationResult(inspection=inspection, mode=config.project.mode)
|
|
214
|
+
|
|
215
|
+
for relative in _DIRECTORIES:
|
|
216
|
+
(root / relative).mkdir(parents=True, exist_ok=True)
|
|
217
|
+
|
|
218
|
+
if existing_config_is_valid:
|
|
219
|
+
result.files.append(
|
|
220
|
+
FileResult(path=Path(".agentic/config.yaml"), status=FileStatus.UNCHANGED)
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
result.files.append(_safe_install(root, ".agentic/config.yaml", dump_config(config)))
|
|
224
|
+
|
|
225
|
+
generated = {
|
|
226
|
+
".gitignore": _GITIGNORE,
|
|
227
|
+
"AGENTS.md": _AGENTS,
|
|
228
|
+
"requirements/README.md": _REQUIREMENTS_README,
|
|
229
|
+
}
|
|
230
|
+
generated.update(generated_project_files())
|
|
231
|
+
result.files.extend(_safe_install(root, path, content) for path, content in generated.items())
|
|
232
|
+
|
|
233
|
+
if initialize_git and not inspection.has_git:
|
|
234
|
+
git_initializer(root)
|
|
235
|
+
result.git_initialized = True
|
|
236
|
+
|
|
237
|
+
RuntimeStore(root).initialize()
|
|
238
|
+
|
|
239
|
+
report_path = root / ".agentic" / "reports" / "initialization.md"
|
|
240
|
+
report_path.write_text(_report(result), encoding="utf-8")
|
|
241
|
+
return result
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Project discovery used by safe initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_TECHNOLOGY_MARKERS: dict[str, tuple[str, ...]] = {
|
|
9
|
+
"Docker": (
|
|
10
|
+
"Dockerfile",
|
|
11
|
+
"compose.yaml",
|
|
12
|
+
"compose.yml",
|
|
13
|
+
"docker-compose.yaml",
|
|
14
|
+
"docker-compose.yml",
|
|
15
|
+
),
|
|
16
|
+
"Django": ("manage.py",),
|
|
17
|
+
"JavaScript": ("package.json",),
|
|
18
|
+
"Next.js": ("next.config.js", "next.config.mjs", "next.config.ts"),
|
|
19
|
+
"Python": ("pyproject.toml", "setup.py", "requirements.txt"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_PACKAGE_MANAGER_MARKERS: dict[str, tuple[str, ...]] = {
|
|
23
|
+
"npm": ("package-lock.json",),
|
|
24
|
+
"pip": ("pyproject.toml", "requirements.txt", "setup.py"),
|
|
25
|
+
"pnpm": ("pnpm-lock.yaml",),
|
|
26
|
+
"Poetry": ("poetry.lock",),
|
|
27
|
+
"uv": ("uv.lock",),
|
|
28
|
+
"Yarn": ("yarn.lock",),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ProjectInspection:
|
|
33
|
+
"""Read-only facts discovered about an initialization target."""
|
|
34
|
+
|
|
35
|
+
root: Path
|
|
36
|
+
is_empty: bool
|
|
37
|
+
git_root: Path | None
|
|
38
|
+
technologies: tuple[str, ...]
|
|
39
|
+
package_managers: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def has_git(self) -> bool:
|
|
43
|
+
return self.git_root is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _matching_labels(root: Path, markers: dict[str, tuple[str, ...]]) -> tuple[str, ...]:
|
|
47
|
+
excluded = {".agentic", ".git", ".venv", "node_modules"}
|
|
48
|
+
|
|
49
|
+
def exists(candidate: str) -> bool:
|
|
50
|
+
direct = root / candidate
|
|
51
|
+
if direct.exists():
|
|
52
|
+
return True
|
|
53
|
+
name = Path(candidate).name
|
|
54
|
+
for match in root.rglob(name):
|
|
55
|
+
relative = match.relative_to(root)
|
|
56
|
+
if len(relative.parts) <= 4 and not excluded.intersection(relative.parts):
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
return tuple(
|
|
61
|
+
label
|
|
62
|
+
for label, candidates in markers.items()
|
|
63
|
+
if any(exists(candidate) for candidate in candidates)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_git_root(root: Path) -> Path | None:
|
|
68
|
+
"""Find a repository marker in the target or one of its parents."""
|
|
69
|
+
|
|
70
|
+
for candidate in (root, *root.parents):
|
|
71
|
+
if (candidate / ".git").exists():
|
|
72
|
+
return candidate
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def inspect_project(root: Path) -> ProjectInspection:
|
|
77
|
+
"""Inspect a target without modifying it or invoking project tooling."""
|
|
78
|
+
|
|
79
|
+
resolved = root.expanduser().resolve()
|
|
80
|
+
if not resolved.exists():
|
|
81
|
+
is_empty = True
|
|
82
|
+
elif not resolved.is_dir():
|
|
83
|
+
raise NotADirectoryError(f"initialization target is not a directory: {resolved}")
|
|
84
|
+
else:
|
|
85
|
+
is_empty = next(resolved.iterdir(), None) is None
|
|
86
|
+
|
|
87
|
+
return ProjectInspection(
|
|
88
|
+
root=resolved,
|
|
89
|
+
is_empty=is_empty,
|
|
90
|
+
git_root=find_git_root(resolved),
|
|
91
|
+
technologies=_matching_labels(resolved, _TECHNOLOGY_MARKERS),
|
|
92
|
+
package_managers=_matching_labels(resolved, _PACKAGE_MANAGER_MARKERS),
|
|
93
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Persistent workflow lifecycle and recovery behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agentic_framework.adapters.generator import APPROVAL_STAGES, STAGES
|
|
10
|
+
from agentic_framework.exceptions import StateError
|
|
11
|
+
from agentic_framework.state import RuntimeStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class WorkflowService:
|
|
16
|
+
"""Apply validated lifecycle transitions to a project runtime."""
|
|
17
|
+
|
|
18
|
+
project_root: Path
|
|
19
|
+
store: RuntimeStore = field(init=False)
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
self.store = RuntimeStore(self.project_root)
|
|
23
|
+
if not self.store.database_path.is_file():
|
|
24
|
+
raise StateError(
|
|
25
|
+
f"runtime database does not exist; run adt init: {self.store.database_path}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def status(self) -> dict[str, Any]:
|
|
29
|
+
return self.store.workflow()
|
|
30
|
+
|
|
31
|
+
def start(self) -> dict[str, Any]:
|
|
32
|
+
current = self.status()
|
|
33
|
+
if current["status"] == "paused":
|
|
34
|
+
raise StateError("workflow is paused; use adt resume")
|
|
35
|
+
if current["status"] != "active":
|
|
36
|
+
self.store.update_workflow("active", current["current_stage"])
|
|
37
|
+
return self.status()
|
|
38
|
+
|
|
39
|
+
def pause(self) -> dict[str, Any]:
|
|
40
|
+
current = self.status()
|
|
41
|
+
if current["status"] != "active":
|
|
42
|
+
raise StateError("only an active workflow can be paused")
|
|
43
|
+
self.store.update_workflow("paused", current["current_stage"])
|
|
44
|
+
self.store.create_checkpoint("pause")
|
|
45
|
+
return self.status()
|
|
46
|
+
|
|
47
|
+
def stop(self) -> dict[str, Any]:
|
|
48
|
+
current = self.status()
|
|
49
|
+
if current["status"] not in {"active", "paused"}:
|
|
50
|
+
raise StateError("only an active or paused workflow can be stopped")
|
|
51
|
+
self.store.update_workflow("stopped", current["current_stage"])
|
|
52
|
+
self.store.create_checkpoint("stop")
|
|
53
|
+
return self.status()
|
|
54
|
+
|
|
55
|
+
def resume(self) -> dict[str, Any]:
|
|
56
|
+
current = self.status()
|
|
57
|
+
if current["status"] not in {"paused", "stopped"}:
|
|
58
|
+
raise StateError("only a paused or stopped workflow can be resumed")
|
|
59
|
+
checkpoint = self.store.validate_latest_checkpoint()
|
|
60
|
+
if checkpoint is None:
|
|
61
|
+
raise StateError("cannot resume without a valid checkpoint")
|
|
62
|
+
checkpoint_stage = checkpoint["workflow"]["current_stage"]
|
|
63
|
+
if checkpoint_stage not in STAGES:
|
|
64
|
+
raise StateError(f"checkpoint contains an unknown workflow stage: {checkpoint_stage}")
|
|
65
|
+
self.store.update_workflow("active", checkpoint_stage)
|
|
66
|
+
return self.status()
|
|
67
|
+
|
|
68
|
+
def continue_workflow(self) -> dict[str, Any]:
|
|
69
|
+
current = self.status()
|
|
70
|
+
if current["status"] != "active":
|
|
71
|
+
raise StateError("workflow must be active before it can continue")
|
|
72
|
+
stage = current["current_stage"]
|
|
73
|
+
if stage not in STAGES:
|
|
74
|
+
raise StateError(f"runtime contains an unknown workflow stage: {stage}")
|
|
75
|
+
if stage in APPROVAL_STAGES and self.store.approval_status(stage) != "approved":
|
|
76
|
+
raise StateError(f"stage requires approval before continuing: {stage}")
|
|
77
|
+
index = STAGES.index(stage)
|
|
78
|
+
next_stage = STAGES[min(index + 1, len(STAGES) - 1)]
|
|
79
|
+
next_status = "completed" if next_stage == "completed" else "active"
|
|
80
|
+
self.store.update_workflow(next_status, next_stage)
|
|
81
|
+
self.store.create_checkpoint("stage_transition")
|
|
82
|
+
return self.status()
|
|
83
|
+
|
|
84
|
+
def approve(self, stage: str) -> None:
|
|
85
|
+
if stage not in STAGES:
|
|
86
|
+
raise StateError(f"unknown workflow stage: {stage}")
|
|
87
|
+
self.store.set_approval(stage, "approved")
|
|
88
|
+
|
|
89
|
+
def revise(self, stage: str, feedback: str) -> None:
|
|
90
|
+
if stage not in STAGES:
|
|
91
|
+
raise StateError(f"unknown workflow stage: {stage}")
|
|
92
|
+
if not feedback.strip():
|
|
93
|
+
raise StateError("revision feedback cannot be blank")
|
|
94
|
+
self.store.set_approval(stage, "revision_requested", feedback.strip())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Framework-specific exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgenticFrameworkError(Exception):
|
|
5
|
+
"""Base class for expected framework errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(AgenticFrameworkError):
|
|
9
|
+
"""Raised when configuration cannot be read or validated."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InitializationError(AgenticFrameworkError):
|
|
13
|
+
"""Raised when a target project cannot be initialized safely."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RequirementsError(AgenticFrameworkError):
|
|
17
|
+
"""Raised when requirements cannot be versioned safely."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StateError(AgenticFrameworkError):
|
|
21
|
+
"""Raised when persistent runtime state is invalid or a transition is unsafe."""
|
|
22
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Console and JSONL event logging utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_MANAGED_HANDLER = "_agentic_framework_handler"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JsonLineFormatter(logging.Formatter):
|
|
15
|
+
"""Render each log record as one machine-readable JSON object."""
|
|
16
|
+
|
|
17
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
18
|
+
payload: dict[str, Any] = {
|
|
19
|
+
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
|
|
20
|
+
"level": record.levelname,
|
|
21
|
+
"logger": record.name,
|
|
22
|
+
"message": record.getMessage(),
|
|
23
|
+
}
|
|
24
|
+
event_data = getattr(record, "event_data", None)
|
|
25
|
+
if event_data:
|
|
26
|
+
payload["event"] = event_data
|
|
27
|
+
if record.exc_info:
|
|
28
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
29
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _managed(handler: logging.Handler) -> logging.Handler:
|
|
33
|
+
setattr(handler, _MANAGED_HANDLER, True)
|
|
34
|
+
return handler
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configure_logging(
|
|
38
|
+
*,
|
|
39
|
+
level: str | int = logging.INFO,
|
|
40
|
+
event_log: Path | None = None,
|
|
41
|
+
) -> logging.Logger:
|
|
42
|
+
"""Configure ADF logging without disturbing handlers owned by the host application."""
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger("agentic_framework")
|
|
45
|
+
logger.setLevel(level)
|
|
46
|
+
logger.propagate = False
|
|
47
|
+
|
|
48
|
+
for handler in list(logger.handlers):
|
|
49
|
+
if getattr(handler, _MANAGED_HANDLER, False):
|
|
50
|
+
handler.close()
|
|
51
|
+
logger.removeHandler(handler)
|
|
52
|
+
|
|
53
|
+
console = _managed(logging.StreamHandler())
|
|
54
|
+
console.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
|
|
55
|
+
logger.addHandler(console)
|
|
56
|
+
|
|
57
|
+
if event_log is not None:
|
|
58
|
+
event_log.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
jsonl = _managed(logging.FileHandler(event_log, encoding="utf-8"))
|
|
60
|
+
jsonl.setFormatter(JsonLineFormatter())
|
|
61
|
+
logger.addHandler(jsonl)
|
|
62
|
+
|
|
63
|
+
return logger
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def log_event(logger: logging.Logger, message: str, **event_data: Any) -> None:
|
|
67
|
+
"""Record a normal log message with optional structured event fields."""
|
|
68
|
+
|
|
69
|
+
logger.info(message, extra={"event_data": event_data})
|
|
70
|
+
|