pantheon-opencode 1.4.3 β†’ 1.5.0-beta.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.
Files changed (99) hide show
  1. package/.pantheon/code-mode/README.md +93 -0
  2. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  3. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  4. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  5. package/.pantheon/code-mode/compress-inline.py +450 -0
  6. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  7. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  8. package/.pantheon/code-mode/eval-run.py +197 -0
  9. package/.pantheon/code-mode/eval-static.py +321 -0
  10. package/.pantheon/code-mode/example-sync.sh +5 -0
  11. package/.pantheon/code-mode/session-end-save.py +186 -0
  12. package/.pantheon/code-mode/session-end-save.sh +18 -0
  13. package/.pantheon/code-mode/test-allowed.sh +5 -0
  14. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  15. package/.pantheon/code-mode/test-env.sh +7 -0
  16. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  17. package/.pantheon/code-mode/test-timeout.sh +6 -0
  18. package/README.md +161 -1101
  19. package/README.pt-BR.md +222 -0
  20. package/bin/pantheon-init.mjs +30 -11
  21. package/opencode.json +1 -0
  22. package/package.json +14 -3
  23. package/plugin.json +15 -0
  24. package/scripts/_pantheon_paths.py +48 -7
  25. package/scripts/ci-validate-yaml.py +7 -2
  26. package/scripts/code_mode_server.py +33 -2
  27. package/scripts/cost.mjs +30 -14
  28. package/scripts/doctor.mjs +135 -127
  29. package/scripts/generate-preset-docs.mjs +127 -66
  30. package/scripts/generate-prompts.sh +2 -2
  31. package/scripts/install/cli-ui.mjs +8 -1
  32. package/scripts/install/config-migration.mjs +401 -0
  33. package/scripts/install/health-check.mjs +3 -3
  34. package/scripts/install/model-picker.mjs +72 -23
  35. package/scripts/install/opencode-version.mjs +47 -0
  36. package/scripts/install/opencode.mjs +261 -88
  37. package/scripts/install/plugin.mjs +112 -42
  38. package/scripts/install/shared.mjs +13 -158
  39. package/scripts/install-mcp.mjs +44 -232
  40. package/scripts/mcp_persistence_server.py +382 -16
  41. package/scripts/mcp_resources_server.py +144 -40
  42. package/scripts/memory_mcp_server.py +2 -2
  43. package/scripts/probe-context-rehydrate.mjs +240 -0
  44. package/scripts/probe-pantheon-cost.mjs +304 -0
  45. package/scripts/recover-zenodo-deposition.mjs +146 -0
  46. package/scripts/sync-tui.mjs +32 -7
  47. package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
  48. package/scripts/uninstall.mjs +298 -954
  49. package/scripts/validate-zenodo-release.mjs +154 -0
  50. package/scripts/zenodo-artifact.mjs +21 -0
  51. package/scripts/zenodo-release-state.mjs +53 -0
  52. package/src/agents/zeus.md +25 -14
  53. package/src/mcp/_pantheon_paths.py +48 -7
  54. package/src/mcp/code_mode_server.py +33 -2
  55. package/src/mcp/eval_store.py +1 -1
  56. package/src/mcp/mcp_persistence_server.py +382 -16
  57. package/src/mcp/mcp_resources_server.py +144 -40
  58. package/src/mcp/pantheon_vision_server.py +1 -1
  59. package/src/mcp/toon_codec.py +491 -0
  60. package/src/pantheon/background-job-board.ts +67 -1
  61. package/src/pantheon/cost-command.ts +60 -39
  62. package/src/pantheon/delegate-manager.ts +669 -0
  63. package/src/pantheon/delegation-classifier.ts +307 -0
  64. package/src/pantheon/delegation-enforce.ts +13 -12
  65. package/src/pantheon/delegation-finalize.ts +374 -0
  66. package/src/pantheon/delegation-notify.ts +212 -0
  67. package/src/pantheon/delegation-toast.ts +73 -0
  68. package/src/pantheon/delegation.ts +1120 -0
  69. package/src/pantheon/goal-loop.ts +2 -2
  70. package/src/pantheon/goal-store.ts +1 -1
  71. package/src/pantheon/hashline/tool.ts +2 -2
  72. package/src/pantheon/model-command.ts +2 -1
  73. package/src/pantheon/permission-eval.ts +284 -0
  74. package/src/pantheon/step-cap.ts +110 -0
  75. package/src/pantheon/token-opt.ts +781 -0
  76. package/src/pantheon/toon-codec.ts +481 -0
  77. package/src/pantheon/tui-version-check.ts +1 -3
  78. package/src/pantheon/v2-bridge.ts +129 -0
  79. package/src/pantheon/v2-events.ts +131 -0
  80. package/src/pantheon/v2-hooks.ts +287 -0
  81. package/src/pantheon/v2-tool-definitions.ts +331 -0
  82. package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +824 -0
  85. package/src/plugin.ts +236 -42
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +28 -24
  98. package/src/skills/README.md +0 -2
  99. package/src/pantheon/tool-context.ts +0 -41
@@ -0,0 +1,93 @@
1
+ # Pantheon Code Mode Scripts
2
+
3
+ Orchestration scripts executed via the `pantheon-code-mode` MCP server
4
+ (`execute_code_script`). All scripts live in this directory.
5
+
6
+ ## Script Index
7
+
8
+ | Script | Language | Purpose |
9
+ |--------|----------|---------|
10
+ | `checkpoint_session.py` | Python | Full checkpoint management: init, save, status, resume, list, health, archive, cleanup |
11
+ | `checkpoint-session.sh` | Bash | Shell wrapper for `checkpoint_session.py` |
12
+ | `compress-inline.py` | Python | Inline context compression β€” score, compress, stats, batch modes |
13
+ | `session-end-save.py` | Python | Session-end Vector DB backup scaffold |
14
+ | `session-end-save.sh` | Bash | Shell wrapper for `session-end-save.py` |
15
+ | `example-sync.sh` | Bash | Minimal demo script for testing `execute_code_script` |
16
+
17
+ ## checkpoint_session.py Commands
18
+
19
+ | Command | Description |
20
+ |---------|-------------|
21
+ | `init <slug>` | Create session.json and heartbeat.json |
22
+ | `save <slug>` | Save checkpoint + update heartbeat |
23
+ | `status <slug>` | Show human-readable session status |
24
+ | `resume <slug>` | Print latest checkpoint as JSON |
25
+ | `list <slug>` | List all checkpoints for a slug |
26
+ | `health <slug>` | Validate session consistency (required fields, checkpoint integrity, stale detection) |
27
+ | `archive <slug>` | Move session to `.pantheon/deepwork/archive/<slug>/` |
28
+ | `cleanup [--dry-run]` | Remove orphan directories, temp files, flag stale sessions |
29
+
30
+ ## Invocation
31
+
32
+ From an agent prompt, call via the MCP tool:
33
+
34
+ ```
35
+ execute_code_script("checkpoint-session.sh", args=["init", "my-task"])
36
+ ```
37
+
38
+ Or directly for cleanup (which doesn't need `checkpoint-session.sh`):
39
+
40
+ ```
41
+ execute_code_script("checkpoint_session.py", args=["cleanup", "--dry-run"])
42
+ ```
43
+
44
+ All scripts are idempotent unless noted otherwise.
45
+
46
+ ## Script Metadata (YAML Frontmatter)
47
+
48
+ Scripts may declare optional metadata in a comment-style YAML frontmatter
49
+ block at the top of the file (after the shebang). Comment lines keep the
50
+ script a valid executable in both bash and python:
51
+
52
+ ```python
53
+ #!/usr/bin/env python3
54
+ # ---
55
+ # description: Runs the checkpoint save
56
+ # timeout: 5
57
+ # allowed_args:
58
+ # - compress
59
+ # - --text
60
+ # ---
61
+ import sys
62
+ ...
63
+ ```
64
+
65
+ Supported keys:
66
+
67
+ | Key | Type | Purpose |
68
+ |-----|------|---------|
69
+ | `description` | string | Human-readable purpose, surfaced in tool output metadata |
70
+ | `timeout` | int (seconds) | Per-script execution timeout override (default 30s). The subprocess is killed when it elapses. |
71
+ | `allowed_args` | list of strings | Allowlist of CLI args accepted by `execute_code_script`. Passing an unlisted arg is rejected before execution. Omit for unrestricted args. |
72
+
73
+ Metadata is exposed in two places:
74
+
75
+ - `execute_code_script(..., json_output=true)` returns a structured dict with
76
+ `stdout`, `stderr`, `exit_code`, `duration_ms`, `timed_out`, `timeout_s`
77
+ and `metadata` (the parsed frontmatter).
78
+ - `pantheon://code-mode/scripts/{name}` resource prepends a `# metadata`
79
+ section when frontmatter is present.
80
+
81
+ Malformed or missing frontmatter fails open: the script executes with the
82
+ 30s default timeout and no arg restriction.
83
+
84
+ ## Conventions
85
+
86
+ - **Wrapper pattern**: `.sh` files are thin wrappers that pass `$@` to a
87
+ `.py` implementation. This keeps CLI ergonomics (shell-native `--flags`)
88
+ while keeping logic in Python where it's more reliable.
89
+ - **Exit codes**: 0 = success / no issues, 1 = action taken (recovery
90
+ dispatched, etc.), 2+ = error.
91
+ - **Paths**: Scripts resolve their own directory at runtime; never hardcode.
92
+ - **Output**: Artifacts go under `.pantheon/memory-bank/.tmp/` (gitignored).
93
+ See `skill: artifact-management`.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """CLI shim β€” delegates to ``checkpoint_session`` module."""
3
+
4
+ from checkpoint_session import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+ # Wrapper for checkpoint_session.py
3
+ # Usage: ./checkpoint-session.sh <command> <slug>
4
+ #
5
+ # Commands: init, save, status, resume, list, health, archive, cleanup
6
+
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+
11
+ python3 "$SCRIPT_DIR/checkpoint_session.py" "$@"
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Checkpoint Session Manager for Auto-Continue V2.
4
+
5
+ Structured persistence for session checkpoints, heartbeat tracking, and
6
+ session metadata for long autonomous deepwork sessions.
7
+
8
+ Usage:
9
+ python checkpoint_session.py <command> <slug> [options]
10
+
11
+ Commands:
12
+ init β€” Initialize session files (session.json + heartbeat.json)
13
+ save β€” Save checkpoint and update heartbeat
14
+ status β€” Show current session status
15
+ resume β€” Read latest checkpoint for resume
16
+ list β€” List all checkpoints for slug
17
+ health β€” Validate session consistency
18
+ archive β€” Move session to archive/
19
+ cleanup β€” Clean orphaned sessions and temp files (--dry-run for preview)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import sys
27
+ import shutil
28
+ from datetime import UTC, datetime, timedelta
29
+ from pathlib import Path
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Constants
33
+ # ---------------------------------------------------------------------------
34
+
35
+ SCHEMA_VERSION = 2
36
+ DEEPDIR = Path(".pantheon/deepwork")
37
+ ARCHIVEDIR = DEEPDIR / "archive"
38
+ STALE_THRESHOLD = timedelta(hours=24)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Path helpers
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def get_slug_dir(slug: str) -> Path:
47
+ """Return the deepwork directory path for a given slug."""
48
+ return DEEPDIR / slug
49
+
50
+
51
+ def get_checkpoints(slug_dir: Path) -> list[tuple[int, Path]]:
52
+ """Return sorted list of (number, path) for all checkpoint files.
53
+
54
+ Only files matching checkpoint-N.json where *N* is a positive
55
+ integer are considered. Dotfiles and non-numeric suffixes are
56
+ ignored.
57
+ """
58
+ checkpoints: list[tuple[int, Path]] = []
59
+ for f in slug_dir.glob("checkpoint-*.json"):
60
+ stem = f.stem # e.g. "checkpoint-3"
61
+ parts = stem.split("-", 1)
62
+ if len(parts) != 2: # noqa: PLR2004
63
+ continue
64
+ try:
65
+ n = int(parts[1])
66
+ except (ValueError, IndexError):
67
+ continue
68
+ checkpoints.append((n, f))
69
+ checkpoints.sort(key=lambda x: x[0])
70
+ return checkpoints
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Commands
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def cmd_init(slug: str) -> None:
79
+ """Create session.json and heartbeat.json for *slug*."""
80
+ sdir = get_slug_dir(slug)
81
+ sdir.mkdir(parents=True, exist_ok=True)
82
+
83
+ now = datetime.now(UTC).isoformat()
84
+
85
+ session = {
86
+ "task_id": slug,
87
+ "mode": "deepwork",
88
+ "start_time": now,
89
+ "last_activity": now,
90
+ "status": "in_progress",
91
+ "gate_history": [],
92
+ "stop_reason": None,
93
+ "stopped_at": None,
94
+ }
95
+ _write_json(sdir / "session.json", session)
96
+
97
+ heartbeat = {
98
+ "slug": slug,
99
+ "last_action": now,
100
+ "turn_count": 0,
101
+ "current_phase": 0,
102
+ "status": "alive",
103
+ "next_action": "initialized",
104
+ }
105
+ _write_json(sdir / "heartbeat.json", heartbeat)
106
+
107
+ print(f"Session initialized for {slug}")
108
+
109
+
110
+ def cmd_save(slug: str) -> None:
111
+ """Save a checkpoint and update heartbeat / session metadata."""
112
+ sdir = get_slug_dir(slug)
113
+ if not sdir.exists():
114
+ print(f"No session found for {slug}")
115
+ return
116
+
117
+ now = datetime.now(UTC).isoformat()
118
+
119
+ # Read existing STATUS.md for context hash
120
+ context_hash = _compute_context_hash(sdir)
121
+
122
+ # Load or create session metadata
123
+ session_file = sdir / "session.json"
124
+ if session_file.exists():
125
+ session = _read_json(session_file)
126
+ else:
127
+ session = {
128
+ "task_id": slug,
129
+ "mode": "deepwork",
130
+ "start_time": now,
131
+ "last_activity": now,
132
+ "status": "in_progress",
133
+ "gate_history": [],
134
+ "stop_reason": None,
135
+ "stopped_at": None,
136
+ }
137
+
138
+ turn_count = session.get("turn_count", 0) + 1
139
+ current_phase = session.get("current_phase", 0)
140
+
141
+ # Update session
142
+ session["last_activity"] = now
143
+ session["turn_count"] = turn_count
144
+ _write_json(session_file, session)
145
+
146
+ # Update heartbeat
147
+ heartbeat = {
148
+ "slug": slug,
149
+ "last_action": now,
150
+ "turn_count": turn_count,
151
+ "current_phase": current_phase,
152
+ "status": "alive",
153
+ "next_action": "checkpoint saved",
154
+ }
155
+ _write_json(sdir / "heartbeat.json", heartbeat)
156
+
157
+ # Determine next checkpoint number
158
+ checkpoints = get_checkpoints(sdir)
159
+ n = (checkpoints[-1][0] + 1) if checkpoints else 1
160
+
161
+ # Build checkpoint
162
+ checkpoint = {
163
+ "slug": slug,
164
+ "phase": current_phase,
165
+ "turn_count": turn_count,
166
+ "timestamp": now,
167
+ "context_hash": context_hash,
168
+ "version": SCHEMA_VERSION,
169
+ }
170
+ _write_json(sdir / f"checkpoint-{n}.json", checkpoint)
171
+
172
+ print(f"Checkpoint {n} saved for {slug}")
173
+
174
+
175
+ def cmd_status(slug: str) -> None:
176
+ """Print human-readable session status."""
177
+ sdir = get_slug_dir(slug)
178
+ if not sdir.exists():
179
+ print(f"No session found for {slug}")
180
+ return
181
+
182
+ print(f"=== Session: {slug} ===\n")
183
+
184
+ session_file = sdir / "session.json"
185
+ if session_file.exists():
186
+ session = _read_json(session_file)
187
+ print(f"Status: {session.get('status', 'unknown')}")
188
+ print(f"Started: {session.get('start_time', 'unknown')}")
189
+ print(f"Last Activity: {session.get('last_activity', 'unknown')}")
190
+ gates = session.get("gate_history", [])
191
+ print(f"Gates Passed: {len(gates)}")
192
+ print(f"Turn Count: {session.get('turn_count', 0)}")
193
+ print(f"Phase: {session.get('current_phase', 0)}")
194
+ else:
195
+ print("No session.json found")
196
+
197
+ hb_file = sdir / "heartbeat.json"
198
+ if hb_file.exists():
199
+ hb = _read_json(hb_file)
200
+ print(f"Agent Status: {hb.get('status', 'unknown')}")
201
+ print(f"Next Action: {hb.get('next_action', 'unknown')}")
202
+
203
+ checkpoints = get_checkpoints(sdir)
204
+ print(f"\nCheckpoints: {len(checkpoints)}")
205
+ for n, _ in checkpoints[-5:]: # show last 5
206
+ print(f" checkpoint-{n}.json")
207
+
208
+
209
+ def cmd_resume(slug: str) -> None:
210
+ """Print the latest checkpoint as JSON (for programmatic consumption)."""
211
+ sdir = get_slug_dir(slug)
212
+ checkpoints = get_checkpoints(sdir)
213
+ if not checkpoints:
214
+ print(f"No checkpoints found for {slug}")
215
+ return
216
+
217
+ _, path = checkpoints[-1]
218
+ cp = _read_json(path)
219
+ print(json.dumps(cp, indent=2))
220
+
221
+
222
+ def cmd_list(slug: str) -> None:
223
+ """List all checkpoints for *slug*."""
224
+ sdir = get_slug_dir(slug)
225
+ checkpoints = get_checkpoints(sdir)
226
+ if not checkpoints:
227
+ print(f"No checkpoints found for {slug}")
228
+ return
229
+
230
+ print(f"Checkpoints for {slug}:")
231
+ for n, path in checkpoints:
232
+ cp = _read_json(path)
233
+ ts = cp.get("timestamp", "unknown")
234
+ phase = cp.get("phase", "?")
235
+ turns = cp.get("turn_count", "?")
236
+ print(f" [{n}] phase={phase} turns={turns} @ {ts}")
237
+
238
+
239
+ def cmd_health(slug: str) -> None:
240
+ """Validate session consistency."""
241
+ sdir = get_slug_dir(slug)
242
+ issues = []
243
+
244
+ if not sdir.exists():
245
+ print(f"❌ Session not found: {slug}")
246
+ return
247
+
248
+ session: dict = {}
249
+ hb: dict = {}
250
+
251
+ # Check session.json
252
+ session_file = sdir / "session.json"
253
+ if not session_file.exists():
254
+ issues.append("Missing session.json")
255
+ else:
256
+ try:
257
+ session = _read_json(session_file)
258
+ required = ["task_id", "status", "start_time", "last_activity"]
259
+ for field in required:
260
+ if field not in session:
261
+ issues.append(f"session.json missing field: {field}")
262
+ except (json.JSONDecodeError, ValueError):
263
+ issues.append("session.json is corrupted (invalid JSON)")
264
+
265
+ # Check heartbeat.json
266
+ hb_file = sdir / "heartbeat.json"
267
+ if not hb_file.exists():
268
+ issues.append("Missing heartbeat.json")
269
+ else:
270
+ try:
271
+ hb = _read_json(hb_file)
272
+ if hb.get("status") == "stalled":
273
+ issues.append("Session is marked as STALLED")
274
+ except (json.JSONDecodeError, ValueError):
275
+ issues.append("heartbeat.json is corrupted (invalid JSON)")
276
+
277
+ # Check checkpoint integrity
278
+ checkpoints = get_checkpoints(sdir)
279
+ if checkpoints:
280
+ for n, path in checkpoints:
281
+ try:
282
+ cp = _read_json(path)
283
+ if cp.get("version", 1) > SCHEMA_VERSION:
284
+ issues.append(f"checkpoint-{n}.json has newer schema version")
285
+ if "slug" not in cp or "timestamp" not in cp:
286
+ issues.append(f"checkpoint-{n}.json missing required fields")
287
+ except (json.JSONDecodeError, ValueError):
288
+ issues.append(f"checkpoint-{n}.json is corrupted")
289
+
290
+ # Check sequential numbering
291
+ numbers = [n for n, _ in checkpoints]
292
+ expected = list(range(numbers[0], numbers[-1] + 1))
293
+ if numbers != expected:
294
+ missing = set(expected) - set(numbers)
295
+ issues.append(f"Missing checkpoints: {sorted(missing)}")
296
+
297
+ # Check for stale activity
298
+ if session_file.exists() and hb_file.exists() and session:
299
+ try:
300
+ last_action = session.get("last_activity", "")
301
+ if last_action:
302
+ last_time = datetime.fromisoformat(last_action)
303
+ if datetime.now(UTC) - last_time > STALE_THRESHOLD:
304
+ issues.append(f"No activity for > {STALE_THRESHOLD.total_seconds() / 3600:.0f}h (stale)")
305
+ except (ValueError, TypeError):
306
+ issues.append("Could not parse last_activity timestamp")
307
+
308
+ # Report
309
+ if not issues:
310
+ print(f"βœ… Session '{slug}' is healthy")
311
+ print(f" Checkpoints: {len(checkpoints)}, Status: {session.get('status', 'unknown')}")
312
+ else:
313
+ print(f"⚠️ Session '{slug}' has {len(issues)} issue(s):")
314
+ for issue in issues:
315
+ print(f" - {issue}")
316
+
317
+
318
+ def cmd_archive(slug: str) -> None:
319
+ """Move session from deepwork/<slug>/ to deepwork/archive/<slug>/."""
320
+ sdir = get_slug_dir(slug)
321
+ if not sdir.exists():
322
+ print(f"Session not found: {slug}")
323
+ return
324
+
325
+ archive_dir = ARCHIVEDIR / slug
326
+ if archive_dir.exists():
327
+ print(f"Session '{slug}' already exists in archive. Remove it first or use a different slug.")
328
+ return
329
+
330
+ archive_dir.parent.mkdir(parents=True, exist_ok=True)
331
+ shutil.move(str(sdir), str(archive_dir))
332
+ print(f"πŸ“¦ Session '{slug}' archived to .pantheon/deepwork/archive/{slug}/")
333
+
334
+
335
+ def cmd_cleanup(dry_run: bool = False) -> None:
336
+ """Clean orphaned sessions and temp files.
337
+
338
+ Scans deepwork/ for:
339
+ - Empty directories (no session.json)
340
+ - Sessions with stale heartbeats (> 24h no activity)
341
+ - Temp files (.tmp left from atomic writes)
342
+ """
343
+ if not DEEPDIR.exists():
344
+ print("No deepwork directory found.")
345
+ return
346
+
347
+ now = datetime.now(UTC)
348
+ removed_dirs = 0
349
+ removed_files = 0
350
+
351
+ for item in sorted(DEEPDIR.iterdir()):
352
+ if not item.is_dir() or item.name == "archive" or item.name.startswith("."):
353
+ continue
354
+
355
+ session_file = item / "session.json"
356
+ hb_file = item / "heartbeat.json"
357
+
358
+ # Check if it's an orphan (no session.json)
359
+ if not session_file.exists():
360
+ if dry_run:
361
+ print(f" [dry-run] Would remove orphan directory: {item.name}/")
362
+ else:
363
+ shutil.rmtree(item)
364
+ removed_dirs += 1
365
+ print(f" Removed orphan directory: {item.name}/")
366
+ continue
367
+
368
+ # Check for stale sessions
369
+ if hb_file.exists():
370
+ try:
371
+ hb = _read_json(hb_file)
372
+ last_action = hb.get("last_action", "")
373
+ if last_action:
374
+ last_time = datetime.fromisoformat(last_action)
375
+ if now - last_time > STALE_THRESHOLD:
376
+ if dry_run:
377
+ print(f" [dry-run] Would flag stale session: {item.name}/ (last action: {last_action})")
378
+ else:
379
+ print(f" ⏸️ Session '{item.name}' stale since {last_action}")
380
+ except (ValueError, json.JSONDecodeError):
381
+ pass
382
+
383
+ # Clean temp files
384
+ for tmp_file in item.glob("*.tmp"):
385
+ if dry_run:
386
+ print(f" [dry-run] Would remove temp file: {tmp_file.name}")
387
+ else:
388
+ tmp_file.unlink()
389
+ removed_files += 1
390
+ print(f" Removed temp file: {item.name}/{tmp_file.name}")
391
+
392
+ if not dry_run:
393
+ total = removed_dirs + removed_files
394
+ if total == 0:
395
+ print("βœ… Nothing to clean.")
396
+ else:
397
+ print(f"βœ… Cleaned {removed_dirs} dir(s) and {removed_files} file(s).")
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # Internal helpers
402
+ # ---------------------------------------------------------------------------
403
+
404
+
405
+ def _write_json(path: Path, data: dict) -> None:
406
+ """Atomically write *data* to *path* as JSON."""
407
+ tmp = path.with_suffix(".tmp")
408
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
409
+ tmp.replace(path)
410
+
411
+
412
+ def _read_json(path: Path) -> dict:
413
+ """Read and return JSON content from *path*."""
414
+ return json.loads(path.read_text(encoding="utf-8"))
415
+
416
+
417
+ def _compute_context_hash(sdir: Path) -> str:
418
+ """Return a short SHA-256 hex digest of STATUS.md (if it exists)."""
419
+ status_file = sdir / "STATUS.md"
420
+ if status_file.exists():
421
+ return hashlib.sha256(status_file.read_bytes()).hexdigest()[:8]
422
+ return ""
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # CLI entry-point
427
+ # ---------------------------------------------------------------------------
428
+
429
+ COMMANDS = {
430
+ "init": cmd_init,
431
+ "save": cmd_save,
432
+ "status": cmd_status,
433
+ "resume": cmd_resume,
434
+ "list": cmd_list,
435
+ "health": cmd_health,
436
+ "archive": cmd_archive,
437
+ "cleanup": cmd_cleanup,
438
+ }
439
+
440
+
441
+ def main(argv: list[str] | None = None) -> None:
442
+ """Parse CLI arguments and dispatch the requested command."""
443
+ if argv is None:
444
+ argv = sys.argv[1:]
445
+
446
+ if len(argv) < 1:
447
+ print(
448
+ "Usage: python checkpoint_session.py <command> [slug] [options]\n"
449
+ f"Commands: {', '.join(sorted(COMMANDS))}\n"
450
+ "Note: 'cleanup' does not require a slug (use --dry-run for preview).",
451
+ file=sys.stderr,
452
+ )
453
+ sys.exit(1)
454
+
455
+ command = argv[0]
456
+
457
+ # Special handling for cleanup (no slug required)
458
+ if command == "cleanup":
459
+ dry_run = "--dry-run" in argv[1:]
460
+ cmd_cleanup(dry_run=dry_run)
461
+ return
462
+
463
+ # All other commands require a slug
464
+ if len(argv) < 2: # noqa: PLR2004
465
+ print(f"Usage: python checkpoint_session.py {command} <slug>", file=sys.stderr)
466
+ sys.exit(1)
467
+
468
+ slug = argv[1]
469
+
470
+ if command not in COMMANDS:
471
+ print(
472
+ f"Unknown command: {command!r}. Available: {', '.join(sorted(COMMANDS))}",
473
+ file=sys.stderr,
474
+ )
475
+ sys.exit(1)
476
+
477
+ COMMANDS[command](slug)
478
+
479
+
480
+ if __name__ == "__main__":
481
+ main()