delimit-cli 4.0.0 → 4.0.2

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.
@@ -0,0 +1,371 @@
1
+ """
2
+ Session Phoenix — Cross-model session resurrection (LED-218).
3
+
4
+ When a session dies from rate limits, context overflow, or model switch,
5
+ the user runs `delimit revive` in any model to restore working state.
6
+
7
+ Architecture:
8
+ capture_soul() -> ~/.delimit/souls/{project_hash}/{timestamp}.json
9
+ revive() -> structured context blob any AI model can read
10
+
11
+ Complements delimit_session_handoff (ledger state) by saving the
12
+ working context: task, decisions, files, blockers, next steps.
13
+ """
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import time
20
+ import uuid
21
+ from dataclasses import asdict, dataclass, field
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ MAX_SOULS_PER_PROJECT = 10
27
+ SOULS_BASE_DIR = Path.home() / ".delimit" / "souls"
28
+ _capture_counter = 0 # Monotonic counter for sub-second ordering
29
+
30
+
31
+ @dataclass
32
+ class SessionSoul:
33
+ """Compressed session state that survives death."""
34
+
35
+ soul_id: str = ""
36
+ created_at: str = ""
37
+ source_model: str = "unknown"
38
+ project_path: str = ""
39
+
40
+ # What was being worked on
41
+ active_task: str = ""
42
+ task_status: str = "in_progress" # in_progress, blocked, almost_done
43
+
44
+ # Key decisions made this session
45
+ decisions: List[str] = field(default_factory=list)
46
+
47
+ # Files touched
48
+ files_modified: List[str] = field(default_factory=list)
49
+ files_created: List[str] = field(default_factory=list)
50
+
51
+ # Context that matters
52
+ key_context: List[str] = field(default_factory=list)
53
+ blockers: List[str] = field(default_factory=list)
54
+ next_steps: List[str] = field(default_factory=list)
55
+
56
+ # Technical state
57
+ git_branch: str = ""
58
+ git_sha: str = ""
59
+ uncommitted_changes: int = 0
60
+
61
+ # Token stats
62
+ tokens_used: int = 0
63
+ context_fullness: float = 0.0
64
+
65
+
66
+ def _project_hash(project_path: str) -> str:
67
+ """Stable hash for a project path, used as directory name."""
68
+ normalized = os.path.realpath(project_path)
69
+ return hashlib.sha256(normalized.encode()).hexdigest()[:12]
70
+
71
+
72
+ def _project_dir(project_path: str) -> Path:
73
+ """Return the soul storage directory for a project."""
74
+ return SOULS_BASE_DIR / _project_hash(project_path)
75
+
76
+
77
+ def _run_git(args: List[str], cwd: str = "") -> str:
78
+ """Run a git command and return stdout, or empty string on failure."""
79
+ try:
80
+ result = subprocess.run(
81
+ ["git"] + args,
82
+ capture_output=True,
83
+ text=True,
84
+ timeout=5,
85
+ cwd=cwd or None,
86
+ )
87
+ if result.returncode == 0:
88
+ return result.stdout.strip()
89
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
90
+ pass
91
+ return ""
92
+
93
+
94
+ def _detect_git_state(project_path: str) -> Dict[str, Any]:
95
+ """Auto-detect git branch, sha, modified/created files, uncommitted count."""
96
+ cwd = project_path or os.getcwd()
97
+
98
+ branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
99
+ sha = _run_git(["rev-parse", "--short", "HEAD"], cwd=cwd)
100
+
101
+ # Uncommitted changes (staged + unstaged + untracked)
102
+ porcelain = _run_git(["status", "--porcelain"], cwd=cwd)
103
+ porcelain_lines = [l for l in porcelain.splitlines() if l.strip()] if porcelain else []
104
+ uncommitted = len(porcelain_lines)
105
+
106
+ # Files modified (tracked, staged or unstaged)
107
+ diff_names = _run_git(["diff", "--name-only", "HEAD"], cwd=cwd)
108
+ files_modified = [l.strip() for l in diff_names.splitlines() if l.strip()] if diff_names else []
109
+
110
+ # New untracked files
111
+ untracked_raw = _run_git(["ls-files", "--others", "--exclude-standard"], cwd=cwd)
112
+ files_created = [l.strip() for l in untracked_raw.splitlines() if l.strip()] if untracked_raw else []
113
+
114
+ return {
115
+ "git_branch": branch,
116
+ "git_sha": sha,
117
+ "uncommitted_changes": uncommitted,
118
+ "files_modified": files_modified,
119
+ "files_created": files_created,
120
+ }
121
+
122
+
123
+ def capture_soul(
124
+ active_task: str = "",
125
+ decisions: Optional[List[str]] = None,
126
+ key_context: Optional[List[str]] = None,
127
+ blockers: Optional[List[str]] = None,
128
+ next_steps: Optional[List[str]] = None,
129
+ source_model: str = "unknown",
130
+ project_path: str = "",
131
+ task_status: str = "in_progress",
132
+ tokens_used: int = 0,
133
+ context_fullness: float = 0.0,
134
+ ) -> SessionSoul:
135
+ """Capture current session state as a soul and persist it to disk."""
136
+ project_path = project_path or os.getcwd()
137
+ git_state = _detect_git_state(project_path)
138
+
139
+ soul = SessionSoul(
140
+ soul_id=str(uuid.uuid4())[:8],
141
+ created_at=datetime.now(timezone.utc).isoformat(),
142
+ source_model=source_model,
143
+ project_path=project_path,
144
+ active_task=active_task,
145
+ task_status=task_status,
146
+ decisions=decisions or [],
147
+ files_modified=git_state["files_modified"],
148
+ files_created=git_state["files_created"],
149
+ key_context=key_context or [],
150
+ blockers=blockers or [],
151
+ next_steps=next_steps or [],
152
+ git_branch=git_state["git_branch"],
153
+ git_sha=git_state["git_sha"],
154
+ uncommitted_changes=git_state["uncommitted_changes"],
155
+ tokens_used=tokens_used,
156
+ context_fullness=context_fullness,
157
+ )
158
+
159
+ _store_soul(soul)
160
+ return soul
161
+
162
+
163
+ def _store_soul(soul: SessionSoul) -> Path:
164
+ """Persist a soul to disk and maintain the latest pointer."""
165
+ global _capture_counter
166
+ proj_dir = _project_dir(soul.project_path)
167
+ proj_dir.mkdir(parents=True, exist_ok=True)
168
+
169
+ # Timestamp + monotonic counter for correct ordering within same second
170
+ _capture_counter += 1
171
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
172
+ filename = f"{ts}_{_capture_counter:06d}_{soul.soul_id}.json"
173
+ filepath = proj_dir / filename
174
+
175
+ data = asdict(soul)
176
+ filepath.write_text(json.dumps(data, indent=2))
177
+
178
+ # Update latest.json as a copy (symlinks can be fragile across systems)
179
+ latest = proj_dir / "latest.json"
180
+ latest.write_text(json.dumps(data, indent=2))
181
+
182
+ # Auto-prune to MAX_SOULS_PER_PROJECT (keep latest N by name sort)
183
+ _prune_souls(proj_dir)
184
+
185
+ return filepath
186
+
187
+
188
+ def _prune_souls(proj_dir: Path) -> None:
189
+ """Keep only the latest MAX_SOULS_PER_PROJECT souls per project."""
190
+ soul_files = sorted(
191
+ [f for f in proj_dir.iterdir() if f.name != "latest.json" and f.suffix == ".json"],
192
+ key=lambda f: f.name,
193
+ )
194
+ while len(soul_files) > MAX_SOULS_PER_PROJECT:
195
+ oldest = soul_files.pop(0)
196
+ oldest.unlink(missing_ok=True)
197
+
198
+
199
+ def _load_soul(path: Path) -> Optional[SessionSoul]:
200
+ """Load a soul from a JSON file."""
201
+ try:
202
+ data = json.loads(path.read_text())
203
+ return SessionSoul(**{k: v for k, v in data.items() if k in SessionSoul.__dataclass_fields__})
204
+ except (json.JSONDecodeError, TypeError, KeyError, OSError):
205
+ return None
206
+
207
+
208
+ def list_souls(project_path: str = "") -> List[SessionSoul]:
209
+ """List all stored souls for a project, newest first."""
210
+ project_path = project_path or os.getcwd()
211
+ proj_dir = _project_dir(project_path)
212
+ if not proj_dir.exists():
213
+ return []
214
+
215
+ soul_files = sorted(
216
+ [f for f in proj_dir.iterdir() if f.name != "latest.json" and f.suffix == ".json"],
217
+ key=lambda f: f.name,
218
+ reverse=True,
219
+ )
220
+ souls = []
221
+ for f in soul_files:
222
+ soul = _load_soul(f)
223
+ if soul:
224
+ souls.append(soul)
225
+ return souls
226
+
227
+
228
+ def get_latest_soul(project_path: str = "") -> Optional[SessionSoul]:
229
+ """Get the most recent soul for a project."""
230
+ project_path = project_path or os.getcwd()
231
+ latest = _project_dir(project_path) / "latest.json"
232
+ if latest.exists():
233
+ return _load_soul(latest)
234
+ return None
235
+
236
+
237
+ def _format_revival(soul: SessionSoul) -> str:
238
+ """Format a soul into a readable context string for any AI model."""
239
+ lines = []
240
+ lines.append("=" * 60)
241
+ lines.append("SESSION PHOENIX -- Revived Session Context")
242
+ lines.append("=" * 60)
243
+ lines.append("")
244
+
245
+ lines.append(f"Soul ID: {soul.soul_id}")
246
+ lines.append(f"Captured: {soul.created_at}")
247
+ lines.append(f"Source Model: {soul.source_model}")
248
+ lines.append(f"Project: {soul.project_path}")
249
+ lines.append("")
250
+
251
+ # Current task
252
+ lines.append("--- ACTIVE TASK ---")
253
+ if soul.active_task:
254
+ lines.append(f" {soul.active_task}")
255
+ lines.append(f" Status: {soul.task_status}")
256
+ else:
257
+ lines.append(" (none recorded)")
258
+ lines.append("")
259
+
260
+ # Decisions
261
+ if soul.decisions:
262
+ lines.append("--- KEY DECISIONS ---")
263
+ for d in soul.decisions:
264
+ lines.append(f" - {d}")
265
+ lines.append("")
266
+
267
+ # Files
268
+ if soul.files_modified or soul.files_created:
269
+ lines.append("--- FILES CHANGED ---")
270
+ for f in soul.files_modified:
271
+ lines.append(f" M {f}")
272
+ for f in soul.files_created:
273
+ lines.append(f" + {f}")
274
+ lines.append("")
275
+
276
+ # Context
277
+ if soul.key_context:
278
+ lines.append("--- KEY CONTEXT ---")
279
+ for c in soul.key_context:
280
+ lines.append(f" - {c}")
281
+ lines.append("")
282
+
283
+ # Blockers
284
+ if soul.blockers:
285
+ lines.append("--- BLOCKERS ---")
286
+ for b in soul.blockers:
287
+ lines.append(f" ! {b}")
288
+ lines.append("")
289
+
290
+ # Next steps
291
+ if soul.next_steps:
292
+ lines.append("--- NEXT STEPS ---")
293
+ for i, s in enumerate(soul.next_steps, 1):
294
+ lines.append(f" {i}. {s}")
295
+ lines.append("")
296
+
297
+ # Git state
298
+ lines.append("--- GIT STATE ---")
299
+ lines.append(f" Branch: {soul.git_branch or '(unknown)'}")
300
+ lines.append(f" SHA: {soul.git_sha or '(unknown)'}")
301
+ lines.append(f" Uncommitted changes: {soul.uncommitted_changes}")
302
+ lines.append("")
303
+
304
+ # Token stats
305
+ if soul.tokens_used or soul.context_fullness:
306
+ lines.append("--- SESSION STATS ---")
307
+ if soul.tokens_used:
308
+ lines.append(f" Tokens used: ~{soul.tokens_used:,}")
309
+ if soul.context_fullness:
310
+ lines.append(f" Context fullness: {soul.context_fullness:.0%}")
311
+ lines.append("")
312
+
313
+ lines.append("=" * 60)
314
+ return "\n".join(lines)
315
+
316
+
317
+ def revive(project_path: str = "", soul_id: str = "") -> Dict[str, Any]:
318
+ """Revive the latest session soul for this project.
319
+
320
+ Returns a structured dict with both the raw soul data and a
321
+ formatted context string that can be injected into any model.
322
+ """
323
+ project_path = project_path or os.getcwd()
324
+
325
+ if soul_id:
326
+ # Search for a specific soul by ID
327
+ for soul in list_souls(project_path):
328
+ if soul.soul_id == soul_id:
329
+ return {
330
+ "status": "revived",
331
+ "soul": asdict(soul),
332
+ "context": _format_revival(soul),
333
+ }
334
+ return {
335
+ "status": "not_found",
336
+ "message": f"No soul with ID '{soul_id}' found for project {project_path}",
337
+ }
338
+
339
+ # Get latest
340
+ soul = get_latest_soul(project_path)
341
+ if not soul:
342
+ return {
343
+ "status": "no_souls",
344
+ "message": f"No session souls found for {project_path}. Nothing to revive.",
345
+ "hint": "Use delimit_soul_capture to save session state before ending.",
346
+ }
347
+
348
+ return {
349
+ "status": "revived",
350
+ "soul": asdict(soul),
351
+ "context": _format_revival(soul),
352
+ }
353
+
354
+
355
+ def should_auto_capture(
356
+ context_fullness: float = 0.0,
357
+ session_age_minutes: int = 0,
358
+ last_capture_minutes_ago: int = -1,
359
+ ) -> bool:
360
+ """Determine if we should auto-capture a soul.
361
+
362
+ Triggers:
363
+ - Context > 70% full
364
+ - Session > 30 minutes old with no capture in the last 15 minutes
365
+ - Explicit session end (handled by caller, not this function)
366
+ """
367
+ if context_fullness >= 0.7:
368
+ return True
369
+ if session_age_minutes >= 30 and (last_capture_minutes_ago < 0 or last_capture_minutes_ago >= 15):
370
+ return True
371
+ return False
@@ -39,7 +39,7 @@ DEFAULT_ROSTER = {
39
39
  "fallback_model": "codex-gpt-5.4",
40
40
  },
41
41
  "ops": {
42
- "role": "Strategy, Deliberation, Community, Analysis",
42
+ "role": "Strategy, Deliberation, Outreach, Competitive Intel",
43
43
  "default_model": "grok-4",
44
44
  "fallback_model": "gemini-3.1-pro-preview",
45
45
  },
@@ -274,7 +274,7 @@ APPROVAL_TIERS = {
274
274
  "deploy_staging": "auto_approved",
275
275
  "social_post": "founder_email",
276
276
  "social_low_risk": "auto_after_consensus",
277
- "community_issue": "founder_email",
277
+ "outreach_issue": "founder_email",
278
278
  "ledger_update": "auto_approved",
279
279
  "code_commit": "auto_approved",
280
280
  "security_audit": "auto_approved",