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,461 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+
7
+ from campfire_cli.app.document.service.document_rule_service import DocumentRuleService
8
+ from campfire_cli.app.document.service.document_scanner import iter_documents
9
+ from campfire_cli.app.maintenance.schema.maintenance_schema import (
10
+ DocumentState,
11
+ DomainState,
12
+ Issue,
13
+ MaintenanceResult,
14
+ MaintenanceRunRecord,
15
+ SpaceState,
16
+ )
17
+ from campfire_cli.app.maintenance.service import archive_service as project_archive
18
+ from campfire_cli.app.maintenance.service import moc_service as governance_sync
19
+ from campfire_cli.app.maintenance.service.maintenance_protocol import (
20
+ MaintenanceRepositoryProtocol,
21
+ )
22
+ from campfire_cli.app.maintenance.service.plan_service import MaintenancePlanService
23
+ from campfire_cli.app.workspace.service.structure_service import DomainService
24
+ from campfire_cli.common.documents.markdown import parse_document
25
+ from campfire_cli.common.filesystem import atomic_write, safe_path
26
+ from campfire_cli.common.governance import (
27
+ capture_snapshot,
28
+ enrich_issue,
29
+ filter_issues,
30
+ optimistic_write_lock,
31
+ )
32
+ from campfire_cli.common.hashing import file_sha256
33
+ from campfire_cli.common.reports.json_report import render_json_report
34
+ from campfire_cli.common.reports.markdown_report import render_maintenance_report
35
+ from campfire_cli.config.settings import WorkspaceSettings
36
+
37
+
38
+ class MaintenanceService:
39
+ def __init__(
40
+ self, settings: WorkspaceSettings, repository: MaintenanceRepositoryProtocol
41
+ ) -> None:
42
+ self._settings = settings
43
+ self._repository = repository
44
+ self._rules = DocumentRuleService(settings.document_types, settings.frontmatter_schema)
45
+ self._plans = MaintenancePlanService(settings, repository)
46
+
47
+ def check(
48
+ self,
49
+ *,
50
+ scope: str | None = None,
51
+ code: str | None = None,
52
+ severity: str | None = None,
53
+ summary: bool = False,
54
+ ) -> MaintenanceResult:
55
+ structure = DomainService(self._settings.vault_root, self._settings.state_root)
56
+ discovered_domains, _domain_issues = structure.discover()
57
+ discovered_spaces, _space_issues = structure.spaces.discover()
58
+ spaces, domains = self._topology_states(discovered_spaces, discovered_domains)
59
+ documents: list[DocumentState] = []
60
+ issues: list[Issue] = []
61
+ paths = self._iter_documents()
62
+ for path in paths:
63
+ relative = path.relative_to(self._settings.vault_root).as_posix()
64
+ text = path.read_text(encoding="utf-8")
65
+ parsed = parse_document(text)
66
+ document_type = parsed.frontmatter.get("type")
67
+ documents.append(
68
+ DocumentState(
69
+ path=relative,
70
+ content_hash=file_sha256(path),
71
+ document_type=document_type if isinstance(document_type, str) else None,
72
+ domain_id=self._nearest_domain(path),
73
+ status=parsed.frontmatter.get("status"),
74
+ )
75
+ )
76
+ issues.extend(
77
+ Issue.model_validate(enrich_issue(item))
78
+ for item in self._rules.check_document(self._settings.vault_root, path)
79
+ )
80
+ issues.extend(
81
+ Issue.model_validate(enrich_issue(item))
82
+ for item in self._rules.check_collection(self._settings.vault_root, paths)
83
+ )
84
+ issues.extend(Issue.model_validate(enrich_issue(item)) for item in self._check_inbox())
85
+ for skills_root in self._settings.skill_targets():
86
+ issues.extend(
87
+ Issue.model_validate(enrich_issue(item))
88
+ for item in self._rules.check_templates(skills_root.parent, skills_root)
89
+ )
90
+ issues = list({(item.path, item.code, item.detail): item for item in issues}.values())
91
+ result = MaintenanceResult(
92
+ status="ok" if not issues else "needs-review",
93
+ document_count=len(documents),
94
+ issue_count=len(issues),
95
+ total_issue_count=len(issues),
96
+ issues=issues,
97
+ issue_counts=self._issue_counts(issues),
98
+ space_count=len(spaces),
99
+ domain_count=len(domains),
100
+ )
101
+ completed_at = datetime.now(UTC)
102
+ run = MaintenanceRunRecord(
103
+ run_id=str(uuid4()),
104
+ status=result.status,
105
+ scanned_count=result.document_count,
106
+ issue_count=result.issue_count,
107
+ started_at=completed_at,
108
+ finished_at=completed_at,
109
+ )
110
+ observed = {document.path: document.content_hash for document in documents}
111
+ observed.update({f"{item.path}/_空间.md": item.source_hash for item in spaces})
112
+ observed.update({f"{item.path}/_领域.md": item.source_hash for item in domains})
113
+ with optimistic_write_lock(
114
+ self._settings.state_root, observed, self._settings.vault_root
115
+ ) as changed:
116
+ if changed:
117
+ return self._concurrent_result(changed)
118
+ self._repository.replace_current_state(documents, issues, spaces, domains)
119
+ self._repository.save_run(run)
120
+ self._export_current_report(result, completed_at)
121
+ selected = filter_issues(
122
+ (item.model_dump() for item in issues), scope=scope, code=code, severity=severity
123
+ )
124
+ selected_issues = [Issue.model_validate(item) for item in selected]
125
+ scoped_documents = [
126
+ item
127
+ for item in documents
128
+ if scope is None or self._path_matches_scope(item.path, scope)
129
+ ]
130
+ return MaintenanceResult(
131
+ status="ok" if not selected_issues else "needs-review",
132
+ document_count=len(scoped_documents),
133
+ issue_count=len(selected_issues),
134
+ total_issue_count=len(issues),
135
+ issues=[] if summary else selected_issues,
136
+ issue_counts=self._issue_counts(selected_issues),
137
+ scope=scope,
138
+ workspace_status=result.status,
139
+ space_count=len(spaces),
140
+ domain_count=len(domains),
141
+ )
142
+
143
+ def _export_current_report(self, result: MaintenanceResult, exported_at: datetime) -> None:
144
+ payload = result.model_dump(mode="json")
145
+ payload["exported_at"] = exported_at.isoformat()
146
+ report_root = self._settings.state_root / "reports"
147
+ atomic_write(report_root / "current.json", render_json_report(payload))
148
+ atomic_write(report_root / "current.md", render_maintenance_report(payload))
149
+
150
+ def plan(
151
+ self,
152
+ plan_id: str,
153
+ *,
154
+ scope: str | None = None,
155
+ spec_path: Path | None = None,
156
+ ) -> MaintenanceResult:
157
+ return self._plans.plan(plan_id, scope=scope, spec_path=spec_path)
158
+
159
+ def show_plan(self, plan_id: str) -> MaintenanceResult:
160
+ return self._plans.show(plan_id)
161
+
162
+ def apply(self, plan_id: str, confirm: bool) -> MaintenanceResult:
163
+ return self._plans.apply(plan_id, confirm)
164
+
165
+ def verify(self, plan_id: str) -> MaintenanceResult:
166
+ return self._plans.verify(plan_id)
167
+
168
+ def sync(self, dry_run: bool = False, scope: str | None = None) -> MaintenanceResult:
169
+ domains, issues = DomainService(
170
+ self._settings.vault_root, self._settings.state_root
171
+ ).discover()
172
+ if scope:
173
+ scope_path = (self._settings.vault_root / scope).resolve()
174
+ if not self._is_within_workspace(scope_path):
175
+ return self._sync_blocked("scope-outside-workspace", scope, scope)
176
+ domains = [
177
+ domain
178
+ for domain in domains
179
+ if domain.path == scope_path
180
+ or scope_path in domain.path.parents
181
+ or domain.path in scope_path.parents
182
+ ]
183
+ issues = [issue for issue in issues if self._path_matches_scope(issue["path"], scope)]
184
+ if not domains:
185
+ return self._sync_blocked("scope-missing", scope, scope)
186
+ if issues:
187
+ return MaintenanceResult(
188
+ status="blocked",
189
+ document_count=0,
190
+ issue_count=len(issues),
191
+ issues=[Issue.model_validate(enrich_issue(item)) for item in issues],
192
+ blocked_phase="preflight",
193
+ blocked_scope=scope or "workspace",
194
+ )
195
+ scoped_documents = [
196
+ path
197
+ for path in self._iter_documents()
198
+ if any(domain.path == path.parent or domain.path in path.parents for domain in domains)
199
+ ]
200
+ snapshot = capture_snapshot(self._settings.vault_root, scoped_documents)
201
+ marker_name = self._settings.governance.get("domain_marker", "_领域.md")
202
+ profile = self._settings.document_types.get("profiles", {}).get("project-docs", [])
203
+ type_mapping = {
204
+ name: self._settings.document_types["types"][name]
205
+ for name in profile
206
+ if name in self._settings.document_types["types"]
207
+ }
208
+ changes: list[tuple[Path, str]] = []
209
+ generated_snapshot: dict[str, str | None] = {}
210
+ note_count = 0
211
+ notes_by_domain = {
212
+ domain.id: governance_sync.direct_notes(domain, marker_name) for domain in domains
213
+ }
214
+ project_domain_ids = {
215
+ domain.id for domain in domains if domain.governance == "project-docs"
216
+ }
217
+ domain_by_note = {
218
+ note: domain_id
219
+ for domain_id, notes in notes_by_domain.items()
220
+ for note in notes
221
+ if domain_id not in project_domain_ids
222
+ }
223
+ all_notes = sorted(
224
+ domain_by_note,
225
+ key=lambda path: str(path.relative_to(self._settings.vault_root)).casefold(),
226
+ )
227
+ all_relations = governance_sync.generate_relations(
228
+ all_notes,
229
+ domain_by_note,
230
+ self._settings.vault_root,
231
+ int(self._settings.governance["related_limit"]),
232
+ float(self._settings.governance["related_min_score"]),
233
+ int(self._settings.governance["cross_domain_related_limit"]),
234
+ float(self._settings.governance["cross_domain_min_score"]),
235
+ )
236
+ for domain in sorted(domains, key=lambda item: item.id):
237
+ notes = notes_by_domain[domain.id]
238
+ note_count += len(notes)
239
+ if domain.governance == "project-docs":
240
+ generated = governance_sync.generate_project_domain_content(
241
+ domain, domains, notes, marker_name, type_mapping
242
+ )
243
+ else:
244
+ relation_page = None
245
+ if notes:
246
+ relations = {note: all_relations[note] for note in notes}
247
+ relation_page = domain.path / "generated" / f"相关文档-{domain.name}.md"
248
+ relation_content = governance_sync.relation_markdown(domain, notes, relations)
249
+ existing = (
250
+ relation_page.read_text(encoding="utf-8") if relation_page.is_file() else ""
251
+ )
252
+ generated_snapshot[
253
+ relation_page.relative_to(self._settings.vault_root).as_posix()
254
+ ] = file_sha256(relation_page) if relation_page.is_file() else None
255
+ if existing != relation_content:
256
+ changes.append((relation_page, relation_content))
257
+ generated = governance_sync.generate_domain_content(
258
+ domain, domains, notes, relation_page
259
+ )
260
+ moc = domain.path / f"{domain.moc}.md"
261
+ if not moc.is_file():
262
+ return MaintenanceResult(
263
+ status="blocked",
264
+ document_count=note_count,
265
+ issue_count=1,
266
+ issues=[
267
+ Issue.model_validate(
268
+ enrich_issue(
269
+ {
270
+ "code": "moc-missing",
271
+ "path": moc.relative_to(self._settings.vault_root).as_posix(),
272
+ }
273
+ )
274
+ )
275
+ ],
276
+ blocked_phase="preflight",
277
+ blocked_scope=scope or "workspace",
278
+ )
279
+ current_moc = moc.read_text(encoding="utf-8")
280
+ generated_snapshot[moc.relative_to(self._settings.vault_root).as_posix()] = file_sha256(
281
+ moc
282
+ )
283
+ updated = governance_sync.replace_generated_region(current_moc, generated)
284
+ if updated != current_moc:
285
+ changes.append((moc, updated))
286
+ operations = [
287
+ {
288
+ "action": "update" if path.is_file() else "create",
289
+ "path": path.relative_to(self._settings.vault_root).as_posix(),
290
+ "reason": "refresh-generated-content",
291
+ }
292
+ for path, _content in changes
293
+ ]
294
+ for path, expected in generated_snapshot.items():
295
+ snapshot.setdefault(path, expected)
296
+ if not dry_run and changes:
297
+ with optimistic_write_lock(
298
+ self._settings.state_root, snapshot, self._settings.vault_root
299
+ ) as changed:
300
+ if changed:
301
+ return self._concurrent_result(changed)
302
+ for path, content in changes:
303
+ atomic_write(path, content)
304
+ return MaintenanceResult(
305
+ status="dry-run" if dry_run else "synced",
306
+ document_count=note_count,
307
+ issue_count=0,
308
+ generated_file_count=len(changes),
309
+ write_performed=bool(changes and not dry_run),
310
+ operations=operations,
311
+ )
312
+
313
+ def _sync_blocked(self, code: str, path: str, scope: str | None) -> MaintenanceResult:
314
+ return MaintenanceResult(
315
+ status="blocked",
316
+ document_count=0,
317
+ issue_count=1,
318
+ issues=[Issue.model_validate(enrich_issue({"code": code, "path": path}))],
319
+ blocked_phase="preflight",
320
+ blocked_scope=scope or "workspace",
321
+ )
322
+
323
+ def _is_within_workspace(self, path: Path) -> bool:
324
+ return path == self._settings.vault_root or self._settings.vault_root in path.parents
325
+
326
+ @staticmethod
327
+ def _path_matches_scope(path: str, scope: str) -> bool:
328
+ normalized_path = path.strip("/")
329
+ normalized_scope = scope.strip("/")
330
+ return normalized_path == normalized_scope or normalized_path.startswith(
331
+ normalized_scope + "/"
332
+ )
333
+
334
+ def archive(self, confirm: bool = False, scope: str | None = None) -> MaintenanceResult:
335
+ items, raw_issues = project_archive.collect_items(
336
+ self._settings.vault_root, self._settings.governance
337
+ )
338
+ if scope is not None:
339
+ scope_path = safe_path(self._settings.vault_root, scope)
340
+ items = [
341
+ item
342
+ for item in items
343
+ if item.source == scope_path or scope_path in item.source.parents
344
+ ]
345
+ paths = {
346
+ item.source.relative_to(self._settings.vault_root).as_posix() for item in items
347
+ }
348
+ raw_issues = [issue for issue in raw_issues if issue.get("path") in paths]
349
+ applied: list[dict[str, str]] = []
350
+ if confirm and items:
351
+ snapshot = capture_snapshot(
352
+ self._settings.vault_root,
353
+ [path for item in items for path in (item.source, item.target)],
354
+ )
355
+ with optimistic_write_lock(
356
+ self._settings.state_root, snapshot, self._settings.vault_root
357
+ ) as changed:
358
+ if changed:
359
+ return self._concurrent_result(changed)
360
+ applied = project_archive.apply_items(
361
+ self._settings.vault_root,
362
+ items,
363
+ raw_issues,
364
+ __import__("datetime").date.today().isoformat(),
365
+ )
366
+ issues = [Issue.model_validate(enrich_issue(item)) for item in raw_issues]
367
+ return MaintenanceResult(
368
+ status="issues-found" if issues else ("applied" if confirm else "ok"),
369
+ document_count=len(items),
370
+ issue_count=len(issues),
371
+ issues=issues,
372
+ issue_counts=self._issue_counts(issues),
373
+ changed_document_count=len(applied),
374
+ write_performed=bool(applied),
375
+ operations=applied,
376
+ )
377
+
378
+ def _iter_documents(self) -> list[Path]:
379
+ return iter_documents(self._settings.vault_root, self._settings.document_types)
380
+
381
+ def _nearest_domain(self, path: Path) -> str | None:
382
+ current = path.parent
383
+ while current == self._settings.vault_root or self._settings.vault_root in current.parents:
384
+ marker = current / "_领域.md"
385
+ if marker.is_file():
386
+ parsed = parse_document(marker.read_text(encoding="utf-8"))
387
+ value = parsed.frontmatter.get("domain_id")
388
+ return value if isinstance(value, str) else None
389
+ if current == self._settings.vault_root:
390
+ break
391
+ current = current.parent
392
+ return None
393
+
394
+ def _topology_states(self, spaces, domains) -> tuple[list[SpaceState], list[DomainState]]:
395
+ space_states: list[SpaceState] = []
396
+ seen_spaces: set[str] = set()
397
+ for space in spaces:
398
+ marker = self._settings.vault_root / space.path / "_空间.md"
399
+ if not space.id or space.id in seen_spaces or not marker.is_file():
400
+ continue
401
+ seen_spaces.add(space.id)
402
+ space_states.append(
403
+ SpaceState(
404
+ space_id=space.id,
405
+ name=space.name,
406
+ path=space.path,
407
+ space_type=space.type,
408
+ status=space.status,
409
+ source_hash=file_sha256(marker),
410
+ )
411
+ )
412
+ domain_states: list[DomainState] = []
413
+ seen_domains: set[str] = set()
414
+ for domain in domains:
415
+ marker = domain.path / "_领域.md"
416
+ if not domain.id or domain.id in seen_domains or not marker.is_file():
417
+ continue
418
+ seen_domains.add(domain.id)
419
+ domain_states.append(
420
+ DomainState(
421
+ domain_id=domain.id,
422
+ space_id=domain.space_id,
423
+ parent_domain_id=domain.parent_domain,
424
+ project_id=domain.project_id,
425
+ name=domain.name,
426
+ path=domain.path.relative_to(self._settings.vault_root).as_posix(),
427
+ domain_type=domain.type,
428
+ governance=domain.governance,
429
+ moc=domain.moc,
430
+ status=domain.status,
431
+ source_hash=file_sha256(marker),
432
+ )
433
+ )
434
+ return space_states, domain_states
435
+
436
+ def _check_inbox(self) -> list[Issue]:
437
+ inbox = self._settings.vault_root / self._settings.governance.get("inbox", "_收件箱")
438
+ if not inbox.is_dir():
439
+ return [Issue(code="inbox-missing", path=inbox.name)]
440
+ return []
441
+
442
+ @staticmethod
443
+ def _issue_counts(issues: list[Issue]) -> dict[str, int]:
444
+ result: dict[str, int] = {}
445
+ for issue in issues:
446
+ result[issue.code] = result.get(issue.code, 0) + 1
447
+ return dict(sorted(result.items()))
448
+
449
+ @staticmethod
450
+ def _concurrent_result(paths: list[str]) -> MaintenanceResult:
451
+ issues = [
452
+ Issue.model_validate(enrich_issue({"code": "concurrent-change", "path": path}))
453
+ for path in paths
454
+ ]
455
+ return MaintenanceResult(
456
+ status="blocked",
457
+ document_count=0,
458
+ issue_count=len(issues),
459
+ issues=issues,
460
+ issue_counts=MaintenanceService._issue_counts(issues),
461
+ )