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,189 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from campfire_cli.app.workspace.schema.workspace_schema import WorkspaceConfigCheckResult
6
+ from campfire_cli.config.defaults import config_section
7
+ from campfire_cli.config.settings import WorkspaceSettings
8
+
9
+
10
+ class WorkspaceConfigService:
11
+ """Validate packaged product defaults and one Workspace's effective contracts."""
12
+
13
+ def __init__(self, settings: WorkspaceSettings) -> None:
14
+ self.settings = settings
15
+
16
+ def check(self) -> WorkspaceConfigCheckResult:
17
+ issues: list[dict[str, Any]] = []
18
+ checked = ["config.yml"]
19
+ self._check_workspace_template(issues)
20
+ self._check_policy_lists(issues)
21
+ self._check_document_types(issues)
22
+ self._check_profiles(issues)
23
+ self._check_named_list("skills", self.settings.skills, "managed_skills", issues)
24
+ self._check_named_list("bases", self.settings.bases, "managed_bases", issues)
25
+ for field in (
26
+ "inbox",
27
+ "space_marker",
28
+ "domain_marker",
29
+ "related_limit",
30
+ "related_min_score",
31
+ "cross_domain_related_limit",
32
+ "cross_domain_min_score",
33
+ ):
34
+ if not self.settings.governance.get(field):
35
+ self._issue(issues, "governance", field, "required-value-missing")
36
+ return WorkspaceConfigCheckResult(
37
+ status="ok" if not issues else "issues-found", checked=checked, issues=issues
38
+ )
39
+
40
+ def _check_workspace_template(self, issues: list[dict[str, Any]]) -> None:
41
+ config = config_section("workspace")
42
+ directories = config.get("system_directories", [])
43
+ spaces = config.get("spaces", [])
44
+ self._unique("workspace", "system_directories", directories, issues)
45
+ ids = [item.get("id") for item in spaces if isinstance(item, dict)]
46
+ paths = [item.get("path") for item in spaces if isinstance(item, dict)]
47
+ self._unique("workspace", "spaces.id", ids, issues)
48
+ self._unique("workspace", "spaces.path", paths, issues)
49
+ system_roots = {str(item).split("/", 1)[0] for item in directories}
50
+ for index, item in enumerate(spaces):
51
+ if not isinstance(item, dict):
52
+ self._issue(issues, "workspace", f"spaces.{index}", "object-required")
53
+ continue
54
+ for field in ("id", "name", "path", "type"):
55
+ if not item.get(field):
56
+ self._issue(
57
+ issues,
58
+ "workspace",
59
+ f"spaces.{index}.{field}",
60
+ "required-value-missing",
61
+ )
62
+ if item.get("path") in system_roots:
63
+ self._issue(
64
+ issues,
65
+ "workspace",
66
+ f"spaces.{index}.path",
67
+ "system-space-path-conflict",
68
+ )
69
+
70
+ def _check_policy_lists(self, issues: list[dict[str, Any]]) -> None:
71
+ for name, fields in (
72
+ ("project", ("statuses",)),
73
+ ("archive", ("reasons", "reasons_requiring_successor")),
74
+ ("issues", ("warning_codes",)),
75
+ ):
76
+ config = config_section(name)
77
+ for field in fields:
78
+ self._unique(name, field, config.get(field), issues)
79
+ archive = config_section("archive")
80
+ unknown = set(archive.get("reasons_requiring_successor", [])) - set(
81
+ archive.get("reasons", [])
82
+ )
83
+ for value in sorted(unknown):
84
+ self._issue(
85
+ issues,
86
+ "archive",
87
+ "reasons_requiring_successor",
88
+ "unknown-reference",
89
+ value,
90
+ )
91
+
92
+ def _check_document_types(self, issues: list[dict[str, Any]]) -> None:
93
+ config = self.settings.document_types
94
+ types = config.get("types")
95
+ if not isinstance(types, dict) or not types:
96
+ self._issue(issues, "document_types", "types", "mapping-required")
97
+ return
98
+ prefixes = [item.get("prefix") for item in types.values() if isinstance(item, dict)]
99
+ self._unique("document_types", "types.prefix", prefixes, issues)
100
+ for name, item in types.items():
101
+ if not isinstance(item, dict) or not item.get("prefix") or not item.get("label"):
102
+ self._issue(issues, "document_types", f"types.{name}", "invalid-type")
103
+ for profile, names in config.get("profiles", {}).items():
104
+ for name in names:
105
+ if name not in types:
106
+ self._issue(
107
+ issues,
108
+ "document_types",
109
+ f"profiles.{profile}",
110
+ "unknown-reference",
111
+ name,
112
+ )
113
+
114
+ def _check_profiles(self, issues: list[dict[str, Any]]) -> None:
115
+ config = self.settings.frontmatter_schema
116
+ profiles = config.get("profiles")
117
+ if not isinstance(profiles, dict) or "base" not in profiles:
118
+ self._issue(issues, "frontmatter_schema", "profiles.base", "mapping-required")
119
+ return
120
+ for name, profile in profiles.items():
121
+ if not isinstance(profile, dict):
122
+ self._issue(
123
+ issues, "frontmatter_schema", f"profiles.{name}", "invalid-profile"
124
+ )
125
+ continue
126
+ parent = profile.get("extends")
127
+ if parent and (parent != "base" or name == "base"):
128
+ self._issue(
129
+ issues,
130
+ "frontmatter_schema",
131
+ f"profiles.{name}.extends",
132
+ "invalid-profile-inheritance",
133
+ parent,
134
+ )
135
+ self._unique(
136
+ "frontmatter_schema",
137
+ f"profiles.{name}.field_order",
138
+ profile.get("field_order", []),
139
+ issues,
140
+ )
141
+ resolver = config.get("resolver", {})
142
+ referenced = [resolver.get("fallback")]
143
+ referenced.extend(resolver.get("profile_by_type", {}).values())
144
+ referenced.extend(resolver.get("profile_by_governance", {}).values())
145
+ for name in referenced:
146
+ if name not in profiles:
147
+ self._issue(
148
+ issues,
149
+ "frontmatter_schema",
150
+ "resolver",
151
+ "unknown-reference",
152
+ name,
153
+ )
154
+
155
+ def _check_named_list(
156
+ self,
157
+ filename: str,
158
+ config: dict[str, Any],
159
+ field: str,
160
+ issues: list[dict[str, Any]],
161
+ ) -> None:
162
+ self._unique(filename, field, config.get(field), issues)
163
+
164
+ @classmethod
165
+ def _unique(
166
+ cls,
167
+ filename: str,
168
+ field: str,
169
+ values: Any,
170
+ issues: list[dict[str, Any]],
171
+ ) -> None:
172
+ if not isinstance(values, list) or any(
173
+ not isinstance(item, str) or not item for item in values
174
+ ):
175
+ cls._issue(issues, filename, field, "non-empty-string-list-required")
176
+ return
177
+ duplicates = sorted({item for item in values if values.count(item) > 1})
178
+ for value in duplicates:
179
+ cls._issue(issues, filename, field, "duplicate-value", value)
180
+
181
+ @staticmethod
182
+ def _issue(
183
+ issues: list[dict[str, Any]],
184
+ path: str,
185
+ field: str,
186
+ code: str,
187
+ actual: Any = None,
188
+ ) -> None:
189
+ issues.append({"code": code, "path": path, "field": field, "actual": actual})
@@ -0,0 +1,316 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from urllib.parse import quote
6
+
7
+ from campfire_cli.app.maintenance.service.maintenance_service import MaintenanceService
8
+ from campfire_cli.app.workspace.repository.manifest_repository import (
9
+ WorkspaceManifestRepository,
10
+ )
11
+ from campfire_cli.app.workspace.schema.restructure_schema import DomainRestructureResult
12
+ from campfire_cli.app.workspace.schema.workspace_schema import Domain, ManifestProject
13
+ from campfire_cli.app.workspace.service.structure_service import (
14
+ DOMAIN_MARKER,
15
+ ID_RE,
16
+ DomainService,
17
+ parse_marker,
18
+ reserved_directories,
19
+ )
20
+ from campfire_cli.app.workspace.service.workspace_protocol import WorkspaceRepositoryProtocol
21
+ from campfire_cli.common.documents.markdown import parse_document, render_document
22
+ from campfire_cli.common.exceptions import ConfigurationError
23
+ from campfire_cli.common.filesystem import atomic_write, safe_path, workspace_write_lock
24
+ from campfire_cli.config.settings import WorkspaceSettings
25
+
26
+
27
+ class DomainRestructureService:
28
+ """Plan and execute structural changes to one declared Domain."""
29
+
30
+ def __init__(
31
+ self,
32
+ settings: WorkspaceSettings,
33
+ workspace_repository: WorkspaceRepositoryProtocol,
34
+ maintenance: MaintenanceService,
35
+ manifest_repository: WorkspaceManifestRepository | None = None,
36
+ ) -> None:
37
+ self._settings = settings
38
+ self._workspaces = workspace_repository
39
+ self._maintenance = maintenance
40
+ self._manifests = manifest_repository or WorkspaceManifestRepository()
41
+ self._domains = DomainService(settings.vault_root, settings.state_root)
42
+
43
+ def rename(
44
+ self,
45
+ domain_id: str,
46
+ name: str,
47
+ *,
48
+ rename_directory: bool = False,
49
+ target_path: str | None = None,
50
+ project_name: str | None = None,
51
+ confirm: bool = False,
52
+ ) -> DomainRestructureResult:
53
+ domain = self._domain(domain_id)
54
+ new_name = name.strip()
55
+ if not new_name:
56
+ raise ConfigurationError("Domain name 不能为空")
57
+ if target_path and rename_directory:
58
+ raise ConfigurationError("--target-path 与 --rename-directory 只能使用一个")
59
+ target = domain.path
60
+ if target_path:
61
+ target = self._validate_target(target_path, domain)
62
+ elif rename_directory:
63
+ target = domain.path.with_name(self._directory_name(domain.path.name, new_name))
64
+ return self._change(
65
+ domain,
66
+ target=target,
67
+ name=new_name,
68
+ new_id=domain.id,
69
+ parent_domain=domain.parent_domain,
70
+ project_name=project_name,
71
+ confirm=confirm,
72
+ )
73
+
74
+ def move(
75
+ self,
76
+ domain_id: str,
77
+ target_path: str,
78
+ *,
79
+ parent_domain: str | None = None,
80
+ confirm: bool = False,
81
+ ) -> DomainRestructureResult:
82
+ domain = self._domain(domain_id)
83
+ target = self._validate_target(target_path, domain)
84
+ if parent_domain:
85
+ parent = self._domain(parent_domain)
86
+ if parent.path not in target.parents:
87
+ raise ConfigurationError("目标路径必须位于指定父 Domain 下")
88
+ if parent.governance != domain.governance:
89
+ raise ConfigurationError("移动后的子 Domain 必须继承父 Domain governance")
90
+ return self._change(
91
+ domain,
92
+ target=target,
93
+ name=domain.name,
94
+ new_id=domain.id,
95
+ parent_domain=parent_domain,
96
+ project_name=None,
97
+ confirm=confirm,
98
+ )
99
+
100
+ def rekey(
101
+ self, domain_id: str, new_id: str, *, confirm: bool = False
102
+ ) -> DomainRestructureResult:
103
+ if not ID_RE.fullmatch(new_id):
104
+ raise ConfigurationError("Domain id 只能使用小写字母、数字和连字符")
105
+ domain = self._domain(domain_id)
106
+ if any(item.id == new_id for item in self._domains.discover()[0]):
107
+ raise ConfigurationError(f"Domain id 已存在:{new_id}")
108
+ return self._change(
109
+ domain,
110
+ target=domain.path,
111
+ name=domain.name,
112
+ new_id=new_id,
113
+ parent_domain=domain.parent_domain,
114
+ project_name=None,
115
+ confirm=confirm,
116
+ )
117
+
118
+ def _change(
119
+ self,
120
+ domain: Domain,
121
+ *,
122
+ target: Path,
123
+ name: str,
124
+ new_id: str,
125
+ parent_domain: str | None,
126
+ project_name: str | None,
127
+ confirm: bool,
128
+ ) -> DomainRestructureResult:
129
+ old_relative = domain.path.relative_to(self._settings.vault_root).as_posix()
130
+ new_relative = target.relative_to(self._settings.vault_root).as_posix()
131
+ projects = [
132
+ project
133
+ for project in self._workspaces.list_projects(self._settings.workspace_id)
134
+ if project.document_domain == old_relative
135
+ ]
136
+ operations: list[dict[str, str]] = []
137
+ if target != domain.path:
138
+ operations.append(
139
+ {"action": "move-domain", "source": old_relative, "path": new_relative}
140
+ )
141
+ if name != domain.name:
142
+ operations.append({"action": "rename-domain", "source": domain.name, "path": name})
143
+ if new_id != domain.id:
144
+ operations.append({"action": "rekey-domain", "source": domain.id, "path": new_id})
145
+ if parent_domain != domain.parent_domain:
146
+ operations.append(
147
+ {
148
+ "action": "update-parent-domain",
149
+ "source": domain.parent_domain or "none",
150
+ "path": parent_domain or "none",
151
+ }
152
+ )
153
+ for project in projects:
154
+ operations.append({"action": "update-project", "path": project.id})
155
+ if not confirm:
156
+ return self._result(domain, new_id, name, new_relative, operations, projects, False)
157
+ if target != domain.path and target.exists():
158
+ raise ConfigurationError(f"目标路径已存在:{new_relative}")
159
+ with workspace_write_lock(self._settings.state_root):
160
+ current = self._domain(domain.id)
161
+ if current.path != domain.path or current.name != domain.name:
162
+ raise ConfigurationError("Domain 在预览后发生变化,请重新执行")
163
+ if target != domain.path:
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ domain.path.rename(target)
166
+ marker = target / DOMAIN_MARKER
167
+ self._update_marker(marker, name=name, domain_id=new_id, parent_domain=parent_domain)
168
+ if new_id != domain.id:
169
+ self._rewrite_child_parent_ids(target, domain.id, new_id)
170
+ self._rewrite_domain_id_references(domain.id, new_id)
171
+ self._rewrite_path_references(old_relative, new_relative)
172
+ for project in projects:
173
+ updated = project.model_copy(
174
+ update={
175
+ "document_domain": new_relative,
176
+ "name": project_name.strip() if project_name else project.name,
177
+ }
178
+ )
179
+ self._workspaces.save_project(updated)
180
+ self._sync_manifest()
181
+ sync = self._maintenance.sync(scope=new_relative)
182
+ index = self._maintenance.check(summary=True)
183
+ operations.extend(
184
+ {str(key): str(value) for key, value in operation.items()}
185
+ for operation in sync.operations
186
+ )
187
+ domain_check = self._domains.check()
188
+ affected_prefix = f"{new_relative}/"
189
+ issues = [
190
+ issue
191
+ for issue in domain_check.issues
192
+ if str(issue.get("path", "")).startswith(affected_prefix)
193
+ or issue.get("path") == new_relative
194
+ ]
195
+ issues.extend(issue.model_dump(mode="json") for issue in sync.issues)
196
+ issues.extend(issue.model_dump(mode="json") for issue in index.issues)
197
+ result = self._result(domain, new_id, name, new_relative, operations, projects, True)
198
+ if issues:
199
+ result.status = "needs-review"
200
+ result.issues = issues
201
+ return result
202
+
203
+ def _domain(self, domain_id: str) -> Domain:
204
+ matches = [item for item in self._domains.discover()[0] if item.id == domain_id]
205
+ if len(matches) != 1:
206
+ raise ConfigurationError(f"Domain 不存在或不唯一:{domain_id}")
207
+ return matches[0]
208
+
209
+ def _validate_target(self, value: str, domain: Domain) -> Path:
210
+ target = safe_path(self._settings.vault_root, value)
211
+ spaces = self._domains.spaces.discover()[0]
212
+ owners = [
213
+ space for space in spaces if (self._settings.vault_root / space.path) in target.parents
214
+ ]
215
+ if len(owners) != 1:
216
+ raise ConfigurationError("Domain 目标路径必须位于唯一一个已声明 Space 下")
217
+ relative_to_space = target.relative_to(self._settings.vault_root / owners[0].path)
218
+ if any(
219
+ part in reserved_directories() or part.startswith(".")
220
+ for part in relative_to_space.parts
221
+ ):
222
+ raise ConfigurationError("Domain 目标路径不能使用保留目录")
223
+ if target != domain.path and target.exists():
224
+ raise ConfigurationError(f"目标路径已存在:{value}")
225
+ return target
226
+
227
+ @staticmethod
228
+ def _directory_name(current: str, name: str) -> str:
229
+ if re.fullmatch(r"【[^】]+】文档中心", current):
230
+ return f"【{name}】文档中心"
231
+ return name
232
+
233
+ @staticmethod
234
+ def _update_marker(
235
+ marker: Path, *, name: str, domain_id: str, parent_domain: str | None
236
+ ) -> None:
237
+ parsed = parse_document(marker.read_text(encoding="utf-8"))
238
+ frontmatter = dict(parsed.frontmatter)
239
+ frontmatter["name"] = name
240
+ frontmatter["domain_id"] = domain_id
241
+ if parent_domain:
242
+ frontmatter["parent_domain"] = parent_domain
243
+ else:
244
+ frontmatter.pop("parent_domain", None)
245
+ body = re.sub(r"^# .+$", f"# {name}", parsed.body, count=1, flags=re.MULTILINE)
246
+ atomic_write(marker, render_document(frontmatter, body, list(frontmatter)))
247
+
248
+ def _rewrite_child_parent_ids(self, root: Path, old_id: str, new_id: str) -> None:
249
+ for marker in root.rglob(DOMAIN_MARKER):
250
+ if (
251
+ marker == root / DOMAIN_MARKER
252
+ or parse_marker(marker).get("parent_domain") != old_id
253
+ ):
254
+ continue
255
+ parsed = parse_document(marker.read_text(encoding="utf-8"))
256
+ frontmatter = dict(parsed.frontmatter)
257
+ frontmatter["parent_domain"] = new_id
258
+ atomic_write(marker, render_document(frontmatter, parsed.body, list(frontmatter)))
259
+
260
+ def _rewrite_domain_id_references(self, old_id: str, new_id: str) -> None:
261
+ pattern = re.compile(
262
+ rf"(?m)^(\s*(?:domain|domain_id|parent_domain):\s*)(['\"]?){re.escape(old_id)}\2\s*$"
263
+ )
264
+ for path in self._settings.vault_root.rglob("*.md"):
265
+ if ".git" in path.relative_to(self._settings.vault_root).parts:
266
+ continue
267
+ text = path.read_text(encoding="utf-8")
268
+ updated = pattern.sub(rf"\g<1>\g<2>{new_id}\g<2>", text)
269
+ if updated != text:
270
+ atomic_write(path, updated)
271
+
272
+ def _rewrite_path_references(self, old: str, new: str) -> None:
273
+ if old == new:
274
+ return
275
+ ignored = {".git", ".obsidian"}
276
+ for path in self._settings.vault_root.rglob("*"):
277
+ if not path.is_file() or path.suffix.lower() not in {".md", ".canvas", ".base"}:
278
+ continue
279
+ if any(part in ignored for part in path.relative_to(self._settings.vault_root).parts):
280
+ continue
281
+ text = path.read_text(encoding="utf-8")
282
+ updated = text.replace(old, new).replace(quote(old), quote(new))
283
+ if updated != text:
284
+ atomic_write(path, updated)
285
+
286
+ def _sync_manifest(self) -> None:
287
+ manifest = self._manifests.load(self._settings.vault_root)
288
+ if manifest is None:
289
+ raise ConfigurationError("Workspace 缺少 .campfire.yaml")
290
+ manifest.projects = [
291
+ ManifestProject.model_validate(
292
+ project.model_dump(exclude={"workspace_id", "local_path"})
293
+ )
294
+ for project in self._workspaces.list_projects(self._settings.workspace_id)
295
+ ]
296
+ self._manifests.save(self._settings.vault_root, manifest)
297
+
298
+ @staticmethod
299
+ def _result(
300
+ domain: Domain,
301
+ domain_id: str,
302
+ name: str,
303
+ path: str,
304
+ operations: list[dict[str, str]],
305
+ projects: list,
306
+ written: bool,
307
+ ) -> DomainRestructureResult:
308
+ return DomainRestructureResult(
309
+ status="applied" if written else "planned",
310
+ domain_id=domain_id,
311
+ name=name,
312
+ path=path,
313
+ operations=operations,
314
+ affected_projects=[project.id for project in projects],
315
+ write_performed=written,
316
+ )