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,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 @@
|
|
|
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]))
|