pantheon-opencode 1.4.3 β 1.5.0-beta.10
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.
- package/.pantheon/code-mode/README.md +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/manifest.json +21 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +292 -1080
- package/README.pt-BR.md +308 -0
- package/bin/pantheon-init.mjs +207 -31
- package/opencode.json +1 -0
- package/package.json +34 -14
- package/plugin.json +15 -0
- package/pyproject.toml +65 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +451 -44
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +306 -129
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +23 -10
- package/scripts/install/config-migration.mjs +399 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +396 -121
- package/scripts/install/plugin.mjs +89 -49
- package/scripts/install/shared.mjs +55 -159
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +45 -232
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +798 -60
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +135 -42
- package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +27 -16
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +451 -44
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +798 -60
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +110 -2
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +356 -69
- package/src/pantheon/delegate-manager.ts +766 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +406 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1337 -0
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +689 -0
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +148 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +261 -51
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/tui.d.ts +116 -24
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +416 -149
- package/src/plugins/tui/dist/tui.tsx +456 -87
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +462 -95
- package/src/skills/README.md +0 -2
- 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,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()
|