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,316 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from campfire_cli.app.document.service.frontmatter_formatter import ordered_keys
8
+ from campfire_cli.app.document.service.profile_registry import ProfileRegistry
9
+ from campfire_cli.common.documents.document_types import prefixed_name
10
+ from campfire_cli.common.documents.markdown import parse_document
11
+
12
+ DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
13
+
14
+
15
+ class DocumentRuleService:
16
+ """Single entry point for document and template contract validation."""
17
+
18
+ def __init__(self, type_config: dict[str, Any], schema: dict[str, Any]) -> None:
19
+ self._type_config = type_config
20
+ self._schema = schema
21
+ self._profiles = ProfileRegistry(type_config, schema)
22
+
23
+ def check_document(self, root: Path, path: Path) -> list[dict[str, Any]]:
24
+ relative = path.relative_to(root).as_posix()
25
+ parsed = parse_document(path.read_text(encoding="utf-8"))
26
+ if not parsed.has_frontmatter:
27
+ return [{"code": "frontmatter-missing", "path": relative}]
28
+ frontmatter = parsed.frontmatter
29
+ issues: list[dict[str, Any]] = []
30
+ document_type = frontmatter.get("type")
31
+ types = self._type_config.get("types", {})
32
+ if isinstance(document_type, list):
33
+ issues.append(
34
+ {
35
+ "code": "document-type-multiple",
36
+ "path": relative,
37
+ "field": "type",
38
+ "actual": document_type,
39
+ "allowed": sorted(types),
40
+ }
41
+ )
42
+ elif not isinstance(document_type, str) or not document_type:
43
+ issues.append({"code": "document-type-missing", "path": relative, "field": "type"})
44
+ elif document_type not in types:
45
+ issues.append(
46
+ {
47
+ "code": "document-type-invalid",
48
+ "path": relative,
49
+ "detail": str(document_type),
50
+ "field": "type",
51
+ "actual": document_type,
52
+ "allowed": sorted(types),
53
+ }
54
+ )
55
+ elif not path.name.startswith(types[document_type]["prefix"]):
56
+ issues.append(
57
+ {
58
+ "code": "document-prefix-mismatch",
59
+ "path": relative,
60
+ "detail": types[document_type]["prefix"],
61
+ "field": "filename",
62
+ "actual": path.name,
63
+ "allowed": [types[document_type]["prefix"]],
64
+ }
65
+ )
66
+ elif prefixed_name(path.name, document_type, self._type_config) != path.name:
67
+ issues.append(
68
+ {
69
+ "code": "document-name-bracket-category",
70
+ "path": relative,
71
+ "field": "filename",
72
+ "actual": path.name,
73
+ "allowed": [prefixed_name(path.name, document_type, self._type_config)],
74
+ }
75
+ )
76
+ rules = self._rules(document_type, path, frontmatter)
77
+ actual_order = list(frontmatter)
78
+ expected_order = ordered_keys(
79
+ [(key, []) for key in actual_order], list(rules["field_order"])
80
+ )
81
+ if actual_order != expected_order:
82
+ issues.append(
83
+ {
84
+ "code": "frontmatter-field-order-invalid",
85
+ "path": relative,
86
+ "field": "frontmatter",
87
+ "actual": actual_order,
88
+ "allowed": expected_order,
89
+ }
90
+ )
91
+ conditional = {
92
+ field
93
+ for condition in rules["conditional_required"]
94
+ if all(frontmatter.get(key) == value for key, value in condition["when"].items())
95
+ for field in condition["require"]
96
+ }
97
+ for field in dict.fromkeys([*rules["required"], *conditional]):
98
+ if field not in frontmatter:
99
+ issues.append(
100
+ {
101
+ "code": "frontmatter-field-missing",
102
+ "path": relative,
103
+ "detail": field,
104
+ "field": field,
105
+ }
106
+ )
107
+ continue
108
+ value = frontmatter[field]
109
+ if value is None or value == "" or field in conditional and not value:
110
+ issues.append(
111
+ {
112
+ "code": "frontmatter-field-empty",
113
+ "path": relative,
114
+ "detail": field,
115
+ "field": field,
116
+ "actual": value,
117
+ }
118
+ )
119
+ for field in frontmatter:
120
+ if rules["unknown_fields"] == "report" and field not in rules["allowed"]:
121
+ issues.append(
122
+ {
123
+ "code": "frontmatter-field-not-allowed",
124
+ "path": relative,
125
+ "detail": field,
126
+ "field": field,
127
+ "actual": frontmatter[field],
128
+ "allowed": rules["allowed"],
129
+ }
130
+ )
131
+ for field in rules["lists"]:
132
+ if field in frontmatter and not isinstance(frontmatter[field], list):
133
+ issues.append(
134
+ {
135
+ "code": "frontmatter-list-invalid",
136
+ "path": relative,
137
+ "detail": field,
138
+ "field": field,
139
+ "actual": frontmatter[field],
140
+ "allowed": ["list"],
141
+ }
142
+ )
143
+ for field in rules["dates"]:
144
+ value = frontmatter.get(field)
145
+ if value is not None and not DATE_RE.fullmatch(str(value)):
146
+ issues.append(
147
+ {
148
+ "code": "frontmatter-date-invalid",
149
+ "path": relative,
150
+ "detail": field,
151
+ "field": field,
152
+ "actual": value,
153
+ "allowed": ["YYYY-MM-DD"],
154
+ }
155
+ )
156
+ for field, allowed in rules["enums"].items():
157
+ value = frontmatter.get(field)
158
+ if value is not None and value not in allowed:
159
+ issues.append(
160
+ {
161
+ "code": "frontmatter-enum-invalid",
162
+ "path": relative,
163
+ "detail": f"{field}={value}",
164
+ "field": field,
165
+ "actual": value,
166
+ "allowed": allowed,
167
+ }
168
+ )
169
+ issues.extend(self._state_invariants(relative, path, document_type, frontmatter))
170
+ return issues
171
+
172
+ def check_collection(self, root: Path, paths: list[Path]) -> list[dict[str, Any]]:
173
+ """Validate invariants that require seeing more than one document."""
174
+ current_documents: dict[tuple[str, str, str, str], list[str]] = {}
175
+ for path in paths:
176
+ frontmatter = parse_document(path.read_text(encoding="utf-8")).frontmatter
177
+ document_type = frontmatter.get("type")
178
+ if document_type not in {"moc", "product-spec"}:
179
+ continue
180
+ if frontmatter.get("status") != "current":
181
+ continue
182
+ project = frontmatter.get("project")
183
+ domain = frontmatter.get("domain")
184
+ if not project or not domain:
185
+ continue
186
+ physical_domain = path.parent.relative_to(root).as_posix()
187
+ key = (physical_domain, str(project), str(domain), str(document_type))
188
+ current_documents.setdefault(key, []).append(path.relative_to(root).as_posix())
189
+ issues: list[dict[str, Any]] = []
190
+ for (_physical, project, domain, document_type), matches in sorted(
191
+ current_documents.items()
192
+ ):
193
+ if len(matches) < 2:
194
+ continue
195
+ issues.append(
196
+ {
197
+ "code": "project-doc-current-conflict",
198
+ "path": matches[0],
199
+ "detail": f"{project}/{domain}/{document_type}",
200
+ "actual": sorted(matches),
201
+ "allowed": ["one-current-document"],
202
+ }
203
+ )
204
+ return issues
205
+
206
+ def check_templates(self, root: Path, skills_root: Path) -> list[dict[str, Any]]:
207
+ if not skills_root.is_dir():
208
+ return []
209
+ issues: list[dict[str, Any]] = []
210
+ for path in sorted(skills_root.rglob("*.md")):
211
+ parsed = parse_document(path.read_text(encoding="utf-8"))
212
+ document_type = parsed.frontmatter.get("type")
213
+ if not parsed.has_frontmatter or document_type not in self._type_config.get(
214
+ "types", {}
215
+ ):
216
+ continue
217
+ rules = self._rules(document_type, path, parsed.frontmatter)
218
+ for field, allowed in rules["enums"].items():
219
+ value = parsed.frontmatter.get(field)
220
+ if value is not None and value not in allowed:
221
+ issues.append(
222
+ {
223
+ "code": "template-enum-invalid",
224
+ "path": path.relative_to(root).as_posix(),
225
+ "detail": f"{field}={value}",
226
+ "field": field,
227
+ "actual": value,
228
+ "allowed": allowed,
229
+ }
230
+ )
231
+ return issues
232
+
233
+ def known_fields(self) -> set[str]:
234
+ return self._profiles.known_fields()
235
+
236
+ def validate_patch(
237
+ self,
238
+ root: Path,
239
+ path: Path,
240
+ current: dict[str, Any],
241
+ patch: dict[str, Any],
242
+ ) -> list[dict[str, Any]]:
243
+ """Validate values a restructure would write, without requiring unrelated fields."""
244
+ merged = {**current, **patch}
245
+ rules = self._rules(merged.get("type"), path, merged)
246
+ relative = path.relative_to(root).as_posix()
247
+ issues: list[dict[str, Any]] = []
248
+ for field in patch:
249
+ if rules["unknown_fields"] == "report" and field not in rules["allowed"]:
250
+ issues.append(
251
+ {
252
+ "code": "frontmatter-field-not-allowed",
253
+ "path": relative,
254
+ "detail": field,
255
+ "field": field,
256
+ "actual": patch[field],
257
+ "allowed": rules["allowed"],
258
+ }
259
+ )
260
+ for field in rules["lists"]:
261
+ if field in patch and not isinstance(patch[field], list):
262
+ issues.append(
263
+ {"code": "frontmatter-list-invalid", "path": relative, "detail": field}
264
+ )
265
+ for field in rules["dates"]:
266
+ value = patch.get(field)
267
+ if value is not None and not DATE_RE.fullmatch(str(value)):
268
+ issues.append(
269
+ {"code": "frontmatter-date-invalid", "path": relative, "detail": field}
270
+ )
271
+ for field, allowed in rules["enums"].items():
272
+ value = patch.get(field)
273
+ if value is not None and value not in allowed:
274
+ issues.append(
275
+ {
276
+ "code": "frontmatter-enum-invalid",
277
+ "path": relative,
278
+ "detail": f"{field}={value}",
279
+ "field": field,
280
+ "actual": value,
281
+ "allowed": allowed,
282
+ }
283
+ )
284
+ return issues
285
+
286
+ def _rules(self, document_type: Any, path: Path, frontmatter: dict[str, Any]) -> dict[str, Any]:
287
+ return self._profiles.resolve(document_type, frontmatter, path).model_dump()
288
+
289
+ @staticmethod
290
+ def _state_invariants(
291
+ relative: str,
292
+ path: Path,
293
+ document_type: Any,
294
+ frontmatter: dict[str, Any],
295
+ ) -> list[dict[str, Any]]:
296
+ issues: list[dict[str, Any]] = []
297
+ status = frontmatter.get("status")
298
+ lifecycle = frontmatter.get("lifecycle")
299
+ in_archive = "archive" in path.parts
300
+ if in_archive != (status == "archived" and lifecycle == "archived") and (
301
+ in_archive or status == "archived" or lifecycle == "archived"
302
+ ):
303
+ issues.append({"code": "document-archive-state-mismatch", "path": relative})
304
+ if document_type != "task":
305
+ return issues
306
+ if status == "draft":
307
+ issues.append({"code": "task-status-draft-invalid", "path": relative})
308
+ if lifecycle not in {"completed", "archived"} and frontmatter.get("completed"):
309
+ issues.append(
310
+ {
311
+ "code": "task-completed-date-premature",
312
+ "path": relative,
313
+ "detail": str(lifecycle),
314
+ }
315
+ )
316
+ return issues
@@ -0,0 +1,38 @@
1
+ """Discover documents in the configured managed scope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from campfire_cli.common.documents.document_types import safe_path
9
+
10
+
11
+ def exempt_document(path: Path, config: dict[str, Any]) -> bool:
12
+ """Return whether a Markdown file is owned by another app or explicitly unmanaged."""
13
+ return path.name in set(config.get("exempt_basenames", []))
14
+
15
+
16
+ def iter_documents(root: Path, config: dict[str, Any]) -> list[Path]:
17
+ root = root.resolve()
18
+ ignored = set(config.get("ignored_directories", []))
19
+ documents: set[Path] = set()
20
+ space_marker = config.get("space_marker", "_空间.md")
21
+ spaces = [
22
+ marker.parent.relative_to(root).as_posix() for marker in root.glob(f"*/{space_marker}")
23
+ ]
24
+ if spaces:
25
+ inboxes = [value for value in config.get("scope_roots", []) if value.startswith("_收件箱/")]
26
+ scope_roots = [*spaces, *inboxes]
27
+ else:
28
+ scope_roots = config.get("scope_roots", [])
29
+ for raw_root in scope_roots:
30
+ scope = safe_path(root, raw_root)
31
+ if not scope.exists():
32
+ continue
33
+ for path in scope.rglob("*.md"):
34
+ relative = path.relative_to(root)
35
+ if exempt_document(path, config) or any(part in ignored for part in relative.parts):
36
+ continue
37
+ documents.add(path.resolve())
38
+ return sorted(documents, key=lambda path: str(path.relative_to(root)).casefold())
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from campfire_cli.app.document.service.document_rule_service import DocumentRuleService
7
+ from campfire_cli.app.document.service.document_scanner import exempt_document
8
+ from campfire_cli.app.document.service.frontmatter_formatter import format_text
9
+ from campfire_cli.app.document.service.profile_registry import ProfileRegistry
10
+ from campfire_cli.common.documents.markdown import parse_document
11
+ from campfire_cli.common.exceptions import ConfigurationError, GovernanceBlockedError
12
+ from campfire_cli.common.filesystem import atomic_write, safe_path
13
+ from campfire_cli.common.governance import capture_snapshot, optimistic_write_lock
14
+ from campfire_cli.config.settings import WorkspaceSettings
15
+
16
+
17
+ class DocumentService:
18
+ def __init__(self, settings: WorkspaceSettings) -> None:
19
+ self._settings = settings
20
+ self._rules = DocumentRuleService(settings.document_types, settings.frontmatter_schema)
21
+ self._profiles = ProfileRegistry(settings.document_types, settings.frontmatter_schema)
22
+
23
+ def check(self, relative_path: str) -> dict[str, Any]:
24
+ path = self._document_path(relative_path)
25
+ if self._is_exempt(path):
26
+ return self._not_applicable(relative_path, path)
27
+ issues = self._rules.check_document(self._settings.vault_root, path)
28
+ return {
29
+ "status": "ok" if not issues else "needs-review",
30
+ "workspace_id": self._settings.workspace_id,
31
+ "path": relative_path,
32
+ "issue_count": len(issues),
33
+ "issues": issues,
34
+ }
35
+
36
+ def inspect(self, relative_path: str) -> dict[str, Any]:
37
+ path = self._document_path(relative_path)
38
+ if self._is_exempt(path):
39
+ return self._not_applicable(relative_path, path)
40
+ parsed = parse_document(path.read_text(encoding="utf-8"))
41
+ document_type = parsed.frontmatter.get("type")
42
+ profile = self._profiles.resolve(document_type, parsed.frontmatter, path)
43
+ domain_id: str | None = None
44
+ current = path.parent
45
+ while current == self._settings.vault_root or self._settings.vault_root in current.parents:
46
+ marker = current / "_领域.md"
47
+ if marker.is_file():
48
+ value = parse_document(marker.read_text(encoding="utf-8")).frontmatter.get(
49
+ "domain_id"
50
+ )
51
+ domain_id = value if isinstance(value, str) else None
52
+ break
53
+ if current == self._settings.vault_root:
54
+ break
55
+ current = current.parent
56
+ issues = self._rules.check_document(self._settings.vault_root, path)
57
+ return {
58
+ "status": "ok" if not issues else "needs-review",
59
+ "workspace_id": self._settings.workspace_id,
60
+ "path": relative_path,
61
+ "domain_id": domain_id,
62
+ "type": document_type if isinstance(document_type, str) else None,
63
+ "profile": profile.model_dump(),
64
+ "issues": issues,
65
+ }
66
+
67
+ def format(self, relative_path: str, confirm: bool = False) -> dict[str, Any]:
68
+ path = self._document_path(relative_path)
69
+ if self._is_exempt(path):
70
+ return self._not_applicable(relative_path, path)
71
+ original = path.read_text(encoding="utf-8")
72
+ parsed = parse_document(original)
73
+ if not parsed.has_frontmatter:
74
+ return {
75
+ "status": "blocked",
76
+ "workspace_id": self._settings.workspace_id,
77
+ "path": relative_path,
78
+ "write_performed": False,
79
+ "issues": [{"code": "frontmatter-missing", "path": relative_path}],
80
+ }
81
+ profile = self._profiles.resolve(parsed.frontmatter.get("type"), parsed.frontmatter, path)
82
+ formatted, errors = format_text(original, list(profile.field_order))
83
+ if errors:
84
+ return {
85
+ "status": "blocked",
86
+ "workspace_id": self._settings.workspace_id,
87
+ "path": relative_path,
88
+ "write_performed": False,
89
+ "issues": [{"code": code, "path": relative_path} for code in errors],
90
+ }
91
+ changed = formatted != original
92
+ if changed and confirm:
93
+ snapshot = capture_snapshot(self._settings.vault_root, [path])
94
+ with optimistic_write_lock(
95
+ self._settings.state_root, snapshot, self._settings.vault_root
96
+ ) as concurrent:
97
+ if concurrent:
98
+ raise GovernanceBlockedError(
99
+ "文档在格式化期间发生变化:" + ", ".join(concurrent)
100
+ )
101
+ atomic_write(path, formatted)
102
+ return {
103
+ "status": "formatted" if changed and confirm else "planned" if changed else "current",
104
+ "workspace_id": self._settings.workspace_id,
105
+ "path": relative_path,
106
+ "profile": profile.name,
107
+ "write_performed": changed and confirm,
108
+ }
109
+
110
+ def _document_path(self, relative_path: str) -> Path:
111
+ path = safe_path(self._settings.vault_root, relative_path)
112
+ if not path.is_file() or path.suffix.lower() != ".md":
113
+ raise ConfigurationError(f"Markdown 文档不存在:{relative_path}")
114
+ return path
115
+
116
+ def _not_applicable(self, relative_path: str, path: Path) -> dict[str, Any]:
117
+ marker_commands = {
118
+ self._settings.governance.get("space_marker", "_空间.md"): "workspace space check",
119
+ self._settings.governance.get("domain_marker", "_领域.md"): "workspace domain check",
120
+ }
121
+ return {
122
+ "status": "not-applicable",
123
+ "workspace_id": self._settings.workspace_id,
124
+ "path": relative_path,
125
+ "reason": "document-exempt",
126
+ "owner_command": marker_commands.get(path.name),
127
+ "issues": [],
128
+ }
129
+
130
+ def _is_exempt(self, path: Path) -> bool:
131
+ relative = path.relative_to(self._settings.vault_root)
132
+ collaboration_root = self._settings.governance.get("collaboration_root", "_协作")
133
+ if relative.parts and relative.parts[0] == collaboration_root:
134
+ return True
135
+ markers = {
136
+ self._settings.governance.get("space_marker", "_空间.md"),
137
+ self._settings.governance.get("domain_marker", "_领域.md"),
138
+ }
139
+ return path.name in markers or exempt_document(path, self._settings.document_types)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from campfire_cli.app.document.repository.document_type_repository import (
6
+ DocumentTypeRepository,
7
+ )
8
+
9
+
10
+ class DocumentTypeService:
11
+ def __init__(
12
+ self,
13
+ workspace_id: str,
14
+ repository: DocumentTypeRepository,
15
+ ) -> None:
16
+ self._workspace_id = workspace_id
17
+ self._repository = repository
18
+
19
+ def list_types(self) -> dict[str, Any]:
20
+ contract = self._repository.load()
21
+ return {
22
+ "status": "ok",
23
+ "workspace_id": self._workspace_id,
24
+ "types": [
25
+ {"name": name, **definition}
26
+ for name, definition in contract.get("types", {}).items()
27
+ ],
28
+ }
@@ -0,0 +1,98 @@
1
+ """
2
+ SPEC:
3
+ name: frontmatter_apply
4
+ purpose: 执行已逐字段审批的 frontmatter 治理计划
5
+ default_env_file: none
6
+ env_override: none
7
+ idempotent: true
8
+ behavior:
9
+ - 仅写入 approved true 且 value 非 null 的字段
10
+ - 保留正文与未审批字段
11
+ safety:
12
+ - 预检存在问题时不写入
13
+ - 不移动、改名或删除文件,不编造字段值
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from campfire_cli.app.document.service.profile_registry import ProfileRegistry
23
+ from campfire_cli.common.documents.document_types import safe_path, set_frontmatter_scalar
24
+
25
+
26
+ def render(value: Any) -> str:
27
+ if isinstance(value, bool):
28
+ return str(value).lower()
29
+ if isinstance(value, list):
30
+ return json.dumps(value, ensure_ascii=False)
31
+ return str(value)
32
+
33
+
34
+ def preflight(
35
+ root: Path, plan: dict[str, Any], type_config: dict[str, Any], schema: dict[str, Any]
36
+ ) -> tuple[list[tuple[Path, dict[str, Any]]], list[dict[str, str]]]:
37
+ profiles = ProfileRegistry(type_config, schema)
38
+ operations = []
39
+ issues = []
40
+ for item in plan.get("items", []):
41
+ profile_name = item.get("profile", "base")
42
+ if profile_name not in {profile.name for profile in profiles.list()}:
43
+ issues.append(
44
+ {
45
+ "code": "frontmatter-profile-invalid",
46
+ "path": str(item.get("path", "")),
47
+ }
48
+ )
49
+ continue
50
+ allowed = set(profiles.get(profile_name).allowed)
51
+ try:
52
+ path = safe_path(root, item.get("path", ""))
53
+ except (ValueError, TypeError):
54
+ issues.append(
55
+ {"code": "frontmatter-plan-path-invalid", "path": str(item.get("path", ""))}
56
+ )
57
+ continue
58
+ approved = {
59
+ k: v.get("value")
60
+ for k, v in item.get("fields", {}).items()
61
+ if v.get("approved") is True
62
+ }
63
+ if not approved:
64
+ continue
65
+ if not path.is_file():
66
+ issues.append({"code": "frontmatter-source-missing", "path": item.get("path", "")})
67
+ for field, value in approved.items():
68
+ if field not in allowed:
69
+ issues.append(
70
+ {
71
+ "code": "frontmatter-field-unknown",
72
+ "path": item.get("path", ""),
73
+ "detail": field,
74
+ }
75
+ )
76
+ if value is None:
77
+ issues.append(
78
+ {
79
+ "code": "frontmatter-approved-null",
80
+ "path": item.get("path", ""),
81
+ "detail": field,
82
+ }
83
+ )
84
+ operations.append((path, approved))
85
+ return operations, issues
86
+
87
+
88
+ def apply(operations: list[tuple[Path, dict[str, Any]]]) -> int:
89
+ changed = 0
90
+ for path, fields in operations:
91
+ original = path.read_text(encoding="utf-8")
92
+ updated = original
93
+ for field, value in fields.items():
94
+ updated = set_frontmatter_scalar(updated, field, render(value))
95
+ if updated != original:
96
+ path.write_text(updated, encoding="utf-8")
97
+ changed += 1
98
+ return changed