campfire-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- campfire_cli/__init__.py +3 -0
- campfire_cli/alembic/__init__.py +1 -0
- campfire_cli/alembic/env.py +37 -0
- campfire_cli/alembic/script.py.mako +22 -0
- campfire_cli/alembic/versions/001_initial_schema.py +81 -0
- campfire_cli/alembic/versions/002_workspace_project_registry.py +47 -0
- campfire_cli/alembic/versions/003_unified_workspace_state.py +113 -0
- campfire_cli/alembic/versions/004_restructure_workspace_domain.py +23 -0
- campfire_cli/alembic/versions/005_decision_channel.py +71 -0
- campfire_cli/alembic/versions/006_workspace_topology_index.py +61 -0
- campfire_cli/alembic/versions/007_workspace_adoption.py +38 -0
- campfire_cli/alembic/versions/__init__.py +1 -0
- campfire_cli/app/SPEC.md +12 -0
- campfire_cli/app/__init__.py +1 -0
- campfire_cli/app/base/SPEC.md +3 -0
- campfire_cli/app/base/__init__.py +1 -0
- campfire_cli/app/base/cli/base_cli.py +37 -0
- campfire_cli/app/base/repository/base_repository.py +17 -0
- campfire_cli/app/base/schema/base_schema.py +18 -0
- campfire_cli/app/base/service/base_service.py +129 -0
- campfire_cli/app/decision/SPEC.md +16 -0
- campfire_cli/app/decision/__init__.py +1 -0
- campfire_cli/app/decision/cli/decision_cli.py +112 -0
- campfire_cli/app/decision/repository/decision_repository.py +199 -0
- campfire_cli/app/decision/schema/decision_schema.py +56 -0
- campfire_cli/app/decision/service/decision_projection_service.py +151 -0
- campfire_cli/app/decision/service/decision_service.py +124 -0
- campfire_cli/app/document/SPEC.md +51 -0
- campfire_cli/app/document/__init__.py +1 -0
- campfire_cli/app/document/cli/__init__.py +1 -0
- campfire_cli/app/document/cli/document_cli.py +63 -0
- campfire_cli/app/document/cli/profile_cli.py +65 -0
- campfire_cli/app/document/cli/type_cli.py +49 -0
- campfire_cli/app/document/repository/__init__.py +1 -0
- campfire_cli/app/document/repository/document_profile_repository.py +17 -0
- campfire_cli/app/document/repository/document_type_repository.py +17 -0
- campfire_cli/app/document/service/__init__.py +1 -0
- campfire_cli/app/document/service/document_profile_service.py +66 -0
- campfire_cli/app/document/service/document_rule_service.py +316 -0
- campfire_cli/app/document/service/document_scanner.py +38 -0
- campfire_cli/app/document/service/document_service.py +139 -0
- campfire_cli/app/document/service/document_type_service.py +28 -0
- campfire_cli/app/document/service/frontmatter_apply.py +98 -0
- campfire_cli/app/document/service/frontmatter_formatter.py +104 -0
- campfire_cli/app/document/service/frontmatter_plan.py +75 -0
- campfire_cli/app/document/service/profile_registry.py +166 -0
- campfire_cli/app/document/service/type_apply.py +185 -0
- campfire_cli/app/document/service/type_plan.py +80 -0
- campfire_cli/app/maintenance/SPEC.md +22 -0
- campfire_cli/app/maintenance/__init__.py +1 -0
- campfire_cli/app/maintenance/cli/maintenance_cli.py +103 -0
- campfire_cli/app/maintenance/repository/maintenance_repository.py +115 -0
- campfire_cli/app/maintenance/schema/maintenance_schema.py +109 -0
- campfire_cli/app/maintenance/service/archive_service.py +255 -0
- campfire_cli/app/maintenance/service/link_service.py +91 -0
- campfire_cli/app/maintenance/service/maintenance_protocol.py +25 -0
- campfire_cli/app/maintenance/service/maintenance_service.py +461 -0
- campfire_cli/app/maintenance/service/moc_service.py +310 -0
- campfire_cli/app/maintenance/service/plan_service.py +411 -0
- campfire_cli/app/skill/SPEC.md +3 -0
- campfire_cli/app/skill/__init__.py +1 -0
- campfire_cli/app/skill/cli/skill_cli.py +44 -0
- campfire_cli/app/skill/repository/skill_repository.py +33 -0
- campfire_cli/app/skill/schema/skill_schema.py +18 -0
- campfire_cli/app/skill/service/skill_service.py +146 -0
- campfire_cli/app/workspace/SPEC.md +25 -0
- campfire_cli/app/workspace/__init__.py +1 -0
- campfire_cli/app/workspace/cli/__init__.py +1 -0
- campfire_cli/app/workspace/cli/adoption_cli.py +83 -0
- campfire_cli/app/workspace/cli/config_cli.py +30 -0
- campfire_cli/app/workspace/cli/domain_cli.py +101 -0
- campfire_cli/app/workspace/cli/project_cli.py +135 -0
- campfire_cli/app/workspace/cli/restructure_cli.py +133 -0
- campfire_cli/app/workspace/cli/space_cli.py +65 -0
- campfire_cli/app/workspace/cli/workspace_cli.py +145 -0
- campfire_cli/app/workspace/repository/__init__.py +1 -0
- campfire_cli/app/workspace/repository/adoption_repository.py +55 -0
- campfire_cli/app/workspace/repository/manifest_repository.py +38 -0
- campfire_cli/app/workspace/repository/restructure_repository.py +93 -0
- campfire_cli/app/workspace/repository/workspace_repository.py +125 -0
- campfire_cli/app/workspace/schema/__init__.py +1 -0
- campfire_cli/app/workspace/schema/adoption_schema.py +47 -0
- campfire_cli/app/workspace/schema/restructure_schema.py +64 -0
- campfire_cli/app/workspace/schema/workspace_schema.py +225 -0
- campfire_cli/app/workspace/service/__init__.py +1 -0
- campfire_cli/app/workspace/service/adoption_protocol.py +11 -0
- campfire_cli/app/workspace/service/adoption_service.py +364 -0
- campfire_cli/app/workspace/service/config_service.py +189 -0
- campfire_cli/app/workspace/service/domain_restructure_service.py +316 -0
- campfire_cli/app/workspace/service/project_service.py +389 -0
- campfire_cli/app/workspace/service/restructure_protocol.py +19 -0
- campfire_cli/app/workspace/service/restructure_service.py +392 -0
- campfire_cli/app/workspace/service/restructure_verifier.py +83 -0
- campfire_cli/app/workspace/service/structure_service.py +494 -0
- campfire_cli/app/workspace/service/workspace_protocol.py +24 -0
- campfire_cli/app/workspace/service/workspace_service.py +304 -0
- campfire_cli/common/SPEC.md +12 -0
- campfire_cli/common/__init__.py +1 -0
- campfire_cli/common/database/__init__.py +4 -0
- campfire_cli/common/database/migrations.py +18 -0
- campfire_cli/common/database/models.py +215 -0
- campfire_cli/common/database/session.py +30 -0
- campfire_cli/common/documents/__init__.py +1 -0
- campfire_cli/common/documents/document_types.py +119 -0
- campfire_cli/common/documents/frontmatter_schema.py +42 -0
- campfire_cli/common/documents/markdown.py +66 -0
- campfire_cli/common/exceptions/__init__.py +7 -0
- campfire_cli/common/exceptions/base.py +19 -0
- campfire_cli/common/filesystem/__init__.py +6 -0
- campfire_cli/common/filesystem/atomic.py +30 -0
- campfire_cli/common/filesystem/locking.py +55 -0
- campfire_cli/common/governance/__init__.py +13 -0
- campfire_cli/common/governance/issues.py +38 -0
- campfire_cli/common/governance/locking.py +31 -0
- campfire_cli/common/governance/snapshots.py +26 -0
- campfire_cli/common/hashing.py +10 -0
- campfire_cli/common/reports/__init__.py +1 -0
- campfire_cli/common/reports/json_report.py +8 -0
- campfire_cli/common/reports/markdown_report.py +29 -0
- campfire_cli/config/SPEC.md +5 -0
- campfire_cli/config/defaults.py +64 -0
- campfire_cli/config/settings.py +50 -0
- campfire_cli/container.py +130 -0
- campfire_cli/main.py +133 -0
- campfire_cli/resources/__init__.py +1 -0
- campfire_cli/resources/bases/Agent/346/211/247/350/241/214/345/267/245/344/275/234/345/217/260.base +35 -0
- campfire_cli/resources/bases/SPEC.md +3 -0
- campfire_cli/resources/bases//344/273/273/345/212/241/345/267/245/344/275/234/345/217/260.base +49 -0
- campfire_cli/resources/bases//345/206/263/347/255/226/345/267/245/344/275/234/345/217/260.base +40 -0
- campfire_cli/resources/bases//345/276/205/345/275/222/346/241/243/346/226/207/346/241/243.base +28 -0
- campfire_cli/resources/bases//351/241/271/347/233/256/346/226/207/346/241/243.base +35 -0
- campfire_cli/resources/defaults/__init__.py +1 -0
- campfire_cli/resources/defaults/config.yml +458 -0
- campfire_cli/resources/skills/SPEC.md +3 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/SKILL.md +62 -0
- campfire_cli/resources/skills/campfire-context-bootstrap/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/SKILL.md +53 -0
- campfire_cli/resources/skills/campfire-conversation-router/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/inbox-handoff.md +19 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/learning-intake.md +31 -0
- campfire_cli/resources/skills/campfire-conversation-router/references/task-intake.md +28 -0
- campfire_cli/resources/skills/campfire-document-capture/SKILL.md +109 -0
- campfire_cli/resources/skills/campfire-inbox-triage/SKILL.md +23 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/SKILL.md +21 -0
- campfire_cli/resources/skills/campfire-weekly-report-writing/agents/openai.yaml +3 -0
- campfire_cli/resources/skills/campfire-workspace-adoption/SKILL.md +42 -0
- campfire_cli/resources/skills/campfire-workspace-maintenance/SKILL.md +74 -0
- campfire_cli/resources/skills/campfire-workspace-maintenance/references//344/273/273/345/212/241/346/255/243/346/226/207/347/273/223/346/236/204.md +23 -0
- campfire_cli/resources/skills/campfire-workspace-restructure/SKILL.md +52 -0
- campfire_cli-0.1.0.dist-info/METADATA +141 -0
- campfire_cli-0.1.0.dist-info/RECORD +154 -0
- campfire_cli-0.1.0.dist-info/WHEEL +4 -0
- campfire_cli-0.1.0.dist-info/entry_points.txt +2 -0
- campfire_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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: ...
|