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/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