campfire-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. campfire_cli/__init__.py +3 -0
  2. campfire_cli/alembic/__init__.py +1 -0
  3. campfire_cli/alembic/env.py +37 -0
  4. campfire_cli/alembic/script.py.mako +22 -0
  5. campfire_cli/alembic/versions/001_initial_schema.py +81 -0
  6. campfire_cli/alembic/versions/002_workspace_project_registry.py +47 -0
  7. campfire_cli/alembic/versions/003_unified_workspace_state.py +113 -0
  8. campfire_cli/alembic/versions/004_restructure_workspace_domain.py +23 -0
  9. campfire_cli/alembic/versions/005_decision_channel.py +71 -0
  10. campfire_cli/alembic/versions/006_workspace_topology_index.py +61 -0
  11. campfire_cli/alembic/versions/007_workspace_adoption.py +38 -0
  12. campfire_cli/alembic/versions/__init__.py +1 -0
  13. campfire_cli/app/SPEC.md +12 -0
  14. campfire_cli/app/__init__.py +1 -0
  15. campfire_cli/app/base/SPEC.md +3 -0
  16. campfire_cli/app/base/__init__.py +1 -0
  17. campfire_cli/app/base/cli/base_cli.py +37 -0
  18. campfire_cli/app/base/repository/base_repository.py +17 -0
  19. campfire_cli/app/base/schema/base_schema.py +18 -0
  20. campfire_cli/app/base/service/base_service.py +129 -0
  21. campfire_cli/app/decision/SPEC.md +16 -0
  22. campfire_cli/app/decision/__init__.py +1 -0
  23. campfire_cli/app/decision/cli/decision_cli.py +112 -0
  24. campfire_cli/app/decision/repository/decision_repository.py +199 -0
  25. campfire_cli/app/decision/schema/decision_schema.py +56 -0
  26. campfire_cli/app/decision/service/decision_projection_service.py +151 -0
  27. campfire_cli/app/decision/service/decision_service.py +124 -0
  28. campfire_cli/app/document/SPEC.md +51 -0
  29. campfire_cli/app/document/__init__.py +1 -0
  30. campfire_cli/app/document/cli/__init__.py +1 -0
  31. campfire_cli/app/document/cli/document_cli.py +63 -0
  32. campfire_cli/app/document/cli/profile_cli.py +65 -0
  33. campfire_cli/app/document/cli/type_cli.py +49 -0
  34. campfire_cli/app/document/repository/__init__.py +1 -0
  35. campfire_cli/app/document/repository/document_profile_repository.py +17 -0
  36. campfire_cli/app/document/repository/document_type_repository.py +17 -0
  37. campfire_cli/app/document/service/__init__.py +1 -0
  38. campfire_cli/app/document/service/document_profile_service.py +66 -0
  39. campfire_cli/app/document/service/document_rule_service.py +316 -0
  40. campfire_cli/app/document/service/document_scanner.py +38 -0
  41. campfire_cli/app/document/service/document_service.py +139 -0
  42. campfire_cli/app/document/service/document_type_service.py +28 -0
  43. campfire_cli/app/document/service/frontmatter_apply.py +98 -0
  44. campfire_cli/app/document/service/frontmatter_formatter.py +104 -0
  45. campfire_cli/app/document/service/frontmatter_plan.py +75 -0
  46. campfire_cli/app/document/service/profile_registry.py +166 -0
  47. campfire_cli/app/document/service/type_apply.py +185 -0
  48. campfire_cli/app/document/service/type_plan.py +80 -0
  49. campfire_cli/app/maintenance/SPEC.md +22 -0
  50. campfire_cli/app/maintenance/__init__.py +1 -0
  51. campfire_cli/app/maintenance/cli/maintenance_cli.py +103 -0
  52. campfire_cli/app/maintenance/repository/maintenance_repository.py +115 -0
  53. campfire_cli/app/maintenance/schema/maintenance_schema.py +109 -0
  54. campfire_cli/app/maintenance/service/archive_service.py +255 -0
  55. campfire_cli/app/maintenance/service/link_service.py +91 -0
  56. campfire_cli/app/maintenance/service/maintenance_protocol.py +25 -0
  57. campfire_cli/app/maintenance/service/maintenance_service.py +461 -0
  58. campfire_cli/app/maintenance/service/moc_service.py +310 -0
  59. campfire_cli/app/maintenance/service/plan_service.py +411 -0
  60. campfire_cli/app/skill/SPEC.md +3 -0
  61. campfire_cli/app/skill/__init__.py +1 -0
  62. campfire_cli/app/skill/cli/skill_cli.py +44 -0
  63. campfire_cli/app/skill/repository/skill_repository.py +33 -0
  64. campfire_cli/app/skill/schema/skill_schema.py +18 -0
  65. campfire_cli/app/skill/service/skill_service.py +146 -0
  66. campfire_cli/app/workspace/SPEC.md +25 -0
  67. campfire_cli/app/workspace/__init__.py +1 -0
  68. campfire_cli/app/workspace/cli/__init__.py +1 -0
  69. campfire_cli/app/workspace/cli/adoption_cli.py +83 -0
  70. campfire_cli/app/workspace/cli/config_cli.py +30 -0
  71. campfire_cli/app/workspace/cli/domain_cli.py +101 -0
  72. campfire_cli/app/workspace/cli/project_cli.py +135 -0
  73. campfire_cli/app/workspace/cli/restructure_cli.py +133 -0
  74. campfire_cli/app/workspace/cli/space_cli.py +65 -0
  75. campfire_cli/app/workspace/cli/workspace_cli.py +145 -0
  76. campfire_cli/app/workspace/repository/__init__.py +1 -0
  77. campfire_cli/app/workspace/repository/adoption_repository.py +55 -0
  78. campfire_cli/app/workspace/repository/manifest_repository.py +38 -0
  79. campfire_cli/app/workspace/repository/restructure_repository.py +93 -0
  80. campfire_cli/app/workspace/repository/workspace_repository.py +125 -0
  81. campfire_cli/app/workspace/schema/__init__.py +1 -0
  82. campfire_cli/app/workspace/schema/adoption_schema.py +47 -0
  83. campfire_cli/app/workspace/schema/restructure_schema.py +64 -0
  84. campfire_cli/app/workspace/schema/workspace_schema.py +225 -0
  85. campfire_cli/app/workspace/service/__init__.py +1 -0
  86. campfire_cli/app/workspace/service/adoption_protocol.py +11 -0
  87. campfire_cli/app/workspace/service/adoption_service.py +364 -0
  88. campfire_cli/app/workspace/service/config_service.py +189 -0
  89. campfire_cli/app/workspace/service/domain_restructure_service.py +316 -0
  90. campfire_cli/app/workspace/service/project_service.py +389 -0
  91. campfire_cli/app/workspace/service/restructure_protocol.py +19 -0
  92. campfire_cli/app/workspace/service/restructure_service.py +392 -0
  93. campfire_cli/app/workspace/service/restructure_verifier.py +83 -0
  94. campfire_cli/app/workspace/service/structure_service.py +494 -0
  95. campfire_cli/app/workspace/service/workspace_protocol.py +24 -0
  96. campfire_cli/app/workspace/service/workspace_service.py +304 -0
  97. campfire_cli/common/SPEC.md +12 -0
  98. campfire_cli/common/__init__.py +1 -0
  99. campfire_cli/common/database/__init__.py +4 -0
  100. campfire_cli/common/database/migrations.py +18 -0
  101. campfire_cli/common/database/models.py +215 -0
  102. campfire_cli/common/database/session.py +30 -0
  103. campfire_cli/common/documents/__init__.py +1 -0
  104. campfire_cli/common/documents/document_types.py +119 -0
  105. campfire_cli/common/documents/frontmatter_schema.py +42 -0
  106. campfire_cli/common/documents/markdown.py +66 -0
  107. campfire_cli/common/exceptions/__init__.py +7 -0
  108. campfire_cli/common/exceptions/base.py +19 -0
  109. campfire_cli/common/filesystem/__init__.py +6 -0
  110. campfire_cli/common/filesystem/atomic.py +30 -0
  111. campfire_cli/common/filesystem/locking.py +55 -0
  112. campfire_cli/common/governance/__init__.py +13 -0
  113. campfire_cli/common/governance/issues.py +38 -0
  114. campfire_cli/common/governance/locking.py +31 -0
  115. campfire_cli/common/governance/snapshots.py +26 -0
  116. campfire_cli/common/hashing.py +10 -0
  117. campfire_cli/common/reports/__init__.py +1 -0
  118. campfire_cli/common/reports/json_report.py +8 -0
  119. campfire_cli/common/reports/markdown_report.py +29 -0
  120. campfire_cli/config/SPEC.md +5 -0
  121. campfire_cli/config/defaults.py +64 -0
  122. campfire_cli/config/settings.py +50 -0
  123. campfire_cli/container.py +130 -0
  124. campfire_cli/main.py +133 -0
  125. campfire_cli/resources/__init__.py +1 -0
  126. campfire_cli/resources/bases/Agent/346/211/247/350/241/214/345/267/245/344/275/234/345/217/260.base +35 -0
  127. campfire_cli/resources/bases/SPEC.md +3 -0
  128. campfire_cli/resources/bases//344/273/273/345/212/241/345/267/245/344/275/234/345/217/260.base +49 -0
  129. campfire_cli/resources/bases//345/206/263/347/255/226/345/267/245/344/275/234/345/217/260.base +40 -0
  130. campfire_cli/resources/bases//345/276/205/345/275/222/346/241/243/346/226/207/346/241/243.base +28 -0
  131. campfire_cli/resources/bases//351/241/271/347/233/256/346/226/207/346/241/243.base +35 -0
  132. campfire_cli/resources/defaults/__init__.py +1 -0
  133. campfire_cli/resources/defaults/config.yml +458 -0
  134. campfire_cli/resources/skills/SPEC.md +3 -0
  135. campfire_cli/resources/skills/campfire-context-bootstrap/SKILL.md +62 -0
  136. campfire_cli/resources/skills/campfire-context-bootstrap/agents/openai.yaml +3 -0
  137. campfire_cli/resources/skills/campfire-conversation-router/SKILL.md +53 -0
  138. campfire_cli/resources/skills/campfire-conversation-router/agents/openai.yaml +3 -0
  139. campfire_cli/resources/skills/campfire-conversation-router/references/inbox-handoff.md +19 -0
  140. campfire_cli/resources/skills/campfire-conversation-router/references/learning-intake.md +31 -0
  141. campfire_cli/resources/skills/campfire-conversation-router/references/task-intake.md +28 -0
  142. campfire_cli/resources/skills/campfire-document-capture/SKILL.md +109 -0
  143. campfire_cli/resources/skills/campfire-inbox-triage/SKILL.md +23 -0
  144. campfire_cli/resources/skills/campfire-weekly-report-writing/SKILL.md +21 -0
  145. campfire_cli/resources/skills/campfire-weekly-report-writing/agents/openai.yaml +3 -0
  146. campfire_cli/resources/skills/campfire-workspace-adoption/SKILL.md +42 -0
  147. campfire_cli/resources/skills/campfire-workspace-maintenance/SKILL.md +74 -0
  148. campfire_cli/resources/skills/campfire-workspace-maintenance/references//344/273/273/345/212/241/346/255/243/346/226/207/347/273/223/346/236/204.md +23 -0
  149. campfire_cli/resources/skills/campfire-workspace-restructure/SKILL.md +52 -0
  150. campfire_cli-0.1.0.dist-info/METADATA +141 -0
  151. campfire_cli-0.1.0.dist-info/RECORD +154 -0
  152. campfire_cli-0.1.0.dist-info/WHEEL +4 -0
  153. campfire_cli-0.1.0.dist-info/entry_points.txt +2 -0
  154. campfire_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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,7 @@
1
+ from campfire_cli.common.exceptions.base import (
2
+ AppError,
3
+ ConfigurationError,
4
+ GovernanceBlockedError,
5
+ )
6
+
7
+ __all__ = ["AppError", "ConfigurationError", "GovernanceBlockedError"]
@@ -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,10 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+
4
+
5
+ def file_sha256(path: Path) -> str:
6
+ return hashlib.sha256(path.read_bytes()).hexdigest()
7
+
8
+
9
+ def text_sha256(value: str) -> str:
10
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
@@ -0,0 +1 @@
1
+ """Stable machine-readable and human-readable report rendering."""
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def render_json_report(payload: dict[str, Any]) -> str:
8
+ return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
@@ -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
+ )