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,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from campfire_cli.app.base.repository.base_repository import BaseRepository
|
|
8
|
+
from campfire_cli.app.base.schema.base_schema import BaseInfo, BaseResult
|
|
9
|
+
from campfire_cli.common.governance import enrich_issue, optimistic_write_lock
|
|
10
|
+
from campfire_cli.common.hashing import file_sha256, text_sha256
|
|
11
|
+
from campfire_cli.config.defaults import config_section
|
|
12
|
+
from campfire_cli.config.settings import WorkspaceSettings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseService:
|
|
16
|
+
def __init__(self, settings: WorkspaceSettings, repository: BaseRepository) -> None:
|
|
17
|
+
self._settings = settings
|
|
18
|
+
self._repository = repository
|
|
19
|
+
|
|
20
|
+
def list(self) -> BaseResult:
|
|
21
|
+
return BaseResult(status="ok", bases=self._bases())
|
|
22
|
+
|
|
23
|
+
def show(self, name: str, source: str = "ssot") -> BaseResult:
|
|
24
|
+
filename = name if name.endswith(".base") else f"{name}.base"
|
|
25
|
+
root = self._source_root() if source == "ssot" else self._target_root()
|
|
26
|
+
path = root / filename
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return BaseResult(
|
|
29
|
+
status="not-found", issues=[{"code": "base-missing", "path": str(path)}]
|
|
30
|
+
)
|
|
31
|
+
return BaseResult(
|
|
32
|
+
status="ok", bases=[self._info(path, root)], content=self._repository.read(path)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def check(self) -> BaseResult:
|
|
36
|
+
bases = self._bases()
|
|
37
|
+
configured = set(self._managed_names())
|
|
38
|
+
available = {item.name for item in bases}
|
|
39
|
+
issues: list[dict[str, str]] = []
|
|
40
|
+
for name in sorted(configured - available):
|
|
41
|
+
issues.append({"code": "ssot-base-missing", "path": name})
|
|
42
|
+
for item in bases:
|
|
43
|
+
if item.name in configured and item.status != "current":
|
|
44
|
+
issues.append({"code": "managed-base-not-current", "path": item.path})
|
|
45
|
+
try:
|
|
46
|
+
payload = yaml.safe_load(self._repository.read(self._source_root() / item.path))
|
|
47
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("views"), list):
|
|
48
|
+
issues.append({"code": "base-views-missing", "path": item.path})
|
|
49
|
+
except yaml.YAMLError as exc:
|
|
50
|
+
issues.append({"code": "base-yaml-invalid", "path": item.path, "detail": str(exc)})
|
|
51
|
+
return BaseResult(status="ok" if not issues else "needs-review", bases=bases, issues=issues)
|
|
52
|
+
|
|
53
|
+
def sync(self, dry_run: bool = False) -> BaseResult:
|
|
54
|
+
operations: list[dict[str, str]] = []
|
|
55
|
+
writes: list[tuple[Path, str, str | None]] = []
|
|
56
|
+
for name in self._managed_names():
|
|
57
|
+
source = self._source_root() / name
|
|
58
|
+
if not source.is_file():
|
|
59
|
+
continue
|
|
60
|
+
target = self._target_root() / name
|
|
61
|
+
content = self._repository.read(source)
|
|
62
|
+
current = self._repository.read(target) if target.is_file() else None
|
|
63
|
+
if current is not None and self._same_definition(current, content):
|
|
64
|
+
continue
|
|
65
|
+
operations.append(
|
|
66
|
+
{
|
|
67
|
+
"action": "update" if target.exists() else "create",
|
|
68
|
+
"path": name,
|
|
69
|
+
"sha256": text_sha256(content),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
expected = file_sha256(target) if target.is_file() else None
|
|
73
|
+
writes.append((target, content, expected))
|
|
74
|
+
if not dry_run and writes:
|
|
75
|
+
snapshot = {
|
|
76
|
+
path.relative_to(self._settings.vault_root).as_posix(): expected
|
|
77
|
+
for path, _content, expected in writes
|
|
78
|
+
}
|
|
79
|
+
with optimistic_write_lock(
|
|
80
|
+
self._settings.state_root, snapshot, self._settings.vault_root
|
|
81
|
+
) as changed:
|
|
82
|
+
if changed:
|
|
83
|
+
return BaseResult(
|
|
84
|
+
status="blocked",
|
|
85
|
+
issues=[
|
|
86
|
+
enrich_issue({"code": "concurrent-change", "path": path})
|
|
87
|
+
for path in changed
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
for target, content, _expected in writes:
|
|
91
|
+
self._repository.write(target, content)
|
|
92
|
+
return BaseResult(status="dry-run" if dry_run else "synced", operations=operations)
|
|
93
|
+
|
|
94
|
+
def _bases(self) -> list[BaseInfo]:
|
|
95
|
+
return [
|
|
96
|
+
self._info(path, self._source_root())
|
|
97
|
+
for path in sorted(self._source_root().glob("*.base"))
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
def _info(self, path: Path, root: Path) -> BaseInfo:
|
|
101
|
+
payload = yaml.safe_load(self._repository.read(path)) or {}
|
|
102
|
+
views = [
|
|
103
|
+
str(view.get("name", "")) for view in payload.get("views", []) if isinstance(view, dict)
|
|
104
|
+
]
|
|
105
|
+
target = self._target_root() / path.name
|
|
106
|
+
status = "missing-or-stale"
|
|
107
|
+
if target.is_file() and self._same_definition(
|
|
108
|
+
self._repository.read(target), self._repository.read(path)
|
|
109
|
+
):
|
|
110
|
+
status = "current"
|
|
111
|
+
return BaseInfo(
|
|
112
|
+
name=path.name, path=path.relative_to(root).as_posix(), status=status, views=views
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _source_root(self) -> Path:
|
|
116
|
+
return self._repository.source_root()
|
|
117
|
+
|
|
118
|
+
def _target_root(self) -> Path:
|
|
119
|
+
return self._settings.vault_root / self._settings.bases.get("target", "治理视图")
|
|
120
|
+
|
|
121
|
+
def _managed_names(self) -> list[str]:
|
|
122
|
+
return list(config_section("bases").get("managed_bases", []))
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _same_definition(left: str, right: str) -> bool:
|
|
126
|
+
try:
|
|
127
|
+
return yaml.safe_load(left) == yaml.safe_load(right)
|
|
128
|
+
except yaml.YAMLError:
|
|
129
|
+
return left == right
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Decision App SPEC
|
|
2
|
+
|
|
3
|
+
本模块维护人类或高级 Agent 必须作出的显式判断。调用方不判断交互模式:不能唯一决定时创建 Decision,得到答案后写入并关闭;尚未回答的 Decision 自然跨会话保留。
|
|
4
|
+
|
|
5
|
+
SQLite 的 `decisions` 保存当前状态,`decision_events` 追加保存审计历史,两者是 Decision 的 SSOT。Vault 中 `_协作/decisions/` 按 `pending/`、`answered/`、`closed/`、`cancelled/` 保存全状态只读投影,目录与状态一一对应。投影可由 `decision sync` 重建,不接受反向写入。
|
|
6
|
+
|
|
7
|
+
状态机保持最小:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
pending ──answer──> answered ──close──> closed
|
|
11
|
+
└──────────────cancel─────────────> cancelled
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`dedupe_key` 在一个 Workspace 内唯一。重复创建仍处于 `pending` 的同一事项时刷新内容而不产生重复 Decision;已经回答、关闭或取消的 key 不得静默复用。
|
|
15
|
+
|
|
16
|
+
Decision 不发送通知、不调度 Agent,也不直接修改关联文档。未来 Notification 消费 Decision Event,Task Channel 通过稳定 `decision_id` 关联阻塞和恢复事件。
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Persistent human/agent decision workflow."""
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from campfire_cli.common.exceptions import AppError
|
|
10
|
+
|
|
11
|
+
decision_cli = typer.Typer(
|
|
12
|
+
help="维护需要人类或高级 Agent 回答的持久 Decision",
|
|
13
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def emit(result: object) -> None:
|
|
18
|
+
typer.echo(json.dumps(result.model_dump(mode="json"), ensure_ascii=False, indent=2))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def invoke(operation: Callable[[], Any]) -> None:
|
|
22
|
+
try:
|
|
23
|
+
emit(operation())
|
|
24
|
+
except AppError as exc:
|
|
25
|
+
typer.echo(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False))
|
|
26
|
+
raise typer.Exit(exc.exit_code) from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@decision_cli.command("create")
|
|
30
|
+
def create(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
key: str = typer.Option(..., "--key", help="Workspace 内稳定的幂等键"),
|
|
33
|
+
question: str = typer.Option(..., "--question"),
|
|
34
|
+
source_type: str = typer.Option(..., "--source-type"),
|
|
35
|
+
context: str = typer.Option("", "--context"),
|
|
36
|
+
recommendation: str = typer.Option("", "--recommendation"),
|
|
37
|
+
option: list[str] | None = typer.Option(None, "--option"),
|
|
38
|
+
related_document: list[str] | None = typer.Option(None, "--related-document"),
|
|
39
|
+
source_id: str | None = typer.Option(None, "--source-id"),
|
|
40
|
+
session_provider: str | None = typer.Option(None, "--session-provider"),
|
|
41
|
+
session_id: str | None = typer.Option(None, "--session-id"),
|
|
42
|
+
actor: str | None = typer.Option(None, "--actor"),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""创建或刷新一个仍处于 pending 的幂等 Decision。"""
|
|
45
|
+
invoke(
|
|
46
|
+
lambda: ctx.obj.decision.create(
|
|
47
|
+
key=key,
|
|
48
|
+
question=question,
|
|
49
|
+
source_type=source_type,
|
|
50
|
+
context=context,
|
|
51
|
+
recommendation=recommendation,
|
|
52
|
+
options=option,
|
|
53
|
+
related_documents=related_document,
|
|
54
|
+
source_id=source_id,
|
|
55
|
+
session_provider=session_provider,
|
|
56
|
+
session_id=session_id,
|
|
57
|
+
actor=actor,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@decision_cli.command("list")
|
|
63
|
+
def list_decisions(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
status: str | None = typer.Option(None, "--status"),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""列出当前 Workspace 的 Decision。"""
|
|
68
|
+
invoke(lambda: ctx.obj.decision.list(status))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@decision_cli.command("show")
|
|
72
|
+
def show(ctx: typer.Context, decision_id: str) -> None:
|
|
73
|
+
"""显示 Decision 当前内容和完整事件历史。"""
|
|
74
|
+
invoke(lambda: ctx.obj.decision.show(decision_id))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@decision_cli.command("answer")
|
|
78
|
+
def answer(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
decision_id: str,
|
|
81
|
+
answer_text: str = typer.Option(..., "--answer"),
|
|
82
|
+
answered_by: str = typer.Option(..., "--answered-by"),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""回答 pending Decision,并转为 answered。"""
|
|
85
|
+
invoke(lambda: ctx.obj.decision.answer(decision_id, answer_text, answered_by))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@decision_cli.command("close")
|
|
89
|
+
def close(
|
|
90
|
+
ctx: typer.Context,
|
|
91
|
+
decision_id: str,
|
|
92
|
+
actor: str | None = typer.Option(None, "--actor"),
|
|
93
|
+
) -> None:
|
|
94
|
+
"""关闭已经回答且被消费的 Decision。"""
|
|
95
|
+
invoke(lambda: ctx.obj.decision.close(decision_id, actor))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@decision_cli.command("cancel")
|
|
99
|
+
def cancel(
|
|
100
|
+
ctx: typer.Context,
|
|
101
|
+
decision_id: str,
|
|
102
|
+
reason: str = typer.Option(..., "--reason"),
|
|
103
|
+
actor: str | None = typer.Option(None, "--actor"),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""显式取消不再需要回答的 pending Decision。"""
|
|
106
|
+
invoke(lambda: ctx.obj.decision.cancel(decision_id, reason, actor))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@decision_cli.command("sync")
|
|
110
|
+
def sync(ctx: typer.Context) -> None:
|
|
111
|
+
"""从 SQLite 重建全部 Decision 的 Vault 投影。"""
|
|
112
|
+
invoke(ctx.obj.decision.sync)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.orm import Session
|
|
8
|
+
|
|
9
|
+
from campfire_cli.app.decision.schema.decision_schema import DecisionEntry, DecisionEventEntry
|
|
10
|
+
from campfire_cli.common.database.models import Decision, DecisionEvent, utc_now
|
|
11
|
+
from campfire_cli.common.exceptions import GovernanceBlockedError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SqliteDecisionRepository:
|
|
15
|
+
"""Persist Decision snapshots and their append-only audit events atomically."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, session: Session, workspace_id: str) -> None:
|
|
18
|
+
self._session = session
|
|
19
|
+
self._workspace_id = workspace_id
|
|
20
|
+
|
|
21
|
+
def create_or_refresh(
|
|
22
|
+
self,
|
|
23
|
+
decision_id: str,
|
|
24
|
+
values: dict[str, Any],
|
|
25
|
+
actor: str | None,
|
|
26
|
+
) -> DecisionEntry:
|
|
27
|
+
try:
|
|
28
|
+
row = self._session.scalar(
|
|
29
|
+
select(Decision).where(
|
|
30
|
+
Decision.workspace_id == self._workspace_id,
|
|
31
|
+
Decision.dedupe_key == values["dedupe_key"],
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
event_type = "decision.created"
|
|
35
|
+
if row is None:
|
|
36
|
+
row = Decision(id=decision_id, workspace_id=self._workspace_id)
|
|
37
|
+
self._session.add(row)
|
|
38
|
+
elif row.status != "pending":
|
|
39
|
+
raise GovernanceBlockedError(
|
|
40
|
+
f"Decision key 已经结束,不能静默复用:{values['dedupe_key']}"
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
event_type = "decision.refreshed"
|
|
44
|
+
self._assign_content(row, values)
|
|
45
|
+
row.updated_at = utc_now()
|
|
46
|
+
self._append_event(row, event_type, actor, {"dedupe_key": row.dedupe_key})
|
|
47
|
+
self._session.commit()
|
|
48
|
+
except Exception:
|
|
49
|
+
self._session.rollback()
|
|
50
|
+
raise
|
|
51
|
+
return self._entry(row)
|
|
52
|
+
|
|
53
|
+
def list(self, status: str | None = None) -> list[DecisionEntry]:
|
|
54
|
+
statement = select(Decision).where(Decision.workspace_id == self._workspace_id)
|
|
55
|
+
if status is not None:
|
|
56
|
+
statement = statement.where(Decision.status == status)
|
|
57
|
+
statement = statement.order_by(Decision.created_at.desc(), Decision.id)
|
|
58
|
+
rows = self._session.scalars(statement).all()
|
|
59
|
+
return [self._entry(row) for row in rows]
|
|
60
|
+
|
|
61
|
+
def get(self, decision_id: str) -> DecisionEntry:
|
|
62
|
+
row = self._row(decision_id)
|
|
63
|
+
return self._entry(row)
|
|
64
|
+
|
|
65
|
+
def events(self, decision_id: str) -> list[DecisionEventEntry]:
|
|
66
|
+
self._row(decision_id)
|
|
67
|
+
rows = self._session.scalars(
|
|
68
|
+
select(DecisionEvent)
|
|
69
|
+
.where(
|
|
70
|
+
DecisionEvent.workspace_id == self._workspace_id,
|
|
71
|
+
DecisionEvent.decision_id == decision_id,
|
|
72
|
+
)
|
|
73
|
+
.order_by(DecisionEvent.id)
|
|
74
|
+
).all()
|
|
75
|
+
return [
|
|
76
|
+
DecisionEventEntry(
|
|
77
|
+
event_type=row.event_type,
|
|
78
|
+
actor=row.actor,
|
|
79
|
+
payload=json.loads(row.payload_json),
|
|
80
|
+
created_at=row.created_at,
|
|
81
|
+
)
|
|
82
|
+
for row in rows
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def answer(self, decision_id: str, answer: str, actor: str) -> DecisionEntry:
|
|
86
|
+
try:
|
|
87
|
+
row = self._row(decision_id)
|
|
88
|
+
self._require_status(row, "pending")
|
|
89
|
+
now = utc_now()
|
|
90
|
+
row.status = "answered"
|
|
91
|
+
row.answer = answer
|
|
92
|
+
row.answered_by = actor
|
|
93
|
+
row.answered_at = now
|
|
94
|
+
row.updated_at = now
|
|
95
|
+
self._append_event(row, "decision.answered", actor, {"answer": answer})
|
|
96
|
+
self._session.commit()
|
|
97
|
+
except Exception:
|
|
98
|
+
self._session.rollback()
|
|
99
|
+
raise
|
|
100
|
+
return self._entry(row)
|
|
101
|
+
|
|
102
|
+
def close(self, decision_id: str, actor: str | None) -> DecisionEntry:
|
|
103
|
+
try:
|
|
104
|
+
row = self._row(decision_id)
|
|
105
|
+
self._require_status(row, "answered")
|
|
106
|
+
now = utc_now()
|
|
107
|
+
row.status = "closed"
|
|
108
|
+
row.closed_at = now
|
|
109
|
+
row.updated_at = now
|
|
110
|
+
self._append_event(row, "decision.closed", actor, {})
|
|
111
|
+
self._session.commit()
|
|
112
|
+
except Exception:
|
|
113
|
+
self._session.rollback()
|
|
114
|
+
raise
|
|
115
|
+
return self._entry(row)
|
|
116
|
+
|
|
117
|
+
def cancel(self, decision_id: str, reason: str, actor: str | None) -> DecisionEntry:
|
|
118
|
+
try:
|
|
119
|
+
row = self._row(decision_id)
|
|
120
|
+
self._require_status(row, "pending")
|
|
121
|
+
now = utc_now()
|
|
122
|
+
row.status = "cancelled"
|
|
123
|
+
row.cancellation_reason = reason
|
|
124
|
+
row.closed_at = now
|
|
125
|
+
row.updated_at = now
|
|
126
|
+
self._append_event(row, "decision.cancelled", actor, {"reason": reason})
|
|
127
|
+
self._session.commit()
|
|
128
|
+
except Exception:
|
|
129
|
+
self._session.rollback()
|
|
130
|
+
raise
|
|
131
|
+
return self._entry(row)
|
|
132
|
+
|
|
133
|
+
def _row(self, decision_id: str) -> Decision:
|
|
134
|
+
row = self._session.get(Decision, decision_id)
|
|
135
|
+
if row is None or row.workspace_id != self._workspace_id:
|
|
136
|
+
raise GovernanceBlockedError(f"Decision 不存在:{decision_id}")
|
|
137
|
+
return row
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _require_status(row: Decision, expected: str) -> None:
|
|
141
|
+
if row.status != expected:
|
|
142
|
+
raise GovernanceBlockedError(
|
|
143
|
+
f"Decision 状态不允许当前操作:expected={expected}, actual={row.status}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _append_event(
|
|
147
|
+
self,
|
|
148
|
+
row: Decision,
|
|
149
|
+
event_type: str,
|
|
150
|
+
actor: str | None,
|
|
151
|
+
payload: dict[str, Any],
|
|
152
|
+
) -> None:
|
|
153
|
+
self._session.add(
|
|
154
|
+
DecisionEvent(
|
|
155
|
+
workspace_id=self._workspace_id,
|
|
156
|
+
decision_id=row.id,
|
|
157
|
+
event_type=event_type,
|
|
158
|
+
actor=actor,
|
|
159
|
+
payload_json=json.dumps(payload, ensure_ascii=False),
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _assign_content(row: Decision, values: dict[str, Any]) -> None:
|
|
165
|
+
row.dedupe_key = values["dedupe_key"]
|
|
166
|
+
row.question = values["question"]
|
|
167
|
+
row.context = values["context"]
|
|
168
|
+
row.recommendation = values["recommendation"]
|
|
169
|
+
row.options_json = json.dumps(values["options"], ensure_ascii=False)
|
|
170
|
+
row.related_documents_json = json.dumps(values["related_documents"], ensure_ascii=False)
|
|
171
|
+
row.source_type = values["source_type"]
|
|
172
|
+
row.source_id = values["source_id"]
|
|
173
|
+
row.session_provider = values["session_provider"]
|
|
174
|
+
row.session_id = values["session_id"]
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _entry(row: Decision) -> DecisionEntry:
|
|
178
|
+
return DecisionEntry(
|
|
179
|
+
id=row.id,
|
|
180
|
+
workspace_id=row.workspace_id,
|
|
181
|
+
dedupe_key=row.dedupe_key,
|
|
182
|
+
question=row.question,
|
|
183
|
+
context=row.context,
|
|
184
|
+
recommendation=row.recommendation,
|
|
185
|
+
options=json.loads(row.options_json),
|
|
186
|
+
related_documents=json.loads(row.related_documents_json),
|
|
187
|
+
source_type=row.source_type,
|
|
188
|
+
source_id=row.source_id,
|
|
189
|
+
session_provider=row.session_provider,
|
|
190
|
+
session_id=row.session_id,
|
|
191
|
+
status=row.status,
|
|
192
|
+
answer=row.answer,
|
|
193
|
+
answered_by=row.answered_by,
|
|
194
|
+
cancellation_reason=row.cancellation_reason,
|
|
195
|
+
created_at=row.created_at,
|
|
196
|
+
updated_at=row.updated_at,
|
|
197
|
+
answered_at=row.answered_at,
|
|
198
|
+
closed_at=row.closed_at,
|
|
199
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
DecisionStatus = Literal["pending", "answered", "closed", "cancelled"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DecisionEntry(BaseModel):
|
|
12
|
+
id: str
|
|
13
|
+
workspace_id: str
|
|
14
|
+
dedupe_key: str
|
|
15
|
+
question: str
|
|
16
|
+
context: str = ""
|
|
17
|
+
recommendation: str = ""
|
|
18
|
+
options: list[str] = Field(default_factory=list)
|
|
19
|
+
related_documents: list[str] = Field(default_factory=list)
|
|
20
|
+
source_type: str
|
|
21
|
+
source_id: str | None = None
|
|
22
|
+
session_provider: str | None = None
|
|
23
|
+
session_id: str | None = None
|
|
24
|
+
status: DecisionStatus = "pending"
|
|
25
|
+
answer: str | None = None
|
|
26
|
+
answered_by: str | None = None
|
|
27
|
+
cancellation_reason: str | None = None
|
|
28
|
+
created_at: datetime
|
|
29
|
+
updated_at: datetime
|
|
30
|
+
answered_at: datetime | None = None
|
|
31
|
+
closed_at: datetime | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DecisionEventEntry(BaseModel):
|
|
35
|
+
event_type: str
|
|
36
|
+
actor: str | None = None
|
|
37
|
+
payload: dict[str, object] = Field(default_factory=dict)
|
|
38
|
+
created_at: datetime
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DecisionResult(BaseModel):
|
|
42
|
+
status: str
|
|
43
|
+
decision: DecisionEntry | None = None
|
|
44
|
+
events: list[DecisionEventEntry] = Field(default_factory=list)
|
|
45
|
+
projection_operations: list[dict[str, str]] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DecisionListResult(BaseModel):
|
|
49
|
+
status: str = "ok"
|
|
50
|
+
decisions: list[DecisionEntry] = Field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DecisionSyncResult(BaseModel):
|
|
54
|
+
status: str
|
|
55
|
+
pending_count: int
|
|
56
|
+
operations: list[dict[str, str]] = Field(default_factory=list)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from campfire_cli.app.decision.schema.decision_schema import DecisionEntry, DecisionSyncResult
|
|
7
|
+
from campfire_cli.common.filesystem import atomic_write, workspace_write_lock
|
|
8
|
+
from campfire_cli.config.settings import WorkspaceSettings
|
|
9
|
+
|
|
10
|
+
GENERATED_MARKER = "<!-- AUTO-GENERATED:CAMPFIRE-DECISION -->"
|
|
11
|
+
STATUS_DIRECTORIES = ("pending", "answered", "closed", "cancelled")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DecisionProjectionService:
|
|
15
|
+
"""Project SQLite Decisions into disposable, human-readable Markdown."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, settings: WorkspaceSettings) -> None:
|
|
18
|
+
self._settings = settings
|
|
19
|
+
|
|
20
|
+
def sync(self, decisions: list[DecisionEntry]) -> DecisionSyncResult:
|
|
21
|
+
root = self._projection_root()
|
|
22
|
+
expected = {self._path(item): self._render(item) for item in decisions}
|
|
23
|
+
directories = [root / status for status in STATUS_DIRECTORIES]
|
|
24
|
+
managed = {
|
|
25
|
+
path
|
|
26
|
+
for path in root.rglob("Decision-*.md")
|
|
27
|
+
if GENERATED_MARKER in path.read_text(encoding="utf-8")
|
|
28
|
+
}
|
|
29
|
+
operations: list[dict[str, str]] = []
|
|
30
|
+
for path in directories:
|
|
31
|
+
if not path.is_dir():
|
|
32
|
+
operations.append(
|
|
33
|
+
{
|
|
34
|
+
"action": "create-directory",
|
|
35
|
+
"path": path.relative_to(self._settings.vault_root).as_posix(),
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
for path, content in expected.items():
|
|
39
|
+
current = path.read_text(encoding="utf-8") if path.is_file() else None
|
|
40
|
+
if current != content:
|
|
41
|
+
operations.append(
|
|
42
|
+
{
|
|
43
|
+
"action": "update" if current is not None else "create",
|
|
44
|
+
"path": path.relative_to(self._settings.vault_root).as_posix(),
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
for path in sorted(managed - expected.keys()):
|
|
48
|
+
operations.append(
|
|
49
|
+
{
|
|
50
|
+
"action": "delete-projection",
|
|
51
|
+
"path": path.relative_to(self._settings.vault_root).as_posix(),
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
if operations:
|
|
55
|
+
with workspace_write_lock(self._settings.state_root):
|
|
56
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
for path in directories:
|
|
58
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
for path, content in expected.items():
|
|
60
|
+
if not path.is_file() or path.read_text(encoding="utf-8") != content:
|
|
61
|
+
atomic_write(path, content)
|
|
62
|
+
for path in managed - expected.keys():
|
|
63
|
+
path.unlink()
|
|
64
|
+
return DecisionSyncResult(
|
|
65
|
+
status="synced",
|
|
66
|
+
pending_count=sum(item.status == "pending" for item in decisions),
|
|
67
|
+
operations=operations,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _projection_root(self) -> Path:
|
|
71
|
+
return self._settings.vault_root / "_协作" / "decisions"
|
|
72
|
+
|
|
73
|
+
def _path(self, decision: DecisionEntry) -> Path:
|
|
74
|
+
return self._projection_root() / decision.status / f"Decision-{decision.id}.md"
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _render(decision: DecisionEntry) -> str:
|
|
78
|
+
title = DecisionProjectionService._title(decision.question)
|
|
79
|
+
answered_at = decision.answered_at.isoformat() if decision.answered_at else None
|
|
80
|
+
closed_at = decision.closed_at.isoformat() if decision.closed_at else None
|
|
81
|
+
lines = [
|
|
82
|
+
"---",
|
|
83
|
+
f"name: {json.dumps(title, ensure_ascii=False)}",
|
|
84
|
+
"object_type: decision",
|
|
85
|
+
f"decision_id: {decision.id}",
|
|
86
|
+
f"decision_status: {decision.status}",
|
|
87
|
+
f"source_type: {json.dumps(decision.source_type, ensure_ascii=False)}",
|
|
88
|
+
f"source_id: {json.dumps(decision.source_id, ensure_ascii=False)}",
|
|
89
|
+
f"answered_by: {json.dumps(decision.answered_by, ensure_ascii=False)}",
|
|
90
|
+
f"created_at: {decision.created_at.isoformat()}",
|
|
91
|
+
f"updated_at: {decision.updated_at.isoformat()}",
|
|
92
|
+
f"answered_at: {json.dumps(answered_at)}",
|
|
93
|
+
f"closed_at: {json.dumps(closed_at)}",
|
|
94
|
+
"---",
|
|
95
|
+
"",
|
|
96
|
+
GENERATED_MARKER,
|
|
97
|
+
"",
|
|
98
|
+
f"# {title}",
|
|
99
|
+
"",
|
|
100
|
+
"> 此文档由 Campfire 根据 SQLite Decision 自动生成。"
|
|
101
|
+
"请通过 `campfire decision answer` 回答,不要手工修改。",
|
|
102
|
+
"",
|
|
103
|
+
f"- Decision ID:`{decision.id}`",
|
|
104
|
+
f"- 来源:`{decision.source_type}`",
|
|
105
|
+
f"- 来源对象:`{decision.source_id or '-'}`",
|
|
106
|
+
f"- 来源 Session:`{decision.session_provider or '-'} / {decision.session_id or '-'}`",
|
|
107
|
+
"",
|
|
108
|
+
"## 问题",
|
|
109
|
+
"",
|
|
110
|
+
decision.question,
|
|
111
|
+
]
|
|
112
|
+
if decision.context:
|
|
113
|
+
lines.extend(["", "## 背景与已确认事实", "", decision.context])
|
|
114
|
+
if decision.options:
|
|
115
|
+
lines.extend(["", "## 可选方案", ""])
|
|
116
|
+
lines.extend(f"- {option}" for option in decision.options)
|
|
117
|
+
if decision.recommendation:
|
|
118
|
+
lines.extend(["", "## Agent 建议", "", decision.recommendation])
|
|
119
|
+
if decision.related_documents:
|
|
120
|
+
lines.extend(["", "## 关联文档", ""])
|
|
121
|
+
lines.extend(f"- `{path}`" for path in decision.related_documents)
|
|
122
|
+
if decision.answer:
|
|
123
|
+
lines.extend(
|
|
124
|
+
[
|
|
125
|
+
"",
|
|
126
|
+
"## 回答",
|
|
127
|
+
"",
|
|
128
|
+
decision.answer,
|
|
129
|
+
"",
|
|
130
|
+
f"回答者:`{decision.answered_by or '-'}`",
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
elif decision.status == "pending":
|
|
134
|
+
lines.extend(
|
|
135
|
+
[
|
|
136
|
+
"",
|
|
137
|
+
"## 回答方式",
|
|
138
|
+
"",
|
|
139
|
+
"```bash",
|
|
140
|
+
f"campfire decision answer {decision.id} "
|
|
141
|
+
'--answer "<回答>" --answered-by "<回答者>"',
|
|
142
|
+
"```",
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
lines.append("")
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _title(question: str) -> str:
|
|
150
|
+
compact = " ".join(question.split())
|
|
151
|
+
return compact if len(compact) <= 48 else compact[:47] + "…"
|