raise-cli 2.2.1__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 (264) hide show
  1. raise_cli/__init__.py +38 -0
  2. raise_cli/__main__.py +30 -0
  3. raise_cli/adapters/__init__.py +91 -0
  4. raise_cli/adapters/declarative/__init__.py +26 -0
  5. raise_cli/adapters/declarative/adapter.py +267 -0
  6. raise_cli/adapters/declarative/discovery.py +94 -0
  7. raise_cli/adapters/declarative/expressions.py +150 -0
  8. raise_cli/adapters/declarative/reference/__init__.py +1 -0
  9. raise_cli/adapters/declarative/reference/github.yaml +143 -0
  10. raise_cli/adapters/declarative/schema.py +98 -0
  11. raise_cli/adapters/filesystem.py +299 -0
  12. raise_cli/adapters/mcp_bridge.py +10 -0
  13. raise_cli/adapters/mcp_confluence.py +246 -0
  14. raise_cli/adapters/mcp_jira.py +405 -0
  15. raise_cli/adapters/models.py +205 -0
  16. raise_cli/adapters/protocols.py +180 -0
  17. raise_cli/adapters/registry.py +90 -0
  18. raise_cli/adapters/sync.py +149 -0
  19. raise_cli/agents/__init__.py +14 -0
  20. raise_cli/agents/antigravity.yaml +8 -0
  21. raise_cli/agents/claude.yaml +8 -0
  22. raise_cli/agents/copilot.yaml +8 -0
  23. raise_cli/agents/copilot_plugin.py +124 -0
  24. raise_cli/agents/cursor.yaml +7 -0
  25. raise_cli/agents/roo.yaml +8 -0
  26. raise_cli/agents/windsurf.yaml +8 -0
  27. raise_cli/artifacts/__init__.py +30 -0
  28. raise_cli/artifacts/models.py +43 -0
  29. raise_cli/artifacts/reader.py +55 -0
  30. raise_cli/artifacts/renderer.py +104 -0
  31. raise_cli/artifacts/story_design.py +69 -0
  32. raise_cli/artifacts/writer.py +45 -0
  33. raise_cli/backlog/__init__.py +1 -0
  34. raise_cli/backlog/sync.py +115 -0
  35. raise_cli/cli/__init__.py +3 -0
  36. raise_cli/cli/commands/__init__.py +3 -0
  37. raise_cli/cli/commands/_resolve.py +153 -0
  38. raise_cli/cli/commands/adapters.py +362 -0
  39. raise_cli/cli/commands/artifact.py +137 -0
  40. raise_cli/cli/commands/backlog.py +333 -0
  41. raise_cli/cli/commands/base.py +31 -0
  42. raise_cli/cli/commands/discover.py +551 -0
  43. raise_cli/cli/commands/docs.py +130 -0
  44. raise_cli/cli/commands/doctor.py +177 -0
  45. raise_cli/cli/commands/gate.py +223 -0
  46. raise_cli/cli/commands/graph.py +1086 -0
  47. raise_cli/cli/commands/info.py +81 -0
  48. raise_cli/cli/commands/init.py +746 -0
  49. raise_cli/cli/commands/journal.py +167 -0
  50. raise_cli/cli/commands/mcp.py +524 -0
  51. raise_cli/cli/commands/memory.py +467 -0
  52. raise_cli/cli/commands/pattern.py +348 -0
  53. raise_cli/cli/commands/profile.py +59 -0
  54. raise_cli/cli/commands/publish.py +80 -0
  55. raise_cli/cli/commands/release.py +338 -0
  56. raise_cli/cli/commands/session.py +528 -0
  57. raise_cli/cli/commands/signal.py +410 -0
  58. raise_cli/cli/commands/skill.py +350 -0
  59. raise_cli/cli/commands/skill_set.py +145 -0
  60. raise_cli/cli/error_handler.py +158 -0
  61. raise_cli/cli/main.py +163 -0
  62. raise_cli/compat.py +66 -0
  63. raise_cli/config/__init__.py +41 -0
  64. raise_cli/config/agent_plugin.py +105 -0
  65. raise_cli/config/agent_registry.py +233 -0
  66. raise_cli/config/agents.py +120 -0
  67. raise_cli/config/ide.py +32 -0
  68. raise_cli/config/paths.py +379 -0
  69. raise_cli/config/settings.py +180 -0
  70. raise_cli/context/__init__.py +42 -0
  71. raise_cli/context/analyzers/__init__.py +16 -0
  72. raise_cli/context/analyzers/models.py +36 -0
  73. raise_cli/context/analyzers/protocol.py +43 -0
  74. raise_cli/context/analyzers/python.py +292 -0
  75. raise_cli/context/builder.py +1569 -0
  76. raise_cli/context/diff.py +213 -0
  77. raise_cli/context/extractors/__init__.py +13 -0
  78. raise_cli/context/extractors/skills.py +121 -0
  79. raise_cli/core/__init__.py +37 -0
  80. raise_cli/core/files.py +66 -0
  81. raise_cli/core/text.py +174 -0
  82. raise_cli/core/tools.py +441 -0
  83. raise_cli/discovery/__init__.py +50 -0
  84. raise_cli/discovery/analyzer.py +691 -0
  85. raise_cli/discovery/drift.py +355 -0
  86. raise_cli/discovery/scanner.py +1687 -0
  87. raise_cli/doctor/__init__.py +4 -0
  88. raise_cli/doctor/checks/__init__.py +1 -0
  89. raise_cli/doctor/checks/environment.py +110 -0
  90. raise_cli/doctor/checks/project.py +238 -0
  91. raise_cli/doctor/fix.py +80 -0
  92. raise_cli/doctor/models.py +56 -0
  93. raise_cli/doctor/protocol.py +43 -0
  94. raise_cli/doctor/registry.py +100 -0
  95. raise_cli/doctor/report.py +141 -0
  96. raise_cli/doctor/runner.py +95 -0
  97. raise_cli/engines/__init__.py +3 -0
  98. raise_cli/exceptions.py +215 -0
  99. raise_cli/gates/__init__.py +19 -0
  100. raise_cli/gates/builtin/__init__.py +1 -0
  101. raise_cli/gates/builtin/coverage.py +52 -0
  102. raise_cli/gates/builtin/lint.py +48 -0
  103. raise_cli/gates/builtin/tests.py +48 -0
  104. raise_cli/gates/builtin/types.py +48 -0
  105. raise_cli/gates/models.py +40 -0
  106. raise_cli/gates/protocol.py +41 -0
  107. raise_cli/gates/registry.py +141 -0
  108. raise_cli/governance/__init__.py +11 -0
  109. raise_cli/governance/extractor.py +412 -0
  110. raise_cli/governance/models.py +134 -0
  111. raise_cli/governance/parsers/__init__.py +35 -0
  112. raise_cli/governance/parsers/_convert.py +38 -0
  113. raise_cli/governance/parsers/adr.py +274 -0
  114. raise_cli/governance/parsers/backlog.py +356 -0
  115. raise_cli/governance/parsers/constitution.py +119 -0
  116. raise_cli/governance/parsers/epic.py +323 -0
  117. raise_cli/governance/parsers/glossary.py +316 -0
  118. raise_cli/governance/parsers/guardrails.py +345 -0
  119. raise_cli/governance/parsers/prd.py +112 -0
  120. raise_cli/governance/parsers/roadmap.py +118 -0
  121. raise_cli/governance/parsers/vision.py +116 -0
  122. raise_cli/graph/__init__.py +1 -0
  123. raise_cli/graph/backends/__init__.py +57 -0
  124. raise_cli/graph/backends/api.py +137 -0
  125. raise_cli/graph/backends/dual.py +139 -0
  126. raise_cli/graph/backends/pending.py +84 -0
  127. raise_cli/handlers/__init__.py +3 -0
  128. raise_cli/hooks/__init__.py +54 -0
  129. raise_cli/hooks/builtin/__init__.py +1 -0
  130. raise_cli/hooks/builtin/backlog.py +216 -0
  131. raise_cli/hooks/builtin/gate_bridge.py +83 -0
  132. raise_cli/hooks/builtin/jira_sync.py +127 -0
  133. raise_cli/hooks/builtin/memory.py +117 -0
  134. raise_cli/hooks/builtin/telemetry.py +72 -0
  135. raise_cli/hooks/emitter.py +184 -0
  136. raise_cli/hooks/events.py +262 -0
  137. raise_cli/hooks/protocol.py +38 -0
  138. raise_cli/hooks/registry.py +117 -0
  139. raise_cli/mcp/__init__.py +33 -0
  140. raise_cli/mcp/bridge.py +218 -0
  141. raise_cli/mcp/models.py +43 -0
  142. raise_cli/mcp/registry.py +77 -0
  143. raise_cli/mcp/schema.py +41 -0
  144. raise_cli/memory/__init__.py +58 -0
  145. raise_cli/memory/loader.py +247 -0
  146. raise_cli/memory/migration.py +241 -0
  147. raise_cli/memory/models.py +169 -0
  148. raise_cli/memory/writer.py +598 -0
  149. raise_cli/onboarding/__init__.py +103 -0
  150. raise_cli/onboarding/bootstrap.py +324 -0
  151. raise_cli/onboarding/claudemd.py +17 -0
  152. raise_cli/onboarding/conventions.py +742 -0
  153. raise_cli/onboarding/detection.py +374 -0
  154. raise_cli/onboarding/governance.py +443 -0
  155. raise_cli/onboarding/instructions.py +672 -0
  156. raise_cli/onboarding/manifest.py +201 -0
  157. raise_cli/onboarding/memory_md.py +399 -0
  158. raise_cli/onboarding/migration.py +207 -0
  159. raise_cli/onboarding/profile.py +624 -0
  160. raise_cli/onboarding/skill_conflict.py +100 -0
  161. raise_cli/onboarding/skill_manifest.py +176 -0
  162. raise_cli/onboarding/skills.py +437 -0
  163. raise_cli/onboarding/workflows.py +101 -0
  164. raise_cli/output/__init__.py +28 -0
  165. raise_cli/output/console.py +394 -0
  166. raise_cli/output/formatters/__init__.py +9 -0
  167. raise_cli/output/formatters/adapters.py +135 -0
  168. raise_cli/output/formatters/discover.py +439 -0
  169. raise_cli/output/formatters/skill.py +298 -0
  170. raise_cli/publish/__init__.py +3 -0
  171. raise_cli/publish/changelog.py +80 -0
  172. raise_cli/publish/check.py +179 -0
  173. raise_cli/publish/version.py +172 -0
  174. raise_cli/rai_base/__init__.py +22 -0
  175. raise_cli/rai_base/framework/__init__.py +7 -0
  176. raise_cli/rai_base/framework/methodology.yaml +233 -0
  177. raise_cli/rai_base/governance/__init__.py +1 -0
  178. raise_cli/rai_base/governance/architecture/__init__.py +1 -0
  179. raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
  180. raise_cli/rai_base/governance/architecture/system-context.md +34 -0
  181. raise_cli/rai_base/governance/architecture/system-design.md +24 -0
  182. raise_cli/rai_base/governance/backlog.md +8 -0
  183. raise_cli/rai_base/governance/guardrails.md +17 -0
  184. raise_cli/rai_base/governance/prd.md +25 -0
  185. raise_cli/rai_base/governance/vision.md +16 -0
  186. raise_cli/rai_base/identity/__init__.py +8 -0
  187. raise_cli/rai_base/identity/core.md +119 -0
  188. raise_cli/rai_base/identity/perspective.md +119 -0
  189. raise_cli/rai_base/memory/__init__.py +7 -0
  190. raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
  191. raise_cli/schemas/__init__.py +3 -0
  192. raise_cli/schemas/journal.py +49 -0
  193. raise_cli/schemas/session_state.py +117 -0
  194. raise_cli/session/__init__.py +5 -0
  195. raise_cli/session/bundle.py +820 -0
  196. raise_cli/session/close.py +268 -0
  197. raise_cli/session/journal.py +119 -0
  198. raise_cli/session/resolver.py +126 -0
  199. raise_cli/session/state.py +187 -0
  200. raise_cli/skills/__init__.py +44 -0
  201. raise_cli/skills/locator.py +141 -0
  202. raise_cli/skills/name_checker.py +199 -0
  203. raise_cli/skills/parser.py +145 -0
  204. raise_cli/skills/scaffold.py +212 -0
  205. raise_cli/skills/schema.py +132 -0
  206. raise_cli/skills/skillsets.py +195 -0
  207. raise_cli/skills/validator.py +197 -0
  208. raise_cli/skills_base/__init__.py +80 -0
  209. raise_cli/skills_base/contract-template.md +60 -0
  210. raise_cli/skills_base/preamble.md +37 -0
  211. raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
  212. raise_cli/skills_base/rai-debug/SKILL.md +171 -0
  213. raise_cli/skills_base/rai-discover/SKILL.md +167 -0
  214. raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
  215. raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
  216. raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
  217. raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
  218. raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
  219. raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
  220. raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
  221. raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
  222. raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
  223. raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
  224. raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
  225. raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
  226. raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
  227. raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
  228. raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
  229. raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
  230. raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
  231. raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
  232. raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
  233. raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
  234. raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
  235. raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
  236. raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
  237. raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
  238. raise_cli/skills_base/rai-research/SKILL.md +143 -0
  239. raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
  240. raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
  241. raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
  242. raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
  243. raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
  244. raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
  245. raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
  246. raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
  247. raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
  248. raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
  249. raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
  250. raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
  251. raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
  252. raise_cli/telemetry/__init__.py +42 -0
  253. raise_cli/telemetry/schemas.py +285 -0
  254. raise_cli/telemetry/writer.py +217 -0
  255. raise_cli/tier/__init__.py +0 -0
  256. raise_cli/tier/context.py +134 -0
  257. raise_cli/viz/__init__.py +7 -0
  258. raise_cli/viz/generator.py +406 -0
  259. raise_cli-2.2.1.dist-info/METADATA +433 -0
  260. raise_cli-2.2.1.dist-info/RECORD +264 -0
  261. raise_cli-2.2.1.dist-info/WHEEL +4 -0
  262. raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
  263. raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
  264. raise_cli-2.2.1.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,268 @@
1
+ """Session close orchestrator.
2
+
3
+ Processes structured session output and performs all writes atomically:
4
+ 1. Record session in personal/sessions/index.jsonl (developer-specific)
5
+ 2. Append patterns to memory/patterns.jsonl (project knowledge)
6
+ 3. Update coaching corrections in developer.yaml
7
+ 4. Update coaching observations in developer.yaml
8
+ 5. Clear current_session in developer.yaml
9
+ 6. Write session-state.yaml (project-level working state)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+ from datetime import date
17
+ from pathlib import Path
18
+ from typing import cast
19
+
20
+ import yaml
21
+
22
+ from raise_cli.memory.writer import (
23
+ PatternInput,
24
+ PatternSubType,
25
+ SessionInput,
26
+ WriteResult,
27
+ append_pattern,
28
+ append_session,
29
+ )
30
+ from raise_cli.onboarding.profile import (
31
+ DeveloperProfile,
32
+ add_correction,
33
+ end_session,
34
+ save_developer_profile,
35
+ update_coaching,
36
+ )
37
+ from raise_cli.schemas.session_state import (
38
+ CurrentWork,
39
+ EpicProgress,
40
+ LastSession,
41
+ PendingItems,
42
+ SessionState,
43
+ )
44
+ from raise_cli.session.state import save_session_state
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ @dataclass
50
+ class CloseInput:
51
+ """Structured input for session close.
52
+
53
+ Can be populated from CLI flags or from a state file.
54
+ """
55
+
56
+ session_id: str = ""
57
+ summary: str = ""
58
+ session_type: str = "feature"
59
+ outcomes: list[str] = field(default_factory=lambda: list[str]())
60
+ patterns: list[dict[str, str]] = field(
61
+ default_factory=lambda: list[dict[str, str]]()
62
+ )
63
+ corrections: list[dict[str, str]] = field(
64
+ default_factory=lambda: list[dict[str, str]]()
65
+ )
66
+ current_work: dict[str, str] | None = None
67
+ pending: dict[str, list[str]] | None = None
68
+ progress: dict[str, int | str] | None = None
69
+ completed_epics: list[str] = field(default_factory=lambda: list[str]())
70
+ coaching: dict[str, object] | None = None
71
+ notes: str = ""
72
+ narrative: str = ""
73
+ next_session_prompt: str = ""
74
+
75
+
76
+ @dataclass
77
+ class CloseResult:
78
+ """Result of session close operation."""
79
+
80
+ success: bool
81
+ session_id: str = ""
82
+ patterns_added: int = 0
83
+ corrections_added: int = 0
84
+ messages: list[str] = field(default_factory=lambda: list[str]())
85
+
86
+
87
+ def load_state_file(path: Path) -> CloseInput:
88
+ """Load close input from a YAML state file.
89
+
90
+ Args:
91
+ path: Path to the state file written by the AI skill.
92
+
93
+ Returns:
94
+ CloseInput populated from the file.
95
+
96
+ Raises:
97
+ FileNotFoundError: If the file doesn't exist.
98
+ yaml.YAMLError: If the file is not valid YAML.
99
+ """
100
+ content = path.read_text(encoding="utf-8")
101
+ data = yaml.safe_load(content)
102
+ if not isinstance(data, dict):
103
+ msg = f"State file must be a YAML mapping, got {type(data).__name__}"
104
+ raise ValueError(msg)
105
+
106
+ d = cast(dict[str, object], data)
107
+
108
+ return CloseInput(
109
+ session_id=str(d.get("session_id", "")),
110
+ summary=str(d.get("summary", "")),
111
+ session_type=str(d.get("type", "feature")),
112
+ outcomes=list(d.get("outcomes", []) or []), # type: ignore[arg-type]
113
+ patterns=list(d.get("patterns", []) or []), # type: ignore[arg-type]
114
+ corrections=list(d.get("corrections", []) or []), # type: ignore[arg-type]
115
+ current_work=d.get("current_work"), # type: ignore[arg-type]
116
+ pending=d.get("pending"), # type: ignore[arg-type]
117
+ progress=d.get("progress"), # type: ignore[arg-type]
118
+ completed_epics=list(d.get("completed_epics", []) or []), # type: ignore[arg-type]
119
+ coaching=d.get("coaching"), # type: ignore[arg-type]
120
+ notes=str(d.get("notes", "")),
121
+ narrative=str(d.get("narrative", "")),
122
+ next_session_prompt=str(d.get("next_session_prompt", "")),
123
+ )
124
+
125
+
126
+ def process_session_close(
127
+ close_input: CloseInput,
128
+ profile: DeveloperProfile,
129
+ project_path: Path,
130
+ session_id: str | None = None,
131
+ ) -> CloseResult:
132
+ """Process session close — perform all writes.
133
+
134
+ Args:
135
+ close_input: Structured session close data.
136
+ profile: Current developer profile.
137
+ project_path: Absolute path to the project root.
138
+
139
+ Returns:
140
+ CloseResult with operation summary.
141
+ """
142
+ result = CloseResult(success=True)
143
+ memory_dir = project_path / ".raise" / "rai" / "memory"
144
+ personal_dir = project_path / ".raise" / "rai" / "personal"
145
+
146
+ # 1. Record session in personal/sessions/index.jsonl
147
+ session_input = SessionInput(
148
+ topic=close_input.summary,
149
+ session_type=close_input.session_type,
150
+ outcomes=close_input.outcomes,
151
+ )
152
+ session_result = append_session(personal_dir, session_input)
153
+ result.session_id = session_result.id
154
+ result.messages.append(f"Session {session_result.id} recorded")
155
+
156
+ # 2. Append patterns
157
+ for pat_data in close_input.patterns:
158
+ description = pat_data.get("description", "")
159
+ if not description:
160
+ continue
161
+ context = pat_data.get("context", "")
162
+ context_list = [c.strip() for c in context.split(",")]
163
+ pat_type = pat_data.get("type", "process")
164
+ try:
165
+ sub_type = PatternSubType(pat_type)
166
+ except ValueError:
167
+ sub_type = PatternSubType.PROCESS
168
+
169
+ pat_input = PatternInput(
170
+ content=description,
171
+ sub_type=sub_type,
172
+ context=context_list,
173
+ learned_from=result.session_id,
174
+ )
175
+ pat_result: WriteResult = append_pattern(
176
+ memory_dir, pat_input, developer_prefix=profile.get_pattern_prefix()
177
+ )
178
+ result.patterns_added += 1
179
+ result.messages.append(f"Pattern {pat_result.id} added")
180
+
181
+ # 3. Update coaching corrections
182
+ updated_profile = profile
183
+ for corr_data in close_input.corrections:
184
+ what = corr_data.get("what", "")
185
+ lesson = corr_data.get("lesson", "")
186
+ if what and lesson:
187
+ updated_profile = add_correction(
188
+ updated_profile, result.session_id, what, lesson
189
+ )
190
+ result.corrections_added += 1
191
+
192
+ # 4. Update coaching observations
193
+ if close_input.coaching:
194
+ c = close_input.coaching
195
+ updated_profile = update_coaching(
196
+ updated_profile,
197
+ strengths=c.get("strengths"), # type: ignore[arg-type]
198
+ growth_edge=c.get("growth_edge"), # type: ignore[arg-type]
199
+ trust_level=c.get("trust_level"), # type: ignore[arg-type]
200
+ autonomy=c.get("autonomy"), # type: ignore[arg-type]
201
+ relationship=c.get("relationship"), # type: ignore[arg-type]
202
+ communication_notes=c.get("communication_notes"), # type: ignore[arg-type]
203
+ )
204
+ result.messages.append("Coaching updated")
205
+
206
+ # 5. Remove session from active_sessions and save profile
207
+ updated_profile = end_session(updated_profile, session_id=session_id or result.session_id)
208
+ save_developer_profile(updated_profile)
209
+ result.messages.append("Profile updated")
210
+
211
+ # 5. Write session-state.yaml
212
+ if close_input.current_work:
213
+ cw = close_input.current_work
214
+ current_work = CurrentWork(
215
+ release=cw.get("release", ""),
216
+ epic=cw.get("epic", ""),
217
+ story=cw.get("story", ""),
218
+ phase=cw.get("phase", ""),
219
+ branch=cw.get("branch", ""),
220
+ )
221
+ else:
222
+ current_work = CurrentWork(release="", epic="", story="", phase="", branch="")
223
+
224
+ pending = PendingItems()
225
+ if close_input.pending:
226
+ pending = PendingItems(
227
+ decisions=close_input.pending.get("decisions", []),
228
+ blockers=close_input.pending.get("blockers", []),
229
+ next_actions=close_input.pending.get("next_actions", []),
230
+ )
231
+
232
+ # Build progress if provided
233
+ progress: EpicProgress | None = None
234
+ if close_input.progress:
235
+ p = close_input.progress
236
+ progress = EpicProgress(
237
+ epic=str(p.get("epic", "")),
238
+ stories_done=int(p.get("stories_done", 0)),
239
+ stories_total=int(p.get("stories_total", 0)),
240
+ sp_done=int(p.get("sp_done", 0)),
241
+ sp_total=int(p.get("sp_total", 0)),
242
+ )
243
+
244
+ session_state = SessionState(
245
+ current_work=current_work,
246
+ last_session=LastSession(
247
+ id=result.session_id,
248
+ date=date.today(),
249
+ developer=profile.name,
250
+ summary=close_input.summary,
251
+ patterns_captured=[f"PAT-{result.session_id}" for _ in close_input.patterns]
252
+ if close_input.patterns
253
+ else [],
254
+ ),
255
+ pending=pending,
256
+ notes=close_input.notes,
257
+ narrative=close_input.narrative,
258
+ next_session_prompt=close_input.next_session_prompt,
259
+ progress=progress,
260
+ completed_epics=close_input.completed_epics,
261
+ )
262
+ # Write to flat file (not per-session dir) — flat file serves as
263
+ # cross-session continuity buffer. Next session start will migrate
264
+ # it to the new per-session directory.
265
+ save_session_state(project_path, session_state)
266
+ result.messages.append("Session state saved")
267
+
268
+ return result
@@ -0,0 +1,119 @@
1
+ """Session journal — incremental memory persistence.
2
+
3
+ Append-only journal for preserving decisions, insights, and task completions
4
+ across context compaction events. Each entry is a JSONL line in
5
+ .raise/rai/personal/sessions/{session_id}/journal.jsonl.
6
+
7
+ Two consumers:
8
+ - Agent: calls `rai session journal add` to record decisions/insights
9
+ - Hooks: call `rai session journal show --compact` to inject context post-compaction
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from raise_cli.memory.writer import WriteResult, get_next_id
20
+ from raise_cli.schemas.journal import JournalEntry, JournalEntryType
21
+
22
+ JOURNAL_FILE = "journal.jsonl"
23
+
24
+
25
+ def append_journal_entry(
26
+ session_dir: Path,
27
+ entry_type: JournalEntryType,
28
+ content: str,
29
+ tags: list[str] | None = None,
30
+ timestamp: datetime | None = None,
31
+ ) -> WriteResult:
32
+ """Append a journal entry to the session journal.
33
+
34
+ Args:
35
+ session_dir: Path to per-session directory.
36
+ entry_type: Category of entry.
37
+ content: The content to preserve.
38
+ tags: Optional context tags.
39
+ timestamp: When the entry was created. Defaults to now.
40
+
41
+ Returns:
42
+ WriteResult with generated ID.
43
+ """
44
+ file_path = session_dir / JOURNAL_FILE
45
+ entry_id = get_next_id(file_path, "JRN")
46
+ ts = timestamp or datetime.now()
47
+
48
+ data: dict[str, Any] = {
49
+ "id": entry_id,
50
+ "timestamp": ts.isoformat(),
51
+ "entry_type": entry_type.value,
52
+ "content": content,
53
+ "tags": tags or [],
54
+ }
55
+
56
+ file_path.parent.mkdir(parents=True, exist_ok=True)
57
+ with file_path.open("a", encoding="utf-8") as f:
58
+ f.write(json.dumps(data) + "\n")
59
+
60
+ return WriteResult(
61
+ success=True,
62
+ id=entry_id,
63
+ file_path=str(file_path),
64
+ message=f"Journal {entry_id} appended ({entry_type.value})",
65
+ )
66
+
67
+
68
+ def read_journal(
69
+ session_dir: Path,
70
+ last_n: int | None = None,
71
+ ) -> list[JournalEntry]:
72
+ """Read journal entries from a session.
73
+
74
+ Args:
75
+ session_dir: Path to per-session directory.
76
+ last_n: If set, return only the last N entries.
77
+
78
+ Returns:
79
+ List of JournalEntry objects, oldest first.
80
+ """
81
+ file_path = session_dir / JOURNAL_FILE
82
+ if not file_path.exists():
83
+ return []
84
+
85
+ entries: list[JournalEntry] = []
86
+ for line in file_path.read_text(encoding="utf-8").splitlines():
87
+ line = line.strip()
88
+ if not line:
89
+ continue
90
+ data = json.loads(line)
91
+ entries.append(JournalEntry(**data))
92
+
93
+ if last_n is not None:
94
+ entries = entries[-last_n:]
95
+
96
+ return entries
97
+
98
+
99
+ def format_journal_compact(entries: list[JournalEntry]) -> str:
100
+ """Format journal entries for compact context injection.
101
+
102
+ Produces a token-efficient summary suitable for post-compaction
103
+ context injection via hook stdout.
104
+
105
+ Args:
106
+ entries: Journal entries to format.
107
+
108
+ Returns:
109
+ Compact multi-line string.
110
+ """
111
+ if not entries:
112
+ return "No journal entries."
113
+
114
+ lines: list[str] = ["# Session Journal"]
115
+ for entry in entries:
116
+ tag_suffix = f" [{', '.join(entry.tags)}]" if entry.tags else ""
117
+ lines.append(f"- {entry.entry_type.value.upper()}: {entry.content}{tag_suffix}")
118
+
119
+ return "\n".join(lines)
@@ -0,0 +1,126 @@
1
+ """Session ID resolution logic.
2
+
3
+ Resolves session ID from --session flag or RAI_SESSION_ID env var
4
+ following priority order: flag > env var > error (or None for optional).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from raise_cli.exceptions import RaiSessionNotFoundError
10
+
11
+
12
+ def _normalize_session_id(session_id: str) -> str:
13
+ """Normalize session ID to standard format.
14
+
15
+ Accepts:
16
+ - "SES-177" (already normalized)
17
+ - "ses-177" (lowercase prefix)
18
+ - "177" (numeric only)
19
+
20
+ Rejects values containing path traversal components (CWE-23) since
21
+ session IDs are used to construct file paths.
22
+
23
+ Returns:
24
+ Normalized session ID in format "SES-NNN".
25
+
26
+ Raises:
27
+ ValueError: If session_id contains '..' or path separator characters.
28
+ """
29
+ session_id = session_id.strip()
30
+
31
+ if not session_id:
32
+ return session_id # Empty string, caller will handle
33
+
34
+ # CWE-23: Reject path traversal components before any path construction.
35
+ # Session IDs flow from env vars (RAI_SESSION_ID) into get_session_dir().
36
+ if ".." in session_id or "/" in session_id or "\\" in session_id:
37
+ raise ValueError(
38
+ f"Invalid session ID — path traversal characters detected: {session_id!r}"
39
+ )
40
+
41
+ # Already normalized (case-insensitive check)
42
+ if session_id.upper().startswith("SES-"):
43
+ return session_id.upper()
44
+
45
+ # Numeric only — add prefix
46
+ if session_id.isdigit():
47
+ return f"SES-{session_id}"
48
+
49
+ # Unknown format — return as-is (let caller handle invalid formats)
50
+ return session_id
51
+
52
+
53
+ def resolve_session_id(
54
+ session_flag: str | None,
55
+ env_var: str | None,
56
+ ) -> str:
57
+ """Resolve session ID from flag or environment variable.
58
+
59
+ Resolution priority:
60
+ 1. --session flag (explicit, per-command)
61
+ 2. RAI_SESSION_ID env var (per-terminal/process)
62
+ 3. RaiSessionNotFoundError (no session context)
63
+
64
+ Normalization:
65
+ - "177" → "SES-177"
66
+ - "ses-177" → "SES-177"
67
+ - "SES-177" → "SES-177" (no change)
68
+
69
+ Args:
70
+ session_flag: Value from --session CLI flag.
71
+ env_var: Value from RAI_SESSION_ID environment variable.
72
+
73
+ Returns:
74
+ Normalized session ID (e.g., "SES-177").
75
+
76
+ Raises:
77
+ RaiSessionNotFoundError: When neither flag nor env var is provided.
78
+
79
+ Example:
80
+ >>> resolve_session_id(session_flag="177", env_var=None)
81
+ 'SES-177'
82
+ >>> resolve_session_id(session_flag=None, env_var="SES-178")
83
+ 'SES-178'
84
+ """
85
+ # Priority 1: --session flag
86
+ if session_flag and session_flag.strip():
87
+ return _normalize_session_id(session_flag)
88
+
89
+ # Priority 2: RAI_SESSION_ID env var
90
+ if env_var and env_var.strip():
91
+ return _normalize_session_id(env_var)
92
+
93
+ # Priority 3: Error
94
+ raise RaiSessionNotFoundError(
95
+ "No session ID provided",
96
+ hint="Pass --session SES-NNN or set RAI_SESSION_ID environment variable",
97
+ )
98
+
99
+
100
+ def resolve_session_id_optional(
101
+ session_flag: str | None,
102
+ env_var: str | None,
103
+ ) -> str | None:
104
+ """Resolve session ID, returning None when neither source is provided.
105
+
106
+ Same resolution priority as resolve_session_id but returns None instead
107
+ of raising when no session context exists. Use for commands where
108
+ --session is optional (e.g., telemetry emit commands).
109
+
110
+ Args:
111
+ session_flag: Value from --session CLI flag.
112
+ env_var: Value from RAI_SESSION_ID environment variable.
113
+
114
+ Returns:
115
+ Normalized session ID, or None if neither source provided.
116
+ """
117
+ # Priority 1: --session flag
118
+ if session_flag and session_flag.strip():
119
+ return _normalize_session_id(session_flag)
120
+
121
+ # Priority 2: RAI_SESSION_ID env var
122
+ if env_var and env_var.strip():
123
+ return _normalize_session_id(env_var)
124
+
125
+ # Priority 3: None (no session context — that's OK)
126
+ return None
@@ -0,0 +1,187 @@
1
+ """Session state persistence.
2
+
3
+ Reads and writes .raise/rai/personal/session-state.yaml — per-developer working
4
+ state that is overwritten each session-close and read by session-start.
5
+
6
+ Migration: if the old path (.raise/rai/session-state.yaml) exists and the new
7
+ personal/ path does not, the file is moved automatically.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import shutil
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+ from pydantic import ValidationError
18
+
19
+ from raise_cli.config.paths import get_session_dir
20
+ from raise_cli.schemas.session_state import SessionState
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # New path: personal directory (gitignored, per-developer)
25
+ SESSION_STATE_REL_PATH = Path(".raise") / "rai" / "personal" / "session-state.yaml"
26
+
27
+ # Legacy path for migration
28
+ _LEGACY_SESSION_STATE_REL_PATH = Path(".raise") / "rai" / "session-state.yaml"
29
+
30
+
31
+ def get_session_state_path(project_path: Path, session_id: str | None = None) -> Path:
32
+ """Get the absolute path to session state file.
33
+
34
+ When session_id is provided, returns per-session path:
35
+ .raise/rai/personal/sessions/{session_id}/state.yaml
36
+
37
+ When session_id is None, returns legacy flat path:
38
+ .raise/rai/personal/session-state.yaml
39
+
40
+ Args:
41
+ project_path: Absolute path to the project root.
42
+ session_id: Optional session ID for per-session isolation.
43
+
44
+ Returns:
45
+ Path to the session state file.
46
+ """
47
+ if session_id is not None:
48
+ return get_session_dir(session_id, project_path) / "state.yaml"
49
+ return project_path / SESSION_STATE_REL_PATH
50
+
51
+
52
+ def _migrate_session_state(project_path: Path) -> None:
53
+ """Migrate session-state.yaml from old shared path to personal/.
54
+
55
+ Moves .raise/rai/session-state.yaml → .raise/rai/personal/session-state.yaml
56
+ if old exists and new does not.
57
+ """
58
+ old_path = project_path / _LEGACY_SESSION_STATE_REL_PATH
59
+ new_path = get_session_state_path(project_path)
60
+
61
+ if old_path.exists() and not new_path.exists():
62
+ new_path.parent.mkdir(parents=True, exist_ok=True)
63
+ shutil.move(str(old_path), str(new_path))
64
+ logger.info("Migrated session state: %s → %s", old_path, new_path)
65
+
66
+
67
+ def migrate_flat_to_session(project_path: Path, session_id: str) -> bool:
68
+ """One-time migration from flat layout to per-session directory.
69
+
70
+ Moves:
71
+ - personal/session-state.yaml → personal/sessions/{session_id}/state.yaml
72
+ - personal/telemetry/signals.jsonl → personal/sessions/{session_id}/signals.jsonl
73
+
74
+ Args:
75
+ project_path: Absolute path to the project root.
76
+ session_id: Session ID for the target per-session directory.
77
+
78
+ Returns:
79
+ True if migration occurred, False if nothing to migrate.
80
+ """
81
+ personal_dir = project_path / ".raise" / "rai" / "personal"
82
+ flat_state = personal_dir / "session-state.yaml"
83
+ flat_signals = personal_dir / "telemetry" / "signals.jsonl"
84
+
85
+ # Nothing to migrate
86
+ if not flat_state.exists() and not flat_signals.exists():
87
+ return False
88
+
89
+ # Don't migrate if session dir already exists
90
+ session_dir = get_session_dir(session_id, project_path)
91
+ if session_dir.exists():
92
+ return False
93
+
94
+ session_dir.mkdir(parents=True, exist_ok=True)
95
+
96
+ if flat_state.exists():
97
+ shutil.move(str(flat_state), str(session_dir / "state.yaml"))
98
+ logger.info("Migrated state: %s → %s/state.yaml", flat_state, session_dir)
99
+
100
+ if flat_signals.exists():
101
+ shutil.move(str(flat_signals), str(session_dir / "signals.jsonl"))
102
+ logger.info(
103
+ "Migrated signals: %s → %s/signals.jsonl", flat_signals, session_dir
104
+ )
105
+
106
+ return True
107
+
108
+
109
+ def cleanup_session_dir(project_path: Path, session_id: str) -> None:
110
+ """Remove per-session directory after session close.
111
+
112
+ Only removes the specific session directory. Does NOT remove
113
+ shared files (index.jsonl, memory/).
114
+
115
+ Args:
116
+ project_path: Absolute path to the project root.
117
+ session_id: Session ID whose directory to remove.
118
+ """
119
+ session_dir = get_session_dir(session_id, project_path)
120
+ if session_dir.exists():
121
+ shutil.rmtree(session_dir)
122
+ logger.info("Cleaned up session dir: %s", session_dir)
123
+
124
+
125
+ def load_session_state(
126
+ project_path: Path, session_id: str | None = None
127
+ ) -> SessionState | None:
128
+ """Load session state from per-session directory or flat file.
129
+
130
+ When session_id is provided, loads from per-session directory.
131
+ When session_id is None, loads from legacy flat file (with migration).
132
+
133
+ Args:
134
+ project_path: Absolute path to the project root.
135
+ session_id: Optional session ID for per-session isolation.
136
+
137
+ Returns:
138
+ SessionState if file exists and is valid, None otherwise.
139
+ """
140
+ if session_id is None:
141
+ _migrate_session_state(project_path)
142
+ state_path = get_session_state_path(project_path, session_id)
143
+
144
+ if not state_path.exists():
145
+ logger.debug("Session state not found: %s", state_path)
146
+ return None
147
+
148
+ try:
149
+ content = state_path.read_text(encoding="utf-8")
150
+ data = yaml.safe_load(content)
151
+ if data is None:
152
+ logger.warning("Empty session state: %s", state_path)
153
+ return None
154
+ return SessionState.model_validate(data)
155
+ except yaml.YAMLError as e:
156
+ logger.warning("Invalid YAML in session state: %s", e)
157
+ return None
158
+ except ValidationError as e:
159
+ logger.warning("Invalid session state schema: %s", e)
160
+ return None
161
+
162
+
163
+ def save_session_state(
164
+ project_path: Path, state: SessionState, session_id: str | None = None
165
+ ) -> None:
166
+ """Save session state to per-session directory or flat file.
167
+
168
+ When session_id is provided, writes to per-session directory.
169
+ When session_id is None, writes to legacy flat file.
170
+
171
+ Creates parent directories if they don't exist.
172
+ Overwrites any existing file.
173
+
174
+ Args:
175
+ project_path: Absolute path to the project root.
176
+ state: The session state to save.
177
+ session_id: Optional session ID for per-session isolation.
178
+ """
179
+ state_path = get_session_state_path(project_path, session_id)
180
+ state_path.parent.mkdir(parents=True, exist_ok=True)
181
+
182
+ data = state.model_dump(mode="json")
183
+ content = yaml.dump(
184
+ data, default_flow_style=False, allow_unicode=True, sort_keys=False
185
+ )
186
+ state_path.write_text(content, encoding="utf-8")
187
+ logger.debug("Saved session state: %s", state_path)