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,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] + "…"