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,103 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+ HELP_SETTINGS = {"help_option_names": ["-h", "--help"]}
9
+
10
+ maintenance_cli = typer.Typer(help="后续增量文档维护", context_settings=HELP_SETTINGS)
11
+ archive_cli = typer.Typer(help="项目文档两阶段归档", context_settings=HELP_SETTINGS)
12
+ maintenance_cli.add_typer(archive_cli, name="archive")
13
+
14
+
15
+ def emit(result: object) -> None:
16
+ typer.echo(json.dumps(result.model_dump(mode="json"), ensure_ascii=False, indent=2))
17
+
18
+
19
+ @maintenance_cli.command("check")
20
+ def check(
21
+ ctx: typer.Context,
22
+ scope: str | None = typer.Option(None, "--scope", help="只显示指定 Vault 相对路径下的问题"),
23
+ code: str | None = typer.Option(None, "--code", help="只显示指定错误代码"),
24
+ severity: str | None = typer.Option(None, "--severity", help="只显示指定严重级别"),
25
+ summary: bool = typer.Option(False, "--summary", help="只输出统计,不展开问题列表"),
26
+ ) -> None:
27
+ """检查当前受管文档并刷新 SQLite 当前状态。"""
28
+ emit(ctx.obj.maintenance.check(scope=scope, code=code, severity=severity, summary=summary))
29
+
30
+
31
+ @maintenance_cli.command("plan")
32
+ def plan(
33
+ ctx: typer.Context,
34
+ plan_id: str = typer.Option(..., "--id", help="稳定的维护计划 ID"),
35
+ scope: str | None = typer.Option(None, "--scope", help="只规划指定 Vault 相对路径"),
36
+ spec: Path | None = typer.Option(None, "--spec", help="Agent 或人类提供的语义治理 YAML/JSON"),
37
+ ) -> None:
38
+ """生成带稳定 ID、默认未审批的维护计划。"""
39
+ emit(ctx.obj.maintenance.plan(plan_id, scope=scope, spec_path=spec))
40
+
41
+
42
+ @maintenance_cli.command("show")
43
+ def show(ctx: typer.Context, plan_id: str = typer.Option(..., "--plan")) -> None:
44
+ """查看一个稳定 ID 标识的维护计划。"""
45
+ emit(ctx.obj.maintenance.show_plan(plan_id))
46
+
47
+
48
+ @maintenance_cli.command("apply")
49
+ def apply(
50
+ ctx: typer.Context,
51
+ plan_id: str = typer.Option(..., "--plan"),
52
+ confirm: bool = typer.Option(False, "--confirm"),
53
+ ) -> None:
54
+ """预检或执行指定维护计划中的已审批项。"""
55
+ emit(ctx.obj.maintenance.apply(plan_id, confirm))
56
+
57
+
58
+ @maintenance_cli.command("verify")
59
+ def verify(ctx: typer.Context, plan_id: str = typer.Option(..., "--plan")) -> None:
60
+ """只验证指定维护计划涉及的文档。"""
61
+ emit(ctx.obj.maintenance.verify(plan_id))
62
+
63
+
64
+ @maintenance_cli.command("sync")
65
+ def sync(
66
+ ctx: typer.Context,
67
+ dry_run: bool = typer.Option(False, "--dry-run"),
68
+ scope: str | None = typer.Option(None, "--scope", help="只同步指定领域或治理根目录"),
69
+ ) -> None:
70
+ """同步 MOC 等确定性派生内容。"""
71
+ emit(ctx.obj.maintenance.sync(dry_run, scope))
72
+
73
+
74
+ @maintenance_cli.command("run")
75
+ def run(
76
+ ctx: typer.Context,
77
+ scope: str | None = typer.Option(None, "--scope", help="只同步并显示指定范围"),
78
+ ) -> None:
79
+ """执行同步和最终只读检查。"""
80
+ sync_result = ctx.obj.maintenance.sync(False, scope)
81
+ if sync_result.issue_count:
82
+ emit(sync_result)
83
+ raise typer.Exit(3)
84
+ emit(ctx.obj.maintenance.check(scope=scope))
85
+
86
+
87
+ @archive_cli.command("check")
88
+ def archive_check(
89
+ ctx: typer.Context,
90
+ scope: str | None = typer.Option(None, "--scope", help="只检查指定 Vault 相对路径"),
91
+ ) -> None:
92
+ """只读检查归档候选。"""
93
+ emit(ctx.obj.maintenance.archive(False, scope))
94
+
95
+
96
+ @archive_cli.command("apply")
97
+ def archive_apply(
98
+ ctx: typer.Context,
99
+ confirm: bool = typer.Option(False, "--confirm"),
100
+ scope: str | None = typer.Option(None, "--scope", help="只归档指定 Vault 相对路径"),
101
+ ) -> None:
102
+ """执行已经标记并通过检查的归档请求。"""
103
+ emit(ctx.obj.maintenance.archive(confirm, scope))
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from sqlalchemy import delete
6
+ from sqlalchemy.orm import Session
7
+
8
+ from campfire_cli.app.maintenance.schema.maintenance_schema import (
9
+ DocumentState,
10
+ DomainState,
11
+ Issue,
12
+ MaintenancePlan,
13
+ MaintenanceRunRecord,
14
+ SpaceState,
15
+ )
16
+ from campfire_cli.common.database.models import (
17
+ Document,
18
+ GovernanceIssue,
19
+ MaintenanceRun,
20
+ WorkspaceDomain,
21
+ WorkspaceSpace,
22
+ )
23
+ from campfire_cli.common.exceptions import GovernanceBlockedError
24
+ from campfire_cli.common.filesystem import atomic_write
25
+
26
+
27
+ class SqliteMaintenanceRepository:
28
+ def __init__(self, session: Session, workspace_id: str, state_root: Path) -> None:
29
+ self._session = session
30
+ self._workspace_id = workspace_id
31
+ self._state_root = state_root
32
+
33
+ def replace_current_state(
34
+ self,
35
+ documents: list[DocumentState],
36
+ issues: list[Issue],
37
+ spaces: list[SpaceState],
38
+ domains: list[DomainState],
39
+ ) -> None:
40
+ self._session.execute(delete(Document).where(Document.workspace_id == self._workspace_id))
41
+ self._session.execute(
42
+ delete(GovernanceIssue).where(GovernanceIssue.workspace_id == self._workspace_id)
43
+ )
44
+ self._session.execute(
45
+ delete(WorkspaceDomain).where(WorkspaceDomain.workspace_id == self._workspace_id)
46
+ )
47
+ self._session.execute(
48
+ delete(WorkspaceSpace).where(WorkspaceSpace.workspace_id == self._workspace_id)
49
+ )
50
+ self._session.add_all(
51
+ [
52
+ WorkspaceSpace(workspace_id=self._workspace_id, **item.model_dump())
53
+ for item in spaces
54
+ ]
55
+ )
56
+ self._session.add_all(
57
+ [
58
+ WorkspaceDomain(workspace_id=self._workspace_id, **item.model_dump())
59
+ for item in domains
60
+ ]
61
+ )
62
+ self._session.add_all(
63
+ [
64
+ Document(
65
+ workspace_id=self._workspace_id,
66
+ path=item.path,
67
+ content_hash=item.content_hash,
68
+ document_type=item.document_type,
69
+ domain_id=item.domain_id,
70
+ status=item.status,
71
+ )
72
+ for item in documents
73
+ ]
74
+ )
75
+ self._session.add_all(
76
+ [
77
+ GovernanceIssue(
78
+ workspace_id=self._workspace_id,
79
+ path=item.path,
80
+ code=item.code,
81
+ detail=item.detail,
82
+ severity=item.severity,
83
+ )
84
+ for item in issues
85
+ ]
86
+ )
87
+ self._session.commit()
88
+
89
+ def save_run(self, run: MaintenanceRunRecord) -> None:
90
+ self._session.add(
91
+ MaintenanceRun(
92
+ workspace_id=self._workspace_id,
93
+ run_id=run.run_id,
94
+ status=run.status,
95
+ scanned_count=run.scanned_count,
96
+ issue_count=run.issue_count,
97
+ started_at=run.started_at,
98
+ finished_at=run.finished_at,
99
+ )
100
+ )
101
+ self._session.commit()
102
+
103
+ def save_plan(self, plan: MaintenancePlan) -> None:
104
+ atomic_write(self._plan_path(plan.plan_id), plan.model_dump_json(indent=2) + "\n")
105
+
106
+ def load_plan(self, plan_id: str) -> MaintenancePlan:
107
+ path = self._plan_path(plan_id)
108
+ if not path.is_file():
109
+ raise GovernanceBlockedError(f"Maintenance Plan 不存在:{plan_id}")
110
+ return MaintenancePlan.model_validate_json(path.read_text(encoding="utf-8"))
111
+
112
+ def _plan_path(self, plan_id: str) -> Path:
113
+ if not plan_id or Path(plan_id).name != plan_id:
114
+ raise GovernanceBlockedError(f"Maintenance Plan id 非法:{plan_id}")
115
+ return self._state_root / "maintenance" / "plans" / plan_id / "plan.json"
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Issue(BaseModel):
10
+ code: str
11
+ path: str
12
+ detail: str = ""
13
+ severity: str = "error"
14
+ message: str = ""
15
+ suggestion: str = ""
16
+ field: str | None = None
17
+ actual: Any = None
18
+ allowed: list[Any] = Field(default_factory=list)
19
+
20
+
21
+ class DocumentState(BaseModel):
22
+ path: str
23
+ content_hash: str
24
+ document_type: str | None = None
25
+ domain_id: str | None = None
26
+ status: str | None = None
27
+
28
+
29
+ class SpaceState(BaseModel):
30
+ space_id: str
31
+ name: str
32
+ path: str
33
+ space_type: str
34
+ status: str
35
+ source_hash: str
36
+
37
+
38
+ class DomainState(BaseModel):
39
+ domain_id: str
40
+ space_id: str
41
+ parent_domain_id: str | None = None
42
+ project_id: str | None = None
43
+ name: str
44
+ path: str
45
+ domain_type: str
46
+ governance: str
47
+ moc: str
48
+ status: str
49
+ source_hash: str
50
+
51
+
52
+ class MaintenanceResult(BaseModel):
53
+ status: str
54
+ document_count: int
55
+ issue_count: int
56
+ total_issue_count: int = 0
57
+ issues: list[Issue] = Field(default_factory=list)
58
+ changed_document_count: int = 0
59
+ generated_file_count: int = 0
60
+ write_performed: bool = False
61
+ blocked_phase: str | None = None
62
+ blocked_scope: str | None = None
63
+ issue_counts: dict[str, int] = Field(default_factory=dict)
64
+ operations: list[dict[str, Any]] = Field(default_factory=list)
65
+ scope: str | None = None
66
+ workspace_status: str | None = None
67
+ space_count: int = 0
68
+ domain_count: int = 0
69
+
70
+
71
+ class MaintenanceIntentItem(BaseModel):
72
+ path: str
73
+ frontmatter: dict[str, Any] = Field(default_factory=dict)
74
+ filename: str | None = None
75
+ format_frontmatter: bool = True
76
+ reason: str
77
+ approved: bool = False
78
+
79
+
80
+ class MaintenanceIntentSpec(BaseModel):
81
+ schema_version: int = 1
82
+ operations: list[MaintenanceIntentItem]
83
+
84
+
85
+ class MaintenancePlanItem(BaseModel):
86
+ source: str
87
+ target: str
88
+ source_sha256: str
89
+ frontmatter: dict[str, Any] = Field(default_factory=dict)
90
+ format_frontmatter: bool = False
91
+ reason: str
92
+ approved: bool = False
93
+
94
+
95
+ class MaintenancePlan(BaseModel):
96
+ schema_version: int = 1
97
+ plan_id: str
98
+ scope: str | None = None
99
+ config_hash: str
100
+ items: list[MaintenancePlanItem] = Field(default_factory=list)
101
+
102
+
103
+ class MaintenanceRunRecord(BaseModel):
104
+ run_id: str
105
+ status: str
106
+ scanned_count: int
107
+ issue_count: int
108
+ started_at: datetime
109
+ finished_at: datetime
@@ -0,0 +1,255 @@
1
+ """
2
+ SPEC:
3
+ name: project_archive
4
+ purpose: 检查并执行项目文档的显式两阶段归档请求
5
+ default_env_file: none
6
+ env_override: none
7
+ idempotent: true
8
+ behavior:
9
+ - check 模式只读列出归档请求、路径状态和阻塞问题
10
+ - apply 模式只移动校验通过的请求到同领域扁平 archive 目录
11
+ - 完成归档时统一 status、lifecycle、archive_requested 和 archived_at
12
+ safety:
13
+ - 不依据更新时间自动归档
14
+ - 不覆盖同名目标,不删除文档,不处理治理范围外文件
15
+ - 缺少归档原因或必要替代文档时拒绝执行该文档
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from campfire_cli.app.workspace.service.structure_service import (
26
+ DomainService,
27
+ )
28
+ from campfire_cli.app.workspace.service.structure_service import (
29
+ parse_marker as parse_frontmatter,
30
+ )
31
+ from campfire_cli.config.defaults import config_section
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ArchiveItem:
36
+ source: Path
37
+ target: Path
38
+ domain: Path
39
+ in_archive: bool
40
+
41
+
42
+ def is_true(value: str) -> bool:
43
+ return value.strip().lower() == "true"
44
+
45
+
46
+ def list_has_values(value: str) -> bool:
47
+ compact = value.strip()
48
+ return bool(compact and compact not in {"[]", "null", "~"})
49
+
50
+
51
+ def frontmatter_list_has_values(text: str, key: str, parsed_value: str) -> bool:
52
+ if list_has_values(parsed_value):
53
+ return True
54
+ if not text.startswith("---\n"):
55
+ return False
56
+ end = text.find("\n---\n", 4)
57
+ if end < 0:
58
+ return False
59
+ lines = text[4:end].splitlines()
60
+ for index, line in enumerate(lines):
61
+ if not re.match(rf"^{re.escape(key)}:\s*$", line):
62
+ continue
63
+ for following in lines[index + 1 :]:
64
+ if re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*:", following):
65
+ break
66
+ if re.match(r"^\s+-\s+\S", following):
67
+ return True
68
+ return False
69
+
70
+
71
+ def is_under(path: Path, parent: Path) -> bool:
72
+ try:
73
+ path.relative_to(parent)
74
+ return True
75
+ except ValueError:
76
+ return False
77
+
78
+
79
+ def project_domains(vault_root: Path, config: dict[str, Any]) -> list[Path]:
80
+ domains, _ = DomainService(vault_root, vault_root).discover()
81
+ return sorted(
82
+ (item.path for item in domains if item.governance == "project-docs"),
83
+ key=lambda path: (len(path.parts), str(path)),
84
+ )
85
+
86
+
87
+ def owning_domain(path: Path, domains: list[Path]) -> Path | None:
88
+ matches = [domain for domain in domains if is_under(path, domain)]
89
+ return max(matches, key=lambda item: len(item.parts)) if matches else None
90
+
91
+
92
+ def collect_items(
93
+ vault_root: Path, config: dict[str, Any]
94
+ ) -> tuple[list[ArchiveItem], list[dict[str, str]]]:
95
+ domains = project_domains(vault_root, config)
96
+ policy = config_section("archive")
97
+ archive_reasons = set(policy["reasons"])
98
+ reasons_requiring_successor = set(policy["reasons_requiring_successor"])
99
+ marker_name = config.get("domain_marker", "_领域.md")
100
+ ignored = set(config.get("ignored_directories", [])) - {"archive", "记录"}
101
+ items: list[ArchiveItem] = []
102
+ issues: list[dict[str, str]] = []
103
+
104
+ for domain in domains:
105
+ for doc in domain.rglob("*.md"):
106
+ if (
107
+ owning_domain(doc, domains) != domain
108
+ or doc.name == marker_name
109
+ or doc.name.startswith("MOC-")
110
+ ):
111
+ continue
112
+ relative_to_domain = doc.relative_to(domain)
113
+ if any(part in ignored for part in relative_to_domain.parts):
114
+ continue
115
+ in_archive = "archive" in relative_to_domain.parts[:-1]
116
+ meta = parse_frontmatter(doc)
117
+ requested = is_true(meta.get("archive_requested", "false")) if meta else False
118
+ if not requested and not in_archive:
119
+ continue
120
+ target = doc if in_archive else domain / "archive" / doc.name
121
+ item = ArchiveItem(doc, target, domain, in_archive)
122
+ items.append(item)
123
+ rel = str(doc.relative_to(vault_root))
124
+
125
+ if not meta:
126
+ issues.append({"code": "archive-frontmatter-missing", "path": rel})
127
+ continue
128
+ raw_text = doc.read_text(encoding="utf-8")
129
+ reason = meta.get("archive_reason", "")
130
+ if not reason:
131
+ issues.append({"code": "archive-reason-missing", "path": rel})
132
+ elif reason not in archive_reasons:
133
+ issues.append({"code": "archive-reason-invalid", "path": rel, "detail": reason})
134
+ if reason in reasons_requiring_successor and not frontmatter_list_has_values(
135
+ raw_text, "superseded_by", meta.get("superseded_by", "")
136
+ ):
137
+ issues.append({"code": "archive-successor-missing", "path": rel})
138
+ if not in_archive and target.exists():
139
+ issues.append(
140
+ {
141
+ "code": "archive-target-exists",
142
+ "path": rel,
143
+ "detail": str(target.relative_to(vault_root)),
144
+ }
145
+ )
146
+ if in_archive:
147
+ if meta.get("status") != "archived" or meta.get("lifecycle") != "archived":
148
+ issues.append({"code": "archive-path-state-mismatch", "path": rel})
149
+ elif meta.get("status") == "archived" or meta.get("lifecycle") == "archived":
150
+ issues.append({"code": "archive-request-state-premature", "path": rel})
151
+ return sorted(items, key=lambda item: str(item.source)), issues
152
+
153
+
154
+ def issue_paths(issues: list[dict[str, str]]) -> set[str]:
155
+ return {item["path"] for item in issues}
156
+
157
+
158
+ def replace_frontmatter_fields(text: str, values: dict[str, str]) -> str:
159
+ if not text.startswith("---\n"):
160
+ raise ValueError("frontmatter missing")
161
+ end = text.find("\n---\n", 4)
162
+ if end < 0:
163
+ raise ValueError("frontmatter closing marker missing")
164
+ body = text[4:end]
165
+ lines = body.splitlines()
166
+ positions: dict[str, int] = {}
167
+ for index, line in enumerate(lines):
168
+ match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_-]*):", line)
169
+ if match:
170
+ positions[match.group(1)] = index
171
+ for key, value in values.items():
172
+ rendered = f"{key}: {value}"
173
+ if key in positions:
174
+ lines[positions[key]] = rendered
175
+ else:
176
+ insert_at = positions.get("created", len(lines))
177
+ lines.insert(insert_at, rendered)
178
+ positions = {
179
+ field: index + 1 if index >= insert_at else index
180
+ for field, index in positions.items()
181
+ }
182
+ positions[key] = insert_at
183
+ return "---\n" + "\n".join(lines) + text[end:]
184
+
185
+
186
+ def apply_items(
187
+ vault_root: Path, items: list[ArchiveItem], issues: list[dict[str, str]], archived_at: str
188
+ ) -> list[dict[str, str]]:
189
+ applied: list[dict[str, str]] = []
190
+ for item in items:
191
+ rel = str(item.source.relative_to(vault_root))
192
+ # A path/state mismatch is repairable after an explicit manual move.
193
+ # Other issues block the document.
194
+ item_issues = [issue for issue in issues if issue["path"] == rel]
195
+ if any(issue["code"] != "archive-path-state-mismatch" for issue in item_issues):
196
+ continue
197
+ current_meta = parse_frontmatter(item.source)
198
+ if (
199
+ item.in_archive
200
+ and current_meta.get("status") == "archived"
201
+ and current_meta.get("lifecycle") == "archived"
202
+ and not is_true(current_meta.get("archive_requested", "false"))
203
+ and current_meta.get("archived_at")
204
+ ):
205
+ continue
206
+ effective_archived_at = current_meta.get("archived_at") or archived_at
207
+ updated = replace_frontmatter_fields(
208
+ item.source.read_text(encoding="utf-8"),
209
+ {
210
+ "status": "archived",
211
+ "lifecycle": "archived",
212
+ "archive_requested": "false",
213
+ "archived_at": effective_archived_at,
214
+ },
215
+ )
216
+ if item.in_archive:
217
+ item.source.write_text(updated, encoding="utf-8")
218
+ action = "normalized"
219
+ else:
220
+ item.target.parent.mkdir(parents=True, exist_ok=True)
221
+ item.target.write_text(updated, encoding="utf-8")
222
+ item.source.unlink()
223
+ action = "archived"
224
+ applied.append(
225
+ {
226
+ "action": action,
227
+ "source": rel,
228
+ "target": str(item.target.relative_to(vault_root)),
229
+ }
230
+ )
231
+ return applied
232
+
233
+
234
+ def build_result(
235
+ vault_root: Path, config: dict[str, Any], apply: bool, archived_at: str
236
+ ) -> dict[str, Any]:
237
+ vault_root = vault_root.resolve()
238
+ items, issues = collect_items(vault_root, config)
239
+ applied = apply_items(vault_root, items, issues, archived_at) if apply else []
240
+ return {
241
+ "status": "issues-found" if issues else ("applied" if apply else "ok"),
242
+ "mode": "apply" if apply else "check",
243
+ "candidate_count": len(items),
244
+ "applied_count": len(applied),
245
+ "candidates": [
246
+ {
247
+ "source": str(item.source.relative_to(vault_root)),
248
+ "target": str(item.target.relative_to(vault_root)),
249
+ "already_in_archive": item.in_archive,
250
+ }
251
+ for item in items
252
+ ],
253
+ "applied": applied,
254
+ "issues": issues,
255
+ }
@@ -0,0 +1,91 @@
1
+ """
2
+ SPEC:
3
+ name: link_service
4
+ purpose: 只读检查 Vault 或已治理范围的 wikilink、Markdown 链接、嵌入和附件引用
5
+ default_env_file: none
6
+ env_override: none
7
+ idempotent: true
8
+ behavior:
9
+ - 支持 managed 和 vault 两种扫描范围
10
+ - 报告失效、歧义 wikilink 和失效相对链接
11
+ safety:
12
+ - 不修改任何文件
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from collections import defaultdict
19
+ from pathlib import Path
20
+ from urllib.parse import unquote
21
+
22
+ WIKILINK_RE = re.compile(r"!?\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
23
+ MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*\]\(([^)]+)\)")
24
+ FENCED_CODE_RE = re.compile(r"```.*?```|~~~.*?~~~", re.DOTALL)
25
+ INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
26
+
27
+
28
+ def vault_markdown_files(vault_root: Path) -> list[Path]:
29
+ excluded = {".git", ".obsidian", ".campfire"}
30
+ return sorted(
31
+ path
32
+ for path in vault_root.rglob("*.md")
33
+ if not any(part in excluded for part in path.relative_to(vault_root).parts)
34
+ )
35
+
36
+
37
+ def check_links(vault_root: Path, sources: list[Path]) -> list[dict[str, str]]:
38
+ by_stem: dict[str, list[Path]] = defaultdict(list)
39
+ for candidate in vault_root.rglob("*"):
40
+ if candidate.is_file() and ".git" not in candidate.relative_to(vault_root).parts:
41
+ by_stem[candidate.stem].append(candidate)
42
+ issues: list[dict[str, str]] = []
43
+ for source in sources:
44
+ text = INLINE_CODE_RE.sub("", FENCED_CODE_RE.sub("", source.read_text(encoding="utf-8")))
45
+ for raw in WIKILINK_RE.findall(text):
46
+ target = unquote(raw.strip()).replace("\\", "/")
47
+ if "/" in target:
48
+ candidates = [
49
+ vault_root / target,
50
+ vault_root / f"{target}.md",
51
+ source.parent / target,
52
+ source.parent / f"{target}.md",
53
+ ]
54
+ matches = {item.resolve() for item in candidates if item.is_file()}
55
+ else:
56
+ stem = target[:-3] if target.lower().endswith(".md") else target
57
+ matches = set(by_stem.get(stem, []))
58
+ if not matches:
59
+ issues.append(
60
+ {
61
+ "code": "wikilink-missing",
62
+ "path": source.relative_to(vault_root).as_posix(),
63
+ "detail": raw,
64
+ }
65
+ )
66
+ elif len(matches) > 1:
67
+ issues.append(
68
+ {
69
+ "code": "wikilink-ambiguous",
70
+ "path": source.relative_to(vault_root).as_posix(),
71
+ "detail": raw,
72
+ }
73
+ )
74
+ for raw in MARKDOWN_LINK_RE.findall(text):
75
+ target = unquote(raw.strip().strip("<>").split("#", 1)[0])
76
+ if not target or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", target):
77
+ continue
78
+ candidate = (
79
+ vault_root / target.lstrip("/")
80
+ if target.startswith("/")
81
+ else source.parent / target
82
+ )
83
+ if not candidate.exists():
84
+ issues.append(
85
+ {
86
+ "code": "markdown-link-missing",
87
+ "path": source.relative_to(vault_root).as_posix(),
88
+ "detail": raw,
89
+ }
90
+ )
91
+ return issues
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from campfire_cli.app.maintenance.schema.maintenance_schema import (
6
+ DocumentState,
7
+ DomainState,
8
+ Issue,
9
+ MaintenancePlan,
10
+ MaintenanceRunRecord,
11
+ SpaceState,
12
+ )
13
+
14
+
15
+ class MaintenanceRepositoryProtocol(Protocol):
16
+ def replace_current_state(
17
+ self,
18
+ documents: list[DocumentState],
19
+ issues: list[Issue],
20
+ spaces: list[SpaceState],
21
+ domains: list[DomainState],
22
+ ) -> None: ...
23
+ def save_run(self, run: MaintenanceRunRecord) -> None: ...
24
+ def save_plan(self, plan: MaintenancePlan) -> None: ...
25
+ def load_plan(self, plan_id: str) -> MaintenancePlan: ...