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,411 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from campfire_cli.app.document.service import frontmatter_plan, type_apply, type_plan
10
+ from campfire_cli.app.document.service.document_rule_service import DocumentRuleService
11
+ from campfire_cli.app.document.service.document_scanner import iter_documents
12
+ from campfire_cli.app.document.service.frontmatter_formatter import format_text
13
+ from campfire_cli.app.document.service.profile_registry import ProfileRegistry
14
+ from campfire_cli.app.maintenance.schema.maintenance_schema import (
15
+ Issue,
16
+ MaintenanceIntentSpec,
17
+ MaintenancePlan,
18
+ MaintenancePlanItem,
19
+ MaintenanceResult,
20
+ )
21
+ from campfire_cli.app.maintenance.service.maintenance_protocol import (
22
+ MaintenanceRepositoryProtocol,
23
+ )
24
+ from campfire_cli.common.documents.document_types import prefixed_name, set_frontmatter_scalar
25
+ from campfire_cli.common.documents.markdown import parse_document
26
+ from campfire_cli.common.exceptions import GovernanceBlockedError
27
+ from campfire_cli.common.filesystem import atomic_write, safe_path
28
+ from campfire_cli.common.filesystem.locking import workspace_write_lock
29
+ from campfire_cli.common.governance import enrich_issue
30
+ from campfire_cli.common.hashing import file_sha256, text_sha256
31
+ from campfire_cli.config.settings import WorkspaceSettings
32
+
33
+
34
+ class MaintenancePlanService:
35
+ """Create and execute one auditable, scope-bound document maintenance plan."""
36
+
37
+ def __init__(
38
+ self, settings: WorkspaceSettings, repository: MaintenanceRepositoryProtocol
39
+ ) -> None:
40
+ self._settings = settings
41
+ self._repository = repository
42
+ self._rules = DocumentRuleService(settings.document_types, settings.frontmatter_schema)
43
+ self._profiles = ProfileRegistry(settings.document_types, settings.frontmatter_schema)
44
+
45
+ def plan(
46
+ self,
47
+ plan_id: str,
48
+ *,
49
+ scope: str | None = None,
50
+ spec_path: Path | None = None,
51
+ ) -> MaintenanceResult:
52
+ paths = self._scoped_documents(scope)
53
+ if spec_path is not None:
54
+ return self._plan_from_spec(plan_id, scope, spec_path, paths)
55
+ type_items = {
56
+ item["source"]: item
57
+ for item in type_plan.build_plan(
58
+ self._settings.vault_root, self._settings.document_types
59
+ )["items"]
60
+ }
61
+ metadata_items = {
62
+ item["path"]: item
63
+ for item in frontmatter_plan.build_plan(
64
+ self._settings.vault_root,
65
+ self._settings.document_types,
66
+ self._settings.frontmatter_schema,
67
+ )["items"]
68
+ }
69
+ items: list[MaintenancePlanItem] = []
70
+ for path in paths:
71
+ relative = path.relative_to(self._settings.vault_root).as_posix()
72
+ type_item = type_items.get(relative)
73
+ metadata_item = metadata_items.get(relative)
74
+ patch = {
75
+ field: proposal["value"]
76
+ for field, proposal in (metadata_item or {}).get("fields", {}).items()
77
+ if proposal.get("value") is not None
78
+ }
79
+ if type_item and type_item.get("proposed_type"):
80
+ patch["type"] = type_item["proposed_type"]
81
+ text = path.read_text(encoding="utf-8")
82
+ parsed = parse_document(text)
83
+ profile = self._profiles.resolve(
84
+ parsed.frontmatter.get("type"), parsed.frontmatter, path
85
+ )
86
+ formatted, format_errors = format_text(text, list(profile.field_order))
87
+ needs_format = not format_errors and formatted != text
88
+ if not type_item and not metadata_item and not needs_format:
89
+ continue
90
+ reasons = [
91
+ value
92
+ for value in (
93
+ type_item.get("reason") if type_item else None,
94
+ "missing-frontmatter-fields" if metadata_item else None,
95
+ "frontmatter-field-order" if needs_format else None,
96
+ )
97
+ if value
98
+ ]
99
+ items.append(
100
+ MaintenancePlanItem(
101
+ source=relative,
102
+ target=type_item["target"] if type_item else relative,
103
+ source_sha256=file_sha256(path),
104
+ frontmatter=patch,
105
+ format_frontmatter=needs_format or bool(patch),
106
+ reason=", ".join(reasons),
107
+ )
108
+ )
109
+ plan = MaintenancePlan(
110
+ plan_id=plan_id,
111
+ scope=scope,
112
+ config_hash=self._config_hash(),
113
+ items=items,
114
+ )
115
+ self._repository.save_plan(plan)
116
+ return self._plan_result(plan)
117
+
118
+ def show(self, plan_id: str) -> MaintenanceResult:
119
+ return self._plan_result(self._repository.load_plan(plan_id))
120
+
121
+ def apply(self, plan_id: str, confirm: bool) -> MaintenanceResult:
122
+ plan = self._repository.load_plan(plan_id)
123
+ approved = [item for item in plan.items if item.approved]
124
+ unapproved = [item for item in plan.items if not item.approved]
125
+ if unapproved:
126
+ issues = [
127
+ Issue.model_validate(
128
+ enrich_issue(
129
+ {
130
+ "code": "maintenance-item-unapproved",
131
+ "path": item.source,
132
+ "detail": item.reason,
133
+ }
134
+ )
135
+ )
136
+ for item in unapproved
137
+ ]
138
+ return MaintenanceResult(
139
+ status="blocked",
140
+ document_count=len(plan.items),
141
+ issue_count=len(issues),
142
+ issues=issues,
143
+ operations=[item.model_dump(mode="json") for item in plan.items],
144
+ )
145
+ issues = self._preflight(plan, approved)
146
+ if issues or not confirm:
147
+ return MaintenanceResult(
148
+ status="blocked" if issues else "ready",
149
+ document_count=len(approved),
150
+ issue_count=len(issues),
151
+ issues=[Issue.model_validate(enrich_issue(item)) for item in issues],
152
+ operations=[item.model_dump(mode="json") for item in approved],
153
+ )
154
+ with workspace_write_lock(self._settings.state_root):
155
+ locked_issues = self._preflight(plan, approved)
156
+ if locked_issues:
157
+ return MaintenanceResult(
158
+ status="blocked",
159
+ document_count=len(approved),
160
+ issue_count=len(locked_issues),
161
+ issues=[Issue.model_validate(enrich_issue(item)) for item in locked_issues],
162
+ )
163
+ self._apply_items(approved)
164
+ return MaintenanceResult(
165
+ status="applied",
166
+ document_count=len(approved),
167
+ issue_count=0,
168
+ changed_document_count=len(approved),
169
+ write_performed=bool(approved),
170
+ operations=[item.model_dump(mode="json") for item in approved],
171
+ )
172
+
173
+ def verify(self, plan_id: str) -> MaintenanceResult:
174
+ plan = self._repository.load_plan(plan_id)
175
+ raw_issues: list[dict[str, Any]] = []
176
+ approved = [item for item in plan.items if item.approved]
177
+ for item in approved:
178
+ source = safe_path(self._settings.vault_root, item.source)
179
+ target = safe_path(self._settings.vault_root, item.target)
180
+ if not target.is_file():
181
+ raw_issues.append({"code": "maintenance-target-missing", "path": item.target})
182
+ continue
183
+ if source != target and source.exists():
184
+ raw_issues.append({"code": "maintenance-source-still-exists", "path": item.source})
185
+ raw_issues.extend(self._rules.check_document(self._settings.vault_root, target))
186
+ issues = [Issue.model_validate(enrich_issue(item)) for item in raw_issues]
187
+ return MaintenanceResult(
188
+ status="ok" if not issues else "needs-review",
189
+ document_count=len(approved),
190
+ issue_count=len(issues),
191
+ issues=issues,
192
+ issue_counts=self._issue_counts(issues),
193
+ operations=[item.model_dump(mode="json") for item in approved],
194
+ )
195
+
196
+ def _plan_from_spec(
197
+ self,
198
+ plan_id: str,
199
+ scope: str | None,
200
+ spec_path: Path,
201
+ paths: list[Path],
202
+ ) -> MaintenanceResult:
203
+ if not spec_path.is_file():
204
+ return self._blocked_issue("maintenance-spec-missing", str(spec_path))
205
+ try:
206
+ spec = MaintenanceIntentSpec.model_validate(
207
+ yaml.safe_load(spec_path.read_text(encoding="utf-8"))
208
+ )
209
+ except Exception as exc:
210
+ return self._blocked_issue("maintenance-spec-invalid", str(spec_path), str(exc))
211
+ allowed = {path.relative_to(self._settings.vault_root).as_posix() for path in paths}
212
+ items: list[MaintenancePlanItem] = []
213
+ raw_issues: list[dict[str, Any]] = []
214
+ targets: set[str] = set()
215
+ for intent in spec.operations:
216
+ if intent.path not in allowed:
217
+ raw_issues.append({"code": "maintenance-path-outside-scope", "path": intent.path})
218
+ continue
219
+ source = safe_path(self._settings.vault_root, intent.path)
220
+ parsed = parse_document(source.read_text(encoding="utf-8"))
221
+ raw_issues.extend(
222
+ self._rules.validate_patch(
223
+ self._settings.vault_root,
224
+ source,
225
+ parsed.frontmatter,
226
+ intent.frontmatter,
227
+ )
228
+ )
229
+ document_type = intent.frontmatter.get("type", parsed.frontmatter.get("type"))
230
+ target_name = intent.filename
231
+ if target_name is not None and Path(target_name).name != target_name:
232
+ raw_issues.append({"code": "maintenance-filename-invalid", "path": intent.path})
233
+ continue
234
+ if target_name is None and isinstance(document_type, str):
235
+ target_name = prefixed_name(
236
+ source.name, document_type, self._settings.document_types
237
+ )
238
+ target = source.with_name(target_name or source.name)
239
+ target_relative = target.relative_to(self._settings.vault_root).as_posix()
240
+ if target_relative in targets:
241
+ raw_issues.append({"code": "maintenance-target-duplicate", "path": target_relative})
242
+ targets.add(target_relative)
243
+ items.append(
244
+ MaintenancePlanItem(
245
+ source=intent.path,
246
+ target=target_relative,
247
+ source_sha256=file_sha256(source),
248
+ frontmatter=intent.frontmatter,
249
+ format_frontmatter=intent.format_frontmatter,
250
+ reason=intent.reason,
251
+ approved=intent.approved,
252
+ )
253
+ )
254
+ if raw_issues:
255
+ issues = [Issue.model_validate(enrich_issue(item)) for item in raw_issues]
256
+ return MaintenanceResult(
257
+ status="blocked",
258
+ document_count=len(items),
259
+ issue_count=len(issues),
260
+ issues=issues,
261
+ issue_counts=self._issue_counts(issues),
262
+ scope=scope,
263
+ )
264
+ plan = MaintenancePlan(
265
+ plan_id=plan_id,
266
+ scope=scope,
267
+ config_hash=self._config_hash(),
268
+ items=items,
269
+ )
270
+ self._repository.save_plan(plan)
271
+ return self._plan_result(plan)
272
+
273
+ def _preflight(
274
+ self, plan: MaintenancePlan, items: list[MaintenancePlanItem]
275
+ ) -> list[dict[str, Any]]:
276
+ issues: list[dict[str, Any]] = []
277
+ if plan.config_hash != self._config_hash():
278
+ issues.append({"code": "maintenance-config-changed", "path": plan.plan_id})
279
+ targets: set[Path] = set()
280
+ for item in items:
281
+ source = safe_path(self._settings.vault_root, item.source)
282
+ target = safe_path(self._settings.vault_root, item.target)
283
+ if not source.is_file():
284
+ issues.append({"code": "maintenance-source-missing", "path": item.source})
285
+ continue
286
+ if file_sha256(source) != item.source_sha256:
287
+ issues.append({"code": "concurrent-change", "path": item.source})
288
+ if source.parent != target.parent:
289
+ issues.append(
290
+ {
291
+ "code": "maintenance-cross-domain-move",
292
+ "path": item.source,
293
+ "detail": item.target,
294
+ }
295
+ )
296
+ if target in targets:
297
+ issues.append({"code": "maintenance-target-duplicate", "path": item.target})
298
+ if target != source and target.exists():
299
+ issues.append({"code": "maintenance-target-exists", "path": item.target})
300
+ targets.add(target)
301
+ parsed = parse_document(source.read_text(encoding="utf-8"))
302
+ issues.extend(
303
+ self._rules.validate_patch(
304
+ self._settings.vault_root,
305
+ source,
306
+ parsed.frontmatter,
307
+ item.frontmatter,
308
+ )
309
+ )
310
+ document_type = item.frontmatter.get("type", parsed.frontmatter.get("type"))
311
+ if target != source:
312
+ if document_type not in self._settings.document_types.get("types", {}):
313
+ issues.append({"code": "maintenance-type-required", "path": item.source})
314
+ elif target.name != prefixed_name(
315
+ source.name, document_type, self._settings.document_types
316
+ ):
317
+ issues.append(
318
+ {
319
+ "code": "maintenance-target-invalid",
320
+ "path": item.source,
321
+ "detail": target.name,
322
+ }
323
+ )
324
+ return issues
325
+
326
+ def _apply_items(self, items: list[MaintenancePlanItem]) -> None:
327
+ type_operations: list[dict[str, Any]] = []
328
+ for item in items:
329
+ source = safe_path(self._settings.vault_root, item.source)
330
+ current = parse_document(source.read_text(encoding="utf-8")).frontmatter
331
+ document_type = item.frontmatter.get("type", current.get("type"))
332
+ if item.target != item.source or "type" in item.frontmatter:
333
+ type_operations.append(
334
+ {
335
+ "source": source,
336
+ "target": safe_path(self._settings.vault_root, item.target),
337
+ "type": document_type,
338
+ }
339
+ )
340
+ if type_operations:
341
+ type_apply.apply_plan(self._settings.vault_root, type_operations)
342
+ for item in items:
343
+ target = safe_path(self._settings.vault_root, item.target)
344
+ original = target.read_text(encoding="utf-8")
345
+ updated = original
346
+ for field, value in item.frontmatter.items():
347
+ updated = set_frontmatter_scalar(updated, field, self._render_value(value))
348
+ if item.format_frontmatter:
349
+ parsed = parse_document(updated)
350
+ profile = self._profiles.resolve(
351
+ parsed.frontmatter.get("type"), parsed.frontmatter, target
352
+ )
353
+ updated, errors = format_text(updated, list(profile.field_order))
354
+ if errors:
355
+ raise GovernanceBlockedError(
356
+ f"Frontmatter 无法格式化:{item.target}: {', '.join(errors)}"
357
+ )
358
+ if updated != original:
359
+ atomic_write(target, updated)
360
+
361
+ def _scoped_documents(self, scope: str | None) -> list[Path]:
362
+ paths = iter_documents(self._settings.vault_root, self._settings.document_types)
363
+ if scope is None:
364
+ return paths
365
+ scope_path = safe_path(self._settings.vault_root, scope)
366
+ if not scope_path.exists():
367
+ raise GovernanceBlockedError(f"Maintenance Scope 不存在:{scope}")
368
+ return [path for path in paths if path == scope_path or scope_path in path.parents]
369
+
370
+ def _config_hash(self) -> str:
371
+ payload = {
372
+ "document_types": self._settings.document_types,
373
+ "frontmatter_schema": self._settings.frontmatter_schema,
374
+ }
375
+ return text_sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True))
376
+
377
+ @staticmethod
378
+ def _render_value(value: object) -> str:
379
+ if isinstance(value, bool):
380
+ return str(value).lower()
381
+ if isinstance(value, list):
382
+ return json.dumps(value, ensure_ascii=False)
383
+ return str(value)
384
+
385
+ @staticmethod
386
+ def _plan_result(plan: MaintenancePlan) -> MaintenanceResult:
387
+ return MaintenanceResult(
388
+ status="planned",
389
+ document_count=len(plan.items),
390
+ issue_count=0,
391
+ scope=plan.scope,
392
+ operations=[item.model_dump(mode="json") for item in plan.items],
393
+ )
394
+
395
+ @staticmethod
396
+ def _blocked_issue(code: str, path: str, detail: str = "") -> MaintenanceResult:
397
+ issue = Issue.model_validate(enrich_issue({"code": code, "path": path, "detail": detail}))
398
+ return MaintenanceResult(
399
+ status="blocked",
400
+ document_count=0,
401
+ issue_count=1,
402
+ issues=[issue],
403
+ issue_counts={code: 1},
404
+ )
405
+
406
+ @staticmethod
407
+ def _issue_counts(issues: list[Issue]) -> dict[str, int]:
408
+ counts: dict[str, int] = {}
409
+ for issue in issues:
410
+ counts[issue.code] = counts.get(issue.code, 0) + 1
411
+ return dict(sorted(counts.items()))
@@ -0,0 +1,3 @@
1
+ # Skill App SPEC
2
+
3
+ 本模块发现、读取、匹配、校验并同步包内 SSOT Skill。Service 决定托管范围与同步动作,Repository 只读取包资源和 Vault 文件。
@@ -0,0 +1 @@
1
+ """Skill management application module."""
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import typer
6
+
7
+ skill_cli = typer.Typer(
8
+ help="发现、加载、校验和同步 Agent SOP Skill",
9
+ context_settings={"help_option_names": ["-h", "--help"]},
10
+ )
11
+
12
+
13
+ def emit(result: object) -> None:
14
+ typer.echo(json.dumps(result.model_dump(mode="json"), ensure_ascii=False, indent=2))
15
+
16
+
17
+ @skill_cli.command("list")
18
+ def list_skills(ctx: typer.Context) -> None:
19
+ """列出包内 SSOT Skill 及全局同步状态。"""
20
+ emit(ctx.obj.skill.list())
21
+
22
+
23
+ @skill_cli.command("show")
24
+ def show(ctx: typer.Context, name: str, source: str = typer.Option("ssot", "--source")) -> None:
25
+ """读取一个 Skill;source 可选 ssot 或 global。"""
26
+ emit(ctx.obj.skill.show(name, source))
27
+
28
+
29
+ @skill_cli.command("resolve")
30
+ def resolve(ctx: typer.Context, path: str = typer.Option(..., "--path")) -> None:
31
+ """根据目标文档路径解析 Agent 应加载的 Skill。"""
32
+ emit(ctx.obj.skill.resolve(path))
33
+
34
+
35
+ @skill_cli.command("check")
36
+ def check(ctx: typer.Context) -> None:
37
+ """只读校验 SSOT、配置和同步状态。"""
38
+ emit(ctx.obj.skill.check())
39
+
40
+
41
+ @skill_cli.command("sync")
42
+ def sync(ctx: typer.Context, dry_run: bool = typer.Option(False, "--dry-run")) -> None:
43
+ """将托管 Skill 从包内 SSOT 确定性同步到全局 Agent 目录。"""
44
+ emit(ctx.obj.skill.sync(dry_run))
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.resources import files
4
+ from pathlib import Path
5
+
6
+ from campfire_cli.common.filesystem import atomic_write
7
+
8
+
9
+ class SkillRepository:
10
+ def source_root(self) -> Path:
11
+ return Path(str(files("campfire_cli.resources") / "skills"))
12
+
13
+ def read(self, path: Path) -> str:
14
+ return path.read_text(encoding="utf-8")
15
+
16
+ def write(self, path: Path, content: str) -> None:
17
+ atomic_write(path, content)
18
+
19
+ def delete_files(self, root: Path, paths: list[Path]) -> None:
20
+ root = root.resolve()
21
+ for path in paths:
22
+ path.resolve().relative_to(root)
23
+ path.unlink()
24
+ directories = sorted(
25
+ {parent for path in paths for parent in path.parents if parent != root},
26
+ key=lambda item: len(item.parts),
27
+ reverse=True,
28
+ )
29
+ for directory in directories:
30
+ try:
31
+ directory.rmdir()
32
+ except OSError:
33
+ continue
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class SkillInfo(BaseModel):
7
+ name: str
8
+ description: str = ""
9
+ path: str
10
+ status: str = "unknown"
11
+
12
+
13
+ class SkillResult(BaseModel):
14
+ status: str
15
+ skills: list[SkillInfo] = Field(default_factory=list)
16
+ operations: list[dict[str, str]] = Field(default_factory=list)
17
+ issues: list[dict[str, str]] = Field(default_factory=list)
18
+ content: str | None = None
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from campfire_cli.app.skill.repository.skill_repository import SkillRepository
7
+ from campfire_cli.app.skill.schema.skill_schema import SkillInfo, SkillResult
8
+ from campfire_cli.common.governance import enrich_issue, optimistic_write_lock
9
+ from campfire_cli.common.hashing import file_sha256, text_sha256
10
+ from campfire_cli.config.defaults import config_section
11
+ from campfire_cli.config.settings import WorkspaceSettings
12
+
13
+ NAME_RE = re.compile(r"^name:\s*[\"']?([^\n\"']+)", re.MULTILINE)
14
+ DESCRIPTION_RE = re.compile(r"^description:\s*[\"']?([^\n\"']+)", re.MULTILINE)
15
+
16
+
17
+ class SkillService:
18
+ def __init__(self, settings: WorkspaceSettings, repository: SkillRepository) -> None:
19
+ self._settings = settings
20
+ self._repository = repository
21
+
22
+ def list(self) -> SkillResult:
23
+ return SkillResult(status="ok", skills=self._skills())
24
+
25
+ def show(self, name: str, source: str = "ssot") -> SkillResult:
26
+ root = self._source_root() if source == "ssot" else self._target_roots()[0]
27
+ path = root / name / "SKILL.md"
28
+ if not path.is_file():
29
+ return SkillResult(
30
+ status="not-found", issues=[{"code": "skill-missing", "path": str(path)}]
31
+ )
32
+ info = self._parse_skill(path, root)
33
+ return SkillResult(status="ok", skills=[info], content=self._repository.read(path))
34
+
35
+ def resolve(self, path: str) -> SkillResult:
36
+ names = ["campfire-workspace-maintenance"]
37
+ normalized = path.replace("\\", "/")
38
+ if normalized.startswith("_收件箱/"):
39
+ names.append("campfire-inbox-triage")
40
+ elif "工作周报/" in normalized or Path(normalized).name.startswith("周报-"):
41
+ names.append("campfire-weekly-report-writing")
42
+ available = {item.name: item for item in self._skills()}
43
+ return SkillResult(
44
+ status="ok", skills=[available[name] for name in names if name in available]
45
+ )
46
+
47
+ def check(self) -> SkillResult:
48
+ issues: list[dict[str, str]] = []
49
+ configured = set(self._managed_names())
50
+ source = {item.name: item for item in self._skills()}
51
+ for name in sorted(configured - source.keys()):
52
+ issues.append({"code": "ssot-skill-missing", "path": name})
53
+ for name, info in source.items():
54
+ if name != Path(info.path).parent.name:
55
+ issues.append({"code": "skill-name-mismatch", "path": info.path})
56
+ if not info.description:
57
+ issues.append({"code": "skill-description-missing", "path": info.path})
58
+ if name in configured and info.status != "current":
59
+ issues.append({"code": "managed-skill-not-current", "path": info.path})
60
+ return SkillResult(
61
+ status="ok" if not issues else "needs-review",
62
+ skills=list(source.values()),
63
+ issues=issues,
64
+ )
65
+
66
+ def sync(self, dry_run: bool = False) -> SkillResult:
67
+ operations: list[dict[str, str]] = []
68
+ writes: list[tuple[Path, str, str | None]] = []
69
+ source_root = self._source_root()
70
+ for target_root in self._target_roots():
71
+ for name in self._managed_names():
72
+ source_dir = source_root / name
73
+ if not (source_dir / "SKILL.md").is_file():
74
+ continue
75
+ for source in sorted(path for path in source_dir.rglob("*") if path.is_file()):
76
+ relative = source.relative_to(source_root)
77
+ target = target_root / relative
78
+ content = self._repository.read(source)
79
+ current = self._repository.read(target) if target.is_file() else None
80
+ if current == content:
81
+ continue
82
+ operations.append(
83
+ {
84
+ "action": "update" if target.exists() else "create",
85
+ "path": str(target),
86
+ "sha256": text_sha256(content),
87
+ }
88
+ )
89
+ # 期望哈希必须与复核端(_current_hash,原始字节)同口径,
90
+ # 否则 CRLF 存量文件会被误判为其他会话修改。
91
+ expected = file_sha256(target) if target.is_file() else None
92
+ writes.append((target, content, expected))
93
+ if not dry_run and writes:
94
+ # 全局 Skill 目录在 Vault 之外,快照键使用绝对路径。
95
+ snapshot = {str(path): expected for path, _content, expected in writes}
96
+ with optimistic_write_lock(self._settings.state_root, snapshot) as changed:
97
+ if changed:
98
+ return SkillResult(
99
+ status="blocked",
100
+ issues=[
101
+ enrich_issue({"code": "concurrent-change", "path": path})
102
+ for path in changed
103
+ ],
104
+ )
105
+ for target, content, _expected in writes:
106
+ self._repository.write(target, content)
107
+ return SkillResult(status="dry-run" if dry_run else "synced", operations=operations)
108
+
109
+ def _skills(self) -> list[SkillInfo]:
110
+ root = self._source_root()
111
+ targets = self._target_roots()
112
+ result: list[SkillInfo] = []
113
+ for path in sorted(root.glob("*/SKILL.md")):
114
+ info = self._parse_skill(path, root)
115
+ installed = [target / info.name / "SKILL.md" for target in targets]
116
+ info.status = (
117
+ "current"
118
+ if all(
119
+ item.is_file() and self._repository.read(item) == self._repository.read(path)
120
+ for item in installed
121
+ )
122
+ else "missing-or-stale"
123
+ )
124
+ result.append(info)
125
+ return result
126
+
127
+ def _parse_skill(self, path: Path, root: Path) -> SkillInfo:
128
+ content = self._repository.read(path)
129
+ name = NAME_RE.search(content)
130
+ description = DESCRIPTION_RE.search(content)
131
+ return SkillInfo(
132
+ name=name.group(1).strip() if name else path.parent.name,
133
+ description=description.group(1).strip() if description else "",
134
+ path=path.relative_to(root).as_posix(),
135
+ )
136
+
137
+ def _source_root(self) -> Path:
138
+ return self._repository.source_root()
139
+
140
+ def _target_roots(self) -> list[Path]:
141
+ return self._settings.skill_targets()
142
+
143
+ def _managed_names(self) -> list[str]:
144
+ configured = list(self._settings.skills.get("managed_skills", []))
145
+ packaged = config_section("skills").get("managed_skills", [])
146
+ return list(dict.fromkeys([*packaged, *configured]))