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,104 @@
1
+ """
2
+ SPEC:
3
+ name: frontmatter_formatter
4
+ purpose: 按文档有效 Profile 的字段顺序格式化 Vault frontmatter
5
+ default_env_file: none
6
+ env_override: none
7
+ idempotent: true
8
+ behavior:
9
+ - check 模式只读报告字段顺序异常
10
+ - apply 模式只重排顶级字段块并保留字段值、注释和正文
11
+ - 不属于 Profile 的字段按原相对顺序保留在标准字段之后并交由 Validator 报告
12
+ safety:
13
+ - 不新增、删除或修改字段值
14
+ - frontmatter 缺失、未闭合或存在重复顶级字段时不修改
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from campfire_cli.app.document.service.document_scanner import iter_documents
23
+ from campfire_cli.app.document.service.profile_registry import ProfileRegistry
24
+ from campfire_cli.common.documents.document_types import frontmatter_bounds
25
+ from campfire_cli.common.documents.frontmatter_schema import KEY_RE, parse_shape
26
+
27
+
28
+ def split_blocks(text: str) -> tuple[list[str], list[tuple[str, list[str]]], list[str]]:
29
+ bounds = frontmatter_bounds(text)
30
+ if not bounds:
31
+ raise ValueError("frontmatter missing or unclosed")
32
+ lines = text[bounds[0] : bounds[1]].splitlines()
33
+ preamble: list[str] = []
34
+ blocks: list[tuple[str, list[str]]] = []
35
+ current_key: str | None = None
36
+ current_lines: list[str] = []
37
+ for line in lines:
38
+ match = KEY_RE.match(line)
39
+ if match:
40
+ if current_key is not None:
41
+ blocks.append((current_key, current_lines))
42
+ current_key = match.group(1)
43
+ current_lines = [line]
44
+ elif current_key is None:
45
+ preamble.append(line)
46
+ else:
47
+ current_lines.append(line)
48
+ if current_key is not None:
49
+ blocks.append((current_key, current_lines))
50
+ return preamble, blocks, lines
51
+
52
+
53
+ def ordered_keys(blocks: list[tuple[str, list[str]]], field_order: list[str]) -> list[str]:
54
+ keys = [key for key, _ in blocks]
55
+ known = [key for key in field_order if key in keys]
56
+ unknown = [key for key in keys if key not in field_order]
57
+ return known + unknown
58
+
59
+
60
+ def format_text(text: str, field_order: list[str]) -> tuple[str, list[str]]:
61
+ bounds = frontmatter_bounds(text)
62
+ if not bounds:
63
+ return text, ["frontmatter-missing-or-unclosed"]
64
+ preamble, blocks, _ = split_blocks(text)
65
+ keys = [key for key, _ in blocks]
66
+ if len(keys) != len(set(keys)):
67
+ return text, ["frontmatter-duplicate-key"]
68
+ by_key = {key: lines for key, lines in blocks}
69
+ sorted_keys = ordered_keys(blocks, field_order)
70
+ rendered_lines = [*preamble]
71
+ for key in sorted_keys:
72
+ rendered_lines.extend(by_key[key])
73
+ rendered = "---\n" + "\n".join(rendered_lines) + text[bounds[1] :]
74
+ return rendered, []
75
+
76
+
77
+ def build_result(
78
+ root: Path, type_config: dict[str, Any], schema: dict[str, Any], apply: bool
79
+ ) -> dict[str, Any]:
80
+ root = root.resolve()
81
+ profiles = ProfileRegistry(type_config, schema)
82
+ issues: list[dict[str, str]] = []
83
+ changed: list[str] = []
84
+ documents = iter_documents(root, type_config)
85
+ for path in documents:
86
+ original = path.read_text(encoding="utf-8")
87
+ values, _ = parse_shape(original)
88
+ profile = profiles.resolve(values.get("type"), values, path)
89
+ formatted, errors = format_text(original, list(profile.field_order))
90
+ rel = str(path.relative_to(root))
91
+ for error in errors:
92
+ issues.append({"code": error, "path": rel})
93
+ if not errors and formatted != original:
94
+ changed.append(rel)
95
+ if apply:
96
+ path.write_text(formatted, encoding="utf-8")
97
+ return {
98
+ "status": "issues-found" if issues else ("applied" if apply else "ok"),
99
+ "mode": "apply" if apply else "check",
100
+ "document_count": len(documents),
101
+ "reorder_count": len(changed),
102
+ "reordered": changed,
103
+ "issues": issues,
104
+ }
@@ -0,0 +1,75 @@
1
+ """
2
+ SPEC:
3
+ name: frontmatter_plan
4
+ purpose: 生成分层 frontmatter 缺失字段的可审阅补全计划
5
+ default_env_file: none
6
+ env_override: none
7
+ idempotent: true
8
+ behavior:
9
+ - 从一级标题确定性建议 name
10
+ - 对列表字段建议空列表,其余语义字段保留 null
11
+ - 每个字段默认 approved false
12
+ safety:
13
+ - 不修改文档,不编造摘要、日期、状态或业务事实
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from campfire_cli.app.document.service.document_scanner import iter_documents
23
+ from campfire_cli.app.document.service.profile_registry import ProfileRegistry
24
+ from campfire_cli.common.documents.frontmatter_schema import parse_shape
25
+
26
+
27
+ def title(text: str) -> str | None:
28
+ body = text
29
+ if text.startswith("---\n"):
30
+ end = text.find("\n---\n", 4)
31
+ if end >= 0:
32
+ body = text[end + 5 :]
33
+ match = re.search(r"^#\s+(.+?)\s*$", body, re.MULTILINE)
34
+ return match.group(1).strip() if match else None
35
+
36
+
37
+ def build_plan(root: Path, type_config: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
38
+ root = root.resolve()
39
+ profiles = ProfileRegistry(type_config, schema)
40
+ items = []
41
+ for path in iter_documents(root, type_config):
42
+ text = path.read_text(encoding="utf-8")
43
+ values, kinds = parse_shape(text)
44
+ profile = profiles.resolve(values.get("type"), values, path)
45
+ fields = {}
46
+ conditional = set(profile.conditional_fields(values))
47
+ for field in profile.required_for(values):
48
+ if field in values and (values[field] or kinds.get(field) == "list"):
49
+ continue
50
+ value: Any = None
51
+ confidence = "low"
52
+ reason = "semantic-input-required"
53
+ if field == "name" and title(text):
54
+ value = title(text)
55
+ confidence = "high"
56
+ reason = "first-heading"
57
+ elif field in profile.lists and field not in conditional:
58
+ value = []
59
+ confidence = "high"
60
+ reason = "empty-list-default"
61
+ fields[field] = {
62
+ "value": value,
63
+ "confidence": confidence,
64
+ "reason": reason,
65
+ "approved": False,
66
+ }
67
+ if fields:
68
+ items.append(
69
+ {
70
+ "path": str(path.relative_to(root)),
71
+ "profile": profile.name,
72
+ "fields": fields,
73
+ }
74
+ )
75
+ return {"version": 1, "schema_version": schema.get("version", 1), "items": items}
@@ -0,0 +1,166 @@
1
+ """Compile declarative document Profile contracts into effective rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from campfire_cli.common.documents.frontmatter_schema import is_project_context
10
+ from campfire_cli.common.exceptions import ConfigurationError
11
+
12
+
13
+ def ordered_union(*values: list[str] | tuple[str, ...]) -> tuple[str, ...]:
14
+ return tuple(dict.fromkeys(item for group in values for item in group))
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class EffectiveProfile:
19
+ name: str
20
+ field_order: tuple[str, ...]
21
+ required: tuple[str, ...]
22
+ optional: tuple[str, ...]
23
+ enums: dict[str, tuple[str, ...]]
24
+ lists: tuple[str, ...]
25
+ dates: tuple[str, ...]
26
+ conditional_required: tuple[dict[str, Any], ...]
27
+ unknown_fields: str
28
+
29
+ @property
30
+ def allowed(self) -> tuple[str, ...]:
31
+ return ordered_union(self.required, self.optional)
32
+
33
+ def conditional_fields(self, values: dict[str, Any]) -> tuple[str, ...]:
34
+ return ordered_union(
35
+ *[
36
+ condition["require"]
37
+ for condition in self.conditional_required
38
+ if all(values.get(key) == value for key, value in condition["when"].items())
39
+ ]
40
+ )
41
+
42
+ def required_for(self, values: dict[str, Any]) -> tuple[str, ...]:
43
+ return ordered_union(self.required, self.conditional_fields(values))
44
+
45
+ def model_dump(self) -> dict[str, Any]:
46
+ return {
47
+ "name": self.name,
48
+ "field_order": list(self.field_order),
49
+ "required": list(self.required),
50
+ "optional": list(self.optional),
51
+ "allowed": list(self.allowed),
52
+ "enums": {key: list(value) for key, value in self.enums.items()},
53
+ "lists": list(self.lists),
54
+ "dates": list(self.dates),
55
+ "conditional_required": list(self.conditional_required),
56
+ "unknown_fields": self.unknown_fields,
57
+ }
58
+
59
+
60
+ class ProfileRegistry:
61
+ """Compile declarative profile inheritance into immutable effective profiles."""
62
+
63
+ def __init__(self, type_config: dict[str, Any], schema: dict[str, Any]) -> None:
64
+ if schema.get("version") != 2:
65
+ raise ConfigurationError("Frontmatter Schema 必须是 version 2")
66
+ self._type_config = type_config
67
+ self._schema = schema
68
+ raw_profiles = schema.get("profiles", {})
69
+ if "base" not in raw_profiles:
70
+ raise ConfigurationError("Frontmatter Schema 缺少 base Profile")
71
+ self._profiles = {name: self._compile(name, raw_profiles) for name in raw_profiles}
72
+ self._validate_resolver()
73
+
74
+ def list(self) -> list[EffectiveProfile]:
75
+ return list(self._profiles.values())
76
+
77
+ def get(self, name: str) -> EffectiveProfile:
78
+ try:
79
+ return self._profiles[name]
80
+ except KeyError as exc:
81
+ raise ConfigurationError(f"未知 Frontmatter Profile:{name}") from exc
82
+
83
+ def resolve(
84
+ self, document_type: Any, frontmatter: dict[str, Any], path: Path | None = None
85
+ ) -> EffectiveProfile:
86
+ resolver = self._schema.get("resolver", {})
87
+ direct = (
88
+ resolver.get("profile_by_type", {}).get(document_type)
89
+ if isinstance(document_type, str)
90
+ else None
91
+ )
92
+ if direct:
93
+ return self.get(direct)
94
+ project_types = set(self._type_config.get("profiles", {}).get("project-docs", []))
95
+ if (
96
+ isinstance(document_type, str)
97
+ and document_type in project_types
98
+ and (frontmatter.get("project") or is_project_context(path))
99
+ ):
100
+ profile = resolver.get("profile_by_governance", {}).get("project-docs")
101
+ if profile:
102
+ return self.get(profile)
103
+ return self.get(resolver.get("fallback", "base"))
104
+
105
+ def known_fields(self) -> set[str]:
106
+ return {field for profile in self._profiles.values() for field in profile.allowed}
107
+
108
+ def _compile(self, name: str, raw_profiles: dict[str, dict[str, Any]]) -> EffectiveProfile:
109
+ raw = raw_profiles[name]
110
+ parent_name = raw.get("extends")
111
+ if name == "base" and parent_name:
112
+ raise ConfigurationError("base Profile 不能继承其他 Profile")
113
+ if parent_name and parent_name != "base":
114
+ raise ConfigurationError(f"Profile {name} 只能继承 base")
115
+ parent = self._compile("base", raw_profiles) if parent_name else None
116
+ required = ordered_union(parent.required if parent else (), raw.get("required", []))
117
+ optional = ordered_union(parent.optional if parent else (), raw.get("optional", []))
118
+ enums = dict(parent.enums) if parent else {}
119
+ enums.update({key: tuple(value) for key, value in raw.get("enums", {}).items()})
120
+ lists = ordered_union(parent.lists if parent else (), raw.get("lists", []))
121
+ dates = ordered_union(parent.dates if parent else (), raw.get("dates", []))
122
+ conditions = tuple(
123
+ [*(parent.conditional_required if parent else ()), *raw.get("conditional_required", [])]
124
+ )
125
+ order = tuple(raw.get("field_order", parent.field_order if parent else []))
126
+ unknown_fields = raw.get("unknown_fields", parent.unknown_fields if parent else "preserve")
127
+ if unknown_fields not in {"preserve", "report"}:
128
+ raise ConfigurationError(f"Profile {name} unknown_fields 必须是 preserve 或 report")
129
+ allowed = set(required) | set(optional)
130
+ constrained = set(enums) | set(lists) | set(dates)
131
+ condition_fields = {
132
+ field
133
+ for condition in conditions
134
+ for field in [*condition.get("when", {}), *condition.get("require", [])]
135
+ }
136
+ invalid_constraints = sorted((constrained | condition_fields) - allowed)
137
+ if invalid_constraints:
138
+ raise ConfigurationError(f"Profile {name} 约束了未允许字段:{invalid_constraints}")
139
+ if set(order) != allowed:
140
+ missing = sorted(allowed - set(order))
141
+ extra = sorted(set(order) - allowed)
142
+ raise ConfigurationError(
143
+ f"Profile {name} field_order 与允许字段不一致;missing={missing}, extra={extra}"
144
+ )
145
+ return EffectiveProfile(
146
+ name=name,
147
+ field_order=order,
148
+ required=required,
149
+ optional=optional,
150
+ enums=enums,
151
+ lists=lists,
152
+ dates=dates,
153
+ conditional_required=conditions,
154
+ unknown_fields=unknown_fields,
155
+ )
156
+
157
+ def _validate_resolver(self) -> None:
158
+ resolver = self._schema.get("resolver", {})
159
+ names = {
160
+ resolver.get("fallback", "base"),
161
+ *resolver.get("profile_by_type", {}).values(),
162
+ *resolver.get("profile_by_governance", {}).values(),
163
+ }
164
+ unknown = sorted(names - self._profiles.keys())
165
+ if unknown:
166
+ raise ConfigurationError(f"Resolver 引用了未知 Profile:{unknown}")
@@ -0,0 +1,185 @@
1
+ """
2
+ SPEC:
3
+ name: type_apply
4
+ purpose: 执行已审批的 Vault 文档单选 type 与文件名前缀治理计划
5
+ default_env_file: none
6
+ env_override: none
7
+ idempotent: true
8
+ behavior:
9
+ - 仅执行 approved true 的计划项
10
+ - 补齐单选 type 并在原目录内按契约改名
11
+ - 更新唯一文件名 wikilink、明确路径 Markdown 链接和 Canvas 文件节点
12
+ safety:
13
+ - 执行前完整预检,存在任一阻塞问题时不写入
14
+ - 不覆盖目标文件,不跨目录移动,不删除文档正文
15
+ - 不执行缺少 proposed_type 或低置信度未审批项
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import quote
25
+
26
+ from campfire_cli.common.documents.document_types import (
27
+ prefixed_name,
28
+ safe_path,
29
+ set_frontmatter_scalar,
30
+ )
31
+
32
+ WIKILINK_RE = re.compile(r"(!?\[\[)([^\]|#]+)([^\]]*\]\])")
33
+ MARKDOWN_LINK_RE = re.compile(r"(!?\[[^\]]*\]\()([^)]+)(\))")
34
+
35
+
36
+ def load_plan(path: Path) -> dict[str, Any]:
37
+ return json.loads(path.read_text(encoding="utf-8"))
38
+
39
+
40
+ def preflight(
41
+ root: Path, plan: dict[str, Any], config: dict[str, Any]
42
+ ) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
43
+ approved = [item for item in plan.get("items", []) if item.get("approved") is True]
44
+ issues: list[dict[str, str]] = []
45
+ seen_targets: set[Path] = set()
46
+ operations: list[dict[str, Any]] = []
47
+ for item in approved:
48
+ source_name = item.get("source", "")
49
+ target_name = item.get("target", "")
50
+ doc_type = item.get("proposed_type")
51
+ if not source_name or not target_name:
52
+ issues.append(
53
+ {
54
+ "code": "type-plan-path-missing",
55
+ "path": source_name or target_name or "<unknown>",
56
+ }
57
+ )
58
+ continue
59
+ try:
60
+ source = safe_path(root, source_name)
61
+ target = safe_path(root, target_name)
62
+ except ValueError:
63
+ issues.append({"code": "type-plan-path-outside-root", "path": source_name})
64
+ continue
65
+ if not source.is_file():
66
+ issues.append({"code": "type-source-missing", "path": source_name})
67
+ if source.suffix.lower() != ".md" or target.suffix.lower() != ".md":
68
+ issues.append({"code": "type-plan-not-markdown", "path": source_name})
69
+ if source.parent != target.parent:
70
+ issues.append(
71
+ {"code": "type-plan-cross-directory", "path": source_name, "detail": target_name}
72
+ )
73
+ if doc_type not in config["types"]:
74
+ issues.append(
75
+ {"code": "type-plan-type-invalid", "path": source_name, "detail": str(doc_type)}
76
+ )
77
+ elif target.name != prefixed_name(source.name, doc_type, config):
78
+ issues.append(
79
+ {"code": "type-plan-target-invalid", "path": source_name, "detail": target.name}
80
+ )
81
+ if target in seen_targets:
82
+ issues.append({"code": "type-plan-target-duplicate", "path": target_name})
83
+ seen_targets.add(target)
84
+ if target.exists() and target != source:
85
+ issues.append({"code": "type-target-exists", "path": target_name})
86
+ operations.append({"source": source, "target": target, "type": doc_type})
87
+ return operations, issues
88
+
89
+
90
+ def rewrite_wikilinks(text: str, old_stem: str, new_stem: str) -> str:
91
+ def replace(match: re.Match[str]) -> str:
92
+ target = match.group(2)
93
+ normalized = target.replace("\\", "/")
94
+ if "/" in normalized:
95
+ parts = normalized.split("/")
96
+ if parts[-1] == old_stem:
97
+ parts[-1] = new_stem
98
+ target = "/".join(parts)
99
+ elif normalized == old_stem:
100
+ target = new_stem
101
+ return match.group(1) + target + match.group(3)
102
+
103
+ return WIKILINK_RE.sub(replace, text)
104
+
105
+
106
+ def rewrite_same_directory_markdown_links(
107
+ text: str, reference: Path, source: Path, target: Path
108
+ ) -> str:
109
+ if reference.parent.resolve() != source.parent.resolve():
110
+ return text
111
+ plain = {source.name: target.name, f"./{source.name}": f"./{target.name}"}
112
+ encoded = {quote(old): quote(new) for old, new in plain.items()}
113
+ replacements = {**plain, **encoded}
114
+
115
+ def replace(match: re.Match[str]) -> str:
116
+ destination = match.group(2)
117
+ base, separator, fragment = destination.partition("#")
118
+ if base not in replacements:
119
+ return match.group(0)
120
+ rewritten = replacements[base] + (separator + fragment if separator else "")
121
+ return match.group(1) + rewritten + match.group(3)
122
+
123
+ return MARKDOWN_LINK_RE.sub(replace, text)
124
+
125
+
126
+ def reference_files(root: Path) -> list[Path]:
127
+ ignored = {".git"}
128
+ return sorted(
129
+ path
130
+ for path in root.rglob("*")
131
+ if path.is_file()
132
+ and path.suffix.lower() in {".md", ".canvas"}
133
+ and not any(part in ignored for part in path.relative_to(root).parts)
134
+ )
135
+
136
+
137
+ def apply_plan(root: Path, operations: list[dict[str, Any]]) -> dict[str, Any]:
138
+ root = root.resolve()
139
+ original_stem_counts: dict[str, int] = {}
140
+ for path in root.rglob("*.md"):
141
+ if ".git" not in path.relative_to(root).parts:
142
+ original_stem_counts[path.stem] = original_stem_counts.get(path.stem, 0) + 1
143
+
144
+ prepared: list[dict[str, Any]] = []
145
+ for operation in operations:
146
+ source: Path = operation["source"]
147
+ updated = set_frontmatter_scalar(
148
+ source.read_text(encoding="utf-8"), "type", operation["type"]
149
+ )
150
+ prepared.append({**operation, "content": updated})
151
+
152
+ changed_references: set[str] = set()
153
+ for operation in prepared:
154
+ source: Path = operation["source"]
155
+ target: Path = operation["target"]
156
+ target.write_text(operation["content"], encoding="utf-8")
157
+ if target != source:
158
+ source.unlink()
159
+
160
+ for path in reference_files(root):
161
+ text = path.read_text(encoding="utf-8")
162
+ updated = text
163
+ for operation in prepared:
164
+ source: Path = operation["source"]
165
+ target: Path = operation["target"]
166
+ if source == target:
167
+ continue
168
+ if original_stem_counts.get(source.stem) == 1:
169
+ updated = rewrite_wikilinks(updated, source.stem, target.stem)
170
+ if path.suffix.lower() == ".md":
171
+ updated = rewrite_same_directory_markdown_links(updated, path, source, target)
172
+ source_rel = str(source.relative_to(root)).replace("\\", "/")
173
+ target_rel = str(target.relative_to(root)).replace("\\", "/")
174
+ updated = updated.replace(source_rel, target_rel).replace(
175
+ quote(source_rel), quote(target_rel)
176
+ )
177
+ if updated != text:
178
+ path.write_text(updated, encoding="utf-8")
179
+ changed_references.add(str(path.relative_to(root)))
180
+ return {
181
+ "applied_count": len(prepared),
182
+ "renamed_count": sum(item["source"] != item["target"] for item in prepared),
183
+ "reference_file_count": len(changed_references),
184
+ "changed_reference_files": sorted(changed_references),
185
+ }
@@ -0,0 +1,80 @@
1
+ """
2
+ SPEC:
3
+ name: type_plan
4
+ purpose: 为 Vault 文档生成可审阅的 type 与文件名前缀治理计划
5
+ default_env_file: none
6
+ env_override: none
7
+ idempotent: true
8
+ behavior:
9
+ - 已有合法单选 type 时生成高置信度改名建议
10
+ - 只有合法文件名前缀时生成高置信度 type 补全建议
11
+ - 无法确定时保留待 Agent 或人类审批项
12
+ safety:
13
+ - 不修改任何文档
14
+ - 所有计划项默认 approved false
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from campfire_cli.app.document.service.document_scanner import iter_documents
23
+ from campfire_cli.common.documents.document_types import (
24
+ frontmatter_value,
25
+ prefix_type,
26
+ prefixed_name,
27
+ )
28
+
29
+
30
+ def contextual_suggestion(path: Path) -> tuple[str | None, str]:
31
+ value = str(path).lower()
32
+ rules = [
33
+ (("工作周报", "weekly"), "weekly-report"),
34
+ (("会议记录", "会议纪要"), "meeting"),
35
+ (("工作日志",), "work-log"),
36
+ (("开发实验",), "experiment"),
37
+ (("提示词", "prompt"), "prompt"),
38
+ ]
39
+ for needles, doc_type in rules:
40
+ if any(needle.lower() in value for needle in needles):
41
+ return doc_type, "path-context"
42
+ return None, "semantic-review-required"
43
+
44
+
45
+ def plan_item(root: Path, path: Path, config: dict[str, Any]) -> dict[str, Any] | None:
46
+ text = path.read_text(encoding="utf-8")
47
+ doc_type, multiple = frontmatter_value(text, "type")
48
+ by_prefix = prefix_type(path.name, config)
49
+ proposed: str | None = None
50
+ confidence = "low"
51
+ reason = "semantic-review-required"
52
+ if not multiple and doc_type in config["types"]:
53
+ proposed, confidence, reason = doc_type, "high", "existing-valid-type"
54
+ elif not multiple and not doc_type and by_prefix:
55
+ proposed, confidence, reason = by_prefix, "high", "existing-unique-prefix"
56
+ elif not multiple and (not doc_type or doc_type not in config["types"]):
57
+ proposed, reason = contextual_suggestion(path)
58
+ confidence = "medium" if proposed else "low"
59
+ target_name = prefixed_name(path.name, proposed, config) if proposed else path.name
60
+ if proposed is not None and proposed == doc_type and target_name == path.name and not multiple:
61
+ return None
62
+ rel = path.relative_to(root).as_posix()
63
+ target = path.with_name(target_name).relative_to(root).as_posix()
64
+ return {
65
+ "source": rel,
66
+ "target": target,
67
+ "current_type": doc_type,
68
+ "proposed_type": proposed,
69
+ "confidence": confidence,
70
+ "reason": "type-is-sequence" if multiple else reason,
71
+ "approved": False,
72
+ }
73
+
74
+
75
+ def build_plan(root: Path, config: dict[str, Any]) -> dict[str, Any]:
76
+ root = root.resolve()
77
+ items = [
78
+ item for path in iter_documents(root, config) if (item := plan_item(root, path, config))
79
+ ]
80
+ return {"version": 1, "contract_version": config.get("version", 1), "items": items}
@@ -0,0 +1,22 @@
1
+ # Maintenance App SPEC
2
+
3
+ 本模块处理后续增量维护、链接检查、MOC 等自动生成物、两阶段归档和状态索引。Space/Domain 结构由 Workspace App 提供,本模块不重复定义。语义计划使用稳定 ID,默认未审批,写操作必须显式确认;不维护含义不明的 `current-plan`。
4
+
5
+ 文档扫描、类型、Profile、任务状态、模板枚举、Frontmatter 顺序和跨字段不变量均委托 Document App;本模块只负责 Workspace 范围的扫描编排、过滤、统一计划、执行、局部验收、派生内容和报告。写入在治理锁内复核内容哈希和治理配置哈希。
6
+
7
+ 受管范围由当前 Workspace 中实际存在的 `_空间.md` 动态发现,不使用固定 Space 白名单;`scope_roots` 只补充收件箱等非 Space 系统范围。声明文件由 Workspace App 校验,不计入普通文档问题。
8
+
9
+ `maintenance plan --spec` 是 Agent 语义判断进入确定性执行链路的唯一批量入口。Plan 可以修改 Frontmatter、按有效 type 在同一 Domain 内修正文件名并格式化字段顺序;不得跨目录移动。跨 Domain 迁移属于 Workspace Restructure。
10
+
11
+ `maintenance verify` 只检查指定 Plan 涉及的文档,不被其他范围的历史问题影响。`check --scope` 的 status 表示当前筛选结果,`workspace_status` 表示全 Workspace 状态。`sync --scope` 只受当前范围的结构问题阻塞。
12
+
13
+ ```text
14
+ maintenance/
15
+ ├── link_service.py
16
+ ├── moc_service.py
17
+ ├── archive_service.py
18
+ ├── plan_service.py
19
+ └── maintenance_service.py
20
+ ```
21
+
22
+ 归档是 Maintenance 子用例,对外使用 `campfire maintenance archive check/apply`,不建立独立 Archive App。
@@ -0,0 +1 @@
1
+ """Incremental Vault maintenance domain."""