campfire-cli 0.1.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 (154) hide show
  1. campfire_cli/__init__.py +3 -0
  2. campfire_cli/alembic/__init__.py +1 -0
  3. campfire_cli/alembic/env.py +37 -0
  4. campfire_cli/alembic/script.py.mako +22 -0
  5. campfire_cli/alembic/versions/001_initial_schema.py +81 -0
  6. campfire_cli/alembic/versions/002_workspace_project_registry.py +47 -0
  7. campfire_cli/alembic/versions/003_unified_workspace_state.py +113 -0
  8. campfire_cli/alembic/versions/004_restructure_workspace_domain.py +23 -0
  9. campfire_cli/alembic/versions/005_decision_channel.py +71 -0
  10. campfire_cli/alembic/versions/006_workspace_topology_index.py +61 -0
  11. campfire_cli/alembic/versions/007_workspace_adoption.py +38 -0
  12. campfire_cli/alembic/versions/__init__.py +1 -0
  13. campfire_cli/app/SPEC.md +12 -0
  14. campfire_cli/app/__init__.py +1 -0
  15. campfire_cli/app/base/SPEC.md +3 -0
  16. campfire_cli/app/base/__init__.py +1 -0
  17. campfire_cli/app/base/cli/base_cli.py +37 -0
  18. campfire_cli/app/base/repository/base_repository.py +17 -0
  19. campfire_cli/app/base/schema/base_schema.py +18 -0
  20. campfire_cli/app/base/service/base_service.py +129 -0
  21. campfire_cli/app/decision/SPEC.md +16 -0
  22. campfire_cli/app/decision/__init__.py +1 -0
  23. campfire_cli/app/decision/cli/decision_cli.py +112 -0
  24. campfire_cli/app/decision/repository/decision_repository.py +199 -0
  25. campfire_cli/app/decision/schema/decision_schema.py +56 -0
  26. campfire_cli/app/decision/service/decision_projection_service.py +151 -0
  27. campfire_cli/app/decision/service/decision_service.py +124 -0
  28. campfire_cli/app/document/SPEC.md +51 -0
  29. campfire_cli/app/document/__init__.py +1 -0
  30. campfire_cli/app/document/cli/__init__.py +1 -0
  31. campfire_cli/app/document/cli/document_cli.py +63 -0
  32. campfire_cli/app/document/cli/profile_cli.py +65 -0
  33. campfire_cli/app/document/cli/type_cli.py +49 -0
  34. campfire_cli/app/document/repository/__init__.py +1 -0
  35. campfire_cli/app/document/repository/document_profile_repository.py +17 -0
  36. campfire_cli/app/document/repository/document_type_repository.py +17 -0
  37. campfire_cli/app/document/service/__init__.py +1 -0
  38. campfire_cli/app/document/service/document_profile_service.py +66 -0
  39. campfire_cli/app/document/service/document_rule_service.py +316 -0
  40. campfire_cli/app/document/service/document_scanner.py +38 -0
  41. campfire_cli/app/document/service/document_service.py +139 -0
  42. campfire_cli/app/document/service/document_type_service.py +28 -0
  43. campfire_cli/app/document/service/frontmatter_apply.py +98 -0
  44. campfire_cli/app/document/service/frontmatter_formatter.py +104 -0
  45. campfire_cli/app/document/service/frontmatter_plan.py +75 -0
  46. campfire_cli/app/document/service/profile_registry.py +166 -0
  47. campfire_cli/app/document/service/type_apply.py +185 -0
  48. campfire_cli/app/document/service/type_plan.py +80 -0
  49. campfire_cli/app/maintenance/SPEC.md +22 -0
  50. campfire_cli/app/maintenance/__init__.py +1 -0
  51. campfire_cli/app/maintenance/cli/maintenance_cli.py +103 -0
  52. campfire_cli/app/maintenance/repository/maintenance_repository.py +115 -0
  53. campfire_cli/app/maintenance/schema/maintenance_schema.py +109 -0
  54. campfire_cli/app/maintenance/service/archive_service.py +255 -0
  55. campfire_cli/app/maintenance/service/link_service.py +91 -0
  56. campfire_cli/app/maintenance/service/maintenance_protocol.py +25 -0
  57. campfire_cli/app/maintenance/service/maintenance_service.py +461 -0
  58. campfire_cli/app/maintenance/service/moc_service.py +310 -0
  59. campfire_cli/app/maintenance/service/plan_service.py +411 -0
  60. campfire_cli/app/skill/SPEC.md +3 -0
  61. campfire_cli/app/skill/__init__.py +1 -0
  62. campfire_cli/app/skill/cli/skill_cli.py +44 -0
  63. campfire_cli/app/skill/repository/skill_repository.py +33 -0
  64. campfire_cli/app/skill/schema/skill_schema.py +18 -0
  65. campfire_cli/app/skill/service/skill_service.py +146 -0
  66. campfire_cli/app/workspace/SPEC.md +25 -0
  67. campfire_cli/app/workspace/__init__.py +1 -0
  68. campfire_cli/app/workspace/cli/__init__.py +1 -0
  69. campfire_cli/app/workspace/cli/adoption_cli.py +83 -0
  70. campfire_cli/app/workspace/cli/config_cli.py +30 -0
  71. campfire_cli/app/workspace/cli/domain_cli.py +101 -0
  72. campfire_cli/app/workspace/cli/project_cli.py +135 -0
  73. campfire_cli/app/workspace/cli/restructure_cli.py +133 -0
  74. campfire_cli/app/workspace/cli/space_cli.py +65 -0
  75. campfire_cli/app/workspace/cli/workspace_cli.py +145 -0
  76. campfire_cli/app/workspace/repository/__init__.py +1 -0
  77. campfire_cli/app/workspace/repository/adoption_repository.py +55 -0
  78. campfire_cli/app/workspace/repository/manifest_repository.py +38 -0
  79. campfire_cli/app/workspace/repository/restructure_repository.py +93 -0
  80. campfire_cli/app/workspace/repository/workspace_repository.py +125 -0
  81. campfire_cli/app/workspace/schema/__init__.py +1 -0
  82. campfire_cli/app/workspace/schema/adoption_schema.py +47 -0
  83. campfire_cli/app/workspace/schema/restructure_schema.py +64 -0
  84. campfire_cli/app/workspace/schema/workspace_schema.py +225 -0
  85. campfire_cli/app/workspace/service/__init__.py +1 -0
  86. campfire_cli/app/workspace/service/adoption_protocol.py +11 -0
  87. campfire_cli/app/workspace/service/adoption_service.py +364 -0
  88. campfire_cli/app/workspace/service/config_service.py +189 -0
  89. campfire_cli/app/workspace/service/domain_restructure_service.py +316 -0
  90. campfire_cli/app/workspace/service/project_service.py +389 -0
  91. campfire_cli/app/workspace/service/restructure_protocol.py +19 -0
  92. campfire_cli/app/workspace/service/restructure_service.py +392 -0
  93. campfire_cli/app/workspace/service/restructure_verifier.py +83 -0
  94. campfire_cli/app/workspace/service/structure_service.py +494 -0
  95. campfire_cli/app/workspace/service/workspace_protocol.py +24 -0
  96. campfire_cli/app/workspace/service/workspace_service.py +304 -0
  97. campfire_cli/common/SPEC.md +12 -0
  98. campfire_cli/common/__init__.py +1 -0
  99. campfire_cli/common/database/__init__.py +4 -0
  100. campfire_cli/common/database/migrations.py +18 -0
  101. campfire_cli/common/database/models.py +215 -0
  102. campfire_cli/common/database/session.py +30 -0
  103. campfire_cli/common/documents/__init__.py +1 -0
  104. campfire_cli/common/documents/document_types.py +119 -0
  105. campfire_cli/common/documents/frontmatter_schema.py +42 -0
  106. campfire_cli/common/documents/markdown.py +66 -0
  107. campfire_cli/common/exceptions/__init__.py +7 -0
  108. campfire_cli/common/exceptions/base.py +19 -0
  109. campfire_cli/common/filesystem/__init__.py +6 -0
  110. campfire_cli/common/filesystem/atomic.py +30 -0
  111. campfire_cli/common/filesystem/locking.py +55 -0
  112. campfire_cli/common/governance/__init__.py +13 -0
  113. campfire_cli/common/governance/issues.py +38 -0
  114. campfire_cli/common/governance/locking.py +31 -0
  115. campfire_cli/common/governance/snapshots.py +26 -0
  116. campfire_cli/common/hashing.py +10 -0
  117. campfire_cli/common/reports/__init__.py +1 -0
  118. campfire_cli/common/reports/json_report.py +8 -0
  119. campfire_cli/common/reports/markdown_report.py +29 -0
  120. campfire_cli/config/SPEC.md +5 -0
  121. campfire_cli/config/defaults.py +64 -0
  122. campfire_cli/config/settings.py +50 -0
  123. campfire_cli/container.py +130 -0
  124. campfire_cli/main.py +133 -0
  125. campfire_cli/resources/__init__.py +1 -0
  126. campfire_cli/resources/bases/Agent/346/211/247/350/241/214/345/267/245/344/275/234/345/217/260.base +35 -0
  127. campfire_cli/resources/bases/SPEC.md +3 -0
  128. campfire_cli/resources/bases//344/273/273/345/212/241/345/267/245/344/275/234/345/217/260.base +49 -0
  129. campfire_cli/resources/bases//345/206/263/347/255/226/345/267/245/344/275/234/345/217/260.base +40 -0
  130. campfire_cli/resources/bases//345/276/205/345/275/222/346/241/243/346/226/207/346/241/243.base +28 -0
  131. campfire_cli/resources/bases//351/241/271/347/233/256/346/226/207/346/241/243.base +35 -0
  132. campfire_cli/resources/defaults/__init__.py +1 -0
  133. campfire_cli/resources/defaults/config.yml +458 -0
  134. campfire_cli/resources/skills/SPEC.md +3 -0
  135. campfire_cli/resources/skills/campfire-context-bootstrap/SKILL.md +62 -0
  136. campfire_cli/resources/skills/campfire-context-bootstrap/agents/openai.yaml +3 -0
  137. campfire_cli/resources/skills/campfire-conversation-router/SKILL.md +53 -0
  138. campfire_cli/resources/skills/campfire-conversation-router/agents/openai.yaml +3 -0
  139. campfire_cli/resources/skills/campfire-conversation-router/references/inbox-handoff.md +19 -0
  140. campfire_cli/resources/skills/campfire-conversation-router/references/learning-intake.md +31 -0
  141. campfire_cli/resources/skills/campfire-conversation-router/references/task-intake.md +28 -0
  142. campfire_cli/resources/skills/campfire-document-capture/SKILL.md +109 -0
  143. campfire_cli/resources/skills/campfire-inbox-triage/SKILL.md +23 -0
  144. campfire_cli/resources/skills/campfire-weekly-report-writing/SKILL.md +21 -0
  145. campfire_cli/resources/skills/campfire-weekly-report-writing/agents/openai.yaml +3 -0
  146. campfire_cli/resources/skills/campfire-workspace-adoption/SKILL.md +42 -0
  147. campfire_cli/resources/skills/campfire-workspace-maintenance/SKILL.md +74 -0
  148. campfire_cli/resources/skills/campfire-workspace-maintenance/references//344/273/273/345/212/241/346/255/243/346/226/207/347/273/223/346/236/204.md +23 -0
  149. campfire_cli/resources/skills/campfire-workspace-restructure/SKILL.md +52 -0
  150. campfire_cli-0.1.0.dist-info/METADATA +141 -0
  151. campfire_cli-0.1.0.dist-info/RECORD +154 -0
  152. campfire_cli-0.1.0.dist-info/WHEEL +4 -0
  153. campfire_cli-0.1.0.dist-info/entry_points.txt +2 -0
  154. campfire_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class WorkspaceEntry(BaseModel):
10
+ path: str
11
+ status: str = "active"
12
+ default: bool = False
13
+
14
+
15
+ class WorkspaceRegistry(BaseModel):
16
+ schema_version: int = 1
17
+ default_workspace: str | None = None
18
+ workspaces: dict[str, WorkspaceEntry] = Field(default_factory=dict)
19
+
20
+
21
+ class WorkspaceResult(BaseModel):
22
+ status: str
23
+ workspace_id: str
24
+ workspace: str
25
+ state_root: str
26
+ default: bool = False
27
+ created: list[str] = Field(default_factory=list)
28
+ preserved: list[str] = Field(default_factory=list)
29
+ created_directories: list[str] = Field(default_factory=list)
30
+
31
+
32
+ class WorkspaceResolution(BaseModel):
33
+ status: str = "ok"
34
+ workspace_id: str
35
+ workspace: str
36
+ state_root: str | None = None
37
+
38
+
39
+ class WorkspaceListResult(BaseModel):
40
+ status: str = "ok"
41
+ schema_version: int = 1
42
+ default_workspace: str | None = None
43
+ workspaces: dict[str, WorkspaceEntry] = Field(default_factory=dict)
44
+
45
+
46
+ class WorkspaceDefaultResult(BaseModel):
47
+ status: str = "updated"
48
+ default_workspace: str
49
+
50
+
51
+ class WorkspaceCreateRequest(BaseModel):
52
+ workspace_id: str
53
+ path: Path
54
+ make_default: bool = False
55
+
56
+
57
+ class ManifestWorkspace(BaseModel):
58
+ id: str
59
+ name: str
60
+ governance_version: int = 1
61
+
62
+
63
+ class ManifestProject(BaseModel):
64
+ id: str
65
+ name: str
66
+ document_domain: str
67
+ git_remote_url: str | None = None
68
+ default_branch: str | None = None
69
+ status: str = "active"
70
+
71
+
72
+ class WorkspaceManifest(BaseModel):
73
+ schema_version: int = 1
74
+ workspace: ManifestWorkspace
75
+ projects: list[ManifestProject] = Field(default_factory=list)
76
+
77
+
78
+ class WorkspaceSetupResult(WorkspaceResult):
79
+ manifest: str
80
+ manifest_operation: str
81
+ imported_projects: list[str] = Field(default_factory=list)
82
+ unbound_projects: list[str] = Field(default_factory=list)
83
+
84
+
85
+ class Space(BaseModel):
86
+ id: str
87
+ name: str
88
+ path: str
89
+ type: str
90
+ status: str = "active"
91
+
92
+
93
+ class SpaceListResult(BaseModel):
94
+ status: str = "ok"
95
+ spaces: list[Space] = Field(default_factory=list)
96
+
97
+
98
+ class SpaceCheckResult(BaseModel):
99
+ status: str
100
+ spaces: list[Space] = Field(default_factory=list)
101
+ issues: list[dict[str, Any]] = Field(default_factory=list)
102
+
103
+
104
+ class SpaceCreateResult(BaseModel):
105
+ status: str
106
+ space: Space
107
+ operations: list[dict[str, str]] = Field(default_factory=list)
108
+ write_performed: bool = False
109
+
110
+
111
+ class Domain(BaseModel):
112
+ id: str
113
+ name: str
114
+ path: Path
115
+ space_id: str
116
+ type: str
117
+ governance: str
118
+ moc: str
119
+ parent_domain: str | None = None
120
+ project_id: str | None = None
121
+ status: str = "active"
122
+
123
+
124
+ class DomainListResult(BaseModel):
125
+ status: str = "ok"
126
+ domains: list[Domain] = Field(default_factory=list)
127
+
128
+
129
+ class DomainCheckResult(BaseModel):
130
+ status: str
131
+ domains: list[Domain] = Field(default_factory=list)
132
+ issues: list[dict[str, Any]] = Field(default_factory=list)
133
+
134
+
135
+ class DomainCreateResult(BaseModel):
136
+ status: str
137
+ domain: Domain
138
+ operations: list[dict[str, str]] = Field(default_factory=list)
139
+ write_performed: bool = False
140
+
141
+
142
+ class WorkspaceConfigCheckResult(BaseModel):
143
+ status: str
144
+ checked: list[str] = Field(default_factory=list)
145
+ issues: list[dict[str, Any]] = Field(default_factory=list)
146
+
147
+
148
+ class ProjectEntry(BaseModel):
149
+ id: str
150
+ workspace_id: str
151
+ name: str
152
+ document_domain: str
153
+ git_remote_url: str | None = None
154
+ local_path: str | None = None
155
+ default_branch: str | None = None
156
+ status: str = "active"
157
+
158
+
159
+ class ProjectUpsertRequest(BaseModel):
160
+ project_id: str
161
+ workspace_id: str
162
+ name: str
163
+ document_domain: str
164
+ git_remote_url: str | None = None
165
+ local_path: Path | None = None
166
+ default_branch: str | None = None
167
+ status: str = "active"
168
+
169
+
170
+ class ProjectResult(ProjectEntry):
171
+ operation: str = "saved"
172
+
173
+
174
+ class ProjectBindResult(BaseModel):
175
+ status: str = "bound"
176
+ project: ProjectEntry
177
+
178
+
179
+ class ProjectListResult(BaseModel):
180
+ status: str = "ok"
181
+ projects: list[ProjectEntry] = Field(default_factory=list)
182
+
183
+
184
+ class ProjectMatch(BaseModel):
185
+ project: ProjectEntry
186
+ match_basis: list[str] = Field(default_factory=list)
187
+
188
+
189
+ class ProjectResolutionResult(BaseModel):
190
+ status: str
191
+ query_path: str
192
+ git_root: str | None = None
193
+ git_remote_url: str | None = None
194
+ matches: list[ProjectMatch] = Field(default_factory=list)
195
+ remote_matches: list[ProjectMatch] = Field(default_factory=list)
196
+ hint: str | None = None
197
+
198
+
199
+ class ProjectCheckResult(BaseModel):
200
+ status: str
201
+ project: ProjectEntry
202
+ observed: dict[str, Any] = Field(default_factory=dict)
203
+ issues: list[dict[str, Any]] = Field(default_factory=list)
204
+
205
+
206
+ class ProjectCreateResult(BaseModel):
207
+ status: str
208
+ project: ProjectEntry
209
+ document_domain_path: str
210
+ operations: list[dict[str, str]] = Field(default_factory=list)
211
+ write_performed: bool = False
212
+
213
+
214
+ class RegistryExport(BaseModel):
215
+ schema_version: int = 1
216
+ default_workspace: str | None = None
217
+ workspaces: dict[str, WorkspaceEntry] = Field(default_factory=dict)
218
+ projects: list[ProjectEntry] = Field(default_factory=list)
219
+
220
+
221
+ class RegistryTransferResult(BaseModel):
222
+ status: str
223
+ path: str
224
+ workspace_count: int
225
+ project_count: int
@@ -0,0 +1 @@
1
+ """Workspace application services and protocols."""
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from campfire_cli.app.workspace.schema.adoption_schema import AdoptionBatchState
6
+
7
+
8
+ class AdoptionRepositoryProtocol(Protocol):
9
+ def save(self, state: AdoptionBatchState) -> None: ...
10
+
11
+ def load(self, batch: str) -> AdoptionBatchState: ...
@@ -0,0 +1,364 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from campfire_cli.app.maintenance.service.maintenance_service import MaintenanceService
7
+ from campfire_cli.app.workspace.repository.manifest_repository import (
8
+ WorkspaceManifestRepository,
9
+ )
10
+ from campfire_cli.app.workspace.schema.adoption_schema import (
11
+ AdoptionBatchState,
12
+ AdoptionInventoryItem,
13
+ AdoptionPlan,
14
+ AdoptionResult,
15
+ )
16
+ from campfire_cli.app.workspace.schema.workspace_schema import Domain, ManifestProject
17
+ from campfire_cli.app.workspace.service.adoption_protocol import AdoptionRepositoryProtocol
18
+ from campfire_cli.app.workspace.service.structure_service import (
19
+ DOMAIN_MARKER,
20
+ ID_RE,
21
+ DomainService,
22
+ reserved_directories,
23
+ )
24
+ from campfire_cli.app.workspace.service.workspace_protocol import WorkspaceRepositoryProtocol
25
+ from campfire_cli.common.exceptions import ConfigurationError
26
+ from campfire_cli.common.filesystem import atomic_write, safe_path, workspace_write_lock
27
+ from campfire_cli.common.hashing import file_sha256
28
+ from campfire_cli.config.settings import WorkspaceSettings
29
+
30
+ IGNORED_NAMES = {".git", ".obsidian", ".DS_Store", "__pycache__"}
31
+
32
+
33
+ class AdoptionService:
34
+ """Safely stage and adopt one existing folder as a managed Domain."""
35
+
36
+ def __init__(
37
+ self,
38
+ settings: WorkspaceSettings,
39
+ repository: AdoptionRepositoryProtocol,
40
+ workspace_repository: WorkspaceRepositoryProtocol,
41
+ maintenance: MaintenanceService,
42
+ manifest_repository: WorkspaceManifestRepository | None = None,
43
+ ) -> None:
44
+ self._settings = settings
45
+ self._repository = repository
46
+ self._workspaces = workspace_repository
47
+ self._maintenance = maintenance
48
+ self._manifests = manifest_repository or WorkspaceManifestRepository()
49
+ self._domains = DomainService(settings.vault_root, settings.state_root)
50
+
51
+ def inventory(self, batch: str, source: Path, confirm: bool = False) -> AdoptionResult:
52
+ root = source.expanduser().resolve()
53
+ if not root.is_dir():
54
+ raise ConfigurationError(f"待接管目录不存在:{root}")
55
+ if root == self._settings.vault_root:
56
+ raise ConfigurationError("不能把整个 Workspace 作为一个接管批次")
57
+ source_kind = "internal" if self._settings.vault_root in root.parents else "external"
58
+ inventory, issues = self._scan(root)
59
+ staging = (
60
+ self._settings.vault_root / "_收件箱" / "待接管" / batch
61
+ if source_kind == "external"
62
+ else None
63
+ )
64
+ state = AdoptionBatchState(
65
+ batch=batch,
66
+ source_path=str(root),
67
+ source_kind=source_kind,
68
+ staging_path=(
69
+ staging.relative_to(self._settings.vault_root).as_posix() if staging else None
70
+ ),
71
+ status="blocked" if issues else "inventoried",
72
+ inventory=inventory,
73
+ )
74
+ operations = []
75
+ copied = 0
76
+ if source_kind == "external":
77
+ operations = [
78
+ {
79
+ "action": "copy",
80
+ "source": str(root / item.path),
81
+ "path": f"{state.staging_path}/{item.path}",
82
+ }
83
+ for item in inventory
84
+ ]
85
+ if not issues and confirm:
86
+ copy_issues = self._copy_to_staging(root, staging, inventory)
87
+ issues.extend(copy_issues)
88
+ if not issues:
89
+ copied = len(inventory)
90
+ state.status = "staged"
91
+ self._repository.save(state)
92
+ return AdoptionResult(
93
+ status=(
94
+ "blocked"
95
+ if issues
96
+ else "staged"
97
+ if source_kind == "external" and confirm
98
+ else "inventoried"
99
+ ),
100
+ batch=batch,
101
+ source=str(root),
102
+ source_kind=source_kind,
103
+ staging_path=state.staging_path,
104
+ item_count=len(inventory),
105
+ copied_count=copied,
106
+ operations=operations,
107
+ issues=issues,
108
+ write_performed=bool(copied),
109
+ )
110
+
111
+ def plan(
112
+ self,
113
+ batch: str,
114
+ *,
115
+ target_path: str,
116
+ domain_id: str,
117
+ name: str,
118
+ space_id: str,
119
+ domain_type: str,
120
+ governance: str,
121
+ parent_domain: str | None = None,
122
+ project_id: str | None = None,
123
+ ) -> AdoptionResult:
124
+ state = self._repository.load(batch)
125
+ if state.source_kind == "external" and state.status != "staged":
126
+ return self._blocked(state, "adoption-source-not-staged", state.source_path)
127
+ if not ID_RE.fullmatch(domain_id):
128
+ raise ConfigurationError("Domain id 只能使用小写字母、数字和连字符")
129
+ if any(item.id == domain_id for item in self._domains.discover()[0]):
130
+ raise ConfigurationError(f"Domain id 已存在:{domain_id}")
131
+ target = self._validate_target(target_path, space_id)
132
+ source = self._active_source(state)
133
+ if target != source and target.exists():
134
+ raise ConfigurationError(f"目标路径已存在:{target_path}")
135
+ if (source / DOMAIN_MARKER).exists():
136
+ raise ConfigurationError("来源目录已经是受管 Domain")
137
+ if governance == "project-docs" and not project_id:
138
+ raise ConfigurationError("project-docs Domain 必须绑定 Project id")
139
+ if project_id and self._workspaces.get_project(project_id) is None:
140
+ raise ConfigurationError(f"Project 未注册:{project_id}")
141
+ if parent_domain:
142
+ parent = self._domains.show(parent_domain)
143
+ parent_path = self._settings.vault_root / parent.path
144
+ if parent_path not in target.parents:
145
+ raise ConfigurationError("目标路径必须位于指定父 Domain 下")
146
+ if parent.governance != governance:
147
+ raise ConfigurationError("子 Domain 必须继承父 Domain governance")
148
+ plan = AdoptionPlan(
149
+ batch=batch,
150
+ target_path=target.relative_to(self._settings.vault_root).as_posix(),
151
+ domain_id=domain_id,
152
+ name=name.strip(),
153
+ space_id=space_id,
154
+ domain_type=domain_type,
155
+ governance=governance,
156
+ parent_domain=parent_domain,
157
+ project_id=project_id,
158
+ )
159
+ if not plan.name:
160
+ raise ConfigurationError("Domain name 不能为空")
161
+ state.plan = plan
162
+ state.status = "planned"
163
+ self._repository.save(state)
164
+ operations = []
165
+ if target != source:
166
+ operations.append(
167
+ {
168
+ "action": "move-directory",
169
+ "source": self._display_path(source),
170
+ "path": plan.target_path,
171
+ }
172
+ )
173
+ operations.extend(
174
+ [
175
+ {"action": "create-domain-marker", "path": f"{plan.target_path}/_领域.md"},
176
+ {
177
+ "action": "create-moc",
178
+ "path": f"{plan.target_path}/_总览/MOC-{plan.name}总览.md",
179
+ },
180
+ ]
181
+ )
182
+ return self._result(state, "planned", operations=operations)
183
+
184
+ def apply(self, batch: str, confirm: bool = False) -> AdoptionResult:
185
+ state = self._repository.load(batch)
186
+ if state.plan is None:
187
+ return self._blocked(state, "adoption-plan-missing", batch)
188
+ source = self._active_source(state)
189
+ target = safe_path(self._settings.vault_root, state.plan.target_path)
190
+ issues = self._verify_files(source, state.inventory)
191
+ if target != source and target.exists():
192
+ issues.append({"code": "target-exists", "path": state.plan.target_path})
193
+ moc = target / "_总览" / f"MOC-{state.plan.name}总览.md"
194
+ if target == source and ((target / DOMAIN_MARKER).exists() or moc.exists()):
195
+ issues.append({"code": "adoption-declaration-exists", "path": state.plan.target_path})
196
+ if issues or not confirm:
197
+ result = self._result(state, "blocked" if issues else "ready")
198
+ result.issues = issues
199
+ return result
200
+ with workspace_write_lock(self._settings.state_root):
201
+ locked_issues = self._verify_files(source, state.inventory)
202
+ if locked_issues:
203
+ result = self._result(state, "blocked")
204
+ result.issues = locked_issues
205
+ return result
206
+ if target != source:
207
+ target.parent.mkdir(parents=True, exist_ok=True)
208
+ source.rename(target)
209
+ domain = Domain(
210
+ id=state.plan.domain_id,
211
+ name=state.plan.name,
212
+ path=target,
213
+ space_id=state.plan.space_id,
214
+ type=state.plan.domain_type,
215
+ governance=state.plan.governance,
216
+ moc=f"_总览/MOC-{state.plan.name}总览",
217
+ parent_domain=state.plan.parent_domain,
218
+ project_id=state.plan.project_id,
219
+ )
220
+ atomic_write(target / DOMAIN_MARKER, DomainService.render_marker(domain))
221
+ atomic_write(target / f"{domain.moc}.md", DomainService.render_moc(domain))
222
+ if state.plan.project_id:
223
+ project = self._workspaces.get_project(state.plan.project_id)
224
+ if project:
225
+ self._workspaces.save_project(
226
+ project.model_copy(update={"document_domain": state.plan.target_path})
227
+ )
228
+ self._sync_manifest()
229
+ state.status = "applied"
230
+ self._repository.save(state)
231
+ sync = self._maintenance.sync(scope=state.plan.target_path)
232
+ health = self._maintenance.check(scope=state.plan.target_path, summary=True)
233
+ result = self._result(state, "applied")
234
+ result.write_performed = True
235
+ result.operations = [
236
+ {str(key): str(value) for key, value in operation.items()}
237
+ for operation in sync.operations
238
+ ]
239
+ result.issues = [issue.model_dump(mode="json") for issue in health.issues]
240
+ if sync.status == "blocked":
241
+ result.status = "needs-review"
242
+ result.issues.extend(issue.model_dump(mode="json") for issue in sync.issues)
243
+ return result
244
+
245
+ def verify(self, batch: str) -> AdoptionResult:
246
+ state = self._repository.load(batch)
247
+ if state.plan is None:
248
+ return self._blocked(state, "adoption-plan-missing", batch)
249
+ target = safe_path(self._settings.vault_root, state.plan.target_path)
250
+ issues = self._verify_files(target, state.inventory)
251
+ if not (target / DOMAIN_MARKER).is_file():
252
+ issues.append({"code": "domain-marker-missing", "path": state.plan.target_path})
253
+ status = "ok" if not issues else "needs-review"
254
+ result = self._result(state, status)
255
+ result.issues = issues
256
+ return result
257
+
258
+ def _scan(self, root: Path) -> tuple[list[AdoptionInventoryItem], list[dict[str, str]]]:
259
+ items: list[AdoptionInventoryItem] = []
260
+ issues: list[dict[str, str]] = []
261
+ for path in sorted(root.rglob("*")):
262
+ relative = path.relative_to(root)
263
+ if any(part in IGNORED_NAMES or part.startswith(".") for part in relative.parts):
264
+ continue
265
+ if path.is_symlink():
266
+ issues.append({"code": "adoption-symlink", "path": relative.as_posix()})
267
+ elif path.is_file():
268
+ items.append(
269
+ AdoptionInventoryItem(
270
+ path=relative.as_posix(),
271
+ sha256=file_sha256(path),
272
+ size=path.stat().st_size,
273
+ )
274
+ )
275
+ return items, issues
276
+
277
+ def _copy_to_staging(
278
+ self, source: Path, staging: Path, items: list[AdoptionInventoryItem]
279
+ ) -> list[dict[str, str]]:
280
+ issues: list[dict[str, str]] = []
281
+ for item in items:
282
+ target = staging / item.path
283
+ if target.exists() and (not target.is_file() or file_sha256(target) != item.sha256):
284
+ issues.append({"code": "adoption-staging-conflict", "path": item.path})
285
+ if issues:
286
+ return issues
287
+ for item in items:
288
+ target = staging / item.path
289
+ if target.is_file():
290
+ continue
291
+ target.parent.mkdir(parents=True, exist_ok=True)
292
+ shutil.copy2(source / item.path, target)
293
+ return []
294
+
295
+ def _validate_target(self, value: str, space_id: str) -> Path:
296
+ target = safe_path(self._settings.vault_root, value)
297
+ space = self._domains.spaces.show(space_id)
298
+ space_root = self._settings.vault_root / space.path
299
+ if space_root not in target.parents:
300
+ raise ConfigurationError("目标路径必须位于指定 Space 下")
301
+ if any(
302
+ part in reserved_directories() or part.startswith(".")
303
+ for part in target.relative_to(space_root).parts
304
+ ):
305
+ raise ConfigurationError("目标路径不能使用保留目录")
306
+ return target
307
+
308
+ def _active_source(self, state: AdoptionBatchState) -> Path:
309
+ if state.source_kind == "external":
310
+ if not state.staging_path:
311
+ raise ConfigurationError("外部接管批次缺少暂存路径")
312
+ return safe_path(self._settings.vault_root, state.staging_path)
313
+ return Path(state.source_path).resolve()
314
+
315
+ @staticmethod
316
+ def _verify_files(root: Path, inventory: list[AdoptionInventoryItem]) -> list[dict[str, str]]:
317
+ issues: list[dict[str, str]] = []
318
+ for item in inventory:
319
+ path = root / item.path
320
+ if not path.is_file():
321
+ issues.append({"code": "adoption-file-missing", "path": item.path})
322
+ elif file_sha256(path) != item.sha256:
323
+ issues.append({"code": "source-hash-changed", "path": item.path})
324
+ return issues
325
+
326
+ def _sync_manifest(self) -> None:
327
+ manifest = self._manifests.load(self._settings.vault_root)
328
+ if manifest is None:
329
+ raise ConfigurationError("Workspace 缺少 .campfire.yaml")
330
+ manifest.projects = [
331
+ ManifestProject.model_validate(
332
+ project.model_dump(exclude={"workspace_id", "local_path"})
333
+ )
334
+ for project in self._workspaces.list_projects(self._settings.workspace_id)
335
+ ]
336
+ self._manifests.save(self._settings.vault_root, manifest)
337
+
338
+ def _result(
339
+ self,
340
+ state: AdoptionBatchState,
341
+ status: str,
342
+ *,
343
+ operations: list[dict[str, str]] | None = None,
344
+ ) -> AdoptionResult:
345
+ return AdoptionResult(
346
+ status=status,
347
+ batch=state.batch,
348
+ source=state.source_path,
349
+ source_kind=state.source_kind,
350
+ staging_path=state.staging_path,
351
+ target_path=state.plan.target_path if state.plan else None,
352
+ item_count=len(state.inventory),
353
+ operations=operations or [],
354
+ )
355
+
356
+ def _blocked(self, state: AdoptionBatchState, code: str, path: str) -> AdoptionResult:
357
+ result = self._result(state, "blocked")
358
+ result.issues = [{"code": code, "path": path}]
359
+ return result
360
+
361
+ def _display_path(self, path: Path) -> str:
362
+ if self._settings.vault_root in path.parents:
363
+ return path.relative_to(self._settings.vault_root).as_posix()
364
+ return str(path)