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,119 @@
|
|
|
1
|
+
"""Shared helpers for the Vault-wide single-select document type contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(?:\s*(.*))?$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def safe_path(root: Path, relative: str) -> Path:
|
|
13
|
+
root = root.resolve()
|
|
14
|
+
path = (root / relative).resolve()
|
|
15
|
+
path.relative_to(root)
|
|
16
|
+
return path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def frontmatter_bounds(text: str) -> tuple[int, int] | None:
|
|
20
|
+
if not text.startswith("---\n"):
|
|
21
|
+
return None
|
|
22
|
+
end = text.find("\n---\n", 4)
|
|
23
|
+
return (4, end) if end >= 0 else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def frontmatter_value(text: str, key: str) -> tuple[str | None, bool]:
|
|
27
|
+
"""Return scalar value and whether the key is represented as a YAML sequence."""
|
|
28
|
+
bounds = frontmatter_bounds(text)
|
|
29
|
+
if not bounds:
|
|
30
|
+
return None, False
|
|
31
|
+
lines = text[bounds[0] : bounds[1]].splitlines()
|
|
32
|
+
for index, line in enumerate(lines):
|
|
33
|
+
match = FRONTMATTER_KEY_RE.match(line)
|
|
34
|
+
if not match or match.group(1) != key:
|
|
35
|
+
continue
|
|
36
|
+
value = (match.group(2) or "").strip().strip('"').strip("'")
|
|
37
|
+
sequence = value.startswith("[")
|
|
38
|
+
if not value:
|
|
39
|
+
for following in lines[index + 1 :]:
|
|
40
|
+
if FRONTMATTER_KEY_RE.match(following):
|
|
41
|
+
break
|
|
42
|
+
if re.match(r"^\s+-\s+", following):
|
|
43
|
+
sequence = True
|
|
44
|
+
break
|
|
45
|
+
return value or None, sequence
|
|
46
|
+
return None, False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_frontmatter_scalar(text: str, key: str, value: str) -> str:
|
|
50
|
+
bounds = frontmatter_bounds(text)
|
|
51
|
+
if not bounds:
|
|
52
|
+
return f"---\n{key}: {value}\n---\n\n{text}"
|
|
53
|
+
lines = text[bounds[0] : bounds[1]].splitlines()
|
|
54
|
+
output: list[str] = []
|
|
55
|
+
replaced = False
|
|
56
|
+
skipping_sequence = False
|
|
57
|
+
for line in lines:
|
|
58
|
+
match = FRONTMATTER_KEY_RE.match(line)
|
|
59
|
+
if match:
|
|
60
|
+
skipping_sequence = False
|
|
61
|
+
if match.group(1) == key:
|
|
62
|
+
if not replaced:
|
|
63
|
+
output.append(f"{key}: {value}")
|
|
64
|
+
replaced = True
|
|
65
|
+
skipping_sequence = not (match.group(2) or "").strip()
|
|
66
|
+
continue
|
|
67
|
+
if skipping_sequence and re.match(r"^\s+-\s+", line):
|
|
68
|
+
continue
|
|
69
|
+
output.append(line)
|
|
70
|
+
if not replaced:
|
|
71
|
+
output.append(f"{key}: {value}")
|
|
72
|
+
return "---\n" + "\n".join(output) + text[bounds[1] :]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prefix_type(filename: str, config: dict[str, Any]) -> str | None:
|
|
76
|
+
matches = [
|
|
77
|
+
name for name, item in config["types"].items() if filename.startswith(item["prefix"])
|
|
78
|
+
]
|
|
79
|
+
return matches[0] if len(matches) == 1 else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def prefixed_name(filename: str, doc_type: str, config: dict[str, Any]) -> str:
|
|
83
|
+
prefix = config["types"][doc_type]["prefix"]
|
|
84
|
+
known = sorted((item["prefix"] for item in config["types"].values()), key=len, reverse=True)
|
|
85
|
+
stem = filename
|
|
86
|
+
for existing in known:
|
|
87
|
+
if stem.startswith(existing):
|
|
88
|
+
stem = stem[len(existing) :]
|
|
89
|
+
break
|
|
90
|
+
result = prefix + stem
|
|
91
|
+
if config.get("filename_rules", {}).get("flatten_leading_bracket_categories", False):
|
|
92
|
+
result = flatten_leading_bracket_categories(result, prefix)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def flatten_leading_bracket_categories(filename: str, prefix: str) -> str:
|
|
97
|
+
"""Convert PREFIX-【A】【B】Title.md to PREFIX-A-B-Title.md."""
|
|
98
|
+
if not filename.startswith(prefix):
|
|
99
|
+
return filename
|
|
100
|
+
remainder = filename[len(prefix) :]
|
|
101
|
+
categories: list[str] = []
|
|
102
|
+
while True:
|
|
103
|
+
match = re.match(r"^【([^】]+)】\s*", remainder)
|
|
104
|
+
if not match:
|
|
105
|
+
break
|
|
106
|
+
category = match.group(1).strip().strip("-_")
|
|
107
|
+
if category:
|
|
108
|
+
categories.append(category)
|
|
109
|
+
remainder = remainder[match.end() :]
|
|
110
|
+
if not categories:
|
|
111
|
+
return filename
|
|
112
|
+
remainder = remainder.lstrip(" -_")
|
|
113
|
+
parts = [*categories, remainder] if remainder else categories
|
|
114
|
+
return prefix + "-".join(parts)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def profile_mapping(config: dict[str, Any], profile: str) -> dict[str, str]:
|
|
118
|
+
names = config.get("profiles", {}).get(profile, [])
|
|
119
|
+
return {name: config["types"][name]["prefix"] for name in names if name in config["types"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared helpers for layered Vault frontmatter schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from campfire_cli.common.documents.document_types import frontmatter_bounds
|
|
9
|
+
|
|
10
|
+
KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):(?:\s*(.*))?$")
|
|
11
|
+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_shape(text: str) -> tuple[dict[str, str], dict[str, str]]:
|
|
15
|
+
bounds = frontmatter_bounds(text)
|
|
16
|
+
if not bounds:
|
|
17
|
+
return {}, {}
|
|
18
|
+
lines = text[bounds[0] : bounds[1]].splitlines()
|
|
19
|
+
values: dict[str, str] = {}
|
|
20
|
+
kinds: dict[str, str] = {}
|
|
21
|
+
current: str | None = None
|
|
22
|
+
for line in lines:
|
|
23
|
+
match = KEY_RE.match(line)
|
|
24
|
+
if match:
|
|
25
|
+
current = match.group(1)
|
|
26
|
+
value = (match.group(2) or "").strip().strip('"').strip("'")
|
|
27
|
+
values[current] = value
|
|
28
|
+
kinds[current] = "list" if value.startswith("[") else "scalar"
|
|
29
|
+
elif current and re.match(r"^\s+-\s+", line):
|
|
30
|
+
kinds[current] = "list"
|
|
31
|
+
return values, kinds
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_project_context(path: Path | None) -> bool:
|
|
35
|
+
if path is None:
|
|
36
|
+
return False
|
|
37
|
+
for parent in [path.parent, *path.parents]:
|
|
38
|
+
marker = parent / "_领域.md"
|
|
39
|
+
if marker.is_file():
|
|
40
|
+
marker_values, _ = parse_shape(marker.read_text(encoding="utf-8"))
|
|
41
|
+
return marker_values.get("governance") == "project-docs"
|
|
42
|
+
return False
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from yaml import YAMLError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class MarkdownDocument:
|
|
12
|
+
frontmatter: dict[str, Any]
|
|
13
|
+
body: str
|
|
14
|
+
has_frontmatter: bool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_document(text: str) -> MarkdownDocument:
|
|
18
|
+
if not text.startswith("---\n"):
|
|
19
|
+
return MarkdownDocument({}, text, False)
|
|
20
|
+
end = text.find("\n---\n", 4)
|
|
21
|
+
if end < 0:
|
|
22
|
+
return MarkdownDocument({}, text, False)
|
|
23
|
+
raw = text[4:end]
|
|
24
|
+
try:
|
|
25
|
+
value = yaml.safe_load(raw) or {}
|
|
26
|
+
except YAMLError:
|
|
27
|
+
value = _parse_lenient_scalars(raw)
|
|
28
|
+
if not isinstance(value, dict):
|
|
29
|
+
return MarkdownDocument({}, text, False)
|
|
30
|
+
return MarkdownDocument(value, text[end + 5 :], True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_lenient_scalars(raw: str) -> dict[str, Any]:
|
|
34
|
+
"""Recover top-level fields so malformed legacy YAML cannot abort a Vault scan."""
|
|
35
|
+
result: dict[str, Any] = {}
|
|
36
|
+
current_list: str | None = None
|
|
37
|
+
for line in raw.splitlines():
|
|
38
|
+
if line.startswith(" - ") and current_list:
|
|
39
|
+
result[current_list].append(line[4:].strip().strip('"'))
|
|
40
|
+
continue
|
|
41
|
+
current_list = None
|
|
42
|
+
if not line or line[0].isspace() or ":" not in line:
|
|
43
|
+
continue
|
|
44
|
+
key, raw_value = line.split(":", 1)
|
|
45
|
+
key = key.strip()
|
|
46
|
+
value = raw_value.strip()
|
|
47
|
+
if not key:
|
|
48
|
+
continue
|
|
49
|
+
if value == "[]":
|
|
50
|
+
result[key] = []
|
|
51
|
+
elif not value:
|
|
52
|
+
result[key] = []
|
|
53
|
+
current_list = key
|
|
54
|
+
elif value in {"true", "false"}:
|
|
55
|
+
result[key] = value == "true"
|
|
56
|
+
else:
|
|
57
|
+
result[key] = value.strip('"')
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_document(frontmatter: dict[str, Any], body: str, field_order: list[str]) -> str:
|
|
62
|
+
known = [key for key in field_order if key in frontmatter]
|
|
63
|
+
unknown = [key for key in frontmatter if key not in field_order]
|
|
64
|
+
ordered = {key: frontmatter[key] for key in [*known, *unknown]}
|
|
65
|
+
header = yaml.safe_dump(ordered, allow_unicode=True, sort_keys=False).rstrip()
|
|
66
|
+
return f"---\n{header}\n---\n{body.lstrip()}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Protocol-neutral application exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AppError(Exception):
|
|
5
|
+
"""Base error translated by delivery adapters."""
|
|
6
|
+
|
|
7
|
+
exit_code = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(AppError):
|
|
11
|
+
"""Raised when a Vault cannot be located or configured."""
|
|
12
|
+
|
|
13
|
+
exit_code = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GovernanceBlockedError(AppError):
|
|
17
|
+
"""Raised when a write is blocked by governance safety rules."""
|
|
18
|
+
|
|
19
|
+
exit_code = 3
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Safe filesystem primitives shared by application modules."""
|
|
2
|
+
|
|
3
|
+
from campfire_cli.common.filesystem.atomic import atomic_write, safe_path
|
|
4
|
+
from campfire_cli.common.filesystem.locking import workspace_write_lock
|
|
5
|
+
|
|
6
|
+
__all__ = ["atomic_write", "safe_path", "workspace_write_lock"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from campfire_cli.common.exceptions import GovernanceBlockedError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def safe_path(root: Path, relative: str) -> Path:
|
|
11
|
+
candidate = (root / relative).resolve()
|
|
12
|
+
if candidate != root and root not in candidate.parents:
|
|
13
|
+
raise GovernanceBlockedError(f"路径超出 Vault:{relative}")
|
|
14
|
+
return candidate
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
20
|
+
temporary = Path(temporary_name)
|
|
21
|
+
try:
|
|
22
|
+
# newline="\n" 阻止 Windows 文本模式把 \n 翻译为 \r\n;
|
|
23
|
+
# 生成文件的哈希校验(file_sha256)依赖写入与读取的换行口径一致。
|
|
24
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
|
25
|
+
stream.write(content)
|
|
26
|
+
stream.flush()
|
|
27
|
+
os.fsync(stream.fileno())
|
|
28
|
+
temporary.replace(path)
|
|
29
|
+
finally:
|
|
30
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from campfire_cli.common.exceptions import GovernanceBlockedError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def workspace_write_lock(scope_root: Path) -> Iterator[None]:
|
|
13
|
+
"""对 scope_root 范围内的治理写入互斥。
|
|
14
|
+
|
|
15
|
+
锁的粒度由传入的根目录决定,锁文件位于 <scope_root>/locks/write.lock:
|
|
16
|
+
|
|
17
|
+
- Workspace 的 state_root(~/.campfire/workspaces/<id>/):
|
|
18
|
+
互斥该 Workspace 的 Vault 写入与状态刷新;
|
|
19
|
+
- campfire_home(~/.campfire/):互斥跨 Workspace 的全局资源,
|
|
20
|
+
包括注册库写入和全局 Skill 同步。
|
|
21
|
+
"""
|
|
22
|
+
lock = scope_root / "locks" / "write.lock"
|
|
23
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
descriptor = _acquire(lock)
|
|
25
|
+
try:
|
|
26
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
27
|
+
stream.write(str(os.getpid()))
|
|
28
|
+
yield
|
|
29
|
+
finally:
|
|
30
|
+
lock.unlink(missing_ok=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _acquire(lock: Path) -> int:
|
|
34
|
+
try:
|
|
35
|
+
return os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
36
|
+
except FileExistsError as exc:
|
|
37
|
+
try:
|
|
38
|
+
pid = int(lock.read_text(encoding="utf-8").strip())
|
|
39
|
+
except (OSError, ValueError):
|
|
40
|
+
raise GovernanceBlockedError(f"已有治理写操作且锁内容无法确认:{lock}") from exc
|
|
41
|
+
try:
|
|
42
|
+
os.kill(pid, 0)
|
|
43
|
+
except PermissionError:
|
|
44
|
+
raise GovernanceBlockedError(f"已有治理写操作(PID {pid}):{lock}") from exc
|
|
45
|
+
except OSError:
|
|
46
|
+
# 进程不存在:POSIX 抛 ProcessLookupError,Windows 抛 WinError 87,
|
|
47
|
+
# 两者都是 OSError 子类,统一按死进程清理后重试。
|
|
48
|
+
pass
|
|
49
|
+
else:
|
|
50
|
+
raise GovernanceBlockedError(f"已有治理写操作(PID {pid}):{lock}") from exc
|
|
51
|
+
lock.unlink(missing_ok=True)
|
|
52
|
+
try:
|
|
53
|
+
return os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
54
|
+
except FileExistsError as retry_exc:
|
|
55
|
+
raise GovernanceBlockedError(f"已有治理写操作:{lock}") from retry_exc
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared governance diagnostics and optimistic concurrency helpers."""
|
|
2
|
+
|
|
3
|
+
from campfire_cli.common.governance.issues import enrich_issue, filter_issues
|
|
4
|
+
from campfire_cli.common.governance.locking import optimistic_write_lock
|
|
5
|
+
from campfire_cli.common.governance.snapshots import capture_snapshot, snapshot_changes
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"capture_snapshot",
|
|
9
|
+
"enrich_issue",
|
|
10
|
+
"filter_issues",
|
|
11
|
+
"optimistic_write_lock",
|
|
12
|
+
"snapshot_changes",
|
|
13
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from campfire_cli.config.defaults import config_section
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def enrich_issue(issue: dict[str, Any]) -> dict[str, Any]:
|
|
10
|
+
result = dict(issue)
|
|
11
|
+
code = str(result.get("code"))
|
|
12
|
+
policy = config_section("issues")
|
|
13
|
+
fallback = next(
|
|
14
|
+
(content for prefix, content in policy["categories"].items() if code.startswith(prefix)),
|
|
15
|
+
(policy["default"]["message"], policy["default"]["suggestion"]),
|
|
16
|
+
)
|
|
17
|
+
message, suggestion = policy["catalog"].get(code, fallback)
|
|
18
|
+
result.setdefault("message", message)
|
|
19
|
+
result.setdefault("suggestion", suggestion)
|
|
20
|
+
result.setdefault("severity", "warning" if code in policy["warning_codes"] else "error")
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def filter_issues(
|
|
25
|
+
issues: Iterable[dict[str, Any]],
|
|
26
|
+
*,
|
|
27
|
+
scope: str | None = None,
|
|
28
|
+
code: str | None = None,
|
|
29
|
+
severity: str | None = None,
|
|
30
|
+
) -> list[dict[str, Any]]:
|
|
31
|
+
prefix = scope.strip("/") if scope else None
|
|
32
|
+
return [
|
|
33
|
+
issue
|
|
34
|
+
for issue in issues
|
|
35
|
+
if (prefix is None or str(issue.get("path", "")).strip("/").startswith(prefix))
|
|
36
|
+
and (code is None or issue.get("code") == code)
|
|
37
|
+
and (severity is None or issue.get("severity", "error") == severity)
|
|
38
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from campfire_cli.common.filesystem import workspace_write_lock
|
|
8
|
+
from campfire_cli.common.governance.snapshots import snapshot_changes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@contextmanager
|
|
12
|
+
def optimistic_write_lock(
|
|
13
|
+
state_root: Path,
|
|
14
|
+
snapshot: dict[str, str | None],
|
|
15
|
+
root: Path | None = None,
|
|
16
|
+
) -> Iterator[list[str]]:
|
|
17
|
+
"""乐观并发控制的治理写锁:取治理写锁,并复核快照哈希后放行写入。
|
|
18
|
+
|
|
19
|
+
与悲观互斥的 workspace_write_lock(只防提交瞬间的并发)不同,本锁用于
|
|
20
|
+
覆盖"计划到执行"的长窗口:yield 变化路径清单,非空表示计划生成后内容
|
|
21
|
+
被其他会话修改,调用方必须放弃写入(返回 blocked 结果或抛出
|
|
22
|
+
GovernanceBlockedError);空清单表示可安全写入,写入动作在 with 块内
|
|
23
|
+
完成,退出时释放锁。
|
|
24
|
+
|
|
25
|
+
快照的期望哈希必须由 file_sha256 产出,与 snapshot_changes 的复核口径
|
|
26
|
+
一致;不允许各服务手工拼装其他口径的期望哈希。snapshot 的键通常是相对
|
|
27
|
+
root 的 POSIX 路径,也允许绝对路径键(Vault 之外的同步目标,如全局
|
|
28
|
+
Skill 目录),此时 root 不参与该键的解析。
|
|
29
|
+
"""
|
|
30
|
+
with workspace_write_lock(state_root):
|
|
31
|
+
yield snapshot_changes(root or Path(), snapshot)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from campfire_cli.common.hashing import file_sha256
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def capture_snapshot(root: Path, paths: Iterable[Path]) -> dict[str, str | None]:
|
|
10
|
+
root = root.resolve()
|
|
11
|
+
result: dict[str, str | None] = {}
|
|
12
|
+
for path in sorted({item.resolve() for item in paths}):
|
|
13
|
+
relative = path.relative_to(root).as_posix()
|
|
14
|
+
result[relative] = file_sha256(path) if path.is_file() else None
|
|
15
|
+
return result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def snapshot_changes(root: Path, snapshot: dict[str, str | None]) -> list[str]:
|
|
19
|
+
changes: list[str] = []
|
|
20
|
+
for relative, expected in snapshot.items():
|
|
21
|
+
key = Path(relative)
|
|
22
|
+
path = key if key.is_absolute() else root / key
|
|
23
|
+
actual = file_sha256(path) if path.is_file() else None
|
|
24
|
+
if actual != expected:
|
|
25
|
+
changes.append(relative)
|
|
26
|
+
return changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Stable machine-readable and human-readable report rendering."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render_maintenance_report(payload: dict[str, Any]) -> str:
|
|
7
|
+
lines = [
|
|
8
|
+
"# Campfire 当前检查报告",
|
|
9
|
+
"",
|
|
10
|
+
f"- 状态:`{payload['status']}`",
|
|
11
|
+
f"- 文档数:{payload['document_count']}",
|
|
12
|
+
f"- 问题数:{payload['issue_count']}",
|
|
13
|
+
f"- 生成时间:{payload['exported_at']}",
|
|
14
|
+
"",
|
|
15
|
+
"## 待处理问题",
|
|
16
|
+
"",
|
|
17
|
+
]
|
|
18
|
+
issues = payload.get("issues", [])
|
|
19
|
+
if issues:
|
|
20
|
+
for issue in issues:
|
|
21
|
+
lines.append(
|
|
22
|
+
f"- `{issue['severity']}` `{issue['code']}` `{issue['path']}`:"
|
|
23
|
+
f"{issue.get('message') or issue.get('detail', '')}"
|
|
24
|
+
)
|
|
25
|
+
if issue.get("suggestion"):
|
|
26
|
+
lines.append(f" - 建议:{issue['suggestion']}")
|
|
27
|
+
else:
|
|
28
|
+
lines.append("当前没有发现治理问题。")
|
|
29
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Config SPEC
|
|
2
|
+
|
|
3
|
+
配置和状态统一保存在用户级 `CAMPFIRE_HOME`,默认是 `~/.campfire/`。唯一的 `campfire.db` 注册多个 Workspace 和 Project,并按 `workspace_id` 隔离索引与工作流状态。CLI 不读取或创建 Workspace 内 `.campfire/`。
|
|
4
|
+
|
|
5
|
+
包内 `resources/defaults/config.yml` 是产品默认值的唯一来源:Workspace 模板、文档类型、Profile、项目状态、归档原因和 Issue 展示规则按顶级分区集中维护。`~/.campfire/config.yml` 只保存用户覆盖项,使用 mapping 深度合并、列表整体替换;运行状态不得写入配置。
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from functools import cache
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@cache
|
|
14
|
+
def builtin_config() -> dict[str, Any]:
|
|
15
|
+
"""Load the immutable packaged product configuration."""
|
|
16
|
+
resource = files("campfire_cli.resources.defaults").joinpath("config.yml")
|
|
17
|
+
value = yaml.safe_load(resource.read_text(encoding="utf-8"))
|
|
18
|
+
if not isinstance(value, dict) or not isinstance(value.get("version"), int):
|
|
19
|
+
raise ValueError("内置 config.yml 缺少整数 version")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def builtin_section(name: str) -> dict[str, Any]:
|
|
24
|
+
value = builtin_config().get(name)
|
|
25
|
+
if not isinstance(value, dict):
|
|
26
|
+
raise ValueError(f"内置 config.yml 缺少配置分区:{name}")
|
|
27
|
+
return deepcopy(value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_section(name: str) -> dict[str, Any]:
|
|
31
|
+
home = Path(os.environ.get("CAMPFIRE_HOME", "~/.campfire")).expanduser().resolve()
|
|
32
|
+
value = effective_config(home / "config.yml").get(name)
|
|
33
|
+
if not isinstance(value, dict):
|
|
34
|
+
raise ValueError(f"有效 config.yml 缺少配置分区:{name}")
|
|
35
|
+
return deepcopy(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def effective_config(user_path: Path) -> dict[str, Any]:
|
|
39
|
+
"""Merge optional user overrides onto packaged defaults."""
|
|
40
|
+
config = deepcopy(builtin_config())
|
|
41
|
+
if not user_path.is_file():
|
|
42
|
+
return config
|
|
43
|
+
try:
|
|
44
|
+
override = yaml.safe_load(user_path.read_text(encoding="utf-8")) or {}
|
|
45
|
+
except yaml.YAMLError as exc:
|
|
46
|
+
raise ValueError(f"用户 config.yml 不是合法 YAML:{exc}") from exc
|
|
47
|
+
if not isinstance(override, dict):
|
|
48
|
+
raise ValueError("用户 config.yml 必须是 YAML mapping")
|
|
49
|
+
if not isinstance(override.get("version"), int):
|
|
50
|
+
raise ValueError("用户 config.yml 缺少整数 version")
|
|
51
|
+
unknown = sorted(set(override) - set(config))
|
|
52
|
+
if unknown:
|
|
53
|
+
raise ValueError("用户 config.yml 包含未知顶级分区:" + ", ".join(unknown))
|
|
54
|
+
return _deep_merge(config, override)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
58
|
+
result = deepcopy(base)
|
|
59
|
+
for key, value in override.items():
|
|
60
|
+
if isinstance(result.get(key), dict) and isinstance(value, dict):
|
|
61
|
+
result[key] = _deep_merge(result[key], value)
|
|
62
|
+
else:
|
|
63
|
+
result[key] = deepcopy(value)
|
|
64
|
+
return result
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from campfire_cli.common.exceptions import ConfigurationError
|
|
9
|
+
from campfire_cli.config.defaults import effective_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def campfire_home() -> Path:
|
|
13
|
+
return Path(os.environ.get("CAMPFIRE_HOME", "~/.campfire")).expanduser().resolve()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class WorkspaceSettings:
|
|
18
|
+
workspace_id: str
|
|
19
|
+
vault_root: Path
|
|
20
|
+
state_root: Path
|
|
21
|
+
governance: dict[str, Any]
|
|
22
|
+
document_types: dict[str, Any]
|
|
23
|
+
frontmatter_schema: dict[str, Any]
|
|
24
|
+
skills: dict[str, Any]
|
|
25
|
+
bases: dict[str, Any]
|
|
26
|
+
|
|
27
|
+
def skill_targets(self) -> list[Path]:
|
|
28
|
+
override = os.environ.get("CAMPFIRE_SKILL_TARGETS")
|
|
29
|
+
values = override.split(os.pathsep) if override else self.skills.get("targets", [])
|
|
30
|
+
return [Path(value).expanduser().resolve() for value in values]
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def load(cls, workspace_id: str, root: Path) -> WorkspaceSettings:
|
|
34
|
+
if not root.is_dir():
|
|
35
|
+
raise ConfigurationError(f"已注册 Workspace 不存在:{root}")
|
|
36
|
+
state_root = campfire_home() / "workspaces" / workspace_id
|
|
37
|
+
try:
|
|
38
|
+
config = effective_config(campfire_home() / "config.yml")
|
|
39
|
+
except (OSError, ValueError) as exc:
|
|
40
|
+
raise ConfigurationError(f"无法加载 Campfire config.yml:{exc}") from exc
|
|
41
|
+
return cls(
|
|
42
|
+
workspace_id=workspace_id,
|
|
43
|
+
vault_root=root,
|
|
44
|
+
state_root=state_root,
|
|
45
|
+
governance=config["governance"],
|
|
46
|
+
document_types=config["document_types"],
|
|
47
|
+
frontmatter_schema=config["frontmatter_schema"],
|
|
48
|
+
skills=config["skills"],
|
|
49
|
+
bases=config["bases"],
|
|
50
|
+
)
|