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.
- campfire_cli/__init__.py +3 -0
- campfire_cli/alembic/__init__.py +1 -0
- campfire_cli/alembic/env.py +37 -0
- campfire_cli/alembic/script.py.mako +22 -0
- campfire_cli/alembic/versions/001_initial_schema.py +81 -0
- campfire_cli/alembic/versions/002_workspace_project_registry.py +47 -0
- campfire_cli/alembic/versions/003_unified_workspace_state.py +113 -0
- campfire_cli/alembic/versions/004_restructure_workspace_domain.py +23 -0
- campfire_cli/alembic/versions/005_decision_channel.py +71 -0
- campfire_cli/alembic/versions/006_workspace_topology_index.py +61 -0
- campfire_cli/alembic/versions/007_workspace_adoption.py +38 -0
- campfire_cli/alembic/versions/__init__.py +1 -0
- campfire_cli/app/SPEC.md +12 -0
- campfire_cli/app/__init__.py +1 -0
- campfire_cli/app/base/SPEC.md +3 -0
- campfire_cli/app/base/__init__.py +1 -0
- campfire_cli/app/base/cli/base_cli.py +37 -0
- campfire_cli/app/base/repository/base_repository.py +17 -0
- campfire_cli/app/base/schema/base_schema.py +18 -0
- campfire_cli/app/base/service/base_service.py +129 -0
- campfire_cli/app/decision/SPEC.md +16 -0
- campfire_cli/app/decision/__init__.py +1 -0
- campfire_cli/app/decision/cli/decision_cli.py +112 -0
- campfire_cli/app/decision/repository/decision_repository.py +199 -0
- campfire_cli/app/decision/schema/decision_schema.py +56 -0
- campfire_cli/app/decision/service/decision_projection_service.py +151 -0
- campfire_cli/app/decision/service/decision_service.py +124 -0
- campfire_cli/app/document/SPEC.md +51 -0
- campfire_cli/app/document/__init__.py +1 -0
- campfire_cli/app/document/cli/__init__.py +1 -0
- campfire_cli/app/document/cli/document_cli.py +63 -0
- campfire_cli/app/document/cli/profile_cli.py +65 -0
- campfire_cli/app/document/cli/type_cli.py +49 -0
- campfire_cli/app/document/repository/__init__.py +1 -0
- campfire_cli/app/document/repository/document_profile_repository.py +17 -0
- campfire_cli/app/document/repository/document_type_repository.py +17 -0
- campfire_cli/app/document/service/__init__.py +1 -0
- campfire_cli/app/document/service/document_profile_service.py +66 -0
- campfire_cli/app/document/service/document_rule_service.py +316 -0
- campfire_cli/app/document/service/document_scanner.py +38 -0
- campfire_cli/app/document/service/document_service.py +139 -0
- campfire_cli/app/document/service/document_type_service.py +28 -0
- campfire_cli/app/document/service/frontmatter_apply.py +98 -0
- campfire_cli/app/document/service/frontmatter_formatter.py +104 -0
- campfire_cli/app/document/service/frontmatter_plan.py +75 -0
- campfire_cli/app/document/service/profile_registry.py +166 -0
- campfire_cli/app/document/service/type_apply.py +185 -0
- campfire_cli/app/document/service/type_plan.py +80 -0
- campfire_cli/app/maintenance/SPEC.md +22 -0
- campfire_cli/app/maintenance/__init__.py +1 -0
- campfire_cli/app/maintenance/cli/maintenance_cli.py +103 -0
- campfire_cli/app/maintenance/repository/maintenance_repository.py +115 -0
- campfire_cli/app/maintenance/schema/maintenance_schema.py +109 -0
- campfire_cli/app/maintenance/service/archive_service.py +255 -0
- campfire_cli/app/maintenance/service/link_service.py +91 -0
- campfire_cli/app/maintenance/service/maintenance_protocol.py +25 -0
- campfire_cli/app/maintenance/service/maintenance_service.py +461 -0
- campfire_cli/app/maintenance/service/moc_service.py +310 -0
- campfire_cli/app/maintenance/service/plan_service.py +411 -0
- campfire_cli/app/skill/SPEC.md +3 -0
- campfire_cli/app/skill/__init__.py +1 -0
- campfire_cli/app/skill/cli/skill_cli.py +44 -0
- campfire_cli/app/skill/repository/skill_repository.py +33 -0
- campfire_cli/app/skill/schema/skill_schema.py +18 -0
- campfire_cli/app/skill/service/skill_service.py +146 -0
- campfire_cli/app/workspace/SPEC.md +25 -0
- campfire_cli/app/workspace/__init__.py +1 -0
- campfire_cli/app/workspace/cli/__init__.py +1 -0
- campfire_cli/app/workspace/cli/adoption_cli.py +83 -0
- campfire_cli/app/workspace/cli/config_cli.py +30 -0
- campfire_cli/app/workspace/cli/domain_cli.py +101 -0
- campfire_cli/app/workspace/cli/project_cli.py +135 -0
- campfire_cli/app/workspace/cli/restructure_cli.py +133 -0
- campfire_cli/app/workspace/cli/space_cli.py +65 -0
- campfire_cli/app/workspace/cli/workspace_cli.py +145 -0
- campfire_cli/app/workspace/repository/__init__.py +1 -0
- campfire_cli/app/workspace/repository/adoption_repository.py +55 -0
- campfire_cli/app/workspace/repository/manifest_repository.py +38 -0
- campfire_cli/app/workspace/repository/restructure_repository.py +93 -0
- campfire_cli/app/workspace/repository/workspace_repository.py +125 -0
- campfire_cli/app/workspace/schema/__init__.py +1 -0
- campfire_cli/app/workspace/schema/adoption_schema.py +47 -0
- campfire_cli/app/workspace/schema/restructure_schema.py +64 -0
- campfire_cli/app/workspace/schema/workspace_schema.py +225 -0
- campfire_cli/app/workspace/service/__init__.py +1 -0
- campfire_cli/app/workspace/service/adoption_protocol.py +11 -0
- campfire_cli/app/workspace/service/adoption_service.py +364 -0
- campfire_cli/app/workspace/service/config_service.py +189 -0
- campfire_cli/app/workspace/service/domain_restructure_service.py +316 -0
- campfire_cli/app/workspace/service/project_service.py +389 -0
- campfire_cli/app/workspace/service/restructure_protocol.py +19 -0
- campfire_cli/app/workspace/service/restructure_service.py +392 -0
- campfire_cli/app/workspace/service/restructure_verifier.py +83 -0
- campfire_cli/app/workspace/service/structure_service.py +494 -0
- campfire_cli/app/workspace/service/workspace_protocol.py +24 -0
- campfire_cli/app/workspace/service/workspace_service.py +304 -0
- campfire_cli/common/SPEC.md +12 -0
- campfire_cli/common/__init__.py +1 -0
- campfire_cli/common/database/__init__.py +4 -0
- campfire_cli/common/database/migrations.py +18 -0
- campfire_cli/common/database/models.py +215 -0
- campfire_cli/common/database/session.py +30 -0
- campfire_cli/common/documents/__init__.py +1 -0
- campfire_cli/common/documents/document_types.py +119 -0
- campfire_cli/common/documents/frontmatter_schema.py +42 -0
- campfire_cli/common/documents/markdown.py +66 -0
- campfire_cli/common/exceptions/__init__.py +7 -0
- campfire_cli/common/exceptions/base.py +19 -0
- campfire_cli/common/filesystem/__init__.py +6 -0
- campfire_cli/common/filesystem/atomic.py +30 -0
- campfire_cli/common/filesystem/locking.py +55 -0
- campfire_cli/common/governance/__init__.py +13 -0
- campfire_cli/common/governance/issues.py +38 -0
- campfire_cli/common/governance/locking.py +31 -0
- campfire_cli/common/governance/snapshots.py +26 -0
- campfire_cli/common/hashing.py +10 -0
- campfire_cli/common/reports/__init__.py +1 -0
- campfire_cli/common/reports/json_report.py +8 -0
- campfire_cli/common/reports/markdown_report.py +29 -0
- campfire_cli/config/SPEC.md +5 -0
- campfire_cli/config/defaults.py +64 -0
- campfire_cli/config/settings.py +50 -0
- campfire_cli/container.py +130 -0
- campfire_cli/main.py +133 -0
- campfire_cli/resources/__init__.py +1 -0
- campfire_cli/resources/bases/Agent/346/211/247/350/241/214/345/267/245/344/275/234/345/217/260.base +35 -0
- campfire_cli/resources/bases/SPEC.md +3 -0
- campfire_cli/resources/bases//344/273/273/345/212/241/345/267/245/344/275/234/345/217/260.base +49 -0
- campfire_cli/resources/bases//345/206/263/347/255/226/345/267/245/344/275/234/345/217/260.base +40 -0
- campfire_cli/resources/bases//345/276/205/345/275/222/346/241/243/346/226/207/346/241/243.base +28 -0
- campfire_cli/resources/bases//351/241/271/347/233/256/346/226/207/346/241/243.base +35 -0
- campfire_cli/resources/defaults/__init__.py +1 -0
- campfire_cli/resources/defaults/config.yml +458 -0
- campfire_cli/resources/skills/SPEC.md +3 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/SKILL.md +62 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/SKILL.md +53 -0
- campfire_cli/resources/skills/campfire-conversation-router/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/inbox-handoff.md +19 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/learning-intake.md +31 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/task-intake.md +28 -0
- campfire_cli/resources/skills/campfire-document-capture/SKILL.md +109 -0
- campfire_cli/resources/skills/campfire-inbox-triage/SKILL.md +23 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/SKILL.md +21 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-workspace-adoption/SKILL.md +42 -0
- campfire_cli/resources/skills/campfire-workspace-maintenance/SKILL.md +74 -0
- 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
- campfire_cli/resources/skills/campfire-workspace-restructure/SKILL.md +52 -0
- campfire_cli-0.1.0.dist-info/METADATA +141 -0
- campfire_cli-0.1.0.dist-info/RECORD +154 -0
- campfire_cli-0.1.0.dist-info/WHEEL +4 -0
- campfire_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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]: ...
|