xrefkit 0.4.5__tar.gz → 0.4.7__tar.gz

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 (111) hide show
  1. {xrefkit-0.4.5 → xrefkit-0.4.7}/PKG-INFO +1 -1
  2. {xrefkit-0.4.5 → xrefkit-0.4.7}/pyproject.toml +1 -1
  3. xrefkit-0.4.7/tests/test_skills_sync.py +55 -0
  4. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/__init__.py +1 -1
  5. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/cli.py +5 -0
  6. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/skillrun.py +198 -10
  7. xrefkit-0.4.7/xrefkit/skills_sync.py +278 -0
  8. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit.egg-info/PKG-INFO +1 -1
  9. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit.egg-info/SOURCES.txt +2 -0
  10. {xrefkit-0.4.5 → xrefkit-0.4.7}/LICENSE +0 -0
  11. {xrefkit-0.4.5 → xrefkit-0.4.7}/README.md +0 -0
  12. {xrefkit-0.4.5 → xrefkit-0.4.7}/setup.cfg +0 -0
  13. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_base_sync_ownership.py +0 -0
  14. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_boundary_analysis.py +0 -0
  15. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_calibration_lint.py +0 -0
  16. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_check_skill_knowledge_xids.py +0 -0
  17. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_cli.py +0 -0
  18. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_collect_analyzer_sarif.py +0 -0
  19. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_convert_to_xrefkit_skill.py +0 -0
  20. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_cs_scope_probe.py +0 -0
  21. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_csharp_commonality.py +0 -0
  22. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_csharp_naming_profile.py +0 -0
  23. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_ctx.py +0 -0
  24. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_cutover_readiness.py +0 -0
  25. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_dashboard.py +0 -0
  26. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_error_policy_audit.py +0 -0
  27. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_error_policy_locator.py +0 -0
  28. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_fm_multiroot.py +0 -0
  29. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_gate.py +0 -0
  30. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_goal_desired_state.py +0 -0
  31. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_instruction_workflow.py +0 -0
  32. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_knowledge_relations_validator.py +0 -0
  33. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_mcp_setup.py +0 -0
  34. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_ownership.py +0 -0
  35. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_packmeta.py +0 -0
  36. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_project_quality_baseline.py +0 -0
  37. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_resource_provider.py +0 -0
  38. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_runtime_contracts.py +0 -0
  39. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_sarif_to_locator.py +0 -0
  40. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_skill_runtime_audit.py +0 -0
  41. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_skillmeta.py +0 -0
  42. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_structure_catalog.py +0 -0
  43. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_xref.py +0 -0
  44. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_xrefkit_instance.py +0 -0
  45. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_xrefkit_tools.py +0 -0
  46. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_xrefkit_v2_discovery.py +0 -0
  47. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_xrefkit_v2_models.py +0 -0
  48. {xrefkit-0.4.5 → xrefkit-0.4.7}/tests/test_xrefkit_v2_pipeline.py +0 -0
  49. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/__main__.py +0 -0
  50. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/boundary_analysis.py +0 -0
  51. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/catalog_cli.py +0 -0
  52. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/contracts.py +0 -0
  53. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/ctx.py +0 -0
  54. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/dashboard.py +0 -0
  55. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/discovery.py +0 -0
  56. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/gate.py +0 -0
  57. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/goalstate.py +0 -0
  58. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/hashing.py +0 -0
  59. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/import_skill.py +0 -0
  60. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/instance.py +0 -0
  61. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/loaders.py +0 -0
  62. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/__init__.py +0 -0
  63. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/audit.py +0 -0
  64. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/bootstrap.py +0 -0
  65. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/catalog.py +0 -0
  66. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/cli.py +0 -0
  67. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/client_cache.py +0 -0
  68. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/context_registry.py +0 -0
  69. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/context_token.py +0 -0
  70. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/contracts.py +0 -0
  71. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/dist.py +0 -0
  72. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/ownership.py +0 -0
  73. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/repository.py +0 -0
  74. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/schemas.py +0 -0
  75. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/server.py +0 -0
  76. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/setup.py +0 -0
  77. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp/startup_contract_pack.py +0 -0
  78. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/mcp_tools.py +0 -0
  79. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/__init__.py +0 -0
  80. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/common.py +0 -0
  81. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/effective_bundle.py +0 -0
  82. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/local_manifest.py +0 -0
  83. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/package_manifest.py +0 -0
  84. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/run_log.py +0 -0
  85. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/server_config.py +0 -0
  86. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/models/skill_definition.py +0 -0
  87. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/operations_cli.py +0 -0
  88. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/ownership.py +0 -0
  89. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/packmeta.py +0 -0
  90. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/registry.py +0 -0
  91. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resolver.py +0 -0
  92. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resource_provider.py +0 -0
  93. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resources/base/contracts.json +0 -0
  94. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resources/base/current.json +0 -0
  95. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resources/base/generations/7a682a5272907354/contracts.json +0 -0
  96. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resources/base/generations/7a682a5272907354/model_body.md +0 -0
  97. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resources/base/generations/9929294385ccb7b0/contracts.json +0 -0
  98. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resources/base/generations/9929294385ccb7b0/model_body.md +0 -0
  99. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/resources/base/model_body.md +0 -0
  100. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/runlog.py +0 -0
  101. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/skillmeta.py +0 -0
  102. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/structure_catalog.py +0 -0
  103. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/tools/__init__.py +0 -0
  104. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/tools/__main__.py +0 -0
  105. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/v2_cli.py +0 -0
  106. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/workspace.py +0 -0
  107. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit/xref.py +0 -0
  108. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit.egg-info/dependency_links.txt +0 -0
  109. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit.egg-info/entry_points.txt +0 -0
  110. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit.egg-info/requires.txt +0 -0
  111. {xrefkit-0.4.5 → xrefkit-0.4.7}/xrefkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xrefkit
3
- Version: 0.4.5
3
+ Version: 0.4.7
4
4
  Summary: Portable XID, Skill, Knowledge, workflow, and MCP runtime for XRefKit
5
5
  Author: synthaicode
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "xrefkit"
7
- version = "0.4.5"
7
+ version = "0.4.7"
8
8
  description = "Portable XID, Skill, Knowledge, workflow, and MCP runtime for XRefKit"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from xrefkit.skills_sync import sync_bundle
11
+
12
+
13
+ def _zip_bytes() -> bytes:
14
+ payload = io.BytesIO()
15
+ with zipfile.ZipFile(payload, "w") as archive:
16
+ archive.writestr("xrefkit-skills-demo/skills/demo/meta.md", "<!-- xid: skill-demo -->\n# Demo\n")
17
+ archive.writestr("xrefkit-skills-demo/skills/demo/SKILL.md", "<!-- xid: skill-demo-body -->\n# Demo\n")
18
+ archive.writestr("xrefkit-skills-demo/knowledge/demo.md", "<!-- xid: knowledge-demo -->\n# Knowledge\n")
19
+ return payload.getvalue()
20
+
21
+
22
+ def test_sync_bundle_extracts_skill_and_knowledge(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
23
+ release = {
24
+ "tag_name": "skills-2026.07.30",
25
+ "assets": [{"name": "xrefkit-skills-demo-1.0.0.zip", "browser_download_url": "https://example.test/demo.zip"}],
26
+ }
27
+
28
+ def fake_json(_url: str) -> dict:
29
+ return release
30
+
31
+ def fake_download(_asset: dict) -> tuple[str, bytes]:
32
+ return "xrefkit-skills-demo-1.0.0.zip", _zip_bytes()
33
+
34
+ monkeypatch.setattr("xrefkit.skills_sync._github_json", fake_json)
35
+ monkeypatch.setattr("xrefkit.skills_sync._download_asset", fake_download)
36
+
37
+ result = sync_bundle(repo=tmp_path, source_repository="owner/repo", bundle="demo")
38
+
39
+ assert result.release == "skills-2026.07.30"
40
+ assert (tmp_path / "skills/demo/SKILL.md").is_file()
41
+ assert (tmp_path / "knowledge/demo.md").is_file()
42
+ state = json.loads((tmp_path / ".xrefkit/skill-sync/demo.json").read_text(encoding="utf-8"))
43
+ assert state["files"] == ["knowledge/demo.md", "skills/demo/SKILL.md", "skills/demo/meta.md"]
44
+
45
+
46
+ def test_sync_bundle_refuses_unmanaged_collision(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
47
+ release = {"tag_name": "v1", "assets": [{"name": "xrefkit-skills-demo-1.0.0.zip", "browser_download_url": "https://example.test/demo.zip"}]}
48
+ monkeypatch.setattr("xrefkit.skills_sync._github_json", lambda _url: release)
49
+ monkeypatch.setattr("xrefkit.skills_sync._download_asset", lambda _asset: ("demo.zip", _zip_bytes()))
50
+ target = tmp_path / "skills/demo/SKILL.md"
51
+ target.parent.mkdir(parents=True)
52
+ target.write_text("local", encoding="utf-8")
53
+
54
+ with pytest.raises(RuntimeError, match="not owned by sync state"):
55
+ sync_bundle(repo=tmp_path, source_repository="owner/repo", bundle="demo")
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.5"
5
+ __version__ = "0.4.7"
@@ -27,6 +27,7 @@ def _print_help() -> None:
27
27
  " dashboard inspect runtime state\n"
28
28
  " analysis generate proposal-only observation reports\n"
29
29
  " mcp start the integrated MCP server\n"
30
+ " skills synchronize released Skill and Knowledge bundles\n"
30
31
  " package inspect installed Skill packages\n"
31
32
  " show show effective Skill bundles"
32
33
  )
@@ -67,6 +68,10 @@ def main(argv: Sequence[str] | None = None) -> int:
67
68
  from .mcp import main as mcp_main
68
69
 
69
70
  return mcp_main(args[1:])
71
+ if command == "skills":
72
+ from .skills_sync import main as skills_main
73
+
74
+ return skills_main(args[1:])
70
75
 
71
76
  print(f"xrefkit: unknown command: {command}", file=sys.stderr)
72
77
  _print_help()
@@ -434,6 +434,10 @@ def _render_log(
434
434
  worklist_lines = "\n".join(
435
435
  f"- [ ] {name}: {description}" for name, description in WORKLIST_ROWS
436
436
  )
437
+ worklist_report_lines = "\n".join(
438
+ f"| [{name}](#{_safe_slug(name.lower())}) | {description} | Phase checklist | not_checked | [{name}](#{_safe_slug(name.lower())}) |"
439
+ for name, description in WORKLIST_ROWS
440
+ )
437
441
  handoff_source_lines = "\n".join(
438
442
  f"- source_log: `{source['source_log']}` skill_id=`{source['skill_id']}` closure=`{source['closure']}` handoff=`{source['handoff']}`"
439
443
  for source in handoff_sources
@@ -494,6 +498,8 @@ def _render_log(
494
498
  - meta: `{meta_path.as_posix()}`
495
499
  - skill_doc: `{skill_doc.as_posix()}`
496
500
  - task: {task}
501
+ - report_language: `user_language`
502
+ - language_rule: `human-facing report prose follows the user's language; runtime keys, status enums, IDs, paths, and commands remain stable`
497
503
 
498
504
  ## Skill Load Gate
499
505
 
@@ -602,6 +608,36 @@ def _render_log(
602
608
 
603
609
  ## Worklist
604
610
 
611
+ ### Report
612
+
613
+ ### Status
614
+ pending
615
+
616
+ ### Reason
617
+ The workflow protocol has not started; phase results are not yet available.
618
+
619
+ ### Result
620
+ The workflow protocol worklist has not started.
621
+
622
+ ### Checks Performed
623
+
624
+ | Check ID | What was checked | Target / Scope | Result | Evidence / Details |
625
+ | --- | --- | --- | --- | --- |
626
+ {worklist_report_lines}
627
+
628
+ ### Evidence
629
+ - Phase checklist and phase sections in this Run Log
630
+ - Phase events recorded below
631
+
632
+ ### Open Items
633
+ - All workflow phases are not_checked.
634
+
635
+ ### Handoff
636
+ - Next owner: executor
637
+ - Next action: complete Startup and Planning.
638
+
639
+ ### Phase Checklist
640
+
605
641
  {worklist_lines}
606
642
 
607
643
  ## Concrete Work Items
@@ -631,6 +667,33 @@ def _render_log(
631
667
  - policy: `{quality_policy}`
632
668
  - rule: declare acceptance check items as `check`-kind artifacts at planning; an independent quality reviewer sets each to `done` (pass) or `blocked` (fail) with `xrefkit skill artifact`; domain reviews run as separate review Skills orchestrated by the main session and linked here. Required when model_tier is `standard` or `heavy`; optional otherwise
633
669
 
670
+ ### Report
671
+
672
+ ### Status
673
+ pending
674
+
675
+ ### Reason
676
+ Quality acceptance checks have not yet been recorded.
677
+
678
+ ### Result
679
+ No quality checklist items have been recorded yet.
680
+
681
+ ### Checks Performed
682
+
683
+ | Check ID | What was checked | Target / Scope | Result | Evidence / Details |
684
+ | --- | --- | --- | --- | --- |
685
+ | none | No check artifact recorded | Quality Gate | not_checked | [Runtime Artifacts](#runtime-artifacts) |
686
+
687
+ ### Evidence
688
+ - Check-kind artifacts in Runtime Artifacts
689
+
690
+ ### Open Items
691
+ - Quality acceptance checks are pending.
692
+
693
+ ### Handoff
694
+ - Next owner: quality_reviewer
695
+ - Next action: record each acceptance check as a check-kind artifact.
696
+
634
697
  ## Unknowns And Risks
635
698
 
636
699
  - status: `pending`
@@ -675,12 +738,14 @@ def _append_phase_event(text: str, *, phase: str, status: str, role: str | None,
675
738
 
676
739
 
677
740
  def _section_status(text: str, section: str) -> str | None:
678
- marker = f"## {section}\n"
679
- start = text.find(marker)
680
- if start == -1:
741
+ heading = re.compile(rf"^## {re.escape(section)}\s*$", re.MULTILINE)
742
+ match = heading.search(text)
743
+ if match is None:
681
744
  return None
745
+ start = match.start()
682
746
 
683
- next_section = text.find("\n## ", start + len(marker))
747
+ next_heading = re.compile(r"^## (?!#).*$", re.MULTILINE).search(text, match.end())
748
+ next_section = next_heading.start() if next_heading else -1
684
749
  body = text[start:] if next_section == -1 else text[start:next_section]
685
750
  prefix = "- status: `"
686
751
  status_start = body.find(prefix)
@@ -694,12 +759,13 @@ def _section_status(text: str, section: str) -> str | None:
694
759
 
695
760
 
696
761
  def _section_body(text: str, section: str) -> tuple[str | None, int, int]:
697
- marker = f"## {section}\n"
698
- start = text.find(marker)
699
- if start == -1:
762
+ heading = re.compile(rf"^## {re.escape(section)}\s*$", re.MULTILINE)
763
+ match = heading.search(text)
764
+ if match is None:
700
765
  return None, -1, -1
701
- next_section = text.find("\n## ", start + len(marker))
702
- end = len(text) if next_section == -1 else next_section
766
+ start = match.start()
767
+ next_heading = re.compile(r"^## (?!#).*$", re.MULTILINE).search(text, match.end())
768
+ end = len(text) if next_heading is None else next_heading.start()
703
769
  return text[start:end], start, end
704
770
 
705
771
 
@@ -801,7 +867,128 @@ def _set_phase_status(text: str, *, phase: str, status: str) -> str:
801
867
  text, changed = _replace_line(text, old_status, new_status)
802
868
  if changed:
803
869
  break
804
- return text
870
+ return _refresh_quality_report(_refresh_worklist_report(text))
871
+
872
+
873
+ def _refresh_worklist_report(text: str) -> str:
874
+ """Keep the human-facing worklist report aligned with phase checkboxes."""
875
+ body, _, _ = _section_body(text, "Worklist")
876
+ if body is None or "### Phase Checklist\n" not in body:
877
+ return text
878
+
879
+ rows: list[tuple[str, str, str]] = []
880
+ for name, _description in WORKLIST_ROWS:
881
+ match = re.search(
882
+ rf"^- \[(?P<checkbox>[ x!])\] {re.escape(name)}: (?P<description>.+)$",
883
+ body,
884
+ flags=re.MULTILINE,
885
+ )
886
+ if match:
887
+ checkbox = match.group("checkbox")
888
+ result = "pass" if checkbox == "x" else "fail" if checkbox == "!" else "not_checked"
889
+ rows.append((name, match.group("description"), result))
890
+
891
+ if not rows:
892
+ return text
893
+ completed = sum(result == "pass" for _, _, result in rows)
894
+ if completed == len(rows):
895
+ status = "done"
896
+ elif any(result == "fail" for _, _, result in rows):
897
+ status = "blocked"
898
+ elif completed:
899
+ status = "partial"
900
+ else:
901
+ status = "pending"
902
+ open_items = [name for name, _, result in rows if result != "pass"] or ["なし"]
903
+ next_phase = next((name for name, _, result in rows if result != "pass"), "なし")
904
+ table = "\n".join(
905
+ f"| {name} | {description} | {result} | Phase checklist |"
906
+ for name, description, result in rows
907
+ )
908
+ report = (
909
+ "### Report\n\n"
910
+ f"### Status\n{status}\n\n"
911
+ + "### Reason\n"
912
+ + (
913
+ "All workflow phases are complete."
914
+ if status == "done"
915
+ else f"The workflow remains {status}; incomplete phases: {', '.join(open_items)}."
916
+ )
917
+ + "\n\n"
918
+ f"### Result\n{completed} of {len(rows)} workflow protocol phases are complete.\n\n"
919
+ "### Checks Performed\n\n"
920
+ "| Check ID | What was checked | Target / Scope | Result | Evidence / Details |\n"
921
+ "| --- | --- | --- | --- | --- |\n"
922
+ + "\n".join(
923
+ f"| [{name}](#{_safe_slug(name.lower())}) | {description} | Phase checklist | {result} | [{name}](#{_safe_slug(name.lower())}) |"
924
+ for name, description, result in rows
925
+ )
926
+ + "\n\n"
927
+ "### Evidence\n"
928
+ "- Phase checklist and phase sections in this Run Log\n"
929
+ "- Phase events recorded below\n\n"
930
+ "### Open Items\n"
931
+ + "\n".join(f"- {item}" for item in open_items)
932
+ + "\n\n### Handoff\n"
933
+ f"- Next owner: executor\n- Next action: advance {next_phase}.\n"
934
+ )
935
+ updated = re.sub(
936
+ r"(?ms)### Report\n\n.*?(?=\n### Phase Checklist\n)",
937
+ report.rstrip(),
938
+ body,
939
+ count=1,
940
+ )
941
+ start = text.find(body)
942
+ return text if start == -1 else text[:start] + updated + text[start + len(body):]
943
+
944
+
945
+ def _refresh_quality_report(text: str) -> str:
946
+ """Render quality check artifacts as the common checklist report."""
947
+ body, start, end = _section_body(text, "Quality Gate")
948
+ if body is None or "### Report\n" not in body:
949
+ return text
950
+ checks = [artifact for artifact in _parse_artifacts(text) if artifact["kind"] == "check"]
951
+ status = _section_status(text, "Quality Gate") or "pending"
952
+ if checks:
953
+ rows = []
954
+ for check in checks:
955
+ result = {
956
+ "done": "pass",
957
+ "blocked": "fail",
958
+ "na": "not_applicable",
959
+ "pending": "not_checked",
960
+ }.get(check["status"], check["status"])
961
+ rows.append(
962
+ f"| {check['artifact_id']} | {check['note']} | {check['target']} | {result} | [{check['artifact_id']}](#runtime-artifacts) |"
963
+ )
964
+ result_text = f"{len(checks)} quality checklist item(s) are recorded."
965
+ open_items = [check["artifact_id"] for check in checks if check["status"] not in {"done", "na"}] or ["なし"]
966
+ else:
967
+ rows = ["| none | No check artifact recorded | Quality Gate | not_checked | [Runtime Artifacts](#runtime-artifacts) |"]
968
+ result_text = "No quality checklist items have been recorded yet."
969
+ open_items = ["Quality acceptance checks are pending."]
970
+ report = (
971
+ "### Report\n\n"
972
+ f"### Status\n{status}\n\n"
973
+ + "### Reason\n"
974
+ + (
975
+ "All quality checks are complete."
976
+ if status == "done"
977
+ else f"The quality gate remains {status}; open checks: {', '.join(open_items)}."
978
+ )
979
+ + "\n\n"
980
+ f"### Result\n{result_text}\n\n"
981
+ "### Checks Performed\n\n"
982
+ "| Check ID | What was checked | Target / Scope | Result | Evidence / Details |\n"
983
+ "| --- | --- | --- | --- | --- |\n"
984
+ + "\n".join(rows)
985
+ + "\n\n### Evidence\n- Check-kind artifacts in Runtime Artifacts\n\n"
986
+ "### Open Items\n"
987
+ + "\n".join(f"- {item}" for item in open_items)
988
+ + "\n\n### Handoff\n- Next owner: quality_reviewer\n- Next action: resolve open checklist items.\n"
989
+ )
990
+ updated = re.sub(r"(?ms)### Report\n\n.*?(?=\n## Unknowns And Risks\n)", report.rstrip(), body, count=1)
991
+ return text[:start] + updated + text[end:]
805
992
 
806
993
 
807
994
  def _workitem_checkbox(status: str) -> str:
@@ -1116,6 +1303,7 @@ def update_artifact(args) -> SkillRunResult:
1116
1303
  )
1117
1304
 
1118
1305
  text = _replace_runtime_artifacts_section(text, artifacts)
1306
+ text = _refresh_quality_report(text)
1119
1307
  text = _append_phase_event(text, phase=f"artifact:{artifact_id}", status=status, role=role, note=note or target)
1120
1308
  _atomic_write_text(log_path, text)
1121
1309
  return SkillRunResult(ok=True, skill_id=None, skill_doc=None, run_log=str(log_path), errors=[], artifacts=artifacts)
@@ -0,0 +1,278 @@
1
+ """Synchronize released Skill/Knowledge bundles into an XRefKit repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import tempfile
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ import zipfile
14
+ from dataclasses import dataclass
15
+ from pathlib import Path, PurePosixPath
16
+
17
+
18
+ DEFAULT_SOURCE_REPOSITORY = "synthaicode/XRefKit"
19
+ DEFAULT_GITHUB_API = "https://api.github.com"
20
+ ASSET_PREFIX = "xrefkit-skills-"
21
+ VERSION_SUFFIX_RE = re.compile(r"^(?P<bundle>.+)-(?P<version>\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?)$")
22
+ ALLOWED_ROOTS = {"skills", "knowledge", "review_axes", "schemas"}
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SyncResult:
27
+ bundle: str
28
+ release: str
29
+ asset: str
30
+ sha256: str
31
+ files: tuple[str, ...]
32
+ changed: bool
33
+ dry_run: bool = False
34
+
35
+ def to_dict(self) -> dict[str, object]:
36
+ return {
37
+ "bundle": self.bundle,
38
+ "release": self.release,
39
+ "asset": self.asset,
40
+ "sha256": self.sha256,
41
+ "files": list(self.files),
42
+ "changed": self.changed,
43
+ "dry_run": self.dry_run,
44
+ }
45
+
46
+
47
+ def _github_json(url: str) -> dict[str, object]:
48
+ request = urllib.request.Request(
49
+ url,
50
+ headers={
51
+ "Accept": "application/vnd.github+json",
52
+ "User-Agent": "xrefkit-skill-sync",
53
+ },
54
+ )
55
+ try:
56
+ with urllib.request.urlopen(request) as response: # nosec B310 - URL is GitHub API HTTPS
57
+ payload = json.loads(response.read().decode("utf-8"))
58
+ except (urllib.error.URLError, urllib.error.HTTPError) as exc:
59
+ raise RuntimeError(f"could not read GitHub release metadata: {url}") from exc
60
+ if not isinstance(payload, dict):
61
+ raise RuntimeError(f"GitHub release metadata was not an object: {url}")
62
+ return payload
63
+
64
+
65
+ def _release(source_repository: str, release: str) -> dict[str, object]:
66
+ encoded_repo = urllib.parse.quote(source_repository, safe="/")
67
+ if release == "latest":
68
+ url = f"{DEFAULT_GITHUB_API}/repos/{encoded_repo}/releases/latest"
69
+ else:
70
+ encoded_release = urllib.parse.quote(release, safe="")
71
+ url = f"{DEFAULT_GITHUB_API}/repos/{encoded_repo}/releases/tags/{encoded_release}"
72
+ return _github_json(url)
73
+
74
+
75
+ def _assets(release: dict[str, object]) -> list[dict[str, object]]:
76
+ assets = release.get("assets")
77
+ if not isinstance(assets, list):
78
+ raise RuntimeError("GitHub release has no assets list")
79
+ return [item for item in assets if isinstance(item, dict)]
80
+
81
+
82
+ def _asset_for_bundle(release: dict[str, object], bundle: str) -> dict[str, object]:
83
+ expected_prefix = f"{ASSET_PREFIX}{bundle}-"
84
+ candidates = [
85
+ asset
86
+ for asset in _assets(release)
87
+ if isinstance(asset.get("name"), str)
88
+ and asset["name"].startswith(expected_prefix)
89
+ and asset["name"].endswith(".zip")
90
+ ]
91
+ if len(candidates) != 1:
92
+ names = ", ".join(str(item.get("name")) for item in candidates) or "none"
93
+ raise RuntimeError(f"expected one release asset for bundle {bundle}; found: {names}")
94
+ return candidates[0]
95
+
96
+
97
+ def _download_asset(asset: dict[str, object]) -> tuple[str, bytes]:
98
+ name = asset.get("name")
99
+ url = asset.get("browser_download_url")
100
+ if not isinstance(name, str) or not isinstance(url, str):
101
+ raise RuntimeError("GitHub release asset has no usable name or download URL")
102
+ request = urllib.request.Request(url, headers={"User-Agent": "xrefkit-skill-sync"})
103
+ try:
104
+ with urllib.request.urlopen(request) as response: # nosec B310 - URL comes from GitHub release metadata
105
+ return name, response.read()
106
+ except (urllib.error.URLError, urllib.error.HTTPError) as exc:
107
+ raise RuntimeError(f"could not download release asset: {name}") from exc
108
+
109
+
110
+ def _safe_zip_extract(data: bytes, destination: Path) -> None:
111
+ with zipfile.ZipFile(__import__("io").BytesIO(data)) as archive:
112
+ for member in archive.infolist():
113
+ relative = PurePosixPath(member.filename)
114
+ if relative.is_absolute() or ".." in relative.parts:
115
+ raise RuntimeError(f"unsafe path in Skill bundle: {member.filename}")
116
+ target = (destination / Path(*relative.parts)).resolve()
117
+ target.relative_to(destination.resolve())
118
+ if member.is_dir():
119
+ target.mkdir(parents=True, exist_ok=True)
120
+ continue
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ target.write_bytes(archive.read(member))
123
+
124
+
125
+ def _bundle_root(extracted: Path) -> Path:
126
+ if any((extracted / name).is_dir() for name in ALLOWED_ROOTS):
127
+ return extracted
128
+ children = [path for path in extracted.iterdir() if path.is_dir()]
129
+ if len(children) == 1 and any((children[0] / name).is_dir() for name in ALLOWED_ROOTS):
130
+ return children[0]
131
+ raise RuntimeError("Skill bundle must contain skills/ or knowledge/ at its root")
132
+
133
+
134
+ def _bundle_files(root: Path) -> list[tuple[Path, Path]]:
135
+ files: list[tuple[Path, Path]] = []
136
+ for dirname in sorted(ALLOWED_ROOTS):
137
+ base = root / dirname
138
+ if not base.is_dir():
139
+ continue
140
+ for source in sorted(path for path in base.rglob("*") if path.is_file()):
141
+ files.append((source, Path(dirname) / source.relative_to(base)))
142
+ manifest = root / "package_manifest.yaml"
143
+ if manifest.is_file():
144
+ files.append((manifest, Path("package_manifest.yaml")))
145
+ if not files:
146
+ raise RuntimeError("Skill bundle contains no files under skills/ or knowledge/")
147
+ return files
148
+
149
+
150
+ def _state_path(repo: Path, bundle: str) -> Path:
151
+ return repo / ".xrefkit" / "skill-sync" / f"{bundle}.json"
152
+
153
+
154
+ def sync_bundle(
155
+ *,
156
+ repo: Path,
157
+ source_repository: str,
158
+ bundle: str,
159
+ release: str = "latest",
160
+ force: bool = False,
161
+ dry_run: bool = False,
162
+ ) -> SyncResult:
163
+ repo = repo.resolve()
164
+ if not repo.is_dir():
165
+ raise FileNotFoundError(f"XRefKit repository not found: {repo}")
166
+ release_payload = _release(source_repository, release)
167
+ asset = _asset_for_bundle(release_payload, bundle)
168
+ asset_name, data = _download_asset(asset)
169
+ digest = hashlib.sha256(data).hexdigest()
170
+ release_name = str(release_payload.get("tag_name") or release)
171
+
172
+ with tempfile.TemporaryDirectory(prefix="xrefkit-skill-sync-") as temp_dir:
173
+ extracted = Path(temp_dir) / "bundle"
174
+ extracted.mkdir()
175
+ _safe_zip_extract(data, extracted)
176
+ root = _bundle_root(extracted)
177
+ files = _bundle_files(root)
178
+ relative_files = tuple(sorted(destination.as_posix() for _source, destination in files))
179
+
180
+ previous: dict[str, object] = {}
181
+ state = _state_path(repo, bundle)
182
+ if state.is_file():
183
+ try:
184
+ loaded = json.loads(state.read_text(encoding="utf-8"))
185
+ if isinstance(loaded, dict):
186
+ previous = loaded
187
+ except json.JSONDecodeError:
188
+ raise RuntimeError(f"invalid sync state: {state}")
189
+ managed = {str(item) for item in previous.get("files", []) if isinstance(item, str)}
190
+ conflicts = [
191
+ str(destination)
192
+ for _source, destination in files
193
+ if (repo / destination).exists() and destination.as_posix() not in managed and not force
194
+ ]
195
+ if conflicts:
196
+ raise RuntimeError(
197
+ "refusing to overwrite existing files not owned by sync state; "
198
+ f"use --force after review: {', '.join(sorted(conflicts))}"
199
+ )
200
+
201
+ if not dry_run:
202
+ for source, destination in files:
203
+ target = repo / destination
204
+ target.parent.mkdir(parents=True, exist_ok=True)
205
+ target.write_bytes(source.read_bytes())
206
+ state.parent.mkdir(parents=True, exist_ok=True)
207
+ state.write_text(
208
+ json.dumps(
209
+ {
210
+ "source_repository": source_repository,
211
+ "release": release_name,
212
+ "asset": asset_name,
213
+ "sha256": digest,
214
+ "files": list(relative_files),
215
+ },
216
+ ensure_ascii=False,
217
+ indent=2,
218
+ )
219
+ + "\n",
220
+ encoding="utf-8",
221
+ )
222
+ return SyncResult(bundle, release_name, asset_name, digest, relative_files, True, dry_run)
223
+
224
+
225
+ def _print(payload: object, *, as_json: bool) -> None:
226
+ if as_json:
227
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
228
+ return
229
+ results = payload if isinstance(payload, list) else [payload]
230
+ for item in results:
231
+ print(f"synced: {item['bundle']} ({item['release']})")
232
+ print(f"asset: {item['asset']}")
233
+ print(f"files: {len(item['files'])}")
234
+
235
+
236
+ def main(argv: list[str] | None = None) -> int:
237
+ parser = argparse.ArgumentParser(prog="xrefkit skills")
238
+ sub = parser.add_subparsers(dest="command", required=True)
239
+ sync = sub.add_parser("sync", help="download and register Skill/Knowledge bundles from GitHub Releases")
240
+ sync.add_argument("--repo", default=".", help="Target XRefKit repository; defaults to the current directory")
241
+ sync.add_argument("--source-repository", default=DEFAULT_SOURCE_REPOSITORY, help="GitHub owner/repository containing releases")
242
+ sync.add_argument("--bundle", action="append", help="Bundle name, for example csharp; repeat for multiple bundles")
243
+ sync.add_argument("--all", action="store_true", help="Synchronize every xrefkit-skills-*.zip asset in the release")
244
+ sync.add_argument("--release", default="latest", help="Release tag, or latest (default)")
245
+ sync.add_argument("--force", action="store_true", help="Allow overwriting files not owned by a previous sync")
246
+ sync.add_argument("--dry-run", action="store_true", help="Download and validate without writing the repository")
247
+ sync.add_argument("--json", action="store_true")
248
+ args = parser.parse_args(argv)
249
+ if not args.bundle and not args.all:
250
+ parser.error("one of --bundle or --all is required")
251
+ release_payload = _release(args.source_repository, args.release)
252
+ bundles = list(args.bundle or [])
253
+ if args.all:
254
+ for asset in _assets(release_payload):
255
+ name = asset.get("name")
256
+ if not isinstance(name, str) or not name.startswith(ASSET_PREFIX) or not name.endswith(".zip"):
257
+ continue
258
+ stem = name[:-4][len(ASSET_PREFIX) :]
259
+ match = VERSION_SUFFIX_RE.match(stem)
260
+ bundles.append(match.group("bundle") if match else stem)
261
+ bundles = list(dict.fromkeys(bundles))
262
+ results = [
263
+ sync_bundle(
264
+ repo=Path(args.repo),
265
+ source_repository=args.source_repository,
266
+ bundle=bundle,
267
+ release=args.release,
268
+ force=args.force,
269
+ dry_run=args.dry_run,
270
+ ).to_dict()
271
+ for bundle in bundles
272
+ ]
273
+ _print(results, as_json=args.json)
274
+ return 0
275
+
276
+
277
+ if __name__ == "__main__":
278
+ raise SystemExit(main())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xrefkit
3
- Version: 0.4.5
3
+ Version: 0.4.7
4
4
  Summary: Portable XID, Skill, Knowledge, workflow, and MCP runtime for XRefKit
5
5
  Author: synthaicode
6
6
  License: MIT License
@@ -30,6 +30,7 @@ tests/test_runtime_contracts.py
30
30
  tests/test_sarif_to_locator.py
31
31
  tests/test_skill_runtime_audit.py
32
32
  tests/test_skillmeta.py
33
+ tests/test_skills_sync.py
33
34
  tests/test_structure_catalog.py
34
35
  tests/test_xref.py
35
36
  tests/test_xrefkit_instance.py
@@ -62,6 +63,7 @@ xrefkit/resource_provider.py
62
63
  xrefkit/runlog.py
63
64
  xrefkit/skillmeta.py
64
65
  xrefkit/skillrun.py
66
+ xrefkit/skills_sync.py
65
67
  xrefkit/structure_catalog.py
66
68
  xrefkit/v2_cli.py
67
69
  xrefkit/workspace.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes