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.
Files changed (65) hide show
  1. xrefkit/__init__.py +5 -0
  2. xrefkit/__main__.py +5 -0
  3. xrefkit/catalog_cli.py +57 -0
  4. xrefkit/cli.py +71 -0
  5. xrefkit/contracts.py +297 -0
  6. xrefkit/ctx.py +160 -0
  7. xrefkit/dashboard.py +1220 -0
  8. xrefkit/discovery.py +85 -0
  9. xrefkit/gate.py +428 -0
  10. xrefkit/goalstate.py +555 -0
  11. xrefkit/hashing.py +18 -0
  12. xrefkit/import_skill.py +469 -0
  13. xrefkit/instance.py +133 -0
  14. xrefkit/loaders.py +77 -0
  15. xrefkit/mcp/__init__.py +26 -0
  16. xrefkit/mcp/audit.py +168 -0
  17. xrefkit/mcp/bootstrap.py +337 -0
  18. xrefkit/mcp/catalog.py +2638 -0
  19. xrefkit/mcp/cli.py +173 -0
  20. xrefkit/mcp/client_cache.py +356 -0
  21. xrefkit/mcp/context_registry.py +277 -0
  22. xrefkit/mcp/contracts.py +243 -0
  23. xrefkit/mcp/dist.py +234 -0
  24. xrefkit/mcp/ownership.py +246 -0
  25. xrefkit/mcp/repository.py +217 -0
  26. xrefkit/mcp/schemas.py +349 -0
  27. xrefkit/mcp/server.py +773 -0
  28. xrefkit/mcp/startup_contract_pack.py +154 -0
  29. xrefkit/mcp_tools.py +47 -0
  30. xrefkit/models/__init__.py +41 -0
  31. xrefkit/models/common.py +185 -0
  32. xrefkit/models/effective_bundle.py +131 -0
  33. xrefkit/models/local_manifest.py +217 -0
  34. xrefkit/models/package_manifest.py +126 -0
  35. xrefkit/models/run_log.py +276 -0
  36. xrefkit/models/server_config.py +160 -0
  37. xrefkit/models/skill_definition.py +131 -0
  38. xrefkit/operations_cli.py +670 -0
  39. xrefkit/ownership.py +276 -0
  40. xrefkit/packmeta.py +289 -0
  41. xrefkit/registry.py +334 -0
  42. xrefkit/resolver.py +252 -0
  43. xrefkit/resource_provider.py +187 -0
  44. xrefkit/resources/base/contracts.json +178 -0
  45. xrefkit/resources/base/current.json +6 -0
  46. xrefkit/resources/base/generations/7a682a5272907354/contracts.json +178 -0
  47. xrefkit/resources/base/generations/7a682a5272907354/model_body.md +22 -0
  48. xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +178 -0
  49. xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +22 -0
  50. xrefkit/resources/base/model_body.md +22 -0
  51. xrefkit/runlog.py +45 -0
  52. xrefkit/skillmeta.py +1034 -0
  53. xrefkit/skillrun.py +2381 -0
  54. xrefkit/structure_catalog.py +199 -0
  55. xrefkit/tools/__init__.py +119 -0
  56. xrefkit/tools/__main__.py +4 -0
  57. xrefkit/v2_cli.py +130 -0
  58. xrefkit/workspace.py +117 -0
  59. xrefkit/xref.py +1048 -0
  60. xrefkit-0.3.0.dist-info/METADATA +203 -0
  61. xrefkit-0.3.0.dist-info/RECORD +65 -0
  62. xrefkit-0.3.0.dist-info/WHEEL +5 -0
  63. xrefkit-0.3.0.dist-info/entry_points.txt +2 -0
  64. xrefkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  65. 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