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.
- raise_cli/__init__.py +38 -0
- raise_cli/__main__.py +30 -0
- raise_cli/adapters/__init__.py +91 -0
- raise_cli/adapters/declarative/__init__.py +26 -0
- raise_cli/adapters/declarative/adapter.py +267 -0
- raise_cli/adapters/declarative/discovery.py +94 -0
- raise_cli/adapters/declarative/expressions.py +150 -0
- raise_cli/adapters/declarative/reference/__init__.py +1 -0
- raise_cli/adapters/declarative/reference/github.yaml +143 -0
- raise_cli/adapters/declarative/schema.py +98 -0
- raise_cli/adapters/filesystem.py +299 -0
- raise_cli/adapters/mcp_bridge.py +10 -0
- raise_cli/adapters/mcp_confluence.py +246 -0
- raise_cli/adapters/mcp_jira.py +405 -0
- raise_cli/adapters/models.py +205 -0
- raise_cli/adapters/protocols.py +180 -0
- raise_cli/adapters/registry.py +90 -0
- raise_cli/adapters/sync.py +149 -0
- raise_cli/agents/__init__.py +14 -0
- raise_cli/agents/antigravity.yaml +8 -0
- raise_cli/agents/claude.yaml +8 -0
- raise_cli/agents/copilot.yaml +8 -0
- raise_cli/agents/copilot_plugin.py +124 -0
- raise_cli/agents/cursor.yaml +7 -0
- raise_cli/agents/roo.yaml +8 -0
- raise_cli/agents/windsurf.yaml +8 -0
- raise_cli/artifacts/__init__.py +30 -0
- raise_cli/artifacts/models.py +43 -0
- raise_cli/artifacts/reader.py +55 -0
- raise_cli/artifacts/renderer.py +104 -0
- raise_cli/artifacts/story_design.py +69 -0
- raise_cli/artifacts/writer.py +45 -0
- raise_cli/backlog/__init__.py +1 -0
- raise_cli/backlog/sync.py +115 -0
- raise_cli/cli/__init__.py +3 -0
- raise_cli/cli/commands/__init__.py +3 -0
- raise_cli/cli/commands/_resolve.py +153 -0
- raise_cli/cli/commands/adapters.py +362 -0
- raise_cli/cli/commands/artifact.py +137 -0
- raise_cli/cli/commands/backlog.py +333 -0
- raise_cli/cli/commands/base.py +31 -0
- raise_cli/cli/commands/discover.py +551 -0
- raise_cli/cli/commands/docs.py +130 -0
- raise_cli/cli/commands/doctor.py +177 -0
- raise_cli/cli/commands/gate.py +223 -0
- raise_cli/cli/commands/graph.py +1086 -0
- raise_cli/cli/commands/info.py +81 -0
- raise_cli/cli/commands/init.py +746 -0
- raise_cli/cli/commands/journal.py +167 -0
- raise_cli/cli/commands/mcp.py +524 -0
- raise_cli/cli/commands/memory.py +467 -0
- raise_cli/cli/commands/pattern.py +348 -0
- raise_cli/cli/commands/profile.py +59 -0
- raise_cli/cli/commands/publish.py +80 -0
- raise_cli/cli/commands/release.py +338 -0
- raise_cli/cli/commands/session.py +528 -0
- raise_cli/cli/commands/signal.py +410 -0
- raise_cli/cli/commands/skill.py +350 -0
- raise_cli/cli/commands/skill_set.py +145 -0
- raise_cli/cli/error_handler.py +158 -0
- raise_cli/cli/main.py +163 -0
- raise_cli/compat.py +66 -0
- raise_cli/config/__init__.py +41 -0
- raise_cli/config/agent_plugin.py +105 -0
- raise_cli/config/agent_registry.py +233 -0
- raise_cli/config/agents.py +120 -0
- raise_cli/config/ide.py +32 -0
- raise_cli/config/paths.py +379 -0
- raise_cli/config/settings.py +180 -0
- raise_cli/context/__init__.py +42 -0
- raise_cli/context/analyzers/__init__.py +16 -0
- raise_cli/context/analyzers/models.py +36 -0
- raise_cli/context/analyzers/protocol.py +43 -0
- raise_cli/context/analyzers/python.py +292 -0
- raise_cli/context/builder.py +1569 -0
- raise_cli/context/diff.py +213 -0
- raise_cli/context/extractors/__init__.py +13 -0
- raise_cli/context/extractors/skills.py +121 -0
- raise_cli/core/__init__.py +37 -0
- raise_cli/core/files.py +66 -0
- raise_cli/core/text.py +174 -0
- raise_cli/core/tools.py +441 -0
- raise_cli/discovery/__init__.py +50 -0
- raise_cli/discovery/analyzer.py +691 -0
- raise_cli/discovery/drift.py +355 -0
- raise_cli/discovery/scanner.py +1687 -0
- raise_cli/doctor/__init__.py +4 -0
- raise_cli/doctor/checks/__init__.py +1 -0
- raise_cli/doctor/checks/environment.py +110 -0
- raise_cli/doctor/checks/project.py +238 -0
- raise_cli/doctor/fix.py +80 -0
- raise_cli/doctor/models.py +56 -0
- raise_cli/doctor/protocol.py +43 -0
- raise_cli/doctor/registry.py +100 -0
- raise_cli/doctor/report.py +141 -0
- raise_cli/doctor/runner.py +95 -0
- raise_cli/engines/__init__.py +3 -0
- raise_cli/exceptions.py +215 -0
- raise_cli/gates/__init__.py +19 -0
- raise_cli/gates/builtin/__init__.py +1 -0
- raise_cli/gates/builtin/coverage.py +52 -0
- raise_cli/gates/builtin/lint.py +48 -0
- raise_cli/gates/builtin/tests.py +48 -0
- raise_cli/gates/builtin/types.py +48 -0
- raise_cli/gates/models.py +40 -0
- raise_cli/gates/protocol.py +41 -0
- raise_cli/gates/registry.py +141 -0
- raise_cli/governance/__init__.py +11 -0
- raise_cli/governance/extractor.py +412 -0
- raise_cli/governance/models.py +134 -0
- raise_cli/governance/parsers/__init__.py +35 -0
- raise_cli/governance/parsers/_convert.py +38 -0
- raise_cli/governance/parsers/adr.py +274 -0
- raise_cli/governance/parsers/backlog.py +356 -0
- raise_cli/governance/parsers/constitution.py +119 -0
- raise_cli/governance/parsers/epic.py +323 -0
- raise_cli/governance/parsers/glossary.py +316 -0
- raise_cli/governance/parsers/guardrails.py +345 -0
- raise_cli/governance/parsers/prd.py +112 -0
- raise_cli/governance/parsers/roadmap.py +118 -0
- raise_cli/governance/parsers/vision.py +116 -0
- raise_cli/graph/__init__.py +1 -0
- raise_cli/graph/backends/__init__.py +57 -0
- raise_cli/graph/backends/api.py +137 -0
- raise_cli/graph/backends/dual.py +139 -0
- raise_cli/graph/backends/pending.py +84 -0
- raise_cli/handlers/__init__.py +3 -0
- raise_cli/hooks/__init__.py +54 -0
- raise_cli/hooks/builtin/__init__.py +1 -0
- raise_cli/hooks/builtin/backlog.py +216 -0
- raise_cli/hooks/builtin/gate_bridge.py +83 -0
- raise_cli/hooks/builtin/jira_sync.py +127 -0
- raise_cli/hooks/builtin/memory.py +117 -0
- raise_cli/hooks/builtin/telemetry.py +72 -0
- raise_cli/hooks/emitter.py +184 -0
- raise_cli/hooks/events.py +262 -0
- raise_cli/hooks/protocol.py +38 -0
- raise_cli/hooks/registry.py +117 -0
- raise_cli/mcp/__init__.py +33 -0
- raise_cli/mcp/bridge.py +218 -0
- raise_cli/mcp/models.py +43 -0
- raise_cli/mcp/registry.py +77 -0
- raise_cli/mcp/schema.py +41 -0
- raise_cli/memory/__init__.py +58 -0
- raise_cli/memory/loader.py +247 -0
- raise_cli/memory/migration.py +241 -0
- raise_cli/memory/models.py +169 -0
- raise_cli/memory/writer.py +598 -0
- raise_cli/onboarding/__init__.py +103 -0
- raise_cli/onboarding/bootstrap.py +324 -0
- raise_cli/onboarding/claudemd.py +17 -0
- raise_cli/onboarding/conventions.py +742 -0
- raise_cli/onboarding/detection.py +374 -0
- raise_cli/onboarding/governance.py +443 -0
- raise_cli/onboarding/instructions.py +672 -0
- raise_cli/onboarding/manifest.py +201 -0
- raise_cli/onboarding/memory_md.py +399 -0
- raise_cli/onboarding/migration.py +207 -0
- raise_cli/onboarding/profile.py +624 -0
- raise_cli/onboarding/skill_conflict.py +100 -0
- raise_cli/onboarding/skill_manifest.py +176 -0
- raise_cli/onboarding/skills.py +437 -0
- raise_cli/onboarding/workflows.py +101 -0
- raise_cli/output/__init__.py +28 -0
- raise_cli/output/console.py +394 -0
- raise_cli/output/formatters/__init__.py +9 -0
- raise_cli/output/formatters/adapters.py +135 -0
- raise_cli/output/formatters/discover.py +439 -0
- raise_cli/output/formatters/skill.py +298 -0
- raise_cli/publish/__init__.py +3 -0
- raise_cli/publish/changelog.py +80 -0
- raise_cli/publish/check.py +179 -0
- raise_cli/publish/version.py +172 -0
- raise_cli/rai_base/__init__.py +22 -0
- raise_cli/rai_base/framework/__init__.py +7 -0
- raise_cli/rai_base/framework/methodology.yaml +233 -0
- raise_cli/rai_base/governance/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/__init__.py +1 -0
- raise_cli/rai_base/governance/architecture/domain-model.md +20 -0
- raise_cli/rai_base/governance/architecture/system-context.md +34 -0
- raise_cli/rai_base/governance/architecture/system-design.md +24 -0
- raise_cli/rai_base/governance/backlog.md +8 -0
- raise_cli/rai_base/governance/guardrails.md +17 -0
- raise_cli/rai_base/governance/prd.md +25 -0
- raise_cli/rai_base/governance/vision.md +16 -0
- raise_cli/rai_base/identity/__init__.py +8 -0
- raise_cli/rai_base/identity/core.md +119 -0
- raise_cli/rai_base/identity/perspective.md +119 -0
- raise_cli/rai_base/memory/__init__.py +7 -0
- raise_cli/rai_base/memory/patterns-base.jsonl +55 -0
- raise_cli/schemas/__init__.py +3 -0
- raise_cli/schemas/journal.py +49 -0
- raise_cli/schemas/session_state.py +117 -0
- raise_cli/session/__init__.py +5 -0
- raise_cli/session/bundle.py +820 -0
- raise_cli/session/close.py +268 -0
- raise_cli/session/journal.py +119 -0
- raise_cli/session/resolver.py +126 -0
- raise_cli/session/state.py +187 -0
- raise_cli/skills/__init__.py +44 -0
- raise_cli/skills/locator.py +141 -0
- raise_cli/skills/name_checker.py +199 -0
- raise_cli/skills/parser.py +145 -0
- raise_cli/skills/scaffold.py +212 -0
- raise_cli/skills/schema.py +132 -0
- raise_cli/skills/skillsets.py +195 -0
- raise_cli/skills/validator.py +197 -0
- raise_cli/skills_base/__init__.py +80 -0
- raise_cli/skills_base/contract-template.md +60 -0
- raise_cli/skills_base/preamble.md +37 -0
- raise_cli/skills_base/rai-architecture-review/SKILL.md +137 -0
- raise_cli/skills_base/rai-debug/SKILL.md +171 -0
- raise_cli/skills_base/rai-discover/SKILL.md +167 -0
- raise_cli/skills_base/rai-discover-document/SKILL.md +128 -0
- raise_cli/skills_base/rai-discover-scan/SKILL.md +147 -0
- raise_cli/skills_base/rai-discover-start/SKILL.md +145 -0
- raise_cli/skills_base/rai-discover-validate/SKILL.md +142 -0
- raise_cli/skills_base/rai-docs-update/SKILL.md +142 -0
- raise_cli/skills_base/rai-doctor/SKILL.md +120 -0
- raise_cli/skills_base/rai-epic-close/SKILL.md +165 -0
- raise_cli/skills_base/rai-epic-close/templates/retrospective.md +68 -0
- raise_cli/skills_base/rai-epic-design/SKILL.md +146 -0
- raise_cli/skills_base/rai-epic-design/templates/design.md +24 -0
- raise_cli/skills_base/rai-epic-design/templates/scope.md +76 -0
- raise_cli/skills_base/rai-epic-plan/SKILL.md +153 -0
- raise_cli/skills_base/rai-epic-plan/_references/sequencing-strategies.md +67 -0
- raise_cli/skills_base/rai-epic-plan/templates/plan-section.md +49 -0
- raise_cli/skills_base/rai-epic-run/SKILL.md +208 -0
- raise_cli/skills_base/rai-epic-start/SKILL.md +136 -0
- raise_cli/skills_base/rai-epic-start/templates/brief.md +34 -0
- raise_cli/skills_base/rai-mcp-add/SKILL.md +176 -0
- raise_cli/skills_base/rai-mcp-remove/SKILL.md +120 -0
- raise_cli/skills_base/rai-mcp-status/SKILL.md +147 -0
- raise_cli/skills_base/rai-problem-shape/SKILL.md +138 -0
- raise_cli/skills_base/rai-project-create/SKILL.md +144 -0
- raise_cli/skills_base/rai-project-onboard/SKILL.md +162 -0
- raise_cli/skills_base/rai-quality-review/SKILL.md +189 -0
- raise_cli/skills_base/rai-research/SKILL.md +143 -0
- raise_cli/skills_base/rai-research/references/research-prompt-template.md +317 -0
- raise_cli/skills_base/rai-session-close/SKILL.md +176 -0
- raise_cli/skills_base/rai-session-start/SKILL.md +110 -0
- raise_cli/skills_base/rai-story-close/SKILL.md +198 -0
- raise_cli/skills_base/rai-story-design/SKILL.md +203 -0
- raise_cli/skills_base/rai-story-design/references/tech-design-story-v2.md +293 -0
- raise_cli/skills_base/rai-story-implement/SKILL.md +115 -0
- raise_cli/skills_base/rai-story-plan/SKILL.md +135 -0
- raise_cli/skills_base/rai-story-review/SKILL.md +178 -0
- raise_cli/skills_base/rai-story-run/SKILL.md +282 -0
- raise_cli/skills_base/rai-story-start/SKILL.md +166 -0
- raise_cli/skills_base/rai-story-start/templates/story.md +38 -0
- raise_cli/skills_base/rai-welcome/SKILL.md +134 -0
- raise_cli/telemetry/__init__.py +42 -0
- raise_cli/telemetry/schemas.py +285 -0
- raise_cli/telemetry/writer.py +217 -0
- raise_cli/tier/__init__.py +0 -0
- raise_cli/tier/context.py +134 -0
- raise_cli/viz/__init__.py +7 -0
- raise_cli/viz/generator.py +406 -0
- raise_cli-2.2.1.dist-info/METADATA +433 -0
- raise_cli-2.2.1.dist-info/RECORD +264 -0
- raise_cli-2.2.1.dist-info/WHEEL +4 -0
- raise_cli-2.2.1.dist-info/entry_points.txt +40 -0
- raise_cli-2.2.1.dist-info/licenses/LICENSE +190 -0
- 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)
|