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,304 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from campfire_cli.app.workspace.repository.manifest_repository import WorkspaceManifestRepository
8
+ from campfire_cli.app.workspace.schema.workspace_schema import (
9
+ ManifestProject,
10
+ ManifestWorkspace,
11
+ ProjectEntry,
12
+ RegistryExport,
13
+ RegistryTransferResult,
14
+ Space,
15
+ WorkspaceCreateRequest,
16
+ WorkspaceDefaultResult,
17
+ WorkspaceEntry,
18
+ WorkspaceListResult,
19
+ WorkspaceManifest,
20
+ WorkspaceRegistry,
21
+ WorkspaceResolution,
22
+ WorkspaceResult,
23
+ WorkspaceSetupResult,
24
+ )
25
+ from campfire_cli.app.workspace.service.structure_service import SpaceService
26
+ from campfire_cli.app.workspace.service.workspace_protocol import WorkspaceRepositoryProtocol
27
+ from campfire_cli.common.exceptions import ConfigurationError
28
+ from campfire_cli.common.filesystem import atomic_write, workspace_write_lock
29
+ from campfire_cli.config.defaults import config_section
30
+
31
+
32
+ class WorkspaceService:
33
+ def __init__(
34
+ self,
35
+ governance_root: Path,
36
+ repository: WorkspaceRepositoryProtocol,
37
+ manifest_repository: WorkspaceManifestRepository | None = None,
38
+ ) -> None:
39
+ self._root = governance_root
40
+ self._repository = repository
41
+ self._manifests = manifest_repository or WorkspaceManifestRepository()
42
+
43
+ def add(self, request: WorkspaceCreateRequest) -> WorkspaceResult:
44
+ root = request.path.expanduser().resolve()
45
+ if not root.is_dir():
46
+ raise ConfigurationError(f"Workspace 不存在:{root}")
47
+ result = self._initialize(request.workspace_id, root, request.make_default)
48
+ if self._manifests.load(root) is None:
49
+ self._manifests.save(
50
+ root,
51
+ WorkspaceManifest(
52
+ workspace=ManifestWorkspace(id=request.workspace_id, name=request.workspace_id)
53
+ ),
54
+ )
55
+ return result
56
+
57
+ def create(self, request: WorkspaceCreateRequest) -> WorkspaceResult:
58
+ root = request.path.expanduser().resolve()
59
+ self._validate_id(request.workspace_id)
60
+ if root.exists():
61
+ raise ConfigurationError(
62
+ f"目标路径已存在;接入现有 Workspace 请使用 workspace add:{root}"
63
+ )
64
+ template = config_section("workspace")
65
+ spaces = tuple(Space.model_validate(item) for item in template["spaces"])
66
+ scaffold = (*template["system_directories"], *(space.path for space in spaces))
67
+ directories = self._repository.create_scaffold(root, scaffold)
68
+ for space in spaces:
69
+ atomic_write(
70
+ root / space.path / "_空间.md",
71
+ SpaceService.render_marker(space),
72
+ )
73
+ result = self._initialize(request.workspace_id, root, request.make_default)
74
+ result.created_directories = directories
75
+ self._manifests.save(
76
+ root,
77
+ WorkspaceManifest(
78
+ workspace=ManifestWorkspace(id=request.workspace_id, name=request.workspace_id)
79
+ ),
80
+ )
81
+ return result
82
+
83
+ def setup(self, path: Path, make_default: bool = False) -> WorkspaceSetupResult:
84
+ """Attach an existing portable Workspace or create its first Manifest."""
85
+ root = path.expanduser().resolve()
86
+ if not root.is_dir():
87
+ raise ConfigurationError(f"Workspace 不存在:{root}")
88
+ manifest = self._manifests.load(root)
89
+ manifest_operation = "preserved"
90
+ if manifest is None:
91
+ workspace_id = self._registered_id_for_path(root)
92
+ projects = self._repository.list_projects(workspace_id)
93
+ manifest = WorkspaceManifest(
94
+ workspace=ManifestWorkspace(id=workspace_id, name=workspace_id),
95
+ projects=[
96
+ ManifestProject.model_validate(
97
+ project.model_dump(exclude={"workspace_id", "local_path"})
98
+ )
99
+ for project in projects
100
+ ],
101
+ )
102
+ self._manifests.save(root, manifest)
103
+ manifest_operation = "created"
104
+ result = self._initialize(manifest.workspace.id, root, make_default)
105
+ imported: list[str] = []
106
+ unbound: list[str] = []
107
+ for portable in manifest.projects:
108
+ existing = self._repository.get_project(portable.id)
109
+ if existing and existing.workspace_id != manifest.workspace.id:
110
+ raise ConfigurationError(f"Project id 已由其他 Workspace 使用:{portable.id}")
111
+ project = ProjectEntry(
112
+ **portable.model_dump(),
113
+ workspace_id=manifest.workspace.id,
114
+ local_path=existing.local_path if existing else None,
115
+ )
116
+ self._repository.save_project(project)
117
+ imported.append(project.id)
118
+ if not project.local_path:
119
+ unbound.append(project.id)
120
+ return WorkspaceSetupResult(
121
+ **result.model_dump(),
122
+ manifest=str(self._manifests.path(root)),
123
+ manifest_operation=manifest_operation,
124
+ imported_projects=imported,
125
+ unbound_projects=unbound,
126
+ )
127
+
128
+ def list(self) -> WorkspaceListResult:
129
+ registry = self._repository.load_registry()
130
+ return WorkspaceListResult(**registry.model_dump())
131
+
132
+ def show(self, selector: str) -> WorkspaceResolution:
133
+ workspace_id, path = self._resolve(selector)
134
+ return WorkspaceResolution(
135
+ workspace_id=workspace_id,
136
+ workspace=str(path),
137
+ state_root=str(self._root / "workspaces" / workspace_id),
138
+ )
139
+
140
+ def resolve(self, selector: str | None, cwd: Path) -> WorkspaceResolution:
141
+ workspace_id, path = self._resolve(selector, cwd)
142
+ return WorkspaceResolution(
143
+ workspace_id=workspace_id,
144
+ workspace=str(path),
145
+ state_root=str(self._root / "workspaces" / workspace_id),
146
+ )
147
+
148
+ def set_default(self, workspace_id: str) -> WorkspaceDefaultResult:
149
+ with workspace_write_lock(self._root):
150
+ registry = self._repository.load_registry()
151
+ if workspace_id not in registry.workspaces:
152
+ raise ConfigurationError(f"Workspace 未注册:{workspace_id}")
153
+ registry.default_workspace = workspace_id
154
+ self._repository.save_registry(registry)
155
+ return WorkspaceDefaultResult(default_workspace=workspace_id)
156
+
157
+ def export_registry(self, target: Path) -> RegistryTransferResult:
158
+ registry = self._repository.load_registry()
159
+ projects = self._repository.list_projects()
160
+ payload = RegistryExport(
161
+ default_workspace=registry.default_workspace,
162
+ workspaces=registry.workspaces,
163
+ projects=projects,
164
+ )
165
+ destination = target.expanduser().resolve()
166
+ atomic_write(
167
+ destination,
168
+ json.dumps(payload.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n",
169
+ )
170
+ return RegistryTransferResult(
171
+ status="exported",
172
+ path=str(destination),
173
+ workspace_count=len(registry.workspaces),
174
+ project_count=len(projects),
175
+ )
176
+
177
+ def import_registry(self, source: Path, confirm: bool = False) -> RegistryTransferResult:
178
+ source = source.expanduser().resolve()
179
+ if not source.is_file():
180
+ raise ConfigurationError(f"导入文件不存在:{source}")
181
+ payload = RegistryExport.model_validate_json(source.read_text(encoding="utf-8"))
182
+ self._validate_import(payload)
183
+ if confirm:
184
+ with workspace_write_lock(self._root):
185
+ self._repository.replace_registry(
186
+ WorkspaceRegistry(
187
+ schema_version=payload.schema_version,
188
+ default_workspace=payload.default_workspace,
189
+ workspaces=payload.workspaces,
190
+ ),
191
+ payload.projects,
192
+ )
193
+ return RegistryTransferResult(
194
+ status="imported" if confirm else "planned",
195
+ path=str(source),
196
+ workspace_count=len(payload.workspaces),
197
+ project_count=len(payload.projects),
198
+ )
199
+
200
+ def _initialize(self, workspace_id: str, root: Path, make_default: bool) -> WorkspaceResult:
201
+ self._validate_id(workspace_id)
202
+ with workspace_write_lock(self._root):
203
+ config_path = self._root / "config.yml"
204
+ config_created = False
205
+ if not config_path.exists():
206
+ atomic_write(
207
+ config_path,
208
+ "version: 1\n# 只写需要覆盖的 Campfire 默认配置。\n",
209
+ )
210
+ config_created = True
211
+ registry = self._repository.load_registry()
212
+ existing = registry.workspaces.get(workspace_id)
213
+ if existing and Path(existing.path).expanduser().resolve() != root:
214
+ raise ConfigurationError(f"Workspace id 已指向其他路径:{workspace_id}")
215
+ duplicate = next(
216
+ (
217
+ other_id
218
+ for other_id, entry in registry.workspaces.items()
219
+ if other_id != workspace_id and Path(entry.path).expanduser().resolve() == root
220
+ ),
221
+ None,
222
+ )
223
+ if duplicate:
224
+ raise ConfigurationError(f"Workspace 路径已由其他 id 注册:{duplicate}")
225
+ registry.workspaces[workspace_id] = WorkspaceEntry(path=str(root))
226
+ if make_default or not registry.default_workspace:
227
+ registry.default_workspace = workspace_id
228
+ self._repository.save_registry(registry)
229
+ return WorkspaceResult(
230
+ status="initialized",
231
+ workspace_id=workspace_id,
232
+ workspace=str(root),
233
+ state_root=str(self._root / "workspaces" / workspace_id),
234
+ default=registry.default_workspace == workspace_id,
235
+ created=[str(config_path)] if config_created else [],
236
+ preserved=[] if config_created else [str(config_path)],
237
+ )
238
+
239
+ def _resolve(self, selector: str | None, cwd: Path | None = None) -> tuple[str, Path]:
240
+ registry = self._repository.load_registry()
241
+ if selector is not None:
242
+ if selector in registry.workspaces:
243
+ return selector, Path(registry.workspaces[selector].path).expanduser().resolve()
244
+ candidate = Path(selector).expanduser().resolve()
245
+ for workspace_id, entry in registry.workspaces.items():
246
+ if Path(entry.path).expanduser().resolve() == candidate:
247
+ return workspace_id, candidate
248
+ raise ConfigurationError(
249
+ f"Workspace 未注册:{selector};请先运行 campfire workspace add"
250
+ )
251
+ current = (cwd or Path.cwd()).resolve()
252
+ matches = [
253
+ (workspace_id, Path(entry.path).expanduser().resolve())
254
+ for workspace_id, entry in registry.workspaces.items()
255
+ if current == Path(entry.path).expanduser().resolve()
256
+ or Path(entry.path).expanduser().resolve() in current.parents
257
+ ]
258
+ if matches:
259
+ return max(matches, key=lambda item: len(item[1].parts))
260
+ if registry.default_workspace in registry.workspaces:
261
+ entry = registry.workspaces[registry.default_workspace]
262
+ return registry.default_workspace, Path(entry.path).expanduser().resolve()
263
+ raise ConfigurationError(
264
+ "没有可用 Workspace;已有 Vault(含 .campfire.yaml)运行 "
265
+ "campfire setup --workspace <path> --default,"
266
+ "全新目录运行 campfire workspace add --id <id> --path <path> --default"
267
+ )
268
+
269
+ def _registered_id_for_path(self, root: Path) -> str:
270
+ registry = self._repository.load_registry()
271
+ matches = [
272
+ workspace_id
273
+ for workspace_id, entry in registry.workspaces.items()
274
+ if Path(entry.path).expanduser().resolve() == root
275
+ ]
276
+ if len(matches) != 1:
277
+ raise ConfigurationError(
278
+ "Workspace 尚无 Manifest,且无法从本机注册表唯一推断 id;"
279
+ "请先运行 campfire workspace add --id <id> --path <path>"
280
+ )
281
+ return matches[0]
282
+
283
+ @classmethod
284
+ def _validate_import(cls, payload: RegistryExport) -> None:
285
+ if payload.default_workspace and payload.default_workspace not in payload.workspaces:
286
+ raise ConfigurationError("default_workspace 未出现在 workspaces 中")
287
+ for workspace_id, entry in payload.workspaces.items():
288
+ cls._validate_id(workspace_id)
289
+ if not Path(entry.path).expanduser().is_absolute():
290
+ raise ConfigurationError(f"Workspace path 必须是绝对路径:{workspace_id}")
291
+ project_ids: set[str] = set()
292
+ for project in payload.projects:
293
+ if project.id in project_ids:
294
+ raise ConfigurationError(f"Project id 重复:{project.id}")
295
+ project_ids.add(project.id)
296
+ if project.workspace_id not in payload.workspaces:
297
+ raise ConfigurationError(
298
+ f"Project 引用了未注册 Workspace:{project.id}/{project.workspace_id}"
299
+ )
300
+
301
+ @staticmethod
302
+ def _validate_id(workspace_id: str) -> None:
303
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", workspace_id):
304
+ raise ConfigurationError("id 只能使用小写字母、数字和连字符")
@@ -0,0 +1,12 @@
1
+ # Common SPEC
2
+
3
+ 本目录放 Workspace 与 Maintenance 等业务模块共同使用的原子能力。
4
+
5
+ - `documents/`:Markdown、Frontmatter、文档类型、链接、领域、关系和 MOC。
6
+ - `archive/`:归档规则与计划能力。
7
+ - `database/`:SQLite、模型和数据库迁移机制。
8
+ - `filesystem/`:安全路径、原子写入和锁。
9
+ - `reports/`:稳定 JSON 与 Markdown 报告渲染。
10
+ - `governance/`:跨用例共享的规则引擎、Issue 目录和乐观并发快照;治理规则不得散落在 Service。
11
+
12
+ `common/` 不承载完整用例流程;检查、计划、执行与验收的编排仍由 `app/` Service 负责。
@@ -0,0 +1 @@
1
+ """Protocol-neutral infrastructure mechanisms."""
@@ -0,0 +1,4 @@
1
+ from campfire_cli.common.database.migrations import upgrade_database
2
+ from campfire_cli.common.database.session import create_sqlite_engine, open_session
3
+
4
+ __all__ = ["create_sqlite_engine", "open_session", "upgrade_database"]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.resources import as_file, files
4
+ from pathlib import Path
5
+
6
+ from alembic.config import Config
7
+
8
+ from alembic import command
9
+
10
+
11
+ def upgrade_database(database_path: Path) -> None:
12
+ database_path.parent.mkdir(parents=True, exist_ok=True)
13
+ resource = files("campfire_cli").joinpath("alembic")
14
+ with as_file(resource) as script_location:
15
+ config = Config()
16
+ config.set_main_option("script_location", str(script_location))
17
+ config.set_main_option("sqlalchemy.url", f"sqlite:///{database_path}")
18
+ command.upgrade(config, "head")
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
6
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ pass
11
+
12
+
13
+ def utc_now() -> datetime:
14
+ return datetime.now(UTC).replace(tzinfo=None)
15
+
16
+
17
+ class Workspace(Base):
18
+ __tablename__ = "workspaces"
19
+ id: Mapped[str] = mapped_column(String(63), primary_key=True)
20
+ path: Mapped[str] = mapped_column(Text, unique=True)
21
+ is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
22
+ status: Mapped[str] = mapped_column(String(24), default="active")
23
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
24
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now)
25
+
26
+
27
+ class Project(Base):
28
+ __tablename__ = "projects"
29
+ id: Mapped[str] = mapped_column(String(63), primary_key=True)
30
+ workspace_id: Mapped[str] = mapped_column(
31
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
32
+ )
33
+ name: Mapped[str] = mapped_column(String(160))
34
+ document_domain: Mapped[str] = mapped_column(Text)
35
+ git_remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
36
+ local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
37
+ default_branch: Mapped[str | None] = mapped_column(String(160), nullable=True)
38
+ status: Mapped[str] = mapped_column(String(24), default="active")
39
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
40
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now)
41
+
42
+
43
+ class WorkspaceSpace(Base):
44
+ __tablename__ = "spaces"
45
+ __table_args__ = (UniqueConstraint("workspace_id", "space_id"),)
46
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
47
+ workspace_id: Mapped[str] = mapped_column(
48
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
49
+ )
50
+ space_id: Mapped[str] = mapped_column(String(128))
51
+ name: Mapped[str] = mapped_column(String(160))
52
+ path: Mapped[str] = mapped_column(Text)
53
+ space_type: Mapped[str] = mapped_column(String(64))
54
+ status: Mapped[str] = mapped_column(String(24))
55
+ source_hash: Mapped[str] = mapped_column(String(64))
56
+ indexed_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
57
+
58
+
59
+ class WorkspaceDomain(Base):
60
+ __tablename__ = "domains"
61
+ __table_args__ = (UniqueConstraint("workspace_id", "domain_id"),)
62
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
63
+ workspace_id: Mapped[str] = mapped_column(
64
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
65
+ )
66
+ domain_id: Mapped[str] = mapped_column(String(128))
67
+ space_id: Mapped[str] = mapped_column(String(128), index=True)
68
+ parent_domain_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
69
+ project_id: Mapped[str | None] = mapped_column(String(63), nullable=True)
70
+ name: Mapped[str] = mapped_column(String(160))
71
+ path: Mapped[str] = mapped_column(Text)
72
+ domain_type: Mapped[str] = mapped_column(String(64))
73
+ governance: Mapped[str] = mapped_column(String(64))
74
+ moc: Mapped[str] = mapped_column(Text)
75
+ status: Mapped[str] = mapped_column(String(24))
76
+ source_hash: Mapped[str] = mapped_column(String(64))
77
+ indexed_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
78
+
79
+
80
+ class Document(Base):
81
+ __tablename__ = "documents"
82
+ __table_args__ = (UniqueConstraint("workspace_id", "path"),)
83
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
84
+ workspace_id: Mapped[str] = mapped_column(
85
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
86
+ )
87
+ path: Mapped[str] = mapped_column(Text, index=True)
88
+ content_hash: Mapped[str] = mapped_column(String(64))
89
+ document_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
90
+ domain_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
91
+ status: Mapped[str | None] = mapped_column(String(32), nullable=True)
92
+ exists: Mapped[bool] = mapped_column(Boolean, default=True)
93
+ indexed_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
94
+
95
+
96
+ class GovernanceIssue(Base):
97
+ __tablename__ = "governance_issues"
98
+ __table_args__ = (UniqueConstraint("workspace_id", "path", "code", "detail"),)
99
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
100
+ workspace_id: Mapped[str] = mapped_column(
101
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
102
+ )
103
+ path: Mapped[str] = mapped_column(Text)
104
+ code: Mapped[str] = mapped_column(String(96), index=True)
105
+ detail: Mapped[str] = mapped_column(Text, default="")
106
+ severity: Mapped[str] = mapped_column(String(16), default="error")
107
+ status: Mapped[str] = mapped_column(String(16), default="open")
108
+
109
+
110
+ class MaintenanceRun(Base):
111
+ __tablename__ = "maintenance_runs"
112
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
113
+ workspace_id: Mapped[str] = mapped_column(
114
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
115
+ )
116
+ run_id: Mapped[str] = mapped_column(String(36), unique=True)
117
+ status: Mapped[str] = mapped_column(String(24))
118
+ scanned_count: Mapped[int] = mapped_column(Integer, default=0)
119
+ issue_count: Mapped[int] = mapped_column(Integer, default=0)
120
+ started_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
121
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
122
+
123
+
124
+ class Decision(Base):
125
+ __tablename__ = "decisions"
126
+ __table_args__ = (UniqueConstraint("workspace_id", "dedupe_key"),)
127
+ id: Mapped[str] = mapped_column(String(36), primary_key=True)
128
+ workspace_id: Mapped[str] = mapped_column(
129
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
130
+ )
131
+ dedupe_key: Mapped[str] = mapped_column(String(160))
132
+ question: Mapped[str] = mapped_column(Text)
133
+ context: Mapped[str] = mapped_column(Text, default="")
134
+ recommendation: Mapped[str] = mapped_column(Text, default="")
135
+ options_json: Mapped[str] = mapped_column(Text, default="[]")
136
+ related_documents_json: Mapped[str] = mapped_column(Text, default="[]")
137
+ source_type: Mapped[str] = mapped_column(String(64))
138
+ source_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
139
+ session_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
140
+ session_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
141
+ status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
142
+ answer: Mapped[str | None] = mapped_column(Text, nullable=True)
143
+ answered_by: Mapped[str | None] = mapped_column(String(160), nullable=True)
144
+ cancellation_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
145
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
146
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now)
147
+ answered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
148
+ closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
149
+
150
+
151
+ class DecisionEvent(Base):
152
+ __tablename__ = "decision_events"
153
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
154
+ workspace_id: Mapped[str] = mapped_column(
155
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
156
+ )
157
+ decision_id: Mapped[str] = mapped_column(
158
+ ForeignKey("decisions.id", ondelete="CASCADE"), index=True
159
+ )
160
+ event_type: Mapped[str] = mapped_column(String(64), index=True)
161
+ actor: Mapped[str | None] = mapped_column(String(160), nullable=True)
162
+ payload_json: Mapped[str] = mapped_column(Text, default="{}")
163
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
164
+
165
+
166
+ class RestructureBatch(Base):
167
+ __tablename__ = "restructure_batches"
168
+ __table_args__ = (UniqueConstraint("workspace_id", "batch_name"),)
169
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
170
+ workspace_id: Mapped[str] = mapped_column(
171
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
172
+ )
173
+ batch_uuid: Mapped[str] = mapped_column(String(36), unique=True)
174
+ batch_name: Mapped[str] = mapped_column(String(128))
175
+ scope: Mapped[str] = mapped_column(Text)
176
+ status: Mapped[str] = mapped_column(String(24), default="inventoried")
177
+ config_hash: Mapped[str] = mapped_column(String(64))
178
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
179
+
180
+
181
+ class RestructureItem(Base):
182
+ __tablename__ = "restructure_items"
183
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
184
+ workspace_id: Mapped[str] = mapped_column(
185
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
186
+ )
187
+ batch_id: Mapped[int] = mapped_column(ForeignKey("restructure_batches.id"), index=True)
188
+ item_uuid: Mapped[str] = mapped_column(String(36), unique=True)
189
+ source_path: Mapped[str] = mapped_column(Text)
190
+ target_path: Mapped[str] = mapped_column(Text)
191
+ source_hash: Mapped[str] = mapped_column(String(64))
192
+ action: Mapped[str] = mapped_column(String(32))
193
+ proposed_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
194
+ reason: Mapped[str] = mapped_column(Text, default="")
195
+ confidence: Mapped[str] = mapped_column(String(16), default="low")
196
+ approved: Mapped[bool] = mapped_column(Boolean, default=False)
197
+ execution_status: Mapped[str] = mapped_column(String(24), default="pending")
198
+
199
+
200
+ class AdoptionBatch(Base):
201
+ __tablename__ = "adoption_batches"
202
+ __table_args__ = (UniqueConstraint("workspace_id", "batch_name"),)
203
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
204
+ workspace_id: Mapped[str] = mapped_column(
205
+ ForeignKey("workspaces.id", ondelete="CASCADE"), index=True
206
+ )
207
+ batch_name: Mapped[str] = mapped_column(String(128))
208
+ source_path: Mapped[str] = mapped_column(Text)
209
+ source_kind: Mapped[str] = mapped_column(String(16))
210
+ staging_path: Mapped[str | None] = mapped_column(Text, nullable=True)
211
+ status: Mapped[str] = mapped_column(String(24))
212
+ inventory_json: Mapped[str] = mapped_column(Text)
213
+ plan_json: Mapped[str | None] = mapped_column(Text, nullable=True)
214
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
215
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, onupdate=utc_now)
@@ -0,0 +1,30 @@
1
+ from pathlib import Path
2
+
3
+ from sqlalchemy import create_engine, event
4
+ from sqlalchemy.engine import Engine
5
+ from sqlalchemy.orm import Session
6
+
7
+ from campfire_cli.common.database.models import Base
8
+
9
+
10
+ def create_sqlite_engine(database_path: Path) -> Engine:
11
+ database_path.parent.mkdir(parents=True, exist_ok=True)
12
+ engine = create_engine(f"sqlite:///{database_path}")
13
+
14
+ @event.listens_for(engine, "connect")
15
+ def configure_sqlite(dbapi_connection, _connection_record) -> None:
16
+ cursor = dbapi_connection.cursor()
17
+ cursor.execute("PRAGMA foreign_keys=ON")
18
+ cursor.close()
19
+
20
+ with engine.connect() as connection:
21
+ connection.exec_driver_sql("PRAGMA journal_mode=WAL")
22
+ return engine
23
+
24
+
25
+ def initialize_database(engine: Engine) -> None:
26
+ Base.metadata.create_all(engine)
27
+
28
+
29
+ def open_session(engine: Engine) -> Session:
30
+ return Session(engine)
@@ -0,0 +1 @@
1
+ """Stateless Markdown parsing and Workspace document primitives."""