arkaos 2.13.0 → 2.15.0

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.
Files changed (51) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/forge/SKILL.md +649 -0
  3. package/config/constitution.yaml +8 -0
  4. package/config/hooks/post-tool-use.sh +43 -0
  5. package/config/hooks/session-start.sh +24 -0
  6. package/config/hooks/user-prompt-submit.sh +25 -1
  7. package/core/forge/__init__.py +104 -0
  8. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
  10. package/core/forge/__pycache__/handoff.cpython-313.pyc +0 -0
  11. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  12. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  13. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  14. package/core/forge/complexity.py +125 -0
  15. package/core/forge/handoff.py +100 -0
  16. package/core/forge/persistence.py +308 -0
  17. package/core/forge/renderer.py +261 -0
  18. package/core/forge/schema.py +213 -0
  19. package/core/synapse/__init__.py +2 -2
  20. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  22. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  23. package/core/synapse/engine.py +4 -2
  24. package/core/synapse/layers.py +49 -0
  25. package/core/sync/__init__.py +25 -0
  26. package/core/sync/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
  28. package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
  29. package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
  30. package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
  31. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  32. package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
  33. package/core/sync/__pycache__/schema.cpython-313.pyc +0 -0
  34. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  35. package/core/sync/descriptor_syncer.py +166 -0
  36. package/core/sync/discovery.py +256 -0
  37. package/core/sync/engine.py +177 -0
  38. package/core/sync/features/forge.yaml +16 -0
  39. package/core/sync/features/quality-gate.yaml +15 -0
  40. package/core/sync/features/spec-gate.yaml +15 -0
  41. package/core/sync/features/workflow-tiers.yaml +19 -0
  42. package/core/sync/manifest.py +87 -0
  43. package/core/sync/mcp_syncer.py +255 -0
  44. package/core/sync/reporter.py +178 -0
  45. package/core/sync/schema.py +94 -0
  46. package/core/sync/settings_syncer.py +121 -0
  47. package/core/workflow/state_reader.sh +25 -1
  48. package/departments/ops/skills/update/SKILL.md +69 -0
  49. package/installer/update.js +14 -0
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
@@ -0,0 +1,256 @@
1
+ """Project discovery for the ArkaOS Sync Engine.
2
+
3
+ Discovers projects from 3 sources: descriptors, filesystem, and ecosystems.
4
+ Detects tech stacks and deduplicates across sources.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ from core.sync.schema import Project
15
+
16
+
17
+ # --- Stack detection helpers ---
18
+
19
+ def _detect_from_composer(project_path: Path) -> list[str]:
20
+ composer = project_path / "composer.json"
21
+ if not composer.exists():
22
+ return []
23
+ try:
24
+ data = json.loads(composer.read_text())
25
+ require = data.get("require", {})
26
+ if "laravel/framework" in require:
27
+ return ["php", "laravel"]
28
+ return ["php"]
29
+ except (json.JSONDecodeError, OSError):
30
+ return []
31
+
32
+
33
+ def _detect_from_package_json(project_path: Path) -> list[str]:
34
+ pkg = project_path / "package.json"
35
+ if not pkg.exists():
36
+ return []
37
+ try:
38
+ data = json.loads(pkg.read_text())
39
+ deps = {
40
+ **data.get("dependencies", {}),
41
+ **data.get("devDependencies", {}),
42
+ }
43
+ stack: list[str] = []
44
+ if "nuxt" in deps:
45
+ stack.extend(["javascript", "nuxt", "vue"])
46
+ elif "vue" in deps:
47
+ stack.extend(["javascript", "vue"])
48
+ elif "next" in deps:
49
+ stack.extend(["javascript", "next", "react"])
50
+ elif "react" in deps:
51
+ stack.extend(["javascript", "react"])
52
+ else:
53
+ stack.append("javascript")
54
+ return stack
55
+ except (json.JSONDecodeError, OSError):
56
+ return []
57
+
58
+
59
+ def _detect_from_pyproject(project_path: Path) -> list[str]:
60
+ pyproject = project_path / "pyproject.toml"
61
+ if not pyproject.exists():
62
+ return []
63
+ return ["python"]
64
+
65
+
66
+ def detect_stack(project_path: Path) -> list[str]:
67
+ """Detect tech stack from project files.
68
+
69
+ Checks composer.json, package.json, and pyproject.toml in order.
70
+ Returns a deduplicated list of detected technologies.
71
+ """
72
+ stack: list[str] = []
73
+ stack.extend(_detect_from_composer(project_path))
74
+ stack.extend(_detect_from_package_json(project_path))
75
+ stack.extend(_detect_from_pyproject(project_path))
76
+ seen: set[str] = set()
77
+ result: list[str] = []
78
+ for item in stack:
79
+ if item not in seen:
80
+ seen.add(item)
81
+ result.append(item)
82
+ return result
83
+
84
+
85
+ # --- Descriptor discovery helpers ---
86
+
87
+ def _parse_descriptor_frontmatter(text: str) -> dict:
88
+ """Extract YAML frontmatter from a markdown file."""
89
+ if not text.startswith("---"):
90
+ return {}
91
+ parts = text.split("---", 2)
92
+ if len(parts) < 3:
93
+ return {}
94
+ try:
95
+ return yaml.safe_load(parts[1]) or {}
96
+ except yaml.YAMLError:
97
+ return {}
98
+
99
+
100
+ def _read_descriptor_item(item: Path) -> dict:
101
+ """Read a descriptor file and return its frontmatter as a dict."""
102
+ try:
103
+ return _parse_descriptor_frontmatter(item.read_text())
104
+ except OSError:
105
+ return {}
106
+
107
+
108
+ def _process_descriptor_item(item: Path, descriptor_dir: Path) -> "Project | None":
109
+ """Parse a single descriptor file and return a Project or None."""
110
+ fm = _read_descriptor_item(item)
111
+ raw_path = fm.get("path", "")
112
+ if not raw_path:
113
+ return None
114
+ project_path = Path(raw_path)
115
+ if not project_path.exists():
116
+ return None
117
+ name = fm.get("name", project_path.name)
118
+ stack = detect_stack(project_path)
119
+ return Project(
120
+ path=str(project_path),
121
+ name=name,
122
+ ecosystem=fm.get("ecosystem") or None,
123
+ stack=stack,
124
+ descriptor_path=str(item),
125
+ has_mcp_json=(project_path / ".mcp.json").exists(),
126
+ has_settings=(project_path / ".claude").is_dir(),
127
+ )
128
+
129
+
130
+ def discover_from_descriptors(descriptor_dir: Path) -> list[Project]:
131
+ """Discover projects from .md descriptor files with YAML frontmatter.
132
+
133
+ Reads .md files in descriptor_dir and PROJECT.md in subdirectories.
134
+ Skips entries whose paths don't exist on the filesystem.
135
+ """
136
+ if not descriptor_dir.exists():
137
+ return []
138
+
139
+ candidates: list[Path] = list(descriptor_dir.glob("*.md"))
140
+ for subdir in descriptor_dir.iterdir():
141
+ if subdir.is_dir():
142
+ project_md = subdir / "PROJECT.md"
143
+ if project_md.exists():
144
+ candidates.append(project_md)
145
+
146
+ projects: list[Project] = []
147
+ for item in candidates:
148
+ project = _process_descriptor_item(item, descriptor_dir)
149
+ if project is not None:
150
+ projects.append(project)
151
+
152
+ return projects
153
+
154
+
155
+ # --- Filesystem discovery ---
156
+
157
+ def discover_from_filesystem(scan_dirs: list[Path]) -> list[Project]:
158
+ """Discover projects by scanning directories for .mcp.json or .claude/ markers."""
159
+ projects: list[Project] = []
160
+ for scan_dir in scan_dirs:
161
+ if not scan_dir.exists():
162
+ continue
163
+ for subdir in scan_dir.iterdir():
164
+ if not subdir.is_dir():
165
+ continue
166
+ has_mcp = (subdir / ".mcp.json").is_file()
167
+ has_claude = (subdir / ".claude").is_dir()
168
+ has_claude_md = (subdir / "CLAUDE.md").is_file()
169
+ if not has_mcp and not has_claude and not has_claude_md:
170
+ continue
171
+ stack = detect_stack(subdir)
172
+ projects.append(Project(
173
+ path=str(subdir),
174
+ name=subdir.name,
175
+ stack=stack,
176
+ has_mcp_json=has_mcp,
177
+ has_settings=has_claude,
178
+ ))
179
+
180
+ return projects
181
+
182
+
183
+ # --- Ecosystem discovery ---
184
+
185
+ def discover_from_ecosystems(ecosystems_file: Path) -> list[Project]:
186
+ """Discover projects from an ecosystems.json registry file."""
187
+ if not ecosystems_file.exists():
188
+ return []
189
+ try:
190
+ data = json.loads(ecosystems_file.read_text())
191
+ except (json.JSONDecodeError, OSError):
192
+ return []
193
+
194
+ projects: list[Project] = []
195
+ for eco_key, eco_data in data.get("ecosystems", {}).items():
196
+ for proj_name, proj_path_str in eco_data.get("project_paths", {}).items():
197
+ proj_path = Path(proj_path_str)
198
+ if not proj_path.exists():
199
+ continue
200
+ stack = detect_stack(proj_path)
201
+ projects.append(Project(
202
+ path=str(proj_path),
203
+ name=proj_name,
204
+ ecosystem=eco_key,
205
+ stack=stack,
206
+ has_mcp_json=(proj_path / ".mcp.json").exists(),
207
+ has_settings=(proj_path / ".claude").is_dir(),
208
+ ))
209
+
210
+ return projects
211
+
212
+
213
+ # --- Merge and deduplication helpers ---
214
+
215
+ def _merge_project(primary: Project, secondary: Project) -> Project:
216
+ """Merge two Project records — primary data wins over secondary."""
217
+ return Project(
218
+ path=primary.path,
219
+ name=primary.name,
220
+ ecosystem=primary.ecosystem or secondary.ecosystem,
221
+ stack=primary.stack if primary.stack else secondary.stack,
222
+ descriptor_path=primary.descriptor_path or secondary.descriptor_path,
223
+ has_mcp_json=primary.has_mcp_json or secondary.has_mcp_json,
224
+ has_settings=primary.has_settings or secondary.has_settings,
225
+ )
226
+
227
+
228
+ def _deduplicate(projects: list[Project]) -> list[Project]:
229
+ """Deduplicate projects by resolved absolute path. First entry wins."""
230
+ seen: dict[str, Project] = {}
231
+ for project in projects:
232
+ key = str(Path(project.path).resolve())
233
+ if key not in seen:
234
+ seen[key] = project
235
+ else:
236
+ seen[key] = _merge_project(seen[key], project)
237
+ return list(seen.values())
238
+
239
+
240
+ def discover_all_projects(
241
+ descriptor_dir: Path,
242
+ scan_dirs: list[Path],
243
+ ecosystems_file: Path,
244
+ ) -> list[Project]:
245
+ """Discover all projects from all sources, deduplicate, and sort by name.
246
+
247
+ Descriptor data wins over filesystem/ecosystem data during merging.
248
+ """
249
+ descriptor_projects = discover_from_descriptors(descriptor_dir)
250
+ filesystem_projects = discover_from_filesystem(scan_dirs)
251
+ ecosystem_projects = discover_from_ecosystems(ecosystems_file)
252
+
253
+ all_projects = descriptor_projects + ecosystem_projects + filesystem_projects
254
+ deduplicated = _deduplicate(all_projects)
255
+
256
+ return sorted(deduplicated, key=lambda p: p.name)
@@ -0,0 +1,177 @@
1
+ """Engine Orchestrator for the ArkaOS Sync Engine.
2
+
3
+ Coordinates all sync phases and provides a CLI entry point for /arka update.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import re
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from core.sync.manifest import build_manifest
15
+ from core.sync.discovery import discover_all_projects
16
+ from core.sync.mcp_syncer import sync_all_mcps
17
+ from core.sync.settings_syncer import sync_all_settings
18
+ from core.sync.descriptor_syncer import sync_all_descriptors
19
+ from core.sync.reporter import build_report, format_report, write_sync_state
20
+ from core.sync.schema import SyncReport
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Public API
25
+ # ---------------------------------------------------------------------------
26
+
27
+
28
+ def run_sync(arkaos_home: Path, skills_dir: Path, home_path: str) -> SyncReport:
29
+ """Orchestrate all deterministic sync phases and return a SyncReport."""
30
+ previous_version = _read_previous_version(arkaos_home)
31
+ current_version = _read_current_version(arkaos_home)
32
+ features_dir = _resolve_features_dir(arkaos_home)
33
+
34
+ manifest = build_manifest(previous_version, current_version, features_dir)
35
+
36
+ projects = _discover_projects(arkaos_home, skills_dir)
37
+
38
+ registry_path = skills_dir / "arka" / "mcps" / "registry.json"
39
+ mcp_results = sync_all_mcps(projects, registry_path, home_path)
40
+ settings_results = sync_all_settings(mcp_results)
41
+ descriptor_results = sync_all_descriptors(projects)
42
+
43
+ report = build_report(
44
+ previous_version,
45
+ current_version,
46
+ mcp_results,
47
+ settings_results,
48
+ descriptor_results,
49
+ [],
50
+ new_features=manifest.new_features,
51
+ deprecated_features=manifest.deprecated_features,
52
+ )
53
+
54
+ state_file = arkaos_home / "sync-state.json"
55
+ write_sync_state(state_file, report)
56
+
57
+ return report
58
+
59
+
60
+ def main() -> None:
61
+ """CLI entry point for the sync engine."""
62
+ parser = argparse.ArgumentParser(description="ArkaOS Sync Engine")
63
+ parser.add_argument("--home", required=True, help="ArkaOS home directory")
64
+ parser.add_argument("--skills", required=True, help="Skills directory")
65
+ parser.add_argument(
66
+ "--output",
67
+ choices=["text", "json"],
68
+ default="text",
69
+ help="Output format",
70
+ )
71
+ args = parser.parse_args()
72
+
73
+ report = run_sync(
74
+ arkaos_home=Path(args.home),
75
+ skills_dir=Path(args.skills),
76
+ home_path=str(Path.home()),
77
+ )
78
+
79
+ if args.output == "json":
80
+ print(report.model_dump_json(indent=2))
81
+ else:
82
+ print(format_report(report))
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Private helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def _read_previous_version(arkaos_home: Path) -> str:
91
+ """Read version field from sync-state.json, defaulting to pending-sync."""
92
+ state_file = arkaos_home / "sync-state.json"
93
+ if not state_file.exists():
94
+ return "pending-sync"
95
+ try:
96
+ data = json.loads(state_file.read_text())
97
+ return data.get("version", "pending-sync") or "pending-sync"
98
+ except (json.JSONDecodeError, OSError):
99
+ return "pending-sync"
100
+
101
+
102
+ def _read_current_version(arkaos_home: Path) -> str:
103
+ """Read version from VERSION file in the ArkaOS repo."""
104
+ repo_path = _read_repo_path(arkaos_home)
105
+ if repo_path is None:
106
+ return "unknown"
107
+ version_file = repo_path / "VERSION"
108
+ if not version_file.exists():
109
+ return "unknown"
110
+ try:
111
+ return version_file.read_text().strip()
112
+ except OSError:
113
+ return "unknown"
114
+
115
+
116
+ def _read_repo_path(arkaos_home: Path) -> Path | None:
117
+ """Read the absolute repo path from .repo-path file."""
118
+ repo_path_file = arkaos_home / ".repo-path"
119
+ if not repo_path_file.exists():
120
+ return None
121
+ try:
122
+ raw = repo_path_file.read_text().strip()
123
+ return Path(raw) if raw else None
124
+ except OSError:
125
+ return None
126
+
127
+
128
+ def _resolve_features_dir(arkaos_home: Path) -> Path:
129
+ """Resolve the features directory from repo or fallback config."""
130
+ repo_path = _read_repo_path(arkaos_home)
131
+ if repo_path is not None:
132
+ repo_features = repo_path / "core" / "sync" / "features"
133
+ if repo_features.exists():
134
+ return repo_features
135
+
136
+ fallback = arkaos_home / "config" / "sync" / "features"
137
+ return fallback
138
+
139
+
140
+ def _parse_scan_dirs(projects_dir_str: str) -> list[Path]:
141
+ """Parse a projectsDir string, extracting all paths starting with /."""
142
+ segments = re.split(r",\s*", projects_dir_str.strip())
143
+ paths: list[Path] = []
144
+ for segment in segments:
145
+ match = re.match(r"(/[^\s]+)", segment.strip())
146
+ if match:
147
+ paths.append(Path(match.group(1)))
148
+ return paths
149
+
150
+
151
+ def _discover_projects(arkaos_home: Path, skills_dir: Path) -> list:
152
+ """Combine profile.json dirs, descriptor dir, and ecosystems into projects."""
153
+ descriptor_dir = skills_dir / "arka" / "projects"
154
+ ecosystems_file = skills_dir / "arka" / "knowledge" / "ecosystems.json"
155
+
156
+ scan_dirs = _load_scan_dirs_from_profile(arkaos_home)
157
+
158
+ return discover_all_projects(descriptor_dir, scan_dirs, ecosystems_file)
159
+
160
+
161
+ def _load_scan_dirs_from_profile(arkaos_home: Path) -> list[Path]:
162
+ """Read projectsDir from profile.json and parse into scan directory paths."""
163
+ profile_file = arkaos_home / "profile.json"
164
+ if not profile_file.exists():
165
+ return []
166
+ try:
167
+ data = json.loads(profile_file.read_text())
168
+ projects_dir_str = data.get("projectsDir", "")
169
+ if not projects_dir_str:
170
+ return []
171
+ return _parse_scan_dirs(projects_dir_str)
172
+ except (json.JSONDecodeError, OSError):
173
+ return []
174
+
175
+
176
+ if __name__ == "__main__":
177
+ sys.exit(main())
@@ -0,0 +1,16 @@
1
+ name: forge-integration
2
+ added_in: "2.14.0"
3
+ mandatory: true
4
+ section_title: "Forge Integration"
5
+ detection_pattern: "arka-forge"
6
+ deprecated_in: null
7
+ content: |
8
+ ## Forge Integration
9
+
10
+ Complex requests (complexity score >= 5) are automatically routed to
11
+ The Forge for multi-agent planning before execution.
12
+
13
+ - Phase 0.5: Forge analysis (after spec creation, before squad planning)
14
+ - Complexity assessment: automatic via Synapse L8 (ForgeContextLayer)
15
+ - Manual invocation: `/forge` command
16
+ - Handoff: Forge outputs structured plan → squad executes phases
@@ -0,0 +1,15 @@
1
+ name: quality-gate
2
+ added_in: "2.10.0"
3
+ mandatory: true
4
+ section_title: "Quality Gate"
5
+ detection_pattern: "Marta.*CQO|Quality Gate"
6
+ deprecated_in: null
7
+ content: |
8
+ ## Quality Gate
9
+
10
+ Mandatory on every workflow. Nothing ships without approval.
11
+
12
+ - **Marta (CQO):** Orchestrates review, absolute veto power
13
+ - **Eduardo (Copy Director):** Reviews all text output
14
+ - **Francisca (Tech Director):** Reviews all code and technical output
15
+ - Verdict: APPROVED or REJECTED (binary, no partial)
@@ -0,0 +1,15 @@
1
+ name: spec-driven-gate
2
+ added_in: "2.13.0"
3
+ mandatory: true
4
+ section_title: "Spec-Driven Development"
5
+ detection_pattern: "arka-spec"
6
+ deprecated_in: null
7
+ content: |
8
+ ## Spec-Driven Development
9
+
10
+ Phase 0 of all workflows. No implementation begins without a validated spec.
11
+
12
+ - Invocation: automatic before any feature/fix work
13
+ - Gate: spec must be approved before planning phase starts
14
+ - Storage: `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`
15
+ - Review: user approval required on written spec
@@ -0,0 +1,19 @@
1
+ name: workflow-tiers
2
+ added_in: "2.12.0"
3
+ mandatory: true
4
+ section_title: "Workflow Tiers"
5
+ detection_pattern: "Enterprise.*phase|Focused.*phase|Specialist.*phase"
6
+ deprecated_in: null
7
+ content: |
8
+ ## Workflow Tiers
9
+
10
+ Three workflow tiers based on task complexity:
11
+
12
+ | Tier | Phases | When |
13
+ |------|--------|------|
14
+ | Enterprise | 7-10 phases | Complex features, multi-file changes |
15
+ | Focused | 3-5 phases | Medium tasks, single-domain changes |
16
+ | Specialist | 1-2 phases | Simple tasks, quick fixes |
17
+
18
+ Tier selection is automatic based on complexity assessment.
19
+ Quality Gate phase is mandatory on ALL tiers.
@@ -0,0 +1,87 @@
1
+ """Change Manifest Builder — loads feature specs and computes version diffs."""
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ from core.sync.schema import ChangeManifest, FeatureSpec
8
+
9
+ _FIRST_SYNC_MARKERS = {"pending-sync", "none", ""}
10
+
11
+
12
+ def load_features(features_dir: Path) -> list[FeatureSpec]:
13
+ """Load all FeatureSpec instances from YAML files in features_dir."""
14
+ if not features_dir.exists():
15
+ return []
16
+
17
+ features: list[FeatureSpec] = []
18
+ for path in sorted(features_dir.iterdir()):
19
+ if path.suffix != ".yaml":
20
+ continue
21
+ data = yaml.safe_load(path.read_text())
22
+ features.append(FeatureSpec(**data))
23
+
24
+ return features
25
+
26
+
27
+ def build_manifest(
28
+ previous_version: str,
29
+ current_version: str,
30
+ features_dir: Path,
31
+ ) -> ChangeManifest:
32
+ """Build a ChangeManifest comparing previous_version to current_version."""
33
+ features = load_features(features_dir)
34
+ is_first = previous_version in _FIRST_SYNC_MARKERS
35
+
36
+ new_features = _find_new_features(features, previous_version, is_first)
37
+ deprecated_features = _find_deprecated_features(features, previous_version, is_first)
38
+
39
+ return ChangeManifest(
40
+ previous_version=previous_version,
41
+ current_version=current_version,
42
+ is_first_sync=is_first,
43
+ features=features,
44
+ new_features=new_features,
45
+ deprecated_features=deprecated_features,
46
+ )
47
+
48
+
49
+ def _is_version_newer(version: str, baseline: str) -> bool:
50
+ """Return True if version is strictly newer than baseline (semver int tuple)."""
51
+ def parse(v: str) -> tuple[int, ...]:
52
+ return tuple(int(part) for part in v.split("."))
53
+
54
+ return parse(version) > parse(baseline)
55
+
56
+
57
+ def _find_new_features(
58
+ features: list[FeatureSpec],
59
+ previous_version: str,
60
+ is_first: bool,
61
+ ) -> list[str]:
62
+ """Return names of features that are new relative to previous_version."""
63
+ if is_first:
64
+ return [f.name for f in features if f.deprecated_in is None]
65
+
66
+ return [
67
+ f.name
68
+ for f in features
69
+ if _is_version_newer(f.added_in, previous_version)
70
+ ]
71
+
72
+
73
+ def _find_deprecated_features(
74
+ features: list[FeatureSpec],
75
+ previous_version: str,
76
+ is_first: bool,
77
+ ) -> list[str]:
78
+ """Return names of features deprecated after previous_version."""
79
+ if is_first:
80
+ return []
81
+
82
+ return [
83
+ f.name
84
+ for f in features
85
+ if f.deprecated_in is not None
86
+ and _is_version_newer(f.deprecated_in, previous_version)
87
+ ]