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,494 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from datetime import date
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from campfire_cli.app.workspace.schema.workspace_schema import (
9
+ Domain,
10
+ DomainCheckResult,
11
+ DomainCreateResult,
12
+ DomainListResult,
13
+ Space,
14
+ SpaceCheckResult,
15
+ SpaceCreateResult,
16
+ SpaceListResult,
17
+ )
18
+ from campfire_cli.common.exceptions import ConfigurationError
19
+ from campfire_cli.common.filesystem import atomic_write, workspace_write_lock
20
+ from campfire_cli.config.defaults import config_section
21
+
22
+ SPACE_MARKER = "_空间.md"
23
+ DOMAIN_MARKER = "_领域.md"
24
+ ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
25
+
26
+
27
+ def reserved_directories() -> set[str]:
28
+ return set(config_section("workspace")["reserved_directories"])
29
+
30
+
31
+ def parse_marker(path: Path) -> dict[str, str]:
32
+ text = path.read_text(encoding="utf-8")
33
+ if not text.startswith("---\n"):
34
+ return {}
35
+ end = text.find("\n---\n", 4)
36
+ if end < 0:
37
+ return {}
38
+ values: dict[str, str] = {}
39
+ for line in text[4:end].splitlines():
40
+ match = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$", line)
41
+ if match:
42
+ values[match.group(1)] = match.group(2).strip().strip('"').strip("'")
43
+ return values
44
+
45
+
46
+ class SpaceService:
47
+ def __init__(self, workspace_root: Path, lock_root: Path) -> None:
48
+ self.root = workspace_root.resolve()
49
+ self.lock_root = lock_root
50
+
51
+ def discover(self) -> tuple[list[Space], list[dict[str, str]]]:
52
+ spaces: list[Space] = []
53
+ issues: list[dict[str, str]] = []
54
+ seen: set[str] = set()
55
+ for marker in sorted(self.root.glob(f"*/{SPACE_MARKER}")):
56
+ meta = parse_marker(marker)
57
+ relative = marker.relative_to(self.root).as_posix()
58
+ missing = [
59
+ key for key in ("name", "space_id", "space_type", "status") if not meta.get(key)
60
+ ]
61
+ for field in missing:
62
+ issues.append({"code": "space-field-missing", "path": relative, "detail": field})
63
+ space_id = meta.get("space_id", "")
64
+ if space_id in seen:
65
+ issues.append({"code": "duplicate-space-id", "path": relative, "detail": space_id})
66
+ seen.add(space_id)
67
+ spaces.append(
68
+ Space(
69
+ id=space_id,
70
+ name=meta.get("name", marker.parent.name),
71
+ path=marker.parent.relative_to(self.root).as_posix(),
72
+ type=meta.get("space_type", ""),
73
+ status=meta.get("status", "active"),
74
+ )
75
+ )
76
+ return spaces, issues
77
+
78
+ def list(self) -> SpaceListResult:
79
+ spaces, _ = self.discover()
80
+ return SpaceListResult(spaces=spaces)
81
+
82
+ def show(self, space_id: str) -> Space:
83
+ matches = [item for item in self.discover()[0] if item.id == space_id]
84
+ if len(matches) != 1:
85
+ raise ConfigurationError(f"Space 不存在或不唯一:{space_id}")
86
+ return matches[0]
87
+
88
+ def check(self, space_id: str | None = None) -> SpaceCheckResult:
89
+ spaces, issues = self.discover()
90
+ if space_id is not None:
91
+ matches = [space for space in spaces if space.id == space_id]
92
+ if len(matches) != 1:
93
+ raise ConfigurationError(f"Space 不存在或不唯一:{space_id}")
94
+ selected = matches[0]
95
+ spaces = matches
96
+ prefix = f"{selected.path}/"
97
+ issues = [issue for issue in issues if issue.get("path", "").startswith(prefix)]
98
+ return SpaceCheckResult(
99
+ status="ok" if not issues else "issues-found", spaces=spaces, issues=issues
100
+ )
101
+
102
+ def create(
103
+ self, space_id: str, name: str, path: str, space_type: str, confirm: bool = False
104
+ ) -> SpaceCreateResult:
105
+ return self._define(space_id, name, path, space_type, confirm, adopt=False)
106
+
107
+ def adopt(
108
+ self, space_id: str, name: str, path: str, space_type: str, confirm: bool = False
109
+ ) -> SpaceCreateResult:
110
+ """Add a Space declaration to an existing root directory without moving content."""
111
+ return self._define(space_id, name, path, space_type, confirm, adopt=True)
112
+
113
+ def _define(
114
+ self,
115
+ space_id: str,
116
+ name: str,
117
+ path: str,
118
+ space_type: str,
119
+ confirm: bool,
120
+ *,
121
+ adopt: bool,
122
+ ) -> SpaceCreateResult:
123
+ if not ID_RE.fullmatch(space_id):
124
+ raise ConfigurationError("Space id 只能使用小写字母、数字和连字符")
125
+ relative = PurePosixPath(path)
126
+ if (
127
+ relative.is_absolute()
128
+ or len(relative.parts) != 1
129
+ or relative.name in reserved_directories()
130
+ ):
131
+ raise ConfigurationError("Space path 必须是 Workspace 根下的单层非保留目录")
132
+ target = self.root / relative
133
+ space = Space(
134
+ id=space_id,
135
+ name=name.strip(),
136
+ path=relative.as_posix(),
137
+ type=space_type,
138
+ status="active",
139
+ )
140
+ if not space.name or not space.type:
141
+ raise ConfigurationError("Space name 和 type 不能为空")
142
+ existing = self.discover()[0]
143
+ if any(item.id == space_id or item.path == relative.as_posix() for item in existing):
144
+ raise ConfigurationError("Space id 或已声明路径存在")
145
+ if adopt:
146
+ if not target.is_dir():
147
+ raise ConfigurationError("adopt 只接入已存在的目录")
148
+ if (target / SPACE_MARKER).exists():
149
+ raise ConfigurationError("目录已经包含 Space 声明")
150
+ elif target.exists():
151
+ raise ConfigurationError("Space 目标路径已存在;请使用 space adopt 接入")
152
+ operations = [] if adopt else [{"action": "create-directory", "path": space.path}]
153
+ operations.append({"action": "create", "path": f"{space.path}/{SPACE_MARKER}"})
154
+ if confirm:
155
+ with workspace_write_lock(self.lock_root):
156
+ marker = target / SPACE_MARKER
157
+ if adopt and (not target.is_dir() or marker.exists()):
158
+ raise ConfigurationError("接入目录在确认后发生变化,请重新预览")
159
+ if not adopt and target.exists():
160
+ raise ConfigurationError("Space 路径在确认后发生变化,请重新预览")
161
+ target.mkdir(parents=True, exist_ok=adopt)
162
+ atomic_write(marker, self.render_marker(space))
163
+ return SpaceCreateResult(
164
+ status=("adopted" if adopt else "created") if confirm else "planned",
165
+ space=space,
166
+ operations=operations,
167
+ write_performed=confirm,
168
+ )
169
+
170
+ @staticmethod
171
+ def render_marker(space: Space) -> str:
172
+ return "".join(
173
+ [
174
+ "---\n",
175
+ f"name: {json.dumps(space.name, ensure_ascii=False)}\n",
176
+ f"space_id: {space.id}\nspace_type: {space.type}\n",
177
+ f"status: {space.status}\n---\n\n",
178
+ f"# {space.name}空间\n\n",
179
+ "用于组织同类领域;具体内容归属由下级 `_领域.md` 声明。\n",
180
+ ]
181
+ )
182
+
183
+
184
+ class DomainService:
185
+ def __init__(self, workspace_root: Path, lock_root: Path) -> None:
186
+ self.root = workspace_root.resolve()
187
+ self.lock_root = lock_root
188
+ self.spaces = SpaceService(workspace_root, lock_root)
189
+
190
+ def discover(self) -> tuple[list[Domain], list[dict[str, str]]]:
191
+ spaces, issues = self.spaces.discover()
192
+ domains: list[Domain] = []
193
+ seen: set[str] = set()
194
+ for space in spaces:
195
+ space_root = self.root / space.path
196
+ for directory in sorted([space_root, *space_root.rglob("*")]):
197
+ if not directory.is_dir() or directory == space_root:
198
+ continue
199
+ parts = directory.relative_to(space_root).parts
200
+ if any(part in reserved_directories() or part.startswith(".") for part in parts):
201
+ continue
202
+ direct_notes = [
203
+ item for item in directory.glob("*.md") if item.name != DOMAIN_MARKER
204
+ ]
205
+ child_domain = any(
206
+ (child / DOMAIN_MARKER).is_file()
207
+ for child in directory.iterdir()
208
+ if child.is_dir()
209
+ )
210
+ if not (directory / DOMAIN_MARKER).is_file() and (direct_notes or child_domain):
211
+ issues.append(
212
+ {
213
+ "code": "undeclared-domain-directory",
214
+ "path": directory.relative_to(self.root).as_posix(),
215
+ }
216
+ )
217
+ for marker in sorted(space_root.rglob(DOMAIN_MARKER)):
218
+ if any(
219
+ part in reserved_directories()
220
+ for part in marker.relative_to(space_root).parts[:-1]
221
+ ):
222
+ continue
223
+ meta = parse_marker(marker)
224
+ relative = marker.relative_to(self.root).as_posix()
225
+ required = ("name", "domain_id", "domain_type", "governance", "moc", "status")
226
+ for field in required:
227
+ if not meta.get(field):
228
+ issues.append(
229
+ {"code": "domain-field-missing", "path": relative, "detail": field}
230
+ )
231
+ domain_id = meta.get("domain_id", "")
232
+ if domain_id in seen:
233
+ issues.append(
234
+ {"code": "duplicate-domain-id", "path": relative, "detail": domain_id}
235
+ )
236
+ seen.add(domain_id)
237
+ domains.append(
238
+ Domain(
239
+ id=domain_id,
240
+ name=meta.get("name", marker.parent.name),
241
+ path=marker.parent,
242
+ space_id=space.id,
243
+ type=meta.get("domain_type", ""),
244
+ governance=meta.get("governance", ""),
245
+ moc=meta.get("moc", "").replace("[[", "").replace("]]", ""),
246
+ parent_domain=meta.get("parent_domain") or None,
247
+ project_id=meta.get("project_id") or None,
248
+ status=meta.get("status", "active"),
249
+ )
250
+ )
251
+ by_id = {item.id: item for item in domains if item.id}
252
+ for item in domains:
253
+ if item.parent_domain:
254
+ parent = by_id.get(item.parent_domain)
255
+ if not parent:
256
+ issues.append(
257
+ {
258
+ "code": "parent-domain-missing",
259
+ "path": item.path,
260
+ "detail": item.parent_domain,
261
+ }
262
+ )
263
+ elif parent.space_id != item.space_id or parent.path not in item.path.parents:
264
+ issues.append(
265
+ {
266
+ "code": "domain-parent-path-mismatch",
267
+ "path": item.path.relative_to(self.root).as_posix(),
268
+ "detail": item.parent_domain,
269
+ }
270
+ )
271
+ elif item.governance != parent.governance:
272
+ issues.append(
273
+ {
274
+ "code": "domain-governance-mismatch",
275
+ "path": item.path.relative_to(self.root).as_posix(),
276
+ "detail": parent.governance,
277
+ }
278
+ )
279
+ moc = item.path / f"{item.moc}.md"
280
+ if item.moc and not moc.is_file():
281
+ issues.append(
282
+ {"code": "moc-missing", "path": moc.relative_to(self.root).as_posix()}
283
+ )
284
+ return domains, issues
285
+
286
+ def list(self, space_id: str | None = None) -> DomainListResult:
287
+ domains = self.discover()[0]
288
+ return DomainListResult(
289
+ domains=[
290
+ self._external(item)
291
+ for item in domains
292
+ if not space_id or item.space_id == space_id
293
+ ]
294
+ )
295
+
296
+ def show(self, domain_id: str) -> Domain:
297
+ matches = [item for item in self.discover()[0] if item.id == domain_id]
298
+ if len(matches) != 1:
299
+ raise ConfigurationError(f"Domain 不存在或不唯一:{domain_id}")
300
+ return self._external(matches[0])
301
+
302
+ def check(self) -> DomainCheckResult:
303
+ domains, issues = self.discover()
304
+ return DomainCheckResult(
305
+ status="ok" if not issues else "issues-found",
306
+ domains=[self._external(item) for item in domains],
307
+ issues=issues,
308
+ )
309
+
310
+ def create(
311
+ self,
312
+ *,
313
+ domain_id: str,
314
+ name: str,
315
+ path: str,
316
+ space_id: str,
317
+ domain_type: str,
318
+ governance: str,
319
+ parent_domain: str | None = None,
320
+ project_id: str | None = None,
321
+ confirm: bool = False,
322
+ ) -> DomainCreateResult:
323
+ return self._define(
324
+ domain_id=domain_id,
325
+ name=name,
326
+ path=path,
327
+ space_id=space_id,
328
+ domain_type=domain_type,
329
+ governance=governance,
330
+ parent_domain=parent_domain,
331
+ project_id=project_id,
332
+ confirm=confirm,
333
+ adopt=False,
334
+ )
335
+
336
+ def adopt(
337
+ self,
338
+ *,
339
+ domain_id: str,
340
+ name: str,
341
+ path: str,
342
+ space_id: str,
343
+ domain_type: str,
344
+ governance: str,
345
+ parent_domain: str | None = None,
346
+ project_id: str | None = None,
347
+ confirm: bool = False,
348
+ ) -> DomainCreateResult:
349
+ """Add a Domain declaration to an existing directory without moving its content."""
350
+ return self._define(
351
+ domain_id=domain_id,
352
+ name=name,
353
+ path=path,
354
+ space_id=space_id,
355
+ domain_type=domain_type,
356
+ governance=governance,
357
+ parent_domain=parent_domain,
358
+ project_id=project_id,
359
+ confirm=confirm,
360
+ adopt=True,
361
+ )
362
+
363
+ def _define(
364
+ self,
365
+ *,
366
+ domain_id: str,
367
+ name: str,
368
+ path: str,
369
+ space_id: str,
370
+ domain_type: str,
371
+ governance: str,
372
+ parent_domain: str | None,
373
+ project_id: str | None,
374
+ confirm: bool,
375
+ adopt: bool,
376
+ ) -> DomainCreateResult:
377
+ if not ID_RE.fullmatch(domain_id):
378
+ raise ConfigurationError("Domain id 只能使用小写字母、数字和连字符")
379
+ space = self.spaces.show(space_id)
380
+ target = (self.root / PurePosixPath(path)).resolve()
381
+ space_root = (self.root / space.path).resolve()
382
+ if (
383
+ target == space_root
384
+ or space_root not in target.parents
385
+ or any(part in reserved_directories() for part in target.relative_to(space_root).parts)
386
+ ):
387
+ raise ConfigurationError("Domain path 必须位于指定 Space 下且不能使用保留目录")
388
+ existing = self.discover()[0]
389
+ if any(item.id == domain_id or item.path == target for item in existing):
390
+ raise ConfigurationError("Domain id 或已声明路径存在")
391
+ if adopt:
392
+ if not target.is_dir():
393
+ raise ConfigurationError("adopt 只接入已存在的目录")
394
+ if (target / DOMAIN_MARKER).exists():
395
+ raise ConfigurationError("目录已经包含领域声明")
396
+ elif target.exists():
397
+ raise ConfigurationError("Domain 目标路径已存在;请使用 domain adopt 接入")
398
+ if parent_domain:
399
+ parent = next((item for item in existing if item.id == parent_domain), None)
400
+ if (
401
+ not parent
402
+ or parent.space_id != space_id
403
+ or (self.root / parent.path) not in target.parents
404
+ ):
405
+ raise ConfigurationError("父 Domain 不存在、跨 Space 或与目标路径不匹配")
406
+ if governance != parent.governance:
407
+ raise ConfigurationError("子 Domain 必须继承父 Domain 的 governance")
408
+ project_id = parent.project_id or project_id
409
+ if governance == "project-docs" and not project_id:
410
+ raise ConfigurationError("project-docs Domain 必须绑定 Project id")
411
+ moc = f"_总览/MOC-{name}总览"
412
+ domain = Domain(
413
+ id=domain_id,
414
+ name=name.strip(),
415
+ path=target,
416
+ space_id=space_id,
417
+ type=domain_type,
418
+ governance=governance,
419
+ moc=moc,
420
+ parent_domain=parent_domain,
421
+ project_id=project_id,
422
+ )
423
+ relative_path = target.relative_to(self.root).as_posix()
424
+ operations = [] if adopt else [{"action": "create-directory", "path": relative_path}]
425
+ operations.extend(
426
+ [
427
+ {"action": "create", "path": f"{relative_path}/{DOMAIN_MARKER}"},
428
+ {"action": "create", "path": f"{relative_path}/{moc}.md"},
429
+ ]
430
+ )
431
+ if confirm:
432
+ with workspace_write_lock(self.lock_root):
433
+ marker = target / DOMAIN_MARKER
434
+ moc_path = target / f"{moc}.md"
435
+ if adopt and (not target.is_dir() or marker.exists() or moc_path.exists()):
436
+ raise ConfigurationError("接入目录在确认后发生变化,请重新预览")
437
+ if not adopt and target.exists():
438
+ raise ConfigurationError("Domain 路径在确认后发生变化,请重新预览")
439
+ (target / "_总览").mkdir(parents=True, exist_ok=adopt)
440
+ atomic_write(target / DOMAIN_MARKER, self.render_marker(domain))
441
+ atomic_write(moc_path, self.render_moc(domain))
442
+ return DomainCreateResult(
443
+ status=("adopted" if adopt else "created") if confirm else "planned",
444
+ domain=self._external(domain),
445
+ operations=operations,
446
+ write_performed=confirm,
447
+ )
448
+
449
+ def _external(self, domain: Domain) -> Domain:
450
+ return domain.model_copy(update={"path": domain.path.relative_to(self.root)})
451
+
452
+ @staticmethod
453
+ def render_marker(domain: Domain) -> str:
454
+ optional = f"parent_domain: {domain.parent_domain}\n" if domain.parent_domain else ""
455
+ optional += f"project_id: {domain.project_id}\n" if domain.project_id else ""
456
+ return "".join(
457
+ [
458
+ "---\n",
459
+ f"name: {json.dumps(domain.name, ensure_ascii=False)}\n",
460
+ f"domain_id: {domain.id}\ndomain_type: {domain.type}\n",
461
+ f"governance: {domain.governance}\n",
462
+ f'moc: "[[{domain.moc}]]"\n',
463
+ optional,
464
+ "status: active\n---\n\n",
465
+ f"# {domain.name}\n\n",
466
+ "## 领域定位\n\n## 收录范围\n\n## 不收录\n\n",
467
+ "## 与相邻领域的边界\n",
468
+ ]
469
+ )
470
+
471
+ @staticmethod
472
+ def render_moc(domain: Domain) -> str:
473
+ today = date.today().isoformat()
474
+ project_identity = ""
475
+ project_lifecycle = ""
476
+ if domain.governance == "project-docs":
477
+ project_identity = f"project: {domain.project_id}\ndomain: {domain.id}\n"
478
+ project_lifecycle = "lifecycle: maintained\n"
479
+ return "".join(
480
+ [
481
+ "---\n",
482
+ f"name: {json.dumps(domain.name + '总览', ensure_ascii=False)}\n",
483
+ f"description: {json.dumps(domain.name + '领域导航入口。', ensure_ascii=False)}\n",
484
+ "type: moc\n",
485
+ project_identity,
486
+ "status: current\n",
487
+ project_lifecycle,
488
+ f"created: {today}\nupdated: {today}\n",
489
+ "tags: []\n---\n\n",
490
+ f"# {domain.name}总览\n\n",
491
+ "<!-- AUTO-GENERATED:DOMAIN-INDEX:START -->\n\n",
492
+ "<!-- AUTO-GENERATED:DOMAIN-INDEX:END -->\n",
493
+ ]
494
+ )
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ from campfire_cli.app.workspace.schema.workspace_schema import ProjectEntry, WorkspaceRegistry
7
+
8
+
9
+ class WorkspaceRepositoryProtocol(Protocol):
10
+ def load_registry(self) -> WorkspaceRegistry: ...
11
+
12
+ def save_registry(self, registry: WorkspaceRegistry) -> None: ...
13
+
14
+ def list_projects(self, workspace_id: str | None = None) -> list[ProjectEntry]: ...
15
+
16
+ def get_project(self, project_id: str) -> ProjectEntry | None: ...
17
+
18
+ def save_project(self, project: ProjectEntry) -> str: ...
19
+
20
+ def replace_registry(
21
+ self, registry: WorkspaceRegistry, projects: list[ProjectEntry]
22
+ ) -> None: ...
23
+
24
+ def create_scaffold(self, root: Path, directories: tuple[str, ...]) -> list[str]: ...