s-skillkit 0.6.2__tar.gz → 0.6.3__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 (127) hide show
  1. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/CHANGELOG.md +12 -0
  2. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/PKG-INFO +1 -1
  3. s_skillkit-0.6.3/docs/edited-link-migration.md +75 -0
  4. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/pyproject.toml +1 -1
  5. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/__init__.py +2 -0
  6. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/__init__.py +33 -0
  7. s_skillkit-0.6.3/skillkit/installer/edited_projections.py +302 -0
  8. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/models.py +13 -0
  9. s_skillkit-0.6.3/tests/test_edited_link_migration.py +340 -0
  10. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/uv.lock +1 -1
  11. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.gitignore +0 -0
  12. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.gitlab-ci.yml +0 -0
  13. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.gitlab-ci.yml.bak +0 -0
  14. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.importlinter +0 -0
  15. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.importlinter.bak +0 -0
  16. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/.skillgateignore +0 -0
  17. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/AGENTS.md +0 -0
  18. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/LICENSE +0 -0
  19. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/README.md +0 -0
  20. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/adr/0000-template.md +0 -0
  21. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/adr/0001-kanon-navyka-zhivet-v-skillkit.md +0 -0
  22. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/adr/README.md +0 -0
  23. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/agent-projections.md +0 -0
  24. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/docs/safe-updates.md +0 -0
  25. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/check_dist_leaks.py +0 -0
  26. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/cycles_gate.py +0 -0
  27. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/cycles_gate.py.bak +0 -0
  28. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/lock_gate.py +0 -0
  29. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/pins_gate.py +0 -0
  30. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/pins_gate.py.bak +0 -0
  31. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/release_guard.py +0 -0
  32. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/release_guard.py.bak +0 -0
  33. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/scripts/source_guard.py +0 -0
  34. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/binary_installer.py +0 -0
  35. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/bootstrap.py +0 -0
  36. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/canon.py +0 -0
  37. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/collections.py +0 -0
  38. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/config.py +0 -0
  39. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/deps_installer.py +0 -0
  40. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/errors.py +0 -0
  41. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/filter.py +0 -0
  42. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/hook_register.py +0 -0
  43. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/differ.py +0 -0
  44. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/fetchers.py +0 -0
  45. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/fsops.py +0 -0
  46. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/meta.py +0 -0
  47. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/ownership.py +0 -0
  48. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer/projections.py +0 -0
  49. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/__init__.py +0 -0
  50. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/_branding.py +0 -0
  51. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/_detached.py +0 -0
  52. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/config.py +0 -0
  53. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/direct.py +0 -0
  54. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/doctor.py +0 -0
  55. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/install_scripts.py +0 -0
  56. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/publish.py +0 -0
  57. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/self_update.py +0 -0
  58. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/installer_cli/tokens.py +0 -0
  59. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/linker.py +0 -0
  60. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/manifest.py +0 -0
  61. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/mcp_register.py +0 -0
  62. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/path_store.py +0 -0
  63. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/paths.py +0 -0
  64. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/pkg_manager.py +0 -0
  65. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/proc.py +0 -0
  66. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/project.py +0 -0
  67. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/__init__.py +0 -0
  68. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/antigravity.py +0 -0
  69. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/base.py +0 -0
  70. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/claude_code.py +0 -0
  71. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/codex.py +0 -0
  72. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/deepseek.py +0 -0
  73. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/detect.py +0 -0
  74. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/targets/opencode.py +0 -0
  75. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/telemetry.py +0 -0
  76. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/tooling.py +0 -0
  77. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/skillkit/uv_bootstrap.py +0 -0
  78. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/conftest.py +0 -0
  79. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_agent_projections.py +0 -0
  80. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_agent_skill_layouts_2026_07_21.py +0 -0
  81. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_artifact_ownership_2272.py +0 -0
  82. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_binary_installer.py +0 -0
  83. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_bootstrap.py +0 -0
  84. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_canon.py +0 -0
  85. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_claude_frontmatter.py +0 -0
  86. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_collections.py +0 -0
  87. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_config.py +0 -0
  88. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_deepseek_target.py +0 -0
  89. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_deps_binary.py +0 -0
  90. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_deps_installer.py +0 -0
  91. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_destructive_ownership_gate_2026_07_21.py +0 -0
  92. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_errors.py +0 -0
  93. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_filter.py +0 -0
  94. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_git_install.py +0 -0
  95. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_git_payload_bytes.py +0 -0
  96. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_hooks.py +0 -0
  97. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer.py +0 -0
  98. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_direct.py +0 -0
  99. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_doctor.py +0 -0
  100. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_publish.py +0 -0
  101. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_smoke.py +0 -0
  102. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_installer_cli_tokens.py +0 -0
  103. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_legacy_paths_no_orphans_2026_07_21.py +0 -0
  104. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_manifest.py +0 -0
  105. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_mcp_disabled_preference.py +0 -0
  106. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_mcp_register.py +0 -0
  107. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_no_raw_subprocess.py +0 -0
  108. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_packaging_lock.py +0 -0
  109. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_path_store.py +0 -0
  110. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_paths.py +0 -0
  111. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_pkg_manager.py +0 -0
  112. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_pkgsrc_not_in_agent_zone.py +0 -0
  113. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_proc.py +0 -0
  114. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_project.py +0 -0
  115. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_remove_ownership_guard_2026_07_21.py +0 -0
  116. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_safe_update_gate.py +0 -0
  117. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_stub_guard.py +0 -0
  118. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_targets.py +0 -0
  119. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_targets_public_names.py +0 -0
  120. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_telemetry_adapter.py +0 -0
  121. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_tooling.py +0 -0
  122. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_transactional_install.py +0 -0
  123. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_traversal_guard.py +0 -0
  124. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_typed_fields.py +0 -0
  125. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_uv_bootstrap.py +0 -0
  126. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_w268_subdir_skill.py +0 -0
  127. {s_skillkit-0.6.2 → s_skillkit-0.6.3}/tests/test_w272_empty_manifest_guard.py +0 -0
@@ -4,6 +4,18 @@
4
4
  [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/); версионирование —
5
5
  [SemVer](https://semver.org/lang/ru/).
6
6
 
7
+ ## [0.6.3] - 2026-10-14
8
+
9
+ ### Added
10
+
11
+ - Explicit owned-link migration into readable copies while preserving legitimate edits, deletions and reviewed untracked payload, with authenticated historical release baselines kept separate from current snapshots.
12
+ - Bounded migration results report copied untracked paths. Shared source metadata/runtime remain unchanged; strict future updates still refuse local edits and unknown baselines.
13
+
14
+ ### Fixed
15
+
16
+ - Verify completed migration agent, scope, project, Hub identity and historical manifest before accepting a repeated no-write migration.
17
+ - Reject foreign bindings, metadata/runtime links and detected source/staging races; retain executable permissions when copying Linux helpers.
18
+
7
19
  ## [0.6.2] - 2026-10-14
8
20
 
9
21
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: s-skillkit
3
- Version: 0.6.2
3
+ Version: 0.6.3
4
4
  Summary: Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs.
5
5
  Author: Dmitry
6
6
  License: MIT
@@ -0,0 +1,75 @@
1
+ # Preserving an edited owned link as a readable copy
2
+
3
+ `SkillStore.migrate_owned_link_to_copy` is an explicit offline operation for an
4
+ existing owned provider link whose central-store payload contains legitimate
5
+ local edits. Normal install/update/link behavior remains strict.
6
+
7
+ ```python
8
+ result = store.migrate_owned_link_to_copy(
9
+ "demo",
10
+ trusted_release_manifest=authenticated_historical_inventory,
11
+ expected_version="1.2.3",
12
+ expected_commit_sha=historical_commit,
13
+ preserve_local_changes=True,
14
+ reviewed_untracked_paths=("notes.txt",),
15
+ )
16
+ assert result.link_kind == "copy"
17
+ print(result.copied_untracked_paths, result.copied_untracked_count)
18
+ ```
19
+
20
+ The caller obtains the exact historical inventory through an authenticated
21
+ release authority and verifies its identity. Current source bytes, a current
22
+ default branch, and an install bundle for a newer release are not historical
23
+ authority. Legacy commit identifiers are compared exactly; this API does not
24
+ fabricate or normalize them. Missing, mismatched, or unverifiable inventory is
25
+ refused.
26
+
27
+ Only an owned regular Hub store and its exact existing provider link are
28
+ eligible. The version, commit, released hashes, file sizes and declared
29
+ preserved paths must agree with the supplied historical inventory. Every
30
+ untracked regular payload path requires an explicit finite reviewed allowlist;
31
+ the default refuses untracked payload. Review these paths with the canonical
32
+ content/secret gate before approving them. Runtime or auth-looking unknown
33
+ files must not be approved as payload. This API does not infer new runtime
34
+ exclusions or introduce a second secret classifier.
35
+
36
+ The copy preserves current file contents, legitimate deletions and reviewed
37
+ untracked files. The immutable `projection.baseline` records the historical
38
+ release inventory. `migration_current_snapshot` separately records the bytes
39
+ copied during this migration; it never replaces release authority. Subsequent
40
+ install/update/link operations still report these local modifications and
41
+ refuse overwrites, including when `force=True`.
42
+
43
+ The central store, its metadata and its runtime state are unchanged. Canonical
44
+ `PreservedPaths`, target policy and projection runtime roots exclude runtime
45
+ and credentials from the new copy; runtime symlinks are refused. Metadata in
46
+ the copy contains indexed release data and identity, excluding arbitrary
47
+ auth/tool configuration. Runtime state remains at the original store and is
48
+ not duplicated or moved. The caller must establish any later runtime
49
+ projection separately through its approved runtime policy.
50
+
51
+ Migration is bounded to 10,000 payload files and 64 MiB of actual bytes read
52
+ and copied. Staging is a verified owned sibling. Link identity, source
53
+ identity, metadata and payload hashes are checked again before the swap and
54
+ after detaching the original link; failure restores that link. The operation
55
+ does not adopt foreign links, unknown ordinary copies or changed bindings.
56
+ Repeating a completed migration performs a verified no-write operation only
57
+ when its original historical inventory, separate current snapshot, scope and
58
+ source still agree. Normal `link_existing` retains its existing return contract.
59
+
60
+ Recover unverifiable historical inventory from a verified pinned old release
61
+ manifest/bundle, or review/export the original data manually. `force` and a
62
+ snapshot of current edited bytes do not establish a missing release baseline.
63
+
64
+ Completed-copy verification also checks the requested agent, scope, project,
65
+ original Hub identity fields and historical manifest inventory/preserved paths.
66
+ Editing those fields causes refusal without rewriting the copy or its source.
67
+
68
+ Concurrency checks cover persistent link/source/staging changes at the tested
69
+ checkpoints and restore the old link on detected pre-swap failures. The existing
70
+ filesystem copy primitives do not provide an atomic no-follow file handle or an
71
+ exclusive snapshot. These pre/post path and hash checks do not prevent a
72
+ same-user writer from transiently replacing/reverting paths between checks, or
73
+ changing data after the final check. Callers must serialize their own writers
74
+ and inspect the final receipt; this API does not claim a filesystem transaction
75
+ against arbitrary concurrent writers. Shared source data is never rewritten.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "s-skillkit"
3
- version = "0.6.2"
3
+ version = "0.6.3"
4
4
  description = "Локальное ядро управления навыками: install из git/локальной папки, junction/symlink-линковка в scope агента, .skillignore-фильтр, build manifest, project-манифест. 0 завязок на сеть/auth — stdlib + tomli-w + pathspec + platformdirs."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -104,6 +104,7 @@ from skillkit.installer import (
104
104
  LinkKind,
105
105
  LocalPathSource,
106
106
  ManifestFile,
107
+ OwnedLinkMigrationResult,
107
108
  PathTraversalError,
108
109
  PreservedPaths,
109
110
  ProjectionKind,
@@ -155,6 +156,7 @@ __all__ = [
155
156
  "SkillInstaller",
156
157
  "InstallRequest",
157
158
  "InstallResult",
159
+ "OwnedLinkMigrationResult",
158
160
  "RemoveResult",
159
161
  "CredentialResolver",
160
162
  # запечатанный source-spec установки (T1)
@@ -97,6 +97,7 @@ from skillkit.installer.models import (
97
97
  LinkKind,
98
98
  LocalPathSource,
99
99
  ManifestFile,
100
+ OwnedLinkMigrationResult,
100
101
  ProjectionKind,
101
102
  RemoveResult,
102
103
  Scope,
@@ -119,6 +120,7 @@ __all__ = [
119
120
  "InstallManifest",
120
121
  "InstallRequest",
121
122
  "InstallResult",
123
+ "OwnedLinkMigrationResult",
122
124
  "LinkKind",
123
125
  "LocalPathSource",
124
126
  "ManifestFile",
@@ -762,6 +764,37 @@ class SkillStore:
762
764
  link = self._target.slug_dir(dir_name, project=project)
763
765
  return self._link_into_scope(link, store_dir, force=force, projection=projection)
764
766
 
767
+ def migrate_owned_link_to_copy(
768
+ self, dir_name: str, *, trusted_release_manifest: dict[str, Any],
769
+ expected_version: str, expected_commit_sha: str,
770
+ preserve_local_changes: bool = False, project: Path | None = None,
771
+ reviewed_untracked_paths: tuple[str, ...] = (),
772
+ ) -> OwnedLinkMigrationResult:
773
+ """Explicitly preserve edits while detaching an owned published-store link.
774
+
775
+ The caller supplies authenticated historical release inventory. Current
776
+ payload is recorded separately and never authorizes subsequent overwrites.
777
+ Source payload, metadata and runtime state remain unchanged.
778
+ """
779
+ from skillkit.installer.edited_projections import migrate_owned_link
780
+
781
+ if preserve_local_changes is not True:
782
+ raise ValueError("preserve_local_changes=True is required for this migration")
783
+ if (
784
+ not dir_name or dir_name in {".", ".."}
785
+ or dir_name != dir_name.strip() or dir_name.endswith(".")
786
+ or any(char in dir_name for char in "/\\:")
787
+ ):
788
+ raise ValueError("dir_name must be a single store directory name")
789
+ store_dir = self._store_path(dir_name)
790
+ return migrate_owned_link(
791
+ store_dir, self._target.slug_dir(dir_name, project=project), guard=self._guard,
792
+ trusted_release_manifest=trusted_release_manifest,
793
+ expected_version=expected_version, expected_commit_sha=expected_commit_sha,
794
+ target=self._target, project=project,
795
+ reviewed_untracked_paths=reviewed_untracked_paths,
796
+ )
797
+
765
798
  def migrate_scope(
766
799
  self, *, project: Path | None = None, dry_run: bool = False
767
800
  ) -> dict[str, list]:
@@ -0,0 +1,302 @@
1
+ """Explicit owned-link migration; current edits never become release authority."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from skillkit import linker
14
+ from skillkit.installer.meta import (
15
+ PreservedPaths,
16
+ is_managed_dir,
17
+ read_meta,
18
+ stamp_ownership,
19
+ write_meta,
20
+ )
21
+ from skillkit.installer.models import OwnedLinkMigrationResult
22
+ from skillkit.installer.ownership import PathGuard, same_path
23
+ from skillkit.installer.projections import (
24
+ ProjectionConflict,
25
+ _baseline,
26
+ _ignored,
27
+ _index,
28
+ _raise_walk_error,
29
+ _validate_scope_state,
30
+ )
31
+ from skillkit.targets.base import AgentTarget
32
+
33
+ _MAX_BYTES = 64 * 1024 * 1024
34
+ _MAX_FILES = 10_000
35
+ _MIGRATION = "owned-link-preserved-v1"
36
+
37
+
38
+ def _snapshot(root: Path, preserved: PreservedPaths) -> dict[str, str]:
39
+ """Bound actual bytes read, including concurrent growth of individual files."""
40
+ result: dict[str, str] = {}
41
+ total = 0
42
+ for current, dirs, files in os.walk(root, followlinks=False, onerror=_raise_walk_error):
43
+ parent = Path(current)
44
+ for name in list(dirs):
45
+ path = parent / name
46
+ rel = path.relative_to(root).as_posix()
47
+ if linker.is_link(path):
48
+ raise ProjectionConflict("projection-runtime-link", (rel,))
49
+ if _ignored(rel, preserved):
50
+ dirs.remove(name)
51
+ for name in files:
52
+ path = parent / name
53
+ rel = path.relative_to(root).as_posix()
54
+ if linker.is_link(path) or not stat.S_ISREG(path.lstat().st_mode):
55
+ raise ProjectionConflict("projection-runtime-link", (rel,))
56
+ if _ignored(rel, preserved):
57
+ continue
58
+ if len(result) >= _MAX_FILES:
59
+ raise ProjectionConflict("projection-resource-limit", (rel,))
60
+ digest = hashlib.sha256()
61
+ with path.open("rb") as stream:
62
+ while chunk := stream.read(64 * 1024):
63
+ total += len(chunk)
64
+ if total > _MAX_BYTES:
65
+ raise ProjectionConflict("projection-resource-limit", (rel,))
66
+ digest.update(chunk)
67
+ result[rel] = digest.hexdigest()
68
+ return result
69
+
70
+
71
+ def _identity(path: Path) -> tuple[int, int, int]:
72
+ info = path.lstat()
73
+ return info.st_dev, info.st_ino, info.st_mode
74
+
75
+
76
+ def _regular_parents(path: Path) -> None:
77
+ if any(linker.is_link(parent) for parent in path.absolute().parents):
78
+ raise ProjectionConflict("projection-unmanaged-path")
79
+
80
+
81
+ def _copy_snapshot(source: Path, destination: Path, expected: dict[str, str]) -> None:
82
+ total = 0
83
+ for rel, expected_hash in expected.items():
84
+ path = source / rel
85
+ target = destination / rel
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ if linker.is_link(path) or not stat.S_ISREG(path.lstat().st_mode):
88
+ raise ProjectionConflict("projection-source-changed", (rel,))
89
+ identity = _identity(path)
90
+ digest = hashlib.sha256()
91
+ with path.open("rb") as reader, target.open("xb") as writer:
92
+ while chunk := reader.read(64 * 1024):
93
+ total += len(chunk)
94
+ if total > _MAX_BYTES:
95
+ raise ProjectionConflict("projection-resource-limit", (rel,))
96
+ digest.update(chunk)
97
+ writer.write(chunk)
98
+ if digest.hexdigest() != expected_hash or _identity(path) != identity:
99
+ raise ProjectionConflict("projection-source-changed", (rel,))
100
+ shutil.copystat(path, target, follow_symlinks=False)
101
+ if _identity(path) != identity or linker.is_link(path):
102
+ raise ProjectionConflict("projection-source-changed", (rel,))
103
+
104
+
105
+ def migrate_owned_link(
106
+ store_dir: Path,
107
+ slot: Path,
108
+ *,
109
+ guard: PathGuard,
110
+ trusted_release_manifest: dict[str, Any],
111
+ expected_version: str,
112
+ expected_commit_sha: str,
113
+ target: AgentTarget,
114
+ reviewed_untracked_paths: tuple[str, ...],
115
+ project: Path | None,
116
+ ) -> OwnedLinkMigrationResult:
117
+ """Detach a verified owned link while retaining edits and old release hashes."""
118
+ _regular_parents(store_dir)
119
+ _regular_parents(slot)
120
+ metadata_path = store_dir / "_skill_meta.json"
121
+ if (
122
+ linker.is_link(store_dir)
123
+ or linker.is_link(metadata_path)
124
+ or not metadata_path.is_file()
125
+ or metadata_path.stat().st_size > 1024 * 1024
126
+ or not is_managed_dir(store_dir)
127
+ or not guard.inside_store(store_dir)
128
+ ):
129
+ raise ProjectionConflict("projection-unmanaged-path")
130
+ meta = read_meta(store_dir) or {}
131
+ manifest = meta.get("manifest")
132
+ if (
133
+ meta.get("source") != "hub"
134
+ or not isinstance(manifest, dict)
135
+ or not isinstance(trusted_release_manifest, dict)
136
+ or not expected_version
137
+ or not expected_commit_sha
138
+ or meta.get("version") != expected_version
139
+ or meta.get("commit_sha") != expected_commit_sha
140
+ or manifest.get("preserved_paths", [])
141
+ != trusted_release_manifest.get("preserved_paths", [])
142
+ ):
143
+ raise ProjectionConflict("projection-unverifiable-baseline")
144
+ declared = manifest.get("preserved_paths", [])
145
+ if not isinstance(declared, list) or not all(isinstance(x, str) and x for x in declared):
146
+ raise ProjectionConflict("projection-unverifiable-baseline")
147
+ preserved_paths = PreservedPaths.build(manifest, target)
148
+ historical = _index(trusted_release_manifest, preserved_paths, fingerprint=False)
149
+ if historical != _index(manifest, preserved_paths, fingerprint=False):
150
+ raise ProjectionConflict("projection-unverifiable-baseline")
151
+ inventory = []
152
+ for item in trusted_release_manifest["files"]:
153
+ entry = {key: item[key] for key in ("path", "sha256", "size") if key in item}
154
+ size = entry.get("size")
155
+ if size is not None and (type(size) is not int or size < 0):
156
+ raise ProjectionConflict("projection-unverifiable-baseline")
157
+ inventory.append(entry)
158
+ stored_sizes = {item["path"]: item.get("size") for item in manifest["files"]}
159
+ if any(item.get("size") != stored_sizes.get(item["path"]) for item in inventory):
160
+ raise ProjectionConflict("projection-unverifiable-baseline")
161
+ recorded = meta.get("projection_source_baseline")
162
+ if recorded is not None and historical != _index(recorded, preserved_paths, fingerprint=True):
163
+ raise ProjectionConflict("projection-unverifiable-baseline")
164
+ baseline = _baseline(historical)
165
+ source_identity = _identity(store_dir)
166
+ metadata_hash = hashlib.sha256((store_dir / "_skill_meta.json").read_bytes()).hexdigest()
167
+ _validate_scope_state(store_dir, preserved_paths)
168
+ before = _snapshot(store_dir, preserved_paths)
169
+ if "SKILL.md" not in before:
170
+ raise ProjectionConflict("projection-missing-entrypoint")
171
+ untracked = tuple(sorted(set(before) - set(historical)))
172
+ if (
173
+ not isinstance(reviewed_untracked_paths, tuple)
174
+ or len(reviewed_untracked_paths) > _MAX_FILES
175
+ or not all(isinstance(path, str) for path in reviewed_untracked_paths)
176
+ or len(set(reviewed_untracked_paths)) != len(reviewed_untracked_paths)
177
+ or set(reviewed_untracked_paths) != set(untracked)
178
+ ):
179
+ raise ProjectionConflict("projection-unreviewed-untracked-payload", untracked or (".",))
180
+
181
+ def verify_source() -> None:
182
+ _regular_parents(store_dir)
183
+ if (
184
+ linker.is_link(store_dir)
185
+ or linker.is_link(metadata_path)
186
+ or _identity(store_dir) != source_identity
187
+ or hashlib.sha256((store_dir / "_skill_meta.json").read_bytes()).hexdigest()
188
+ != metadata_hash
189
+ or _snapshot(store_dir, preserved_paths) != before
190
+ ):
191
+ raise ProjectionConflict("projection-source-changed")
192
+ _validate_scope_state(store_dir, preserved_paths)
193
+
194
+ if not linker.is_link(slot):
195
+ scope_metadata = slot / "_skill_meta.json"
196
+ if (
197
+ linker.is_link(scope_metadata)
198
+ or not scope_metadata.is_file()
199
+ or scope_metadata.stat().st_size > 1024 * 1024
200
+ or not is_managed_dir(slot)
201
+ ):
202
+ raise ProjectionConflict("projection-unmanaged-path")
203
+ previous = read_meta(slot) or {}
204
+ projection = previous.get("projection", {})
205
+ expected_identity = {key: meta.get(key) for key in (
206
+ "slug", "skill_id", "version", "commit_sha", "source",
207
+ )}
208
+ expected_identity.update(
209
+ agent=target.name, scope="project" if project else "global",
210
+ project=str(project) if project else None,
211
+ )
212
+ previous_manifest = previous.get("manifest")
213
+ if (
214
+ not isinstance(projection, dict)
215
+ or projection.get("migration") != _MIGRATION
216
+ or projection.get("kind") != "copy"
217
+ or not isinstance(projection.get("store_dir"), str)
218
+ or not same_path(Path(projection["store_dir"]), store_dir)
219
+ or projection.get("baseline") != baseline
220
+ or projection.get("source_revision") != expected_commit_sha
221
+ or projection.get("preserved_paths") != list(preserved_paths)
222
+ or previous.get("version") != expected_version
223
+ or previous.get("commit_sha") != expected_commit_sha
224
+ or any(previous.get(key) != value for key, value in expected_identity.items())
225
+ or not isinstance(previous_manifest, dict)
226
+ or previous_manifest.get("preserved_paths", []) != declared
227
+ ):
228
+ raise ProjectionConflict("projection-unverifiable-baseline")
229
+ if _index(previous_manifest, preserved_paths, fingerprint=False) != historical:
230
+ raise ProjectionConflict("projection-unverifiable-baseline")
231
+ previous_sizes = {}
232
+ for item in previous_manifest["files"]:
233
+ size = item.get("size")
234
+ if size is not None and (type(size) is not int or size < 0):
235
+ raise ProjectionConflict("projection-unverifiable-baseline")
236
+ previous_sizes[item["path"]] = size
237
+ if any(item.get("size") != previous_sizes.get(item["path"]) for item in inventory):
238
+ raise ProjectionConflict("projection-unverifiable-baseline")
239
+ expected = _index(
240
+ projection.get("migration_current_snapshot"), preserved_paths, fingerprint=True,
241
+ )
242
+ actual = _snapshot(slot, preserved_paths)
243
+ if actual != expected or actual != before:
244
+ raise ProjectionConflict("projection-local-modifications")
245
+ _validate_scope_state(slot, preserved_paths)
246
+ verify_source()
247
+ return OwnedLinkMigrationResult(untracked)
248
+
249
+ link_identity = _identity(slot)
250
+ raw_target = os.readlink(slot)
251
+
252
+ def verify_link(path: Path) -> None:
253
+ _regular_parents(path)
254
+ if (
255
+ not linker.is_link(path)
256
+ or _identity(path) != link_identity
257
+ or os.readlink(path) != raw_target
258
+ or not same_path(linker.link_target(path) or Path(), store_dir)
259
+ or not guard.is_ours(path, store_dir=store_dir)
260
+ ):
261
+ raise ProjectionConflict("projection-unmanaged-path")
262
+ verify_source()
263
+
264
+ verify_link(slot)
265
+ # Only identity and indexed release data enter the copy, never auth/tool config.
266
+ scope_meta = {
267
+ key: meta[key] for key in ("slug", "skill_id", "version", "commit_sha", "source")
268
+ if key in meta and isinstance(meta[key], (str, int, type(None)))
269
+ }
270
+ scope_meta.update(
271
+ agent=target.name, scope="project" if project else "global",
272
+ project=str(project) if project else None,
273
+ manifest={
274
+ "files": inventory,
275
+ "preserved_paths": declared,
276
+ },
277
+ projection={
278
+ "kind": "copy", "store_dir": str(store_dir),
279
+ "source_revision": expected_commit_sha, "preserved_paths": list(preserved_paths),
280
+ "baseline": baseline, "migration": _MIGRATION,
281
+ "migration_current_snapshot": _baseline(before),
282
+ "copied_untracked_paths": list(untracked),
283
+ },
284
+ )
285
+ staging = Path(tempfile.mkdtemp(prefix=f".{slot.name}.projection-stage-", dir=slot.parent))
286
+ try:
287
+ write_meta(staging, stamp_ownership(scope_meta))
288
+ _copy_snapshot(store_dir, staging, before)
289
+ if _snapshot(staging, preserved_paths) != before:
290
+ raise ProjectionConflict("projection-source-changed")
291
+ verify_link(slot)
292
+
293
+ def verify_swap(path: Path) -> None:
294
+ verify_link(path)
295
+ if _snapshot(staging, preserved_paths) != before:
296
+ raise ProjectionConflict("projection-source-changed")
297
+
298
+ guard.swap_in_scope(staging, slot, store_dir=store_dir, verify_entry=verify_swap)
299
+ finally:
300
+ if os.path.lexists(staging):
301
+ guard.destroy_in_scope(staging)
302
+ return OwnedLinkMigrationResult(untracked)
@@ -27,6 +27,19 @@ Content = Literal["real", "stub"]
27
27
  Source = Literal["hub", "local-path", "git-url"]
28
28
 
29
29
 
30
+ @dataclass(frozen=True)
31
+ class OwnedLinkMigrationResult:
32
+ """A preserved-copy migration reports reviewed untracked paths, never contents."""
33
+
34
+ copied_untracked_paths: tuple[str, ...]
35
+ linked: bool = False
36
+ link_kind: Literal["copy"] = "copy"
37
+
38
+ @property
39
+ def copied_untracked_count(self) -> int:
40
+ return len(self.copied_untracked_paths)
41
+
42
+
30
43
  class ManifestFile(TypedDict, total=False):
31
44
  """Один файл install-манифеста: относительный путь + хеш + размер."""
32
45