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