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/skillmeta.py
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
|
|
11
|
+
from xrefkit.ownership import content_files, load_optional_ownership
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Canonical repo-relative suffixes. Skill metas reference these with a
|
|
15
|
+
# relative prefix whose depth varies by location (skills/<id>/, skills/os/<id>/,
|
|
16
|
+
# skills/packs/<pack>/<id>/), so validation matches on the suffix, not on an
|
|
17
|
+
# exact relative path.
|
|
18
|
+
GUARD_CAPABILITY_REF = "capabilities/management/130_cap_mgt_004_context_direction_guard.md#xid-2F6A3D8C7B11"
|
|
19
|
+
GUARD_KNOWLEDGE_REF = "knowledge/organization/160_context_direction_guard_rules.md#xid-7A2F4C8D1601"
|
|
20
|
+
SKILL_RUNTIME_CAPABILITY_REF = "capabilities/management/140_cap_mgt_005_skill_runtime_envelope.md#xid-4E6D8C2A19B5"
|
|
21
|
+
VALID_GUARD_POLICIES = {"required", "closed_world"}
|
|
22
|
+
VALID_CAPABILITY_LAYERING_POLICIES = {"required"}
|
|
23
|
+
VALID_WORKFLOW_PROTOCOL_POLICIES = {"required"}
|
|
24
|
+
VALID_EXECUTION_MODES = {"local_default", "subagent_preferred", "subagent_required"}
|
|
25
|
+
VALID_MATURITY_LEVELS = {"draft", "trial", "stable", "governed", "deprecated"}
|
|
26
|
+
VALID_CHECK_LEVELS = {"auto", "draft", "trial", "stable", "governed"}
|
|
27
|
+
PROTOCOL_OWNED_ROLE_RESPONSIBILITIES = {"checker", "quality_reviewer", "handoff_owner"}
|
|
28
|
+
LEGACY_DEFAULT_MATURITY = "stable"
|
|
29
|
+
TRIAL_DEFAULT_EXECUTION_MODE = "local_default"
|
|
30
|
+
TRIAL_DEFAULT_GUARD_POLICY = "required"
|
|
31
|
+
REQUIRED_OS_CONTRACT = {
|
|
32
|
+
"version": "1",
|
|
33
|
+
"worklist_policy": "required",
|
|
34
|
+
"execution_role": "required",
|
|
35
|
+
"check_role": "required",
|
|
36
|
+
"logging_policy": "session_required",
|
|
37
|
+
"judgment_log_policy": "required_when_non_trivial",
|
|
38
|
+
"unknown_risk_policy": "explicit",
|
|
39
|
+
"closure_gate": "required",
|
|
40
|
+
"handoff_policy": "explicit",
|
|
41
|
+
}
|
|
42
|
+
# `os_contract: v1` is the compact declaration of the version-1 operating
|
|
43
|
+
# contract above. Both the shorthand and the expanded inline block are valid
|
|
44
|
+
# meta forms; run logs always materialize the expanded block.
|
|
45
|
+
OS_CONTRACT_SHORTHANDS = {"v1": REQUIRED_OS_CONTRACT}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_os_contract(value: object) -> dict[str, str]:
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
shorthand = OS_CONTRACT_SHORTHANDS.get(value.strip().strip("`"))
|
|
51
|
+
return dict(shorthand) if shorthand else {}
|
|
52
|
+
return _parse_key_value_list(value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class SkillMetaResult:
|
|
57
|
+
meta_path: str
|
|
58
|
+
skill_id: str | None
|
|
59
|
+
maturity: str | None
|
|
60
|
+
checked_level: str
|
|
61
|
+
guard_policy: str | None
|
|
62
|
+
capability_layering: str | None
|
|
63
|
+
workflow_protocol: str | None
|
|
64
|
+
tuning: str | None
|
|
65
|
+
role_responsibilities: dict[str, str]
|
|
66
|
+
execution_mode: str | None
|
|
67
|
+
ok: bool
|
|
68
|
+
errors: list[str]
|
|
69
|
+
warnings: list[str]
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict[str, object]:
|
|
72
|
+
return {
|
|
73
|
+
"meta_path": self.meta_path,
|
|
74
|
+
"skill_id": self.skill_id,
|
|
75
|
+
"maturity": self.maturity,
|
|
76
|
+
"checked_level": self.checked_level,
|
|
77
|
+
"guard_policy": self.guard_policy,
|
|
78
|
+
"capability_layering": self.capability_layering,
|
|
79
|
+
"workflow_protocol": self.workflow_protocol,
|
|
80
|
+
"tuning": self.tuning,
|
|
81
|
+
"role_responsibilities": self.role_responsibilities,
|
|
82
|
+
"execution_mode": self.execution_mode,
|
|
83
|
+
"ok": self.ok,
|
|
84
|
+
"errors": self.errors,
|
|
85
|
+
"warnings": self.warnings,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_meta_lines(text: str) -> dict[str, object]:
|
|
90
|
+
data: dict[str, object] = {}
|
|
91
|
+
current_key: str | None = None
|
|
92
|
+
|
|
93
|
+
for raw_line in text.splitlines():
|
|
94
|
+
line = raw_line.rstrip()
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
|
|
97
|
+
if not stripped:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if line.startswith("- ") and ":" in stripped[2:]:
|
|
101
|
+
key, value = stripped[2:].split(":", 1)
|
|
102
|
+
key = key.strip()
|
|
103
|
+
value = value.strip()
|
|
104
|
+
current_key = key
|
|
105
|
+
if value:
|
|
106
|
+
data[key] = value.strip("`")
|
|
107
|
+
else:
|
|
108
|
+
data[key] = []
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if current_key and stripped.startswith("- "):
|
|
112
|
+
value = stripped[2:].strip().strip("`")
|
|
113
|
+
current = data.get(current_key)
|
|
114
|
+
if not isinstance(current, list):
|
|
115
|
+
current = []
|
|
116
|
+
data[current_key] = current
|
|
117
|
+
current.append(value)
|
|
118
|
+
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_key_value_list(value: object) -> dict[str, str]:
|
|
123
|
+
if not isinstance(value, list):
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
parsed: dict[str, str] = {}
|
|
127
|
+
for item in value:
|
|
128
|
+
if not isinstance(item, str) or ":" not in item:
|
|
129
|
+
continue
|
|
130
|
+
key, raw_value = item.split(":", 1)
|
|
131
|
+
parsed[key.strip()] = raw_value.strip().strip("`")
|
|
132
|
+
return parsed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _has_skill_role_responsibilities(value: object) -> bool:
|
|
136
|
+
parsed = _parse_key_value_list(value)
|
|
137
|
+
required = {"executor"}
|
|
138
|
+
return all(parsed.get(role, "").strip() for role in required)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _protocol_owned_role_responsibilities(value: object) -> list[str]:
|
|
142
|
+
parsed = _parse_key_value_list(value)
|
|
143
|
+
return sorted(PROTOCOL_OWNED_ROLE_RESPONSIBILITIES.intersection(parsed))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _has_responsibility(parsed: dict[str, object]) -> bool:
|
|
147
|
+
# Skill-centric consolidation (design 083 D1/D3): the triad's
|
|
148
|
+
# `responsibility` is the Skill's business use, replacing the legacy
|
|
149
|
+
# `role_responsibilities.executor` value (which was always a responsibility,
|
|
150
|
+
# not a role). Superset during migration: either form satisfies the check.
|
|
151
|
+
responsibility = parsed.get("responsibility")
|
|
152
|
+
if isinstance(responsibility, str) and responsibility.strip():
|
|
153
|
+
return True
|
|
154
|
+
return _has_skill_role_responsibilities(parsed.get("role_responsibilities"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_TRACKED_CACHE: dict[str, set[str] | None] = {}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _git_tracked_files(start: Path) -> tuple[Path, set[str]] | None:
|
|
161
|
+
"""Return (repo_root, tracked paths) for the repo containing `start`.
|
|
162
|
+
|
|
163
|
+
Returns None when `start` is not inside a git work tree (temp dirs,
|
|
164
|
+
MCP-materialized checkouts without .git), in which case tracked-ness
|
|
165
|
+
cannot and should not be enforced.
|
|
166
|
+
"""
|
|
167
|
+
start = start.resolve()
|
|
168
|
+
root = None
|
|
169
|
+
for parent in [start, *start.parents]:
|
|
170
|
+
if (parent / ".git").exists():
|
|
171
|
+
root = parent
|
|
172
|
+
break
|
|
173
|
+
if root is None:
|
|
174
|
+
return None
|
|
175
|
+
key = str(root)
|
|
176
|
+
if key not in _TRACKED_CACHE:
|
|
177
|
+
try:
|
|
178
|
+
out = subprocess.run(
|
|
179
|
+
["git", "-C", str(root), "ls-files"],
|
|
180
|
+
capture_output=True,
|
|
181
|
+
text=True,
|
|
182
|
+
check=True,
|
|
183
|
+
).stdout
|
|
184
|
+
_TRACKED_CACHE[key] = set(out.splitlines())
|
|
185
|
+
except Exception:
|
|
186
|
+
_TRACKED_CACHE[key] = None
|
|
187
|
+
tracked = _TRACKED_CACHE[key]
|
|
188
|
+
return None if tracked is None else (root, tracked)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _untracked_observation_refs(meta_path: Path, refs: list) -> list[str]:
|
|
192
|
+
"""Observation refs whose target is not git-tracked (unresolvable in a clone)."""
|
|
193
|
+
located = _git_tracked_files(meta_path.parent)
|
|
194
|
+
if located is None:
|
|
195
|
+
return []
|
|
196
|
+
root, tracked = located
|
|
197
|
+
bad: list[str] = []
|
|
198
|
+
for ref in refs:
|
|
199
|
+
if not isinstance(ref, str) or not ref.strip():
|
|
200
|
+
continue
|
|
201
|
+
target = (meta_path.parent / ref.split("#")[0]).resolve()
|
|
202
|
+
try:
|
|
203
|
+
rel = target.relative_to(root).as_posix()
|
|
204
|
+
except ValueError:
|
|
205
|
+
bad.append(ref)
|
|
206
|
+
continue
|
|
207
|
+
if rel not in tracked:
|
|
208
|
+
bad.append(ref)
|
|
209
|
+
return bad
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _has_required_ref(refs: list, required_suffix: str) -> bool:
|
|
213
|
+
return any(
|
|
214
|
+
isinstance(ref, str) and ref.replace("\\", "/").endswith(required_suffix)
|
|
215
|
+
for ref in refs
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _require_text_field(parsed: dict[str, object], key: str, errors: list[str]) -> None:
|
|
220
|
+
value = parsed.get(key)
|
|
221
|
+
if not isinstance(value, str) or not value.strip():
|
|
222
|
+
errors.append(f"missing {key}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _resolve_maturity(parsed: dict[str, object]) -> tuple[str | None, str | None]:
|
|
226
|
+
maturity = parsed.get("maturity")
|
|
227
|
+
status = parsed.get("status")
|
|
228
|
+
|
|
229
|
+
raw_value = None
|
|
230
|
+
if isinstance(maturity, str) and maturity.strip():
|
|
231
|
+
raw_value = maturity.strip()
|
|
232
|
+
elif isinstance(status, str) and status.strip():
|
|
233
|
+
raw_value = status.strip()
|
|
234
|
+
|
|
235
|
+
if raw_value is None:
|
|
236
|
+
return LEGACY_DEFAULT_MATURITY, None
|
|
237
|
+
if raw_value in VALID_MATURITY_LEVELS:
|
|
238
|
+
return raw_value, raw_value
|
|
239
|
+
return None, raw_value
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _resolve_check_level(*, maturity: str | None, check_level: str) -> str:
|
|
243
|
+
if check_level != "auto":
|
|
244
|
+
return check_level
|
|
245
|
+
if maturity == "deprecated":
|
|
246
|
+
return "draft"
|
|
247
|
+
return maturity or LEGACY_DEFAULT_MATURITY
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _check_review_mode(summary: str, tags: str, skill_id: object, execution_mode: object, errors: list[str]) -> None:
|
|
251
|
+
review_markers = (
|
|
252
|
+
"review" in str(skill_id).lower()
|
|
253
|
+
or " review" in summary.lower()
|
|
254
|
+
or "review" in tags.lower()
|
|
255
|
+
or "self-check" in summary.lower()
|
|
256
|
+
or "self-check" in tags.lower()
|
|
257
|
+
)
|
|
258
|
+
if review_markers and execution_mode == "local_default":
|
|
259
|
+
errors.append("review-oriented skills must use subagent_preferred or subagent_required")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def validate_skill_meta(meta_path: Path, *, check_level: str = "auto") -> SkillMetaResult:
|
|
263
|
+
parsed = _parse_meta_lines(meta_path.read_text(encoding="utf-8"))
|
|
264
|
+
skill_id = parsed.get("skill_id")
|
|
265
|
+
summary = str(parsed.get("summary", ""))
|
|
266
|
+
tags = str(parsed.get("tags", ""))
|
|
267
|
+
maturity, explicit_maturity = _resolve_maturity(parsed)
|
|
268
|
+
is_legacy_meta = explicit_maturity is None
|
|
269
|
+
effective_check_level = _resolve_check_level(maturity=maturity, check_level=check_level)
|
|
270
|
+
guard_policy = parsed.get("guard_policy")
|
|
271
|
+
capability_layering = parsed.get("capability_layering")
|
|
272
|
+
workflow_protocol = parsed.get("workflow_protocol")
|
|
273
|
+
tuning = parsed.get("tuning")
|
|
274
|
+
role_responsibilities = _parse_key_value_list(parsed.get("role_responsibilities"))
|
|
275
|
+
execution_mode = parsed.get("execution_mode")
|
|
276
|
+
constraints = str(parsed.get("constraints", ""))
|
|
277
|
+
capability_refs = parsed.get("capability_refs", [])
|
|
278
|
+
knowledge_refs = parsed.get("knowledge_refs", [])
|
|
279
|
+
observation_refs = parsed.get("observation_refs", [])
|
|
280
|
+
governance_refs = parsed.get("governance_refs", [])
|
|
281
|
+
skill_doc = parsed.get("skill_doc")
|
|
282
|
+
raw_os_contract = parsed.get("os_contract")
|
|
283
|
+
os_contract = resolve_os_contract(raw_os_contract)
|
|
284
|
+
|
|
285
|
+
if not isinstance(capability_refs, list):
|
|
286
|
+
capability_refs = []
|
|
287
|
+
if not isinstance(knowledge_refs, list):
|
|
288
|
+
knowledge_refs = []
|
|
289
|
+
if not isinstance(observation_refs, list):
|
|
290
|
+
observation_refs = []
|
|
291
|
+
if not isinstance(governance_refs, list):
|
|
292
|
+
governance_refs = []
|
|
293
|
+
|
|
294
|
+
errors: list[str] = []
|
|
295
|
+
warnings: list[str] = []
|
|
296
|
+
|
|
297
|
+
if is_legacy_meta:
|
|
298
|
+
warnings.append(
|
|
299
|
+
"maturity is not declared; legacy default 'stable' applies. "
|
|
300
|
+
"Declare maturity explicitly (see 059_skill_maturity_governance)."
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
skill_body = meta_path.parent / "SKILL.md"
|
|
304
|
+
if skill_body.is_file():
|
|
305
|
+
body = skill_body.read_text(encoding="utf-8")
|
|
306
|
+
generic_calibration = re.compile(
|
|
307
|
+
r"(?im)^\s*[-*]\s+(?:downgrade|remove)\s+"
|
|
308
|
+
r"(?:any\s+)?(?:weakly\s+supported|unsupported|overconfident)\s+"
|
|
309
|
+
r"(?:claims?|conclusions?|inferences?|judgments?)\b"
|
|
310
|
+
)
|
|
311
|
+
if generic_calibration.search(body):
|
|
312
|
+
warnings.append(
|
|
313
|
+
"generic calibration wording candidate in SKILL.md; keep generic "
|
|
314
|
+
"claim-evidence disposition in the base runtime and retain here only "
|
|
315
|
+
"Skill-specific evidence, state, scope, or stop rules"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if check_level not in VALID_CHECK_LEVELS:
|
|
319
|
+
errors.append(f"invalid check level: {check_level}")
|
|
320
|
+
if explicit_maturity and explicit_maturity not in VALID_MATURITY_LEVELS:
|
|
321
|
+
errors.append(f"invalid maturity/status: {explicit_maturity}")
|
|
322
|
+
|
|
323
|
+
for key in ("skill_id", "summary", "use_when", "input", "output", "skill_doc"):
|
|
324
|
+
_require_text_field(parsed, key, errors)
|
|
325
|
+
|
|
326
|
+
require_observation_refs = effective_check_level in {"trial", "stable", "governed"} and not (
|
|
327
|
+
check_level == "auto" and is_legacy_meta and effective_check_level == "stable"
|
|
328
|
+
)
|
|
329
|
+
if require_observation_refs:
|
|
330
|
+
if not observation_refs:
|
|
331
|
+
errors.append("trial-or-higher skills must include at least one observation_refs entry")
|
|
332
|
+
for ref in observation_refs:
|
|
333
|
+
if isinstance(ref, str) and re.search(r"(^|/)work/", ref.replace("\\", "/")):
|
|
334
|
+
errors.append(
|
|
335
|
+
f"observation ref points into work/ (local-only): {ref} "
|
|
336
|
+
"(move the record to observations/ — tracked governance evidence)"
|
|
337
|
+
)
|
|
338
|
+
for ref in _untracked_observation_refs(meta_path, observation_refs):
|
|
339
|
+
errors.append(
|
|
340
|
+
f"observation ref is not git-tracked and cannot resolve in a clone: {ref} "
|
|
341
|
+
"(move the record to observations/ and commit it)"
|
|
342
|
+
)
|
|
343
|
+
if capability_layering not in VALID_CAPABILITY_LAYERING_POLICIES:
|
|
344
|
+
errors.append("missing or invalid capability_layering")
|
|
345
|
+
if workflow_protocol not in VALID_WORKFLOW_PROTOCOL_POLICIES:
|
|
346
|
+
errors.append("missing or invalid workflow_protocol")
|
|
347
|
+
if not isinstance(tuning, str) or not tuning.strip():
|
|
348
|
+
errors.append("missing tuning")
|
|
349
|
+
if not _has_responsibility(parsed):
|
|
350
|
+
errors.append(
|
|
351
|
+
"missing responsibility (declare `responsibility:`; the legacy "
|
|
352
|
+
"role_responsibilities.executor value is still accepted)"
|
|
353
|
+
)
|
|
354
|
+
protocol_roles = _protocol_owned_role_responsibilities(parsed.get("role_responsibilities"))
|
|
355
|
+
if protocol_roles:
|
|
356
|
+
errors.append(
|
|
357
|
+
"role_responsibilities must not define protocol-owned roles: "
|
|
358
|
+
+ ", ".join(protocol_roles)
|
|
359
|
+
)
|
|
360
|
+
if effective_check_level == "trial":
|
|
361
|
+
if execution_mode and execution_mode not in VALID_EXECUTION_MODES:
|
|
362
|
+
errors.append("invalid execution_mode")
|
|
363
|
+
if guard_policy and guard_policy not in VALID_GUARD_POLICIES:
|
|
364
|
+
errors.append("invalid guard_policy")
|
|
365
|
+
if capability_layering and capability_layering not in VALID_CAPABILITY_LAYERING_POLICIES:
|
|
366
|
+
errors.append("invalid capability_layering")
|
|
367
|
+
if workflow_protocol and workflow_protocol not in VALID_WORKFLOW_PROTOCOL_POLICIES:
|
|
368
|
+
errors.append("invalid workflow_protocol")
|
|
369
|
+
|
|
370
|
+
if effective_check_level in {"stable", "governed"}:
|
|
371
|
+
if execution_mode not in VALID_EXECUTION_MODES:
|
|
372
|
+
errors.append("missing or invalid execution_mode")
|
|
373
|
+
# Skill-centric consolidation (design 083 / 082 D4): the context-direction
|
|
374
|
+
# guard is ambient (startup contract pack + per-response
|
|
375
|
+
# control_reminder), not composed per Skill. guard_policy is no longer a
|
|
376
|
+
# required per-Skill field; validate it only when a legacy meta still
|
|
377
|
+
# declares it.
|
|
378
|
+
if guard_policy is not None and guard_policy not in VALID_GUARD_POLICIES:
|
|
379
|
+
errors.append("invalid guard_policy")
|
|
380
|
+
if capability_layering not in VALID_CAPABILITY_LAYERING_POLICIES:
|
|
381
|
+
errors.append("missing or invalid capability_layering")
|
|
382
|
+
if workflow_protocol not in VALID_WORKFLOW_PROTOCOL_POLICIES:
|
|
383
|
+
errors.append("missing or invalid workflow_protocol")
|
|
384
|
+
if not isinstance(tuning, str) or not tuning.strip():
|
|
385
|
+
errors.append("missing tuning")
|
|
386
|
+
if not _has_responsibility(parsed):
|
|
387
|
+
errors.append(
|
|
388
|
+
"missing responsibility (declare `responsibility:`; the legacy "
|
|
389
|
+
"role_responsibilities.executor value is still accepted)"
|
|
390
|
+
)
|
|
391
|
+
# Guard capability/knowledge refs are no longer required per Skill; the
|
|
392
|
+
# guard is ambient. A legacy meta may still carry them (harmless). The
|
|
393
|
+
# closed_world escape hatch, when explicitly declared, still needs its
|
|
394
|
+
# constraint text.
|
|
395
|
+
if guard_policy == "closed_world" and "closed-world" not in constraints:
|
|
396
|
+
errors.append("closed_world policy requires explicit closed-world constraint text")
|
|
397
|
+
# capability_refs are no longer required per Skill: the runtime envelope
|
|
398
|
+
# is enforced by workflow_protocol / os_contract (the protocol), not a
|
|
399
|
+
# capability-file reference (design 083 D2 — capabilities/ dissolves).
|
|
400
|
+
if not constraints.strip():
|
|
401
|
+
errors.append("missing constraints")
|
|
402
|
+
_check_review_mode(summary, tags, skill_id, execution_mode, errors)
|
|
403
|
+
if (
|
|
404
|
+
isinstance(raw_os_contract, str)
|
|
405
|
+
and raw_os_contract.strip().strip("`") not in OS_CONTRACT_SHORTHANDS
|
|
406
|
+
):
|
|
407
|
+
errors.append(f"unknown os_contract shorthand: {raw_os_contract}")
|
|
408
|
+
for key, expected_value in REQUIRED_OS_CONTRACT.items():
|
|
409
|
+
actual_value = os_contract.get(key)
|
|
410
|
+
if actual_value != expected_value:
|
|
411
|
+
errors.append(f"os_contract.{key} must be {expected_value}")
|
|
412
|
+
|
|
413
|
+
if effective_check_level == "governed":
|
|
414
|
+
if not governance_refs:
|
|
415
|
+
errors.append("governed skills must include at least one governance_refs entry")
|
|
416
|
+
|
|
417
|
+
return SkillMetaResult(
|
|
418
|
+
meta_path=str(meta_path),
|
|
419
|
+
skill_id=str(skill_id) if skill_id else None,
|
|
420
|
+
maturity=maturity,
|
|
421
|
+
checked_level=effective_check_level,
|
|
422
|
+
guard_policy=str(guard_policy) if guard_policy else None,
|
|
423
|
+
capability_layering=str(capability_layering) if capability_layering else None,
|
|
424
|
+
workflow_protocol=str(workflow_protocol) if workflow_protocol else None,
|
|
425
|
+
tuning=str(tuning) if tuning else None,
|
|
426
|
+
role_responsibilities=role_responsibilities,
|
|
427
|
+
execution_mode=str(execution_mode) if execution_mode else None,
|
|
428
|
+
ok=not errors,
|
|
429
|
+
errors=errors,
|
|
430
|
+
warnings=warnings,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# Publication boundary handling for `xrefkit skill list`.
|
|
435
|
+
# Boundary truth is the directory: skills/ is public, skills_private/ is
|
|
436
|
+
# private. A bare mention of `skills_private/` in public text is a legal
|
|
437
|
+
# conceptual reference (authoring rules talk about the boundary itself);
|
|
438
|
+
# only a concrete path below it leaks a private skill.
|
|
439
|
+
PRIVATE_SCOPE_DIRS = ("skills_private", "knowledge_private", "sources_private")
|
|
440
|
+
PUBLIC_TEXT_DIRS = ("skills", "docs", "knowledge", "capabilities", "agent", "flows", "work/retrospectives")
|
|
441
|
+
PRIVATE_CONCRETE_REF = re.compile(
|
|
442
|
+
r"(?:skills|knowledge|sources)_private/[\w\-./]+"
|
|
443
|
+
)
|
|
444
|
+
OWN_XID_RE = re.compile(r"<!--\s*xid:\s*([A-Za-z0-9_-]+)\s*-->")
|
|
445
|
+
XID_RE = re.compile(r"(?:<!--\s*xid:\s*([A-Za-z0-9_-]+)\s*-->|#xid-([A-Za-z0-9_-]+))")
|
|
446
|
+
LOCAL_MD_REF_RE = re.compile(r"\]\(([^)#]+\.md)(?:#xid-([A-Za-z0-9_-]+))?\)")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _stable_file_hash(path: Path) -> str:
|
|
450
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _extract_xids(text: str) -> list[str]:
|
|
454
|
+
xids: list[str] = []
|
|
455
|
+
for match in XID_RE.finditer(text):
|
|
456
|
+
xid = match.group(1) or match.group(2)
|
|
457
|
+
if xid and xid not in xids:
|
|
458
|
+
xids.append(xid)
|
|
459
|
+
return xids
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _extract_own_xids(text: str) -> list[str]:
|
|
463
|
+
xids: list[str] = []
|
|
464
|
+
for match in OWN_XID_RE.finditer(text):
|
|
465
|
+
xid = match.group(1)
|
|
466
|
+
if xid and xid not in xids:
|
|
467
|
+
xids.append(xid)
|
|
468
|
+
return xids
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _read_text_or_empty(path: Path) -> str:
|
|
472
|
+
try:
|
|
473
|
+
return path.read_text(encoding="utf-8")
|
|
474
|
+
except (OSError, UnicodeDecodeError):
|
|
475
|
+
return ""
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _repo_rel(path: Path, root: Path) -> str:
|
|
479
|
+
try:
|
|
480
|
+
return path.relative_to(root).as_posix()
|
|
481
|
+
except ValueError:
|
|
482
|
+
return path.as_posix()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _source_files(source: Path) -> list[Path]:
|
|
486
|
+
if source.is_file():
|
|
487
|
+
return [source]
|
|
488
|
+
if not source.exists():
|
|
489
|
+
return []
|
|
490
|
+
return sorted(path for path in source.rglob("*") if path.is_file())
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _find_case_insensitive_file(source: Path, name: str) -> Path | None:
|
|
494
|
+
if source.is_file():
|
|
495
|
+
return source if source.name.lower() == name.lower() else None
|
|
496
|
+
if not source.exists():
|
|
497
|
+
return None
|
|
498
|
+
for path in sorted(source.rglob("*")):
|
|
499
|
+
if path.is_file() and path.name.lower() == name.lower():
|
|
500
|
+
return path
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _reference_issues(source: Path, files: list[Path]) -> list[dict[str, str]]:
|
|
505
|
+
issues: list[dict[str, str]] = []
|
|
506
|
+
text_files = [path for path in files if path.suffix.lower() in {".md", ".yaml", ".yml"}]
|
|
507
|
+
for path in text_files:
|
|
508
|
+
text = _read_text_or_empty(path)
|
|
509
|
+
for match in LOCAL_MD_REF_RE.finditer(text):
|
|
510
|
+
raw_target = match.group(1)
|
|
511
|
+
xid = match.group(2)
|
|
512
|
+
target_path = (path.parent / raw_target).resolve()
|
|
513
|
+
if not xid:
|
|
514
|
+
issues.append(
|
|
515
|
+
{
|
|
516
|
+
"kind": "missing_xid_fragment",
|
|
517
|
+
"file": _repo_rel(path, source),
|
|
518
|
+
"target": raw_target,
|
|
519
|
+
}
|
|
520
|
+
)
|
|
521
|
+
if not target_path.exists():
|
|
522
|
+
issues.append(
|
|
523
|
+
{
|
|
524
|
+
"kind": "missing_target",
|
|
525
|
+
"file": _repo_rel(path, source),
|
|
526
|
+
"target": raw_target,
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
return issues
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _current_skill_candidates(root: Path, source_skill_id: str | None, source_xids: list[str]) -> list[dict[str, object]]:
|
|
533
|
+
candidates: list[dict[str, object]] = []
|
|
534
|
+
ownership = load_optional_ownership(root)
|
|
535
|
+
for meta_path in content_files(root, "skills", "meta.md", ownership=ownership):
|
|
536
|
+
parsed = _parse_meta_lines(_read_text_or_empty(meta_path))
|
|
537
|
+
skill_id = parsed.get("skill_id")
|
|
538
|
+
skill_dir = meta_path.parent
|
|
539
|
+
current_own_xids: list[str] = []
|
|
540
|
+
for current_file in (meta_path, skill_dir / "SKILL.md"):
|
|
541
|
+
if current_file.exists():
|
|
542
|
+
current_own_xids.extend(
|
|
543
|
+
x for x in _extract_own_xids(_read_text_or_empty(current_file)) if x not in current_own_xids
|
|
544
|
+
)
|
|
545
|
+
reasons: list[str] = []
|
|
546
|
+
if source_skill_id and skill_id == source_skill_id:
|
|
547
|
+
reasons.append("exact_skill_id")
|
|
548
|
+
overlap = sorted(set(source_xids).intersection(current_own_xids))
|
|
549
|
+
if overlap:
|
|
550
|
+
reasons.append("exact_own_xid")
|
|
551
|
+
if reasons:
|
|
552
|
+
candidates.append(
|
|
553
|
+
{
|
|
554
|
+
"skill_id": str(skill_id) if skill_id else None,
|
|
555
|
+
"path": _repo_rel(skill_dir, root),
|
|
556
|
+
"reasons": reasons,
|
|
557
|
+
"xid_overlap": overlap,
|
|
558
|
+
}
|
|
559
|
+
)
|
|
560
|
+
return candidates
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _contract_gaps(meta_path: Path | None) -> list[str]:
|
|
564
|
+
if meta_path is None:
|
|
565
|
+
return ["missing meta.md"]
|
|
566
|
+
result = validate_skill_meta(meta_path, check_level="trial")
|
|
567
|
+
return result.errors
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _body_split_indicators(skill_doc: Path | None) -> list[str]:
|
|
571
|
+
if skill_doc is None:
|
|
572
|
+
return []
|
|
573
|
+
text = _read_text_or_empty(skill_doc).lower()
|
|
574
|
+
indicators: list[str] = []
|
|
575
|
+
if "os_contract:" in text or "startup xref routing policy" in text or "uncertainty protocol" in text:
|
|
576
|
+
indicators.append("possible_os_core_rule_copy")
|
|
577
|
+
if "## domain facts" in text or "## factual rules" in text or "## source facts" in text:
|
|
578
|
+
indicators.append("possible_knowledge_in_skill_body")
|
|
579
|
+
return indicators
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def build_skill_merge_plan(
|
|
583
|
+
*,
|
|
584
|
+
root: Path,
|
|
585
|
+
source: Path,
|
|
586
|
+
target_skill: str | None = None,
|
|
587
|
+
source_version: str | None = None,
|
|
588
|
+
) -> dict[str, object]:
|
|
589
|
+
root = root.resolve()
|
|
590
|
+
source = source.resolve()
|
|
591
|
+
files = _source_files(source)
|
|
592
|
+
meta_path = _find_case_insensitive_file(source, "meta.md")
|
|
593
|
+
skill_doc = _find_case_insensitive_file(source, "SKILL.md")
|
|
594
|
+
parsed_meta = _parse_meta_lines(_read_text_or_empty(meta_path)) if meta_path else {}
|
|
595
|
+
source_skill_id = parsed_meta.get("skill_id")
|
|
596
|
+
|
|
597
|
+
source_xids: list[str] = []
|
|
598
|
+
referenced_xids: list[str] = []
|
|
599
|
+
for path in files:
|
|
600
|
+
if path.suffix.lower() in {".md", ".yaml", ".yml"}:
|
|
601
|
+
text = _read_text_or_empty(path)
|
|
602
|
+
source_xids.extend(xid for xid in _extract_own_xids(text) if xid not in source_xids)
|
|
603
|
+
referenced_xids.extend(
|
|
604
|
+
xid for xid in _extract_xids(text) if xid not in source_xids and xid not in referenced_xids
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
candidates = _current_skill_candidates(
|
|
608
|
+
root,
|
|
609
|
+
str(source_skill_id) if source_skill_id else target_skill,
|
|
610
|
+
source_xids,
|
|
611
|
+
)
|
|
612
|
+
reference_issues = _reference_issues(source, files)
|
|
613
|
+
contract_gaps = _contract_gaps(meta_path)
|
|
614
|
+
split_indicators = _body_split_indicators(skill_doc)
|
|
615
|
+
|
|
616
|
+
safe_transformations: list[str] = []
|
|
617
|
+
if source.exists():
|
|
618
|
+
safe_transformations.append("run xrefkit xref fix after placing accepted files in the repository")
|
|
619
|
+
if meta_path and contract_gaps:
|
|
620
|
+
safe_transformations.append("scaffold missing trial-level metadata fields before promotion")
|
|
621
|
+
|
|
622
|
+
judgment_required: list[str] = []
|
|
623
|
+
if candidates:
|
|
624
|
+
judgment_required.append("confirm whether the old Skill and candidate current Skill are semantically the same")
|
|
625
|
+
if split_indicators:
|
|
626
|
+
judgment_required.append("review whether detected Skill body sections must be split into knowledge or core references")
|
|
627
|
+
if reference_issues:
|
|
628
|
+
judgment_required.append("review unmanaged or missing references before promotion")
|
|
629
|
+
|
|
630
|
+
if not source.exists():
|
|
631
|
+
proposed = "archive"
|
|
632
|
+
reasons = ["source path does not exist"]
|
|
633
|
+
elif not meta_path and not skill_doc:
|
|
634
|
+
proposed = "archive"
|
|
635
|
+
reasons = ["no meta.md or SKILL.md found"]
|
|
636
|
+
elif split_indicators:
|
|
637
|
+
proposed = "split"
|
|
638
|
+
reasons = split_indicators
|
|
639
|
+
elif candidates:
|
|
640
|
+
proposed = "merge"
|
|
641
|
+
reasons = ["exact structural candidate found"]
|
|
642
|
+
else:
|
|
643
|
+
proposed = "adopt"
|
|
644
|
+
reasons = ["no exact current Skill candidate found"]
|
|
645
|
+
|
|
646
|
+
if target_skill and not candidates:
|
|
647
|
+
proposed = "escalate"
|
|
648
|
+
reasons = ["target_skill was supplied but no exact current Skill candidate matched"]
|
|
649
|
+
judgment_required.append("decide whether supplied target_skill is the intended merge target")
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
"source": {
|
|
653
|
+
"path": str(source),
|
|
654
|
+
"source_version": source_version,
|
|
655
|
+
"files": [
|
|
656
|
+
{
|
|
657
|
+
"path": _repo_rel(path, source),
|
|
658
|
+
"sha256": _stable_file_hash(path),
|
|
659
|
+
}
|
|
660
|
+
for path in files
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
"identity": {
|
|
664
|
+
"source_skill_id": str(source_skill_id) if source_skill_id else None,
|
|
665
|
+
"source_xids": source_xids,
|
|
666
|
+
"referenced_xids": referenced_xids,
|
|
667
|
+
"candidate_targets": candidates,
|
|
668
|
+
},
|
|
669
|
+
"classification": {
|
|
670
|
+
"proposed": proposed,
|
|
671
|
+
"reasons": reasons,
|
|
672
|
+
},
|
|
673
|
+
"safe_transformations": safe_transformations,
|
|
674
|
+
"judgment_required": sorted(set(judgment_required)),
|
|
675
|
+
"contract_gaps": contract_gaps,
|
|
676
|
+
"reference_issues": reference_issues,
|
|
677
|
+
"ownership_issues": [],
|
|
678
|
+
"validation": {
|
|
679
|
+
"commands": [
|
|
680
|
+
"python -m xrefkit xref fix",
|
|
681
|
+
"python -m xrefkit skill check --scope all",
|
|
682
|
+
"python -m xrefkit pack lint",
|
|
683
|
+
"python tools/run_quality_gate.py xrefkit",
|
|
684
|
+
]
|
|
685
|
+
},
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@dataclass
|
|
690
|
+
class SkillListEntry:
|
|
691
|
+
skill_id: str | None
|
|
692
|
+
boundary: str
|
|
693
|
+
path: str
|
|
694
|
+
maturity: str | None
|
|
695
|
+
indexed: bool | None
|
|
696
|
+
|
|
697
|
+
def to_dict(self) -> dict[str, object]:
|
|
698
|
+
return {
|
|
699
|
+
"skill_id": self.skill_id,
|
|
700
|
+
"boundary": self.boundary,
|
|
701
|
+
"path": self.path,
|
|
702
|
+
"maturity": self.maturity,
|
|
703
|
+
"indexed": self.indexed,
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@dataclass
|
|
708
|
+
class SkillIndexEntry:
|
|
709
|
+
skill_id: str
|
|
710
|
+
summary: str
|
|
711
|
+
meta_path: str
|
|
712
|
+
skill_doc: str
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _collect_boundary_entries(root: Path) -> list[SkillListEntry]:
|
|
716
|
+
index_path = root / "skills" / "_index.md"
|
|
717
|
+
index_text = index_path.read_text(encoding="utf-8") if index_path.exists() else ""
|
|
718
|
+
|
|
719
|
+
entries: list[SkillListEntry] = []
|
|
720
|
+
ownership = load_optional_ownership(root)
|
|
721
|
+
for meta_path in content_files(root, "skills", "meta.md", ownership=ownership):
|
|
722
|
+
parsed = _parse_meta_lines(meta_path.read_text(encoding="utf-8"))
|
|
723
|
+
skill_id = parsed.get("skill_id")
|
|
724
|
+
maturity, _ = _resolve_maturity(parsed)
|
|
725
|
+
rel_dir = meta_path.parent.relative_to(root).as_posix()
|
|
726
|
+
indexed = f"{rel_dir}/meta.md" in index_text or f"{rel_dir}/SKILL.md" in index_text
|
|
727
|
+
entries.append(
|
|
728
|
+
SkillListEntry(
|
|
729
|
+
skill_id=str(skill_id) if skill_id else None,
|
|
730
|
+
boundary="public",
|
|
731
|
+
path=rel_dir,
|
|
732
|
+
maturity=maturity,
|
|
733
|
+
indexed=indexed,
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
base = root / "skills_private"
|
|
737
|
+
if base.exists():
|
|
738
|
+
for meta_path in sorted(base.rglob("meta.md")):
|
|
739
|
+
parsed = _parse_meta_lines(meta_path.read_text(encoding="utf-8"))
|
|
740
|
+
skill_id = parsed.get("skill_id")
|
|
741
|
+
maturity, _ = _resolve_maturity(parsed)
|
|
742
|
+
rel_dir = meta_path.parent.relative_to(root).as_posix()
|
|
743
|
+
entries.append(
|
|
744
|
+
SkillListEntry(
|
|
745
|
+
skill_id=str(skill_id) if skill_id else None,
|
|
746
|
+
boundary="private",
|
|
747
|
+
path=rel_dir,
|
|
748
|
+
maturity=maturity,
|
|
749
|
+
indexed=None,
|
|
750
|
+
)
|
|
751
|
+
)
|
|
752
|
+
return entries
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _skill_doc_path(root: Path, meta_path: Path, parsed: dict[str, object]) -> str:
|
|
756
|
+
value = parsed.get("skill_doc")
|
|
757
|
+
if isinstance(value, str) and value.strip():
|
|
758
|
+
candidate = (meta_path.parent / value.strip().strip("`")).resolve()
|
|
759
|
+
else:
|
|
760
|
+
candidate = meta_path.parent / "SKILL.md"
|
|
761
|
+
try:
|
|
762
|
+
return candidate.relative_to(root).as_posix()
|
|
763
|
+
except ValueError:
|
|
764
|
+
return candidate.as_posix()
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _collect_public_skill_index_entries(root: Path) -> list[SkillIndexEntry]:
|
|
768
|
+
entries: list[SkillIndexEntry] = []
|
|
769
|
+
ownership = load_optional_ownership(root)
|
|
770
|
+
for meta_path in content_files(root, "skills", "meta.md", ownership=ownership):
|
|
771
|
+
parsed = _parse_meta_lines(meta_path.read_text(encoding="utf-8"))
|
|
772
|
+
skill_id = parsed.get("skill_id")
|
|
773
|
+
if not isinstance(skill_id, str) or not skill_id.strip():
|
|
774
|
+
continue
|
|
775
|
+
summary = parsed.get("summary")
|
|
776
|
+
rel_meta = meta_path.relative_to(root).as_posix()
|
|
777
|
+
entries.append(
|
|
778
|
+
SkillIndexEntry(
|
|
779
|
+
skill_id=skill_id.strip(),
|
|
780
|
+
summary=str(summary).strip() if isinstance(summary, str) and summary.strip() else "(summary missing)",
|
|
781
|
+
meta_path=rel_meta,
|
|
782
|
+
skill_doc=_skill_doc_path(root, meta_path, parsed),
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
return sorted(entries, key=lambda entry: (entry.meta_path, entry.skill_id))
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def build_generated_skill_index(root: Path) -> str:
|
|
789
|
+
index_path = root / "skills" / "_index.md"
|
|
790
|
+
if index_path.exists():
|
|
791
|
+
text = index_path.read_text(encoding="utf-8")
|
|
792
|
+
prefix = text.split("## Skills (compact)", 1)[0].rstrip()
|
|
793
|
+
else:
|
|
794
|
+
prefix = """<!-- xid: 8D91F66DDBB7 -->
|
|
795
|
+
<a id="xid-8D91F66DDBB7"></a>
|
|
796
|
+
|
|
797
|
+
# Skills Index
|
|
798
|
+
|
|
799
|
+
This page is the routing entry for skills.
|
|
800
|
+
It is intentionally compact for context efficiency.
|
|
801
|
+
When asked "what skills are available?", answer from this file."""
|
|
802
|
+
|
|
803
|
+
lines = [
|
|
804
|
+
prefix,
|
|
805
|
+
"",
|
|
806
|
+
"## Skills (compact)",
|
|
807
|
+
"",
|
|
808
|
+
"Generated by `python -m xrefkit skill index --write` from catalog-visible `meta.md` files.",
|
|
809
|
+
"",
|
|
810
|
+
"Current family paths:",
|
|
811
|
+
"",
|
|
812
|
+
"- `skills/os/` for OS utility Skills",
|
|
813
|
+
"- `skills/packs/<pack>/` for legacy Business Pack paths during transition",
|
|
814
|
+
"- `packs/<pack>/skills/` for shared pack Skills",
|
|
815
|
+
"- `packs/local/<system>/skills/` for local-instance Skills; these are catalog-visible locally but not distributable",
|
|
816
|
+
"- existing top-level `skills/<skill_id>/` paths remain valid for Skills that have not yet moved",
|
|
817
|
+
"",
|
|
818
|
+
]
|
|
819
|
+
for entry in _collect_public_skill_index_entries(root):
|
|
820
|
+
lines.extend(
|
|
821
|
+
[
|
|
822
|
+
f"- `{entry.skill_id}`:",
|
|
823
|
+
f" - summary: {entry.summary}",
|
|
824
|
+
f" - meta: `{entry.meta_path}`",
|
|
825
|
+
f" - skill_doc: `{entry.skill_doc}`",
|
|
826
|
+
]
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
lines.extend(
|
|
830
|
+
[
|
|
831
|
+
"",
|
|
832
|
+
"## Notes",
|
|
833
|
+
"",
|
|
834
|
+
"- Keep this file lightweight; detailed fields belong in `meta.md`.",
|
|
835
|
+
"- Keep behavior/procedure in `SKILL.md`.",
|
|
836
|
+
"- Keep factual domain content in `knowledge/`.",
|
|
837
|
+
"- For the AI Agent OS reorganization view of `skills/`, see:",
|
|
838
|
+
" - [OS utility and business skill classification design](../docs/designs/064_os_utility_and_business_skill_classification_design.md#xid-ECF29DC3E268)",
|
|
839
|
+
" - [Business intake pack dependency design](../docs/packs/business-intake/065_business_intake_pack_dependency_design.md#xid-D334C1964342)",
|
|
840
|
+
"",
|
|
841
|
+
]
|
|
842
|
+
)
|
|
843
|
+
return "\n".join(lines)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def cmd_skill_index(args) -> int:
|
|
847
|
+
root = Path(args.root).resolve()
|
|
848
|
+
rendered = build_generated_skill_index(root)
|
|
849
|
+
out_path = root / "skills" / "_index.md"
|
|
850
|
+
if args.write:
|
|
851
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
852
|
+
current = out_path.read_text(encoding="utf-8") if out_path.exists() else None
|
|
853
|
+
if current != rendered:
|
|
854
|
+
out_path.write_text(rendered, encoding="utf-8", newline="\n")
|
|
855
|
+
if args.json:
|
|
856
|
+
print(json.dumps({"path": out_path.relative_to(root).as_posix(), "changed": current != rendered}, indent=2))
|
|
857
|
+
else:
|
|
858
|
+
print(f"wrote: {out_path.relative_to(root).as_posix()}")
|
|
859
|
+
return 0
|
|
860
|
+
if args.json:
|
|
861
|
+
entries = [entry.__dict__ for entry in _collect_public_skill_index_entries(root)]
|
|
862
|
+
print(json.dumps({"path": "skills/_index.md", "entries": entries}, ensure_ascii=False, indent=2))
|
|
863
|
+
else:
|
|
864
|
+
print(rendered)
|
|
865
|
+
return 0
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _git_tracked_private_files(root: Path) -> list[str] | None:
|
|
869
|
+
try:
|
|
870
|
+
proc = subprocess.run(
|
|
871
|
+
["git", "-C", str(root), "ls-files", "--", *PRIVATE_SCOPE_DIRS],
|
|
872
|
+
capture_output=True,
|
|
873
|
+
text=True,
|
|
874
|
+
check=False,
|
|
875
|
+
)
|
|
876
|
+
except OSError:
|
|
877
|
+
return None
|
|
878
|
+
if proc.returncode != 0:
|
|
879
|
+
return None
|
|
880
|
+
return [line for line in proc.stdout.splitlines() if line.strip()]
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _private_refs_from_public(root: Path) -> list[str]:
|
|
884
|
+
hits: list[str] = []
|
|
885
|
+
for scope in PUBLIC_TEXT_DIRS:
|
|
886
|
+
base = root / scope
|
|
887
|
+
if not base.exists():
|
|
888
|
+
continue
|
|
889
|
+
for path in sorted(base.rglob("*")):
|
|
890
|
+
if path.suffix.lower() not in {".md", ".yaml", ".yml"}:
|
|
891
|
+
continue
|
|
892
|
+
try:
|
|
893
|
+
text = path.read_text(encoding="utf-8")
|
|
894
|
+
except (UnicodeDecodeError, OSError):
|
|
895
|
+
continue
|
|
896
|
+
lines = text.splitlines()
|
|
897
|
+
for match in PRIVATE_CONCRETE_REF.finditer(text):
|
|
898
|
+
line_no = text.count("\n", 0, match.start()) + 1
|
|
899
|
+
# Inline suppression with justification, same idiom as the
|
|
900
|
+
# repo's CA1031 pragmas: a line carrying `private-ref-ok`
|
|
901
|
+
# is a reviewed, deliberate boundary-convention pointer.
|
|
902
|
+
if "private-ref-ok" in lines[line_no - 1]:
|
|
903
|
+
continue
|
|
904
|
+
rel = path.relative_to(root).as_posix()
|
|
905
|
+
hits.append(f"{rel}:{line_no}: {match.group(0)}")
|
|
906
|
+
return hits
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def cmd_skill_list(args) -> int:
|
|
910
|
+
root = Path(args.root).resolve()
|
|
911
|
+
entries = _collect_boundary_entries(root)
|
|
912
|
+
|
|
913
|
+
violations: list[str] = []
|
|
914
|
+
warnings: list[str] = []
|
|
915
|
+
|
|
916
|
+
tracked_private = _git_tracked_private_files(root)
|
|
917
|
+
if tracked_private is None:
|
|
918
|
+
warnings.append("git unavailable: tracked-private check skipped")
|
|
919
|
+
else:
|
|
920
|
+
for tracked in tracked_private:
|
|
921
|
+
violations.append(f"private file is git-tracked (will be published on push): {tracked}")
|
|
922
|
+
|
|
923
|
+
for hit in _private_refs_from_public(root):
|
|
924
|
+
violations.append(f"public asset references a concrete private path: {hit}")
|
|
925
|
+
|
|
926
|
+
public_ids = {e.skill_id for e in entries if e.boundary == "public" and e.skill_id}
|
|
927
|
+
for entry in entries:
|
|
928
|
+
if entry.boundary == "public" and entry.indexed is False:
|
|
929
|
+
warnings.append(
|
|
930
|
+
f"public skill not registered in skills/_index.md (misplaced private skill?): {entry.path}"
|
|
931
|
+
)
|
|
932
|
+
if entry.boundary == "private" and entry.skill_id in public_ids:
|
|
933
|
+
warnings.append(
|
|
934
|
+
f"skill_id exists on both sides of the boundary (mid-migration?): {entry.skill_id}"
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
payload = {
|
|
938
|
+
"skills": [entry.to_dict() for entry in entries],
|
|
939
|
+
"public_count": sum(1 for e in entries if e.boundary == "public"),
|
|
940
|
+
"private_count": sum(1 for e in entries if e.boundary == "private"),
|
|
941
|
+
"violations": violations,
|
|
942
|
+
"warnings": warnings,
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if args.json:
|
|
946
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
947
|
+
else:
|
|
948
|
+
for boundary in ("public", "private"):
|
|
949
|
+
group = [e for e in entries if e.boundary == boundary]
|
|
950
|
+
print(f"{boundary} ({len(group)}):")
|
|
951
|
+
for entry in group:
|
|
952
|
+
indexed = ""
|
|
953
|
+
if entry.indexed is not None:
|
|
954
|
+
indexed = " indexed" if entry.indexed else " NOT-INDEXED"
|
|
955
|
+
print(f" {entry.skill_id or '?':40} {entry.maturity or '?':10}{indexed} {entry.path}")
|
|
956
|
+
for warning in warnings:
|
|
957
|
+
print(f"warning: {warning}")
|
|
958
|
+
for violation in violations:
|
|
959
|
+
print(f"VIOLATION: {violation}")
|
|
960
|
+
print(f"violations: {len(violations)}")
|
|
961
|
+
|
|
962
|
+
return 1 if violations else 0
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def cmd_skill_merge_plan(args) -> int:
|
|
966
|
+
root = Path(args.root).resolve()
|
|
967
|
+
source = (root / args.source).resolve() if not Path(args.source).is_absolute() else Path(args.source).resolve()
|
|
968
|
+
payload = build_skill_merge_plan(
|
|
969
|
+
root=root,
|
|
970
|
+
source=source,
|
|
971
|
+
target_skill=args.target_skill,
|
|
972
|
+
source_version=args.source_version,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
if args.json:
|
|
976
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
977
|
+
else:
|
|
978
|
+
classification = payload["classification"]
|
|
979
|
+
identity = payload["identity"]
|
|
980
|
+
print(f"source: {payload['source']['path']}")
|
|
981
|
+
print(f"source_skill_id: {identity['source_skill_id'] or '-'}")
|
|
982
|
+
print(f"proposed: {classification['proposed']}")
|
|
983
|
+
for reason in classification["reasons"]:
|
|
984
|
+
print(f" reason: {reason}")
|
|
985
|
+
for item in payload["judgment_required"]:
|
|
986
|
+
print(f"judgment_required: {item}")
|
|
987
|
+
for gap in payload["contract_gaps"]:
|
|
988
|
+
print(f"contract_gap: {gap}")
|
|
989
|
+
|
|
990
|
+
return 0
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _iter_meta_files(root: Path, scope: str) -> Iterable[Path]:
|
|
994
|
+
ownership = load_optional_ownership(root)
|
|
995
|
+
yield from content_files(root, "skills", "meta.md", ownership=ownership)
|
|
996
|
+
if scope == "all":
|
|
997
|
+
base = root / "skills_private"
|
|
998
|
+
if base.exists():
|
|
999
|
+
yield from sorted(base.rglob("meta.md"))
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def cmd_skill(args) -> int:
|
|
1003
|
+
root = Path(args.root).resolve()
|
|
1004
|
+
targets: list[Path] = []
|
|
1005
|
+
|
|
1006
|
+
if args.meta:
|
|
1007
|
+
targets.append((root / args.meta).resolve())
|
|
1008
|
+
else:
|
|
1009
|
+
targets.extend(_iter_meta_files(root, args.scope))
|
|
1010
|
+
|
|
1011
|
+
results = [validate_skill_meta(path, check_level=args.level) for path in targets]
|
|
1012
|
+
failed = [result for result in results if not result.ok]
|
|
1013
|
+
|
|
1014
|
+
if args.json:
|
|
1015
|
+
print(json.dumps([result.to_dict() for result in results], ensure_ascii=False, indent=2))
|
|
1016
|
+
else:
|
|
1017
|
+
for result in results:
|
|
1018
|
+
status = "ok" if result.ok else "fail"
|
|
1019
|
+
print(f"{status}: {result.meta_path}")
|
|
1020
|
+
if result.skill_id:
|
|
1021
|
+
print(f" skill_id: {result.skill_id}")
|
|
1022
|
+
if result.maturity:
|
|
1023
|
+
print(f" maturity: {result.maturity}")
|
|
1024
|
+
print(f" checked_level: {result.checked_level}")
|
|
1025
|
+
if result.execution_mode:
|
|
1026
|
+
print(f" execution_mode: {result.execution_mode}")
|
|
1027
|
+
if result.guard_policy:
|
|
1028
|
+
print(f" guard_policy: {result.guard_policy}")
|
|
1029
|
+
for error in result.errors:
|
|
1030
|
+
print(f" error: {error}")
|
|
1031
|
+
for warning in result.warnings:
|
|
1032
|
+
print(f" warning: {warning}")
|
|
1033
|
+
|
|
1034
|
+
return 1 if failed else 0
|