xrefkit 0.3.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.
- xrefkit/__init__.py +5 -0
- xrefkit/__main__.py +5 -0
- xrefkit/catalog_cli.py +57 -0
- xrefkit/cli.py +71 -0
- xrefkit/contracts.py +297 -0
- xrefkit/ctx.py +160 -0
- xrefkit/dashboard.py +1220 -0
- xrefkit/discovery.py +85 -0
- xrefkit/gate.py +428 -0
- xrefkit/goalstate.py +555 -0
- xrefkit/hashing.py +18 -0
- xrefkit/import_skill.py +469 -0
- xrefkit/instance.py +133 -0
- xrefkit/loaders.py +77 -0
- xrefkit/mcp/__init__.py +26 -0
- xrefkit/mcp/audit.py +168 -0
- xrefkit/mcp/bootstrap.py +337 -0
- xrefkit/mcp/catalog.py +2638 -0
- xrefkit/mcp/cli.py +173 -0
- xrefkit/mcp/client_cache.py +356 -0
- xrefkit/mcp/context_registry.py +277 -0
- xrefkit/mcp/contracts.py +243 -0
- xrefkit/mcp/dist.py +234 -0
- xrefkit/mcp/ownership.py +246 -0
- xrefkit/mcp/repository.py +217 -0
- xrefkit/mcp/schemas.py +349 -0
- xrefkit/mcp/server.py +773 -0
- xrefkit/mcp/startup_contract_pack.py +154 -0
- xrefkit/mcp_tools.py +47 -0
- xrefkit/models/__init__.py +41 -0
- xrefkit/models/common.py +185 -0
- xrefkit/models/effective_bundle.py +131 -0
- xrefkit/models/local_manifest.py +217 -0
- xrefkit/models/package_manifest.py +126 -0
- xrefkit/models/run_log.py +276 -0
- xrefkit/models/server_config.py +160 -0
- xrefkit/models/skill_definition.py +131 -0
- xrefkit/operations_cli.py +670 -0
- xrefkit/ownership.py +276 -0
- xrefkit/packmeta.py +289 -0
- xrefkit/registry.py +334 -0
- xrefkit/resolver.py +252 -0
- xrefkit/resource_provider.py +187 -0
- xrefkit/resources/base/contracts.json +178 -0
- xrefkit/resources/base/current.json +6 -0
- xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
- xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
- xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
- xrefkit/resources/base/model_body.md +22 -0
- xrefkit/runlog.py +45 -0
- xrefkit/skillmeta.py +1034 -0
- xrefkit/skillrun.py +2381 -0
- xrefkit/structure_catalog.py +199 -0
- xrefkit/tools/__init__.py +119 -0
- xrefkit/tools/__main__.py +4 -0
- xrefkit/v2_cli.py +130 -0
- xrefkit/workspace.py +117 -0
- xrefkit/xref.py +1048 -0
- xrefkit-0.3.0.dist-info/METADATA +203 -0
- xrefkit-0.3.0.dist-info/RECORD +65 -0
- xrefkit-0.3.0.dist-info/WHEEL +5 -0
- xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
- xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- xrefkit-0.3.0.dist-info/top_level.txt +1 -0
xrefkit/ownership.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OwnershipError(ValueError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Zone:
|
|
15
|
+
id: str
|
|
16
|
+
owner: str
|
|
17
|
+
paths: tuple[str, ...]
|
|
18
|
+
catalog: bool
|
|
19
|
+
distribution: bool
|
|
20
|
+
base_sync: bool
|
|
21
|
+
shadowing: bool
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict[str, Any]:
|
|
24
|
+
return {
|
|
25
|
+
"id": self.id,
|
|
26
|
+
"owner": self.owner,
|
|
27
|
+
"paths": list(self.paths),
|
|
28
|
+
"catalog": self.catalog,
|
|
29
|
+
"distribution": self.distribution,
|
|
30
|
+
"base_sync": self.base_sync,
|
|
31
|
+
"shadowing": self.shadowing,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Ownership:
|
|
37
|
+
zones: tuple[Zone, ...]
|
|
38
|
+
|
|
39
|
+
def zone_for(self, rel_path: str) -> Zone | None:
|
|
40
|
+
normalized = _normalize_rel_path(rel_path)
|
|
41
|
+
for zone in self.zones:
|
|
42
|
+
if any(_matches(pattern, normalized) for pattern in zone.paths):
|
|
43
|
+
return zone
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def base_sync_enabled(self, rel_path: str) -> bool:
|
|
47
|
+
zone = self.zone_for(rel_path)
|
|
48
|
+
return True if zone is None else zone.base_sync
|
|
49
|
+
|
|
50
|
+
def catalog_enabled(self, rel_path: str) -> bool:
|
|
51
|
+
zone = self.zone_for(rel_path)
|
|
52
|
+
return True if zone is None else zone.catalog
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return {"zones": [zone.to_dict() for zone in self.zones]}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_ownership(root: str | Path, filename: str = "ownership.yaml") -> Ownership | None:
|
|
59
|
+
path = Path(root) / filename
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return None
|
|
62
|
+
parsed = _parse_simple_yaml(path.read_text(encoding="utf-8"))
|
|
63
|
+
zones = parsed.get("zones")
|
|
64
|
+
if not isinstance(zones, list):
|
|
65
|
+
raise OwnershipError("ownership.yaml must contain a zones list")
|
|
66
|
+
result = []
|
|
67
|
+
seen: set[str] = set()
|
|
68
|
+
for index, raw in enumerate(zones):
|
|
69
|
+
if not isinstance(raw, dict):
|
|
70
|
+
raise OwnershipError(f"zone {index} must be a mapping")
|
|
71
|
+
zone = _zone_from_mapping(raw, index)
|
|
72
|
+
if zone.id in seen:
|
|
73
|
+
raise OwnershipError(f"duplicate zone id: {zone.id}")
|
|
74
|
+
seen.add(zone.id)
|
|
75
|
+
result.append(zone)
|
|
76
|
+
return Ownership(tuple(result))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_ownership(root: str | Path, ownership: Ownership) -> list[str]:
|
|
80
|
+
errors: list[str] = []
|
|
81
|
+
repo = Path(root).resolve()
|
|
82
|
+
for zone in ownership.zones:
|
|
83
|
+
for pattern in zone.paths:
|
|
84
|
+
if not pattern or pattern.startswith("/") or "\\" in pattern:
|
|
85
|
+
errors.append(f"{zone.id}: invalid path pattern `{pattern}`")
|
|
86
|
+
continue
|
|
87
|
+
literal_prefix = pattern.split("*", 1)[0]
|
|
88
|
+
target = (repo / literal_prefix).resolve()
|
|
89
|
+
try:
|
|
90
|
+
target.relative_to(repo)
|
|
91
|
+
except ValueError:
|
|
92
|
+
errors.append(f"{zone.id}: path escapes repository `{pattern}`")
|
|
93
|
+
return errors
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_optional_ownership(root: str | Path) -> Ownership | None:
|
|
97
|
+
ownership = load_ownership(root)
|
|
98
|
+
if ownership is None:
|
|
99
|
+
return None
|
|
100
|
+
errors = validate_ownership(root, ownership)
|
|
101
|
+
if errors:
|
|
102
|
+
raise OwnershipError("; ".join(errors))
|
|
103
|
+
return ownership
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def content_files(
|
|
107
|
+
root: str | Path,
|
|
108
|
+
family: str,
|
|
109
|
+
pattern: str,
|
|
110
|
+
*,
|
|
111
|
+
ownership: Ownership | None = None,
|
|
112
|
+
) -> list[Path]:
|
|
113
|
+
repo = Path(root).resolve()
|
|
114
|
+
paths: list[Path] = []
|
|
115
|
+
base = repo / family
|
|
116
|
+
if base.exists():
|
|
117
|
+
paths.extend(
|
|
118
|
+
path
|
|
119
|
+
for path in sorted(base.glob(f"**/{pattern}"))
|
|
120
|
+
if _catalog_enabled(repo, ownership, path)
|
|
121
|
+
)
|
|
122
|
+
packs_root = repo / "packs"
|
|
123
|
+
if ownership is not None and packs_root.exists():
|
|
124
|
+
paths.extend(
|
|
125
|
+
path
|
|
126
|
+
for path in sorted(packs_root.glob(f"*/{family}/**/{pattern}"))
|
|
127
|
+
if _catalog_enabled(repo, ownership, path)
|
|
128
|
+
)
|
|
129
|
+
paths.extend(
|
|
130
|
+
path
|
|
131
|
+
for path in sorted(packs_root.glob(f"local/*/{family}/**/{pattern}"))
|
|
132
|
+
if _catalog_enabled(repo, ownership, path)
|
|
133
|
+
)
|
|
134
|
+
return sorted(set(paths))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _zone_from_mapping(raw: dict[str, Any], index: int) -> Zone:
|
|
138
|
+
required = ("id", "owner", "paths", "catalog", "distribution", "base_sync", "shadowing")
|
|
139
|
+
missing = [key for key in required if key not in raw]
|
|
140
|
+
if missing:
|
|
141
|
+
raise OwnershipError(f"zone {index} missing fields: {', '.join(missing)}")
|
|
142
|
+
paths = raw["paths"]
|
|
143
|
+
if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths):
|
|
144
|
+
raise OwnershipError(f"zone {raw.get('id', index)} paths must be a list of strings")
|
|
145
|
+
return Zone(
|
|
146
|
+
id=_as_str(raw["id"], "id"),
|
|
147
|
+
owner=_as_str(raw["owner"], "owner"),
|
|
148
|
+
paths=tuple(_normalize_pattern(path) for path in paths),
|
|
149
|
+
catalog=_as_bool(raw["catalog"], "catalog"),
|
|
150
|
+
distribution=_as_bool(raw["distribution"], "distribution"),
|
|
151
|
+
base_sync=_as_bool(raw["base_sync"], "base_sync"),
|
|
152
|
+
shadowing=_as_bool(raw["shadowing"], "shadowing"),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_simple_yaml(text: str) -> dict[str, Any]:
|
|
157
|
+
root: dict[str, Any] = {}
|
|
158
|
+
current_list_name: str | None = None
|
|
159
|
+
current_item: dict[str, Any] | None = None
|
|
160
|
+
current_item_list_name: str | None = None
|
|
161
|
+
|
|
162
|
+
for line_number, raw_line in enumerate(text.splitlines(), start=1):
|
|
163
|
+
line = raw_line.split("#", 1)[0].rstrip()
|
|
164
|
+
if not line.strip():
|
|
165
|
+
continue
|
|
166
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
167
|
+
stripped = line.strip()
|
|
168
|
+
|
|
169
|
+
if indent == 0:
|
|
170
|
+
if not stripped.endswith(":"):
|
|
171
|
+
raise OwnershipError(f"line {line_number}: top-level value must be a mapping key")
|
|
172
|
+
current_list_name = stripped[:-1]
|
|
173
|
+
root[current_list_name] = []
|
|
174
|
+
current_item = None
|
|
175
|
+
current_item_list_name = None
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
if current_list_name is None:
|
|
179
|
+
raise OwnershipError(f"line {line_number}: nested value before top-level key")
|
|
180
|
+
|
|
181
|
+
if indent == 2 and stripped.startswith("- "):
|
|
182
|
+
value = stripped[2:]
|
|
183
|
+
if ":" not in value:
|
|
184
|
+
raise OwnershipError(f"line {line_number}: list item must start a mapping")
|
|
185
|
+
key, raw_value = _split_key_value(value, line_number)
|
|
186
|
+
current_item = {key: _parse_scalar(raw_value)}
|
|
187
|
+
root[current_list_name].append(current_item)
|
|
188
|
+
current_item_list_name = None
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
if indent == 4:
|
|
192
|
+
if current_item is None:
|
|
193
|
+
raise OwnershipError(f"line {line_number}: mapping value before list item")
|
|
194
|
+
key, raw_value = _split_key_value(stripped, line_number)
|
|
195
|
+
if raw_value == "":
|
|
196
|
+
current_item[key] = []
|
|
197
|
+
current_item_list_name = key
|
|
198
|
+
else:
|
|
199
|
+
current_item[key] = _parse_scalar(raw_value)
|
|
200
|
+
current_item_list_name = None
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
if indent == 6 and stripped.startswith("- "):
|
|
204
|
+
if current_item is None or current_item_list_name is None:
|
|
205
|
+
raise OwnershipError(f"line {line_number}: list value without parent key")
|
|
206
|
+
value = _parse_scalar(stripped[2:])
|
|
207
|
+
if not isinstance(current_item[current_item_list_name], list):
|
|
208
|
+
raise OwnershipError(f"line {line_number}: parent is not a list")
|
|
209
|
+
current_item[current_item_list_name].append(value)
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
raise OwnershipError(f"line {line_number}: unsupported indentation or syntax")
|
|
213
|
+
|
|
214
|
+
return root
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _split_key_value(value: str, line_number: int) -> tuple[str, str]:
|
|
218
|
+
if ":" not in value:
|
|
219
|
+
raise OwnershipError(f"line {line_number}: expected key: value")
|
|
220
|
+
key, raw = value.split(":", 1)
|
|
221
|
+
key = key.strip()
|
|
222
|
+
if not key:
|
|
223
|
+
raise OwnershipError(f"line {line_number}: empty key")
|
|
224
|
+
return key, raw.strip()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parse_scalar(value: str) -> str | bool:
|
|
228
|
+
if value == "true":
|
|
229
|
+
return True
|
|
230
|
+
if value == "false":
|
|
231
|
+
return False
|
|
232
|
+
return value.strip("'\"")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _as_str(value: Any, field: str) -> str:
|
|
236
|
+
if not isinstance(value, str) or not value:
|
|
237
|
+
raise OwnershipError(f"{field} must be a non-empty string")
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _as_bool(value: Any, field: str) -> bool:
|
|
242
|
+
if not isinstance(value, bool):
|
|
243
|
+
raise OwnershipError(f"{field} must be true or false")
|
|
244
|
+
return value
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _normalize_pattern(pattern: str) -> str:
|
|
248
|
+
normalized = pattern.replace("\\", "/")
|
|
249
|
+
if normalized.startswith("./"):
|
|
250
|
+
normalized = normalized[2:]
|
|
251
|
+
if normalized and not normalized.endswith("/"):
|
|
252
|
+
normalized += "/"
|
|
253
|
+
return normalized
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _normalize_rel_path(path: str) -> str:
|
|
257
|
+
normalized = path.replace("\\", "/")
|
|
258
|
+
if normalized.startswith("./"):
|
|
259
|
+
normalized = normalized[2:]
|
|
260
|
+
parts = PurePosixPath(normalized).parts
|
|
261
|
+
if any(part == ".." for part in parts):
|
|
262
|
+
return normalized
|
|
263
|
+
return normalized
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _matches(pattern: str, rel_path: str) -> bool:
|
|
267
|
+
if "*" not in pattern:
|
|
268
|
+
return rel_path == pattern.rstrip("/") or rel_path.startswith(pattern)
|
|
269
|
+
return fnmatch.fnmatch(rel_path, pattern + "*")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _catalog_enabled(root: Path, ownership: Ownership | None, path: Path) -> bool:
|
|
273
|
+
if ownership is None:
|
|
274
|
+
return True
|
|
275
|
+
rel = path.relative_to(root).as_posix()
|
|
276
|
+
return ownership.catalog_enabled(rel)
|
xrefkit/packmeta.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from xrefkit.ownership import load_optional_ownership
|
|
8
|
+
from xrefkit.skillmeta import (
|
|
9
|
+
REQUIRED_OS_CONTRACT,
|
|
10
|
+
VALID_MATURITY_LEVELS,
|
|
11
|
+
_parse_key_value_list,
|
|
12
|
+
_parse_meta_lines,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# A Business Pack is a manifest-driven composition, not a folder of co-located
|
|
16
|
+
# files. `pack.md` declares which assets the pack OWNS (exclusive) and which it
|
|
17
|
+
# USES (shared references that may live anywhere, including the OS core). This
|
|
18
|
+
# lets one Skill/Knowledge be reused by several packs while keeping ownership
|
|
19
|
+
# and the OS-core contract boundary machine-checkable.
|
|
20
|
+
PACK_MANIFEST_NAME = "pack.md"
|
|
21
|
+
PACKS_DIR = "skills/packs"
|
|
22
|
+
TOP_LEVEL_PACKS_DIR = "packs"
|
|
23
|
+
|
|
24
|
+
# Owned assets must not live inside an OS-core scope: that would mean a business
|
|
25
|
+
# pack is redefining the operating layer rather than depending on it.
|
|
26
|
+
OS_CORE_SKILL_PREFIX = "skills/os/"
|
|
27
|
+
|
|
28
|
+
# Flat owns_* / uses_* keys keep the manifest readable AND parseable by the same
|
|
29
|
+
# bullet-list reader as meta.md (which flattens nested lists), so no bespoke
|
|
30
|
+
# manifest parser is needed.
|
|
31
|
+
# Skill-centric consolidation (083): flows/ and capabilities/ are removed, so
|
|
32
|
+
# owns_flows / uses_capabilities are no longer manifest keys.
|
|
33
|
+
OWNS_KEYS = ("owns_skills", "owns_knowledge")
|
|
34
|
+
USES_KEYS = ("uses_skills", "uses_knowledge")
|
|
35
|
+
REQUIRED_TEXT_FIELDS = ("pack_id", "summary", "entry")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current_os_contract_version() -> str:
|
|
39
|
+
return str(REQUIRED_OS_CONTRACT["version"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class PackManifestResult:
|
|
44
|
+
manifest_path: str
|
|
45
|
+
pack_id: str | None
|
|
46
|
+
maturity: str | None
|
|
47
|
+
os_contract_version: str | None
|
|
48
|
+
owns: dict[str, list[str]]
|
|
49
|
+
uses: dict[str, list[str]]
|
|
50
|
+
ok: bool
|
|
51
|
+
errors: list[str] = field(default_factory=list)
|
|
52
|
+
warnings: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, object]:
|
|
55
|
+
return {
|
|
56
|
+
"manifest_path": self.manifest_path,
|
|
57
|
+
"pack_id": self.pack_id,
|
|
58
|
+
"maturity": self.maturity,
|
|
59
|
+
"os_contract_version": self.os_contract_version,
|
|
60
|
+
"owns": self.owns,
|
|
61
|
+
"uses": self.uses,
|
|
62
|
+
"ok": self.ok,
|
|
63
|
+
"errors": self.errors,
|
|
64
|
+
"warnings": self.warnings,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_list(value: object) -> list[str]:
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return [str(item) for item in value if isinstance(item, str) and item.strip()]
|
|
71
|
+
if isinstance(value, str) and value.strip():
|
|
72
|
+
return [value.strip()]
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _strip_fragment(ref: str) -> str:
|
|
77
|
+
return ref.split("#", 1)[0]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_pack_manifest(manifest_path: Path, *, root: Path) -> PackManifestResult:
|
|
81
|
+
text = manifest_path.read_text(encoding="utf-8")
|
|
82
|
+
parsed = _parse_meta_lines(text)
|
|
83
|
+
|
|
84
|
+
pack_id = parsed.get("pack_id")
|
|
85
|
+
maturity = parsed.get("maturity") or parsed.get("status")
|
|
86
|
+
depends_on = _parse_key_value_list(parsed.get("depends_on"))
|
|
87
|
+
os_contract_version = depends_on.get("os_contract_version")
|
|
88
|
+
|
|
89
|
+
owns = {key: _as_list(parsed.get(key)) for key in OWNS_KEYS}
|
|
90
|
+
uses = {key: _as_list(parsed.get(key)) for key in USES_KEYS}
|
|
91
|
+
|
|
92
|
+
errors: list[str] = []
|
|
93
|
+
warnings: list[str] = []
|
|
94
|
+
|
|
95
|
+
for key in REQUIRED_TEXT_FIELDS:
|
|
96
|
+
value = parsed.get(key)
|
|
97
|
+
if not isinstance(value, str) or not value.strip():
|
|
98
|
+
errors.append(f"missing {key}")
|
|
99
|
+
|
|
100
|
+
if not isinstance(maturity, str) or not maturity.strip():
|
|
101
|
+
errors.append("missing maturity")
|
|
102
|
+
elif maturity not in VALID_MATURITY_LEVELS:
|
|
103
|
+
errors.append(f"invalid maturity: {maturity}")
|
|
104
|
+
|
|
105
|
+
if "<!-- xid:" not in text:
|
|
106
|
+
warnings.append("manifest has no XID block; run `xrefkit xref fix` so it is xref-addressable")
|
|
107
|
+
|
|
108
|
+
# OS-core contract gate: if the pack pins a contract version that no longer
|
|
109
|
+
# matches the live OS-core contract, the pack must be revalidated. This makes
|
|
110
|
+
# the prose rule in 065 (revalidate when controls change) enforceable.
|
|
111
|
+
if not os_contract_version:
|
|
112
|
+
errors.append("missing depends_on.os_contract_version")
|
|
113
|
+
elif os_contract_version != current_os_contract_version():
|
|
114
|
+
errors.append(
|
|
115
|
+
f"depends_on.os_contract_version is {os_contract_version} but OS core is "
|
|
116
|
+
f"{current_os_contract_version()}; pack revalidation required"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not any(owns.values()):
|
|
120
|
+
errors.append("pack owns no assets (declare at least one owns_skills/owns_knowledge)")
|
|
121
|
+
|
|
122
|
+
# Owned-asset existence + boundary checks.
|
|
123
|
+
for skill_ref in owns["owns_skills"]:
|
|
124
|
+
norm = skill_ref.replace("\\", "/")
|
|
125
|
+
skill_dir = (root / norm).resolve()
|
|
126
|
+
if not (skill_dir / "meta.md").exists():
|
|
127
|
+
errors.append(f"owned skill has no meta.md: {skill_ref}")
|
|
128
|
+
if norm.startswith(OS_CORE_SKILL_PREFIX):
|
|
129
|
+
errors.append(f"owned skill lives in OS core (boundary violation): {skill_ref}")
|
|
130
|
+
|
|
131
|
+
for asset_ref in owns["owns_knowledge"]:
|
|
132
|
+
target = (root / _strip_fragment(asset_ref).replace("\\", "/")).resolve()
|
|
133
|
+
if not target.exists():
|
|
134
|
+
errors.append(f"owned asset path does not resolve: {asset_ref}")
|
|
135
|
+
|
|
136
|
+
# Used references only need to resolve; they may live anywhere (incl. OS core).
|
|
137
|
+
for use_ref in uses["uses_skills"] + uses["uses_knowledge"]:
|
|
138
|
+
target = (root / _strip_fragment(use_ref).replace("\\", "/")).resolve()
|
|
139
|
+
if not target.exists():
|
|
140
|
+
warnings.append(f"used reference does not resolve: {use_ref}")
|
|
141
|
+
|
|
142
|
+
return PackManifestResult(
|
|
143
|
+
manifest_path=str(manifest_path),
|
|
144
|
+
pack_id=str(pack_id) if pack_id else None,
|
|
145
|
+
maturity=str(maturity) if isinstance(maturity, str) and maturity.strip() else None,
|
|
146
|
+
os_contract_version=os_contract_version,
|
|
147
|
+
owns=owns,
|
|
148
|
+
uses=uses,
|
|
149
|
+
ok=not errors,
|
|
150
|
+
errors=errors,
|
|
151
|
+
warnings=warnings,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def list_packs(root: Path) -> list[dict[str, object]]:
|
|
156
|
+
"""Pack-level catalog derived from manifests, for job-first routing.
|
|
157
|
+
|
|
158
|
+
The manifest is the single source of truth: ownership is read from
|
|
159
|
+
`owns_skills`, and each owned Skill's id/summary is resolved from its meta.
|
|
160
|
+
Nothing here is hand-maintained, so the catalog cannot drift from the packs.
|
|
161
|
+
"""
|
|
162
|
+
packs: list[dict[str, object]] = []
|
|
163
|
+
for manifest in _discover_manifests(root):
|
|
164
|
+
parsed = _parse_meta_lines(manifest.read_text(encoding="utf-8"))
|
|
165
|
+
skills: list[dict[str, str]] = []
|
|
166
|
+
for skill_ref in _as_list(parsed.get("owns_skills")):
|
|
167
|
+
meta = (root / skill_ref.replace("\\", "/") / "meta.md")
|
|
168
|
+
skill_id = skill_ref.rsplit("/", 1)[-1]
|
|
169
|
+
summary = ""
|
|
170
|
+
if meta.exists():
|
|
171
|
+
smeta = _parse_meta_lines(meta.read_text(encoding="utf-8"))
|
|
172
|
+
skill_id = str(smeta.get("skill_id") or skill_id)
|
|
173
|
+
summary = str(smeta.get("summary") or "")
|
|
174
|
+
skills.append({"skill_id": skill_id, "path": skill_ref, "summary": summary})
|
|
175
|
+
packs.append(
|
|
176
|
+
{
|
|
177
|
+
"pack_id": str(parsed.get("pack_id") or manifest.parent.name),
|
|
178
|
+
"summary": str(parsed.get("summary") or ""),
|
|
179
|
+
"maturity": str(parsed.get("maturity") or parsed.get("status") or ""),
|
|
180
|
+
"entry": str(parsed.get("entry") or ""),
|
|
181
|
+
"skills": skills,
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
return packs
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_pack_list(args) -> int:
|
|
188
|
+
root = Path(args.root).resolve()
|
|
189
|
+
packs = list_packs(root)
|
|
190
|
+
|
|
191
|
+
if args.json:
|
|
192
|
+
print(json.dumps(packs, ensure_ascii=False, indent=2))
|
|
193
|
+
else:
|
|
194
|
+
for pack in packs:
|
|
195
|
+
print(f"{pack['pack_id']} ({pack['maturity']})")
|
|
196
|
+
print(f" {pack['summary']}")
|
|
197
|
+
for skill in pack["skills"]:
|
|
198
|
+
print(f" - {skill['skill_id']}: {skill['summary']}")
|
|
199
|
+
print(f"packs: {len(packs)}")
|
|
200
|
+
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _discover_manifests(root: Path) -> list[Path]:
|
|
205
|
+
manifests: list[Path] = []
|
|
206
|
+
legacy_base = root / PACKS_DIR
|
|
207
|
+
if legacy_base.exists():
|
|
208
|
+
manifests.extend(sorted(legacy_base.glob(f"*/{PACK_MANIFEST_NAME}")))
|
|
209
|
+
ownership = load_optional_ownership(root)
|
|
210
|
+
top_base = root / TOP_LEVEL_PACKS_DIR
|
|
211
|
+
if ownership is not None and top_base.exists():
|
|
212
|
+
manifests.extend(
|
|
213
|
+
path
|
|
214
|
+
for path in sorted(top_base.glob(f"*/{PACK_MANIFEST_NAME}"))
|
|
215
|
+
if ownership.catalog_enabled(path.relative_to(root).as_posix())
|
|
216
|
+
)
|
|
217
|
+
return sorted(set(manifests))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _physical_skill_dirs(root: Path, pack_dir: Path) -> list[str]:
|
|
221
|
+
dirs: list[str] = []
|
|
222
|
+
for meta_path in sorted(pack_dir.rglob("meta.md")):
|
|
223
|
+
rel = meta_path.parent.relative_to(root).as_posix()
|
|
224
|
+
dirs.append(rel)
|
|
225
|
+
return dirs
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_pack_lint(args) -> int:
|
|
229
|
+
root = Path(args.root).resolve()
|
|
230
|
+
|
|
231
|
+
if args.manifest:
|
|
232
|
+
manifests = [(root / args.manifest).resolve()]
|
|
233
|
+
else:
|
|
234
|
+
manifests = _discover_manifests(root)
|
|
235
|
+
|
|
236
|
+
results = [validate_pack_manifest(path, root=root) for path in manifests]
|
|
237
|
+
|
|
238
|
+
# Cross-pack ownership exclusivity: an owned asset must belong to exactly one
|
|
239
|
+
# pack. (Shared assets belong in uses_*, not owns_*.)
|
|
240
|
+
owner_of: dict[str, list[str]] = {}
|
|
241
|
+
for result in results:
|
|
242
|
+
pack_label = result.pack_id or result.manifest_path
|
|
243
|
+
for key in OWNS_KEYS:
|
|
244
|
+
for ref in result.owns[key]:
|
|
245
|
+
norm = _strip_fragment(ref).replace("\\", "/").rstrip("/")
|
|
246
|
+
owner_of.setdefault(norm, []).append(pack_label)
|
|
247
|
+
|
|
248
|
+
for asset, owners in owner_of.items():
|
|
249
|
+
if len(owners) > 1:
|
|
250
|
+
for result in results:
|
|
251
|
+
pack_label = result.pack_id or result.manifest_path
|
|
252
|
+
if pack_label in owners:
|
|
253
|
+
result.errors.append(
|
|
254
|
+
f"asset owned by multiple packs (move to uses_*): {asset} -> {sorted(set(owners))}"
|
|
255
|
+
)
|
|
256
|
+
result.ok = False
|
|
257
|
+
|
|
258
|
+
# Orphan warning: a Skill physically under skills/packs/<pack>/ that no
|
|
259
|
+
# manifest claims. Useful during the co-located -> manifest transition.
|
|
260
|
+
owned_skill_dirs = {
|
|
261
|
+
_strip_fragment(ref).replace("\\", "/").rstrip("/")
|
|
262
|
+
for result in results
|
|
263
|
+
for ref in result.owns["owns_skills"]
|
|
264
|
+
}
|
|
265
|
+
for path in manifests:
|
|
266
|
+
result = next(r for r in results if r.manifest_path == str(path))
|
|
267
|
+
for skill_dir in _physical_skill_dirs(root, path.parent):
|
|
268
|
+
if skill_dir not in owned_skill_dirs:
|
|
269
|
+
result.warnings.append(f"skill physically in pack but not owned by any manifest: {skill_dir}")
|
|
270
|
+
|
|
271
|
+
failed = [r for r in results if not r.ok]
|
|
272
|
+
|
|
273
|
+
if args.json:
|
|
274
|
+
print(json.dumps([r.to_dict() for r in results], ensure_ascii=False, indent=2))
|
|
275
|
+
else:
|
|
276
|
+
for result in results:
|
|
277
|
+
status = "ok" if result.ok else "fail"
|
|
278
|
+
print(f"{status}: {result.manifest_path}")
|
|
279
|
+
if result.pack_id:
|
|
280
|
+
print(f" pack_id: {result.pack_id}")
|
|
281
|
+
if result.maturity:
|
|
282
|
+
print(f" maturity: {result.maturity}")
|
|
283
|
+
for warning in result.warnings:
|
|
284
|
+
print(f" warning: {warning}")
|
|
285
|
+
for error in result.errors:
|
|
286
|
+
print(f" error: {error}")
|
|
287
|
+
print(f"packs: {len(results)} failed: {len(failed)}")
|
|
288
|
+
|
|
289
|
+
return 1 if failed else 0
|