packwright 0.1.0__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.
- packwright/__init__.py +3 -0
- packwright/__main__.py +5 -0
- packwright/adapters/__init__.py +10 -0
- packwright/adapters/claude_code.py +377 -0
- packwright/adapters/codex.py +311 -0
- packwright/adapters/cursor.py +375 -0
- packwright/checker/__init__.py +3 -0
- packwright/checker/scoring.py +924 -0
- packwright/cli.py +971 -0
- packwright/core/__init__.py +57 -0
- packwright/core/adapter_layout.py +35 -0
- packwright/core/adopt.py +199 -0
- packwright/core/character_intake.py +1649 -0
- packwright/core/emotion_engine_contract.py +147 -0
- packwright/core/errors.py +13 -0
- packwright/core/handoff.py +531 -0
- packwright/core/install.py +2114 -0
- packwright/core/intake_contract.py +105 -0
- packwright/core/knowledge_contract.py +212 -0
- packwright/core/loader.py +35 -0
- packwright/core/memory_projection.py +126 -0
- packwright/core/naming.py +98 -0
- packwright/core/pack_metadata.py +100 -0
- packwright/core/path_safety.py +70 -0
- packwright/core/resolver.py +66 -0
- packwright/core/validation.py +645 -0
- packwright/core/workspace_contract.py +102 -0
- packwright-0.1.0.dist-info/METADATA +213 -0
- packwright-0.1.0.dist-info/RECORD +33 -0
- packwright-0.1.0.dist-info/WHEEL +5 -0
- packwright-0.1.0.dist-info/entry_points.txt +2 -0
- packwright-0.1.0.dist-info/licenses/LICENSE +21 -0
- packwright-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
EMOTION_ENGINE_USER_VISIBLE_MODES = ("light", "always", "paused")
|
|
2
|
+
EMOTION_ENGINE_MODES = set(EMOTION_ENGINE_USER_VISIBLE_MODES)
|
|
3
|
+
EMOTION_ENGINE_RUNTIME = "adapter_sidecar"
|
|
4
|
+
EMOTION_ENGINE_AVAILABLE_RUNTIME = "adapter_sidecar_available"
|
|
5
|
+
EMOTION_ENGINE_CLAUDE_RUNTIME = "spec_guided_behavior_only"
|
|
6
|
+
EMOTION_ENGINE_CODEX_SIDECAR = "emotion-engine-codex"
|
|
7
|
+
from .adapter_layout import adapter_skill_root, legacy_skill_roots
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
EMOTION_ENGINE_CODEX_SKILL_DIR = f"{adapter_skill_root('codex')}/emotion-engine-codex"
|
|
11
|
+
EMOTION_ENGINE_CODEX_LEGACY_SKILL_DIR = f"{legacy_skill_roots('codex')[0]}/emotion-engine-codex"
|
|
12
|
+
EMOTION_ENGINE_CODEX_STATE_PATH = ".emotion-engine/codex-state.json"
|
|
13
|
+
EMOTION_ENGINE_CODEX_WRAPPER_PATH = "scripts/codex_emotion.sh"
|
|
14
|
+
EMOTION_ENGINE_CODEX_SKILL_PATH = f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/SKILL.md"
|
|
15
|
+
EMOTION_ENGINE_CODEX_SCRIPT_PATH = f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/scripts/codex_emotion.sh"
|
|
16
|
+
EMOTION_ENGINE_CODEX_HELPER_PATH = f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/scripts/emotion_engine_utils.py"
|
|
17
|
+
EMOTION_ENGINE_CODEX_MCP_PATH = f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/scripts/emotion_engine_mcp.py"
|
|
18
|
+
EMOTION_ENGINE_CODEX_MCP_REGISTRATION_PATH = f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/scripts/register_mcp_client.py"
|
|
19
|
+
EMOTION_ENGINE_MANIFEST_PATH = "manifest.json"
|
|
20
|
+
|
|
21
|
+
EMOTION_ENGINE_OVERHEAD = {
|
|
22
|
+
"light": "<1% global token overhead; use only when continuity or milestone settlement matters",
|
|
23
|
+
"always": "~3% target global token overhead, capped at <=5%; optimize state summaries before enabling broadly",
|
|
24
|
+
"paused": "0% runtime overhead while preserving installed state for later resume",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
EMOTION_ENGINE_CODEX_ARTIFACTS = (
|
|
28
|
+
EMOTION_ENGINE_CODEX_WRAPPER_PATH,
|
|
29
|
+
EMOTION_ENGINE_CODEX_SKILL_PATH,
|
|
30
|
+
f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/README.md",
|
|
31
|
+
f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/install.sh",
|
|
32
|
+
EMOTION_ENGINE_CODEX_SCRIPT_PATH,
|
|
33
|
+
EMOTION_ENGINE_CODEX_HELPER_PATH,
|
|
34
|
+
EMOTION_ENGINE_CODEX_MCP_PATH,
|
|
35
|
+
EMOTION_ENGINE_CODEX_MCP_REGISTRATION_PATH,
|
|
36
|
+
f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/scripts/pulse_demo.py",
|
|
37
|
+
f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/emotion-state-template.json",
|
|
38
|
+
f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/spec/emotion-state.schema.json",
|
|
39
|
+
f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/LICENSE",
|
|
40
|
+
EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
EMOTION_ENGINE_CODEX_REQUIRED_MANIFEST_ARTIFACTS = {
|
|
44
|
+
EMOTION_ENGINE_CODEX_WRAPPER_PATH,
|
|
45
|
+
EMOTION_ENGINE_CODEX_SKILL_PATH,
|
|
46
|
+
EMOTION_ENGINE_CODEX_SCRIPT_PATH,
|
|
47
|
+
EMOTION_ENGINE_CODEX_HELPER_PATH,
|
|
48
|
+
EMOTION_ENGINE_CODEX_MCP_PATH,
|
|
49
|
+
EMOTION_ENGINE_CODEX_MCP_REGISTRATION_PATH,
|
|
50
|
+
EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def emotion_engine_feature(adapter, installed, mode="light"):
|
|
55
|
+
return {
|
|
56
|
+
"default_mode": "light",
|
|
57
|
+
"mode": mode,
|
|
58
|
+
"installed": bool(installed),
|
|
59
|
+
"adapter": adapter,
|
|
60
|
+
"user_visible_modes": list(EMOTION_ENGINE_USER_VISIBLE_MODES),
|
|
61
|
+
"state_path": EMOTION_ENGINE_CODEX_STATE_PATH if adapter == "codex" else None,
|
|
62
|
+
"estimated_overhead": EMOTION_ENGINE_OVERHEAD,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def emotion_engine_codex_sidecar_record(mode):
|
|
67
|
+
return {
|
|
68
|
+
"mode": mode,
|
|
69
|
+
"skill_dir": EMOTION_ENGINE_CODEX_SKILL_DIR,
|
|
70
|
+
"state_file": EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def emotion_engine_codex_expected(manifest, adapter_pack=None):
|
|
75
|
+
if not isinstance(manifest, dict):
|
|
76
|
+
return False
|
|
77
|
+
feature = manifest.get("features", {}).get("emotion_engine", {})
|
|
78
|
+
sidecars = manifest.get("sidecars", {})
|
|
79
|
+
if isinstance(feature, dict) and feature.get("installed") is True:
|
|
80
|
+
return True
|
|
81
|
+
if sidecars.get(EMOTION_ENGINE_CODEX_SIDECAR):
|
|
82
|
+
return True
|
|
83
|
+
if adapter_pack:
|
|
84
|
+
return any(artifact in adapter_pack for artifact in EMOTION_ENGINE_CODEX_ARTIFACTS)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def emotion_engine_codex_manifest_issues(manifest, expected_mode=None, required_artifacts=None):
|
|
89
|
+
if not isinstance(manifest, dict):
|
|
90
|
+
return ["manifest is not a mapping"]
|
|
91
|
+
issues = []
|
|
92
|
+
feature = manifest.get("features", {}).get("emotion_engine", {})
|
|
93
|
+
sidecar = manifest.get("sidecars", {}).get(EMOTION_ENGINE_CODEX_SIDECAR, {})
|
|
94
|
+
boundaries = manifest.get("boundaries", {})
|
|
95
|
+
artifacts = set(manifest.get("artifacts", []))
|
|
96
|
+
mode = expected_mode or feature.get("mode")
|
|
97
|
+
required = set(required_artifacts or EMOTION_ENGINE_CODEX_REQUIRED_MANIFEST_ARTIFACTS)
|
|
98
|
+
|
|
99
|
+
if feature.get("installed") is not True:
|
|
100
|
+
issues.append("manifest does not mark Emotion Engine as installed")
|
|
101
|
+
if feature.get("adapter") != "codex":
|
|
102
|
+
issues.append("manifest feature adapter is not codex")
|
|
103
|
+
if mode not in EMOTION_ENGINE_MODES:
|
|
104
|
+
issues.append("manifest Emotion Engine mode is invalid")
|
|
105
|
+
if feature.get("mode") != mode or sidecar.get("mode") != mode or boundaries.get("emotion_engine_mode") != mode:
|
|
106
|
+
issues.append("manifest Emotion Engine mode is inconsistent")
|
|
107
|
+
if feature.get("default_mode") != "light":
|
|
108
|
+
issues.append("manifest Emotion Engine default_mode is not light")
|
|
109
|
+
if boundaries.get("emotion_engine_runtime") != EMOTION_ENGINE_RUNTIME:
|
|
110
|
+
issues.append("manifest runtime boundary is not adapter_sidecar")
|
|
111
|
+
if sidecar.get("skill_dir") != EMOTION_ENGINE_CODEX_SKILL_DIR:
|
|
112
|
+
issues.append("manifest sidecar skill_dir is inconsistent")
|
|
113
|
+
if sidecar.get("state_file") != EMOTION_ENGINE_CODEX_STATE_PATH:
|
|
114
|
+
issues.append("manifest sidecar state_file is inconsistent")
|
|
115
|
+
for artifact in sorted(required):
|
|
116
|
+
if artifact not in artifacts:
|
|
117
|
+
issues.append(f"manifest artifacts missing {artifact}")
|
|
118
|
+
return issues
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def emotion_engine_codex_manifest_diagnostics(manifest, expected_mode=None, required_artifacts=None):
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
"id": _emotion_engine_codex_manifest_issue_id(message),
|
|
125
|
+
"path": EMOTION_ENGINE_MANIFEST_PATH,
|
|
126
|
+
"message": message,
|
|
127
|
+
}
|
|
128
|
+
for message in emotion_engine_codex_manifest_issues(
|
|
129
|
+
manifest,
|
|
130
|
+
expected_mode=expected_mode,
|
|
131
|
+
required_artifacts=required_artifacts,
|
|
132
|
+
)
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def emotion_engine_codex_manifest_consistent(manifest, expected_mode=None, required_artifacts=None):
|
|
137
|
+
return not emotion_engine_codex_manifest_diagnostics(
|
|
138
|
+
manifest,
|
|
139
|
+
expected_mode=expected_mode,
|
|
140
|
+
required_artifacts=required_artifacts,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _emotion_engine_codex_manifest_issue_id(message):
|
|
145
|
+
if message.startswith("manifest artifacts missing"):
|
|
146
|
+
return "emotion_engine_codex_manifest_missing_artifact"
|
|
147
|
+
return "emotion_engine_codex_manifest_drift"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class PackwrightError(Exception):
|
|
2
|
+
"""Base exception for Packwright failures."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PackwrightValidationError(PackwrightError):
|
|
6
|
+
"""Raised when a Packwright document fails structural validation."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, issues):
|
|
9
|
+
self.issues = list(issues)
|
|
10
|
+
message = "Packwright validation failed"
|
|
11
|
+
if self.issues:
|
|
12
|
+
message += ":\n" + "\n".join("- " + issue for issue in self.issues)
|
|
13
|
+
super().__init__(message)
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .errors import PackwrightValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
HANDOFF_SCHEMA = "packwright-handoff/v1"
|
|
9
|
+
HANDOFF_HELPER_PATH = "scripts/packwright_handoff.py"
|
|
10
|
+
HANDOFF_WRAPPER_PATH = "scripts/handoff_export.sh"
|
|
11
|
+
HANDOFF_ARTIFACTS = (HANDOFF_HELPER_PATH, HANDOFF_WRAPPER_PATH)
|
|
12
|
+
HANDOFF_EXECUTABLE_ARTIFACTS = (HANDOFF_WRAPPER_PATH,)
|
|
13
|
+
DEFAULT_HANDOFF_DIR = "workspace/shared/artifacts/handoffs"
|
|
14
|
+
DEFAULT_SESSION_BRIEF_DIR = "workspace/shared/artifacts/session-briefs"
|
|
15
|
+
SUPPORTED_HANDOFF_ADAPTERS = {"codex", "claude-code", "cursor"}
|
|
16
|
+
PORTABLE_STATE_DIRS = ("memory", "workspace")
|
|
17
|
+
DEFAULT_HANDOFF_READS = (
|
|
18
|
+
"memory/index.md",
|
|
19
|
+
"memory/todos.md",
|
|
20
|
+
"memory/session-index.md",
|
|
21
|
+
"memory/source-map.md",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_handoff(
|
|
26
|
+
source_target_dir,
|
|
27
|
+
out_path,
|
|
28
|
+
summary=None,
|
|
29
|
+
changed_paths=None,
|
|
30
|
+
recommended_reads=None,
|
|
31
|
+
next_steps=None,
|
|
32
|
+
include_inventory=False,
|
|
33
|
+
):
|
|
34
|
+
"""Write a reviewable cross-agent handoff file without syncing targets."""
|
|
35
|
+
source_target_dir = Path(source_target_dir)
|
|
36
|
+
out_path = Path(out_path)
|
|
37
|
+
manifest = _load_manifest(source_target_dir)
|
|
38
|
+
adapter = _validated_manifest_adapter(manifest, "source")
|
|
39
|
+
changed = _normalize_handoff_paths(changed_paths or [])
|
|
40
|
+
reads = _normalize_handoff_paths(recommended_reads or _default_handoff_reads(source_target_dir))
|
|
41
|
+
steps = [step.strip() for step in (next_steps or []) if step and step.strip()]
|
|
42
|
+
handoff = {
|
|
43
|
+
"schema": HANDOFF_SCHEMA,
|
|
44
|
+
"source_target_dir": str(source_target_dir),
|
|
45
|
+
"source_adapter": adapter,
|
|
46
|
+
"character": _manifest_character(manifest),
|
|
47
|
+
"summary": summary or "",
|
|
48
|
+
"changed_paths": _handoff_path_records(source_target_dir, changed),
|
|
49
|
+
"recommended_reads": _handoff_path_records(source_target_dir, reads),
|
|
50
|
+
"next_steps": steps,
|
|
51
|
+
"apply_policy": [
|
|
52
|
+
"Use workspace/shared/artifacts/handoffs/ for real cross-agent or cross-runtime handoffs.",
|
|
53
|
+
"Use workspace/shared/artifacts/session-briefs/ for same-agent next-session briefs.",
|
|
54
|
+
"Do not blindly copy memory or workspace files across targets.",
|
|
55
|
+
"Update the receiving target's own memory only after review.",
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
if include_inventory:
|
|
59
|
+
handoff["portable_inventory"] = {
|
|
60
|
+
root: _handoff_path_records(source_target_dir, _portable_files_under(source_target_dir, root))
|
|
61
|
+
for root in PORTABLE_STATE_DIRS
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
out_path.write_text(_render_handoff_markdown(handoff), encoding="utf-8")
|
|
66
|
+
return {
|
|
67
|
+
"schema": HANDOFF_SCHEMA,
|
|
68
|
+
"source_target_dir": str(source_target_dir),
|
|
69
|
+
"source_adapter": adapter,
|
|
70
|
+
"handoff_file": str(out_path),
|
|
71
|
+
"changed_paths": changed,
|
|
72
|
+
"recommended_reads": reads,
|
|
73
|
+
"next_steps": steps,
|
|
74
|
+
"include_inventory": include_inventory,
|
|
75
|
+
"default_handoff_dir": DEFAULT_HANDOFF_DIR,
|
|
76
|
+
"session_brief_dir": DEFAULT_SESSION_BRIEF_DIR,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def handoff_feature():
|
|
81
|
+
return {
|
|
82
|
+
"schema": HANDOFF_SCHEMA,
|
|
83
|
+
"helper": HANDOFF_HELPER_PATH,
|
|
84
|
+
"command": HANDOFF_WRAPPER_PATH,
|
|
85
|
+
"default_handoff_dir": DEFAULT_HANDOFF_DIR,
|
|
86
|
+
"session_brief_dir": DEFAULT_SESSION_BRIEF_DIR,
|
|
87
|
+
"policy": "handoffs are reviewable communication artifacts, not target sync",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def target_handoff_artifacts():
|
|
92
|
+
return {
|
|
93
|
+
HANDOFF_HELPER_PATH: render_target_handoff_helper(),
|
|
94
|
+
HANDOFF_WRAPPER_PATH: render_target_handoff_wrapper(),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_target_handoff_wrapper():
|
|
99
|
+
return (
|
|
100
|
+
"#!/usr/bin/env sh\n"
|
|
101
|
+
"set -eu\n"
|
|
102
|
+
"SCRIPT_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\n"
|
|
103
|
+
"TARGET_DIR=$(CDPATH= cd -- \"$SCRIPT_DIR/..\" && pwd)\n"
|
|
104
|
+
"PYTHON=${PYTHON:-python3}\n"
|
|
105
|
+
"exec \"$PYTHON\" \"$SCRIPT_DIR/packwright_handoff.py\" --source-target-dir \"$TARGET_DIR\" \"$@\"\n"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render_target_handoff_helper():
|
|
110
|
+
return (
|
|
111
|
+
_TARGET_HANDOFF_HELPER_SOURCE.replace("__HANDOFF_SCHEMA__", HANDOFF_SCHEMA)
|
|
112
|
+
.replace("__DEFAULT_HANDOFF_DIR__", DEFAULT_HANDOFF_DIR)
|
|
113
|
+
.replace("__DEFAULT_SESSION_BRIEF_DIR__", DEFAULT_SESSION_BRIEF_DIR)
|
|
114
|
+
.lstrip()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_manifest(target_dir):
|
|
119
|
+
manifest_path = target_dir / "manifest.json"
|
|
120
|
+
try:
|
|
121
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
raise PackwrightValidationError([f"cannot read adapter target manifest {manifest_path}: {exc}"])
|
|
124
|
+
except json.JSONDecodeError as exc:
|
|
125
|
+
raise PackwrightValidationError([f"invalid adapter target manifest {manifest_path}: {exc}"])
|
|
126
|
+
if not isinstance(manifest, dict):
|
|
127
|
+
raise PackwrightValidationError([f"adapter target manifest must be a mapping: {manifest_path}"])
|
|
128
|
+
return manifest
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validated_manifest_adapter(manifest, label):
|
|
132
|
+
adapter = manifest.get("adapter")
|
|
133
|
+
if adapter not in SUPPORTED_HANDOFF_ADAPTERS:
|
|
134
|
+
raise PackwrightValidationError([f"{label} target adapter is unsupported: {adapter!r}"])
|
|
135
|
+
return adapter
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _manifest_character(manifest):
|
|
139
|
+
character = manifest.get("character", {}) if isinstance(manifest, dict) else {}
|
|
140
|
+
if not isinstance(character, dict):
|
|
141
|
+
return {}
|
|
142
|
+
return {
|
|
143
|
+
key: character.get(key)
|
|
144
|
+
for key in ("name", "slug", "relationship_continuity", "direct_emotional_interaction")
|
|
145
|
+
if character.get(key) is not None
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _default_handoff_reads(source_target_dir):
|
|
150
|
+
return [rel_path for rel_path in DEFAULT_HANDOFF_READS if (source_target_dir / rel_path).exists()]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _normalize_handoff_paths(paths):
|
|
154
|
+
normalized = []
|
|
155
|
+
issues = []
|
|
156
|
+
for raw_path in paths:
|
|
157
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
158
|
+
issues.append("handoff paths must be non-empty relative strings")
|
|
159
|
+
continue
|
|
160
|
+
path = Path(raw_path.strip())
|
|
161
|
+
if path.is_absolute() or ".." in path.parts:
|
|
162
|
+
issues.append(f"handoff path must be relative and stay inside the target: {raw_path}")
|
|
163
|
+
continue
|
|
164
|
+
rel_path = path.as_posix()
|
|
165
|
+
if rel_path not in normalized:
|
|
166
|
+
normalized.append(rel_path)
|
|
167
|
+
if issues:
|
|
168
|
+
raise PackwrightValidationError(issues)
|
|
169
|
+
return normalized
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _handoff_path_records(source_target_dir, rel_paths):
|
|
173
|
+
return [_handoff_path_record(source_target_dir, rel_path) for rel_path in rel_paths]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _handoff_path_record(source_target_dir, rel_path):
|
|
177
|
+
path = source_target_dir / rel_path
|
|
178
|
+
record = {"path": rel_path}
|
|
179
|
+
if path.is_file():
|
|
180
|
+
data = path.read_bytes()
|
|
181
|
+
record.update({
|
|
182
|
+
"kind": "file",
|
|
183
|
+
"bytes": len(data),
|
|
184
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
185
|
+
})
|
|
186
|
+
elif path.is_dir():
|
|
187
|
+
files = _portable_files_under(source_target_dir, rel_path)
|
|
188
|
+
record.update({
|
|
189
|
+
"kind": "directory",
|
|
190
|
+
"file_count": len(files),
|
|
191
|
+
})
|
|
192
|
+
else:
|
|
193
|
+
record["kind"] = "missing"
|
|
194
|
+
return record
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _portable_files_under(source_target_dir, rel_root):
|
|
198
|
+
root = source_target_dir / rel_root
|
|
199
|
+
if not root.exists():
|
|
200
|
+
return []
|
|
201
|
+
if root.is_file():
|
|
202
|
+
return [rel_root]
|
|
203
|
+
if not root.is_dir():
|
|
204
|
+
return []
|
|
205
|
+
files = []
|
|
206
|
+
for path in sorted(root.rglob("*")):
|
|
207
|
+
if path.is_file() and _path_stays_in_root(path, source_target_dir):
|
|
208
|
+
files.append(path.relative_to(source_target_dir).as_posix())
|
|
209
|
+
return files
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _render_handoff_markdown(handoff):
|
|
213
|
+
metadata = json.dumps(handoff, indent=2, sort_keys=True, ensure_ascii=False)
|
|
214
|
+
changed = _render_handoff_path_list(handoff["changed_paths"])
|
|
215
|
+
reads = _render_handoff_path_list(handoff["recommended_reads"])
|
|
216
|
+
steps = _render_handoff_steps(handoff["next_steps"])
|
|
217
|
+
summary = handoff["summary"] or "_Source agent has not written a human summary yet._"
|
|
218
|
+
return (
|
|
219
|
+
"# Packwright Handoff\n\n"
|
|
220
|
+
"```json\n"
|
|
221
|
+
f"{metadata}\n"
|
|
222
|
+
"```\n\n"
|
|
223
|
+
"## Summary\n\n"
|
|
224
|
+
f"{summary}\n\n"
|
|
225
|
+
"## Changed State\n\n"
|
|
226
|
+
f"{changed}\n\n"
|
|
227
|
+
"## Recommended Reads\n\n"
|
|
228
|
+
f"{reads}\n\n"
|
|
229
|
+
"## Next Steps\n\n"
|
|
230
|
+
f"{steps}\n\n"
|
|
231
|
+
"## Apply Policy\n\n"
|
|
232
|
+
"- Use `workspace/shared/artifacts/handoffs/` for real cross-agent or cross-runtime handoffs.\n"
|
|
233
|
+
"- Use `workspace/shared/artifacts/session-briefs/` for same-agent next-session briefs.\n"
|
|
234
|
+
"- The receiving agent reads this file first and decides which referenced source files to inspect.\n"
|
|
235
|
+
"- The receiving agent updates its own memory after review; do not blindly copy target files.\n"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _render_handoff_path_list(records):
|
|
240
|
+
if not records:
|
|
241
|
+
return "- No paths declared."
|
|
242
|
+
lines = []
|
|
243
|
+
for record in records:
|
|
244
|
+
detail = record["kind"]
|
|
245
|
+
if record["kind"] == "file":
|
|
246
|
+
detail = f"file, sha256 `{record['sha256']}`"
|
|
247
|
+
elif record["kind"] == "directory":
|
|
248
|
+
detail = f"directory, {record['file_count']} files"
|
|
249
|
+
lines.append(f"- `{record['path']}` ({detail})")
|
|
250
|
+
return "\n".join(lines)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _render_handoff_steps(steps):
|
|
254
|
+
if not steps:
|
|
255
|
+
return "- No next steps declared."
|
|
256
|
+
return "\n".join(f"- {step}" for step in steps)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _path_stays_in_root(path, root_dir):
|
|
260
|
+
try:
|
|
261
|
+
path.resolve().relative_to(root_dir.resolve())
|
|
262
|
+
except ValueError:
|
|
263
|
+
return False
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
_TARGET_HANDOFF_HELPER_SOURCE = r'''
|
|
268
|
+
#!/usr/bin/env python3
|
|
269
|
+
import argparse
|
|
270
|
+
import hashlib
|
|
271
|
+
import json
|
|
272
|
+
import sys
|
|
273
|
+
from pathlib import Path
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
HANDOFF_SCHEMA = "__HANDOFF_SCHEMA__"
|
|
277
|
+
DEFAULT_HANDOFF_DIR = "__DEFAULT_HANDOFF_DIR__"
|
|
278
|
+
DEFAULT_SESSION_BRIEF_DIR = "__DEFAULT_SESSION_BRIEF_DIR__"
|
|
279
|
+
SUPPORTED_ADAPTERS = {"codex", "claude-code", "cursor"}
|
|
280
|
+
PORTABLE_STATE_DIRS = ("memory", "workspace")
|
|
281
|
+
DEFAULT_READS = (
|
|
282
|
+
"memory/index.md",
|
|
283
|
+
"memory/todos.md",
|
|
284
|
+
"memory/session-index.md",
|
|
285
|
+
"memory/source-map.md",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class HandoffError(Exception):
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main(argv=None):
|
|
294
|
+
parser = argparse.ArgumentParser(
|
|
295
|
+
description="Write a Packwright cross-agent handoff file without syncing targets."
|
|
296
|
+
)
|
|
297
|
+
parser.add_argument("--source-target-dir", default=".", help="installed source target directory")
|
|
298
|
+
parser.add_argument("--out", required=True, help="handoff Markdown output path")
|
|
299
|
+
parser.add_argument("--summary", help="human handoff summary written by the source agent")
|
|
300
|
+
parser.add_argument("--changed", action="append", default=[], help="relative source target path changed")
|
|
301
|
+
parser.add_argument("--read", action="append", default=[], dest="reads", help="relative source path to inspect")
|
|
302
|
+
parser.add_argument("--next-step", action="append", default=[], help="next step for the receiving agent")
|
|
303
|
+
parser.add_argument(
|
|
304
|
+
"--include-inventory",
|
|
305
|
+
action="store_true",
|
|
306
|
+
help="include a portable memory/workspace file inventory for review; does not copy files",
|
|
307
|
+
)
|
|
308
|
+
args = parser.parse_args(argv)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
result = create_handoff(
|
|
312
|
+
args.source_target_dir,
|
|
313
|
+
args.out,
|
|
314
|
+
summary=args.summary,
|
|
315
|
+
changed_paths=args.changed,
|
|
316
|
+
recommended_reads=args.reads or None,
|
|
317
|
+
next_steps=args.next_step,
|
|
318
|
+
include_inventory=args.include_inventory,
|
|
319
|
+
)
|
|
320
|
+
except HandoffError as exc:
|
|
321
|
+
print(str(exc), file=sys.stderr)
|
|
322
|
+
return 1
|
|
323
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def create_handoff(
|
|
328
|
+
source_target_dir,
|
|
329
|
+
out_path,
|
|
330
|
+
summary=None,
|
|
331
|
+
changed_paths=None,
|
|
332
|
+
recommended_reads=None,
|
|
333
|
+
next_steps=None,
|
|
334
|
+
include_inventory=False,
|
|
335
|
+
):
|
|
336
|
+
source_target_dir = Path(source_target_dir)
|
|
337
|
+
out_path = Path(out_path)
|
|
338
|
+
manifest = _load_manifest(source_target_dir)
|
|
339
|
+
adapter = _validated_manifest_adapter(manifest)
|
|
340
|
+
changed = _normalize_paths(changed_paths or [])
|
|
341
|
+
reads = _normalize_paths(recommended_reads or _default_reads(source_target_dir))
|
|
342
|
+
steps = [step.strip() for step in (next_steps or []) if step and step.strip()]
|
|
343
|
+
handoff = {
|
|
344
|
+
"schema": HANDOFF_SCHEMA,
|
|
345
|
+
"source_target_dir": str(source_target_dir),
|
|
346
|
+
"source_adapter": adapter,
|
|
347
|
+
"character": _manifest_character(manifest),
|
|
348
|
+
"summary": summary or "",
|
|
349
|
+
"changed_paths": _path_records(source_target_dir, changed),
|
|
350
|
+
"recommended_reads": _path_records(source_target_dir, reads),
|
|
351
|
+
"next_steps": steps,
|
|
352
|
+
"apply_policy": [
|
|
353
|
+
"Use workspace/shared/artifacts/handoffs/ for real cross-agent or cross-runtime handoffs.",
|
|
354
|
+
"Use workspace/shared/artifacts/session-briefs/ for same-agent next-session briefs.",
|
|
355
|
+
"Do not blindly copy memory or workspace files across targets.",
|
|
356
|
+
"Update the receiving target's own memory only after review.",
|
|
357
|
+
],
|
|
358
|
+
}
|
|
359
|
+
if include_inventory:
|
|
360
|
+
handoff["portable_inventory"] = {
|
|
361
|
+
root: _path_records(source_target_dir, _portable_files_under(source_target_dir, root))
|
|
362
|
+
for root in PORTABLE_STATE_DIRS
|
|
363
|
+
}
|
|
364
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
365
|
+
out_path.write_text(_render_markdown(handoff), encoding="utf-8")
|
|
366
|
+
return {
|
|
367
|
+
"schema": HANDOFF_SCHEMA,
|
|
368
|
+
"source_target_dir": str(source_target_dir),
|
|
369
|
+
"source_adapter": adapter,
|
|
370
|
+
"handoff_file": str(out_path),
|
|
371
|
+
"changed_paths": changed,
|
|
372
|
+
"recommended_reads": reads,
|
|
373
|
+
"next_steps": steps,
|
|
374
|
+
"include_inventory": include_inventory,
|
|
375
|
+
"default_handoff_dir": DEFAULT_HANDOFF_DIR,
|
|
376
|
+
"session_brief_dir": DEFAULT_SESSION_BRIEF_DIR,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _load_manifest(target_dir):
|
|
381
|
+
path = target_dir / "manifest.json"
|
|
382
|
+
try:
|
|
383
|
+
manifest = json.loads(path.read_text(encoding="utf-8"))
|
|
384
|
+
except OSError as exc:
|
|
385
|
+
raise HandoffError(f"cannot read target manifest {path}: {exc}")
|
|
386
|
+
except json.JSONDecodeError as exc:
|
|
387
|
+
raise HandoffError(f"invalid target manifest {path}: {exc}")
|
|
388
|
+
if not isinstance(manifest, dict):
|
|
389
|
+
raise HandoffError(f"target manifest must be a mapping: {path}")
|
|
390
|
+
return manifest
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _validated_manifest_adapter(manifest):
|
|
394
|
+
adapter = manifest.get("adapter")
|
|
395
|
+
if adapter not in SUPPORTED_ADAPTERS:
|
|
396
|
+
raise HandoffError(f"source target adapter is unsupported: {adapter!r}")
|
|
397
|
+
return adapter
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _manifest_character(manifest):
|
|
401
|
+
character = manifest.get("character", {}) if isinstance(manifest, dict) else {}
|
|
402
|
+
if not isinstance(character, dict):
|
|
403
|
+
return {}
|
|
404
|
+
return {
|
|
405
|
+
key: character.get(key)
|
|
406
|
+
for key in ("name", "slug", "relationship_continuity", "direct_emotional_interaction")
|
|
407
|
+
if character.get(key) is not None
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _default_reads(source_target_dir):
|
|
412
|
+
return [rel_path for rel_path in DEFAULT_READS if (source_target_dir / rel_path).exists()]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _normalize_paths(paths):
|
|
416
|
+
normalized = []
|
|
417
|
+
issues = []
|
|
418
|
+
for raw_path in paths:
|
|
419
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
420
|
+
issues.append("handoff paths must be non-empty relative strings")
|
|
421
|
+
continue
|
|
422
|
+
path = Path(raw_path.strip())
|
|
423
|
+
if path.is_absolute() or ".." in path.parts:
|
|
424
|
+
issues.append(f"handoff path must be relative and stay inside the target: {raw_path}")
|
|
425
|
+
continue
|
|
426
|
+
rel_path = path.as_posix()
|
|
427
|
+
if rel_path not in normalized:
|
|
428
|
+
normalized.append(rel_path)
|
|
429
|
+
if issues:
|
|
430
|
+
raise HandoffError("; ".join(issues))
|
|
431
|
+
return normalized
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _path_records(source_target_dir, rel_paths):
|
|
435
|
+
return [_path_record(source_target_dir, rel_path) for rel_path in rel_paths]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _path_record(source_target_dir, rel_path):
|
|
439
|
+
path = source_target_dir / rel_path
|
|
440
|
+
record = {"path": rel_path}
|
|
441
|
+
if path.is_file():
|
|
442
|
+
data = path.read_bytes()
|
|
443
|
+
record.update({
|
|
444
|
+
"kind": "file",
|
|
445
|
+
"bytes": len(data),
|
|
446
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
447
|
+
})
|
|
448
|
+
elif path.is_dir():
|
|
449
|
+
files = _portable_files_under(source_target_dir, rel_path)
|
|
450
|
+
record.update({
|
|
451
|
+
"kind": "directory",
|
|
452
|
+
"file_count": len(files),
|
|
453
|
+
})
|
|
454
|
+
else:
|
|
455
|
+
record["kind"] = "missing"
|
|
456
|
+
return record
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _portable_files_under(source_target_dir, rel_root):
|
|
460
|
+
root = source_target_dir / rel_root
|
|
461
|
+
if not root.exists():
|
|
462
|
+
return []
|
|
463
|
+
if root.is_file():
|
|
464
|
+
return [rel_root]
|
|
465
|
+
if not root.is_dir():
|
|
466
|
+
return []
|
|
467
|
+
files = []
|
|
468
|
+
for path in sorted(root.rglob("*")):
|
|
469
|
+
if path.is_file() and _path_stays_in_root(path, source_target_dir):
|
|
470
|
+
files.append(path.relative_to(source_target_dir).as_posix())
|
|
471
|
+
return files
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _render_markdown(handoff):
|
|
475
|
+
metadata = json.dumps(handoff, indent=2, sort_keys=True, ensure_ascii=False)
|
|
476
|
+
changed = _render_path_list(handoff["changed_paths"])
|
|
477
|
+
reads = _render_path_list(handoff["recommended_reads"])
|
|
478
|
+
steps = _render_steps(handoff["next_steps"])
|
|
479
|
+
summary = handoff["summary"] or "_Source agent has not written a human summary yet._"
|
|
480
|
+
return (
|
|
481
|
+
"# Packwright Handoff\n\n"
|
|
482
|
+
"```json\n"
|
|
483
|
+
f"{metadata}\n"
|
|
484
|
+
"```\n\n"
|
|
485
|
+
"## Summary\n\n"
|
|
486
|
+
f"{summary}\n\n"
|
|
487
|
+
"## Changed State\n\n"
|
|
488
|
+
f"{changed}\n\n"
|
|
489
|
+
"## Recommended Reads\n\n"
|
|
490
|
+
f"{reads}\n\n"
|
|
491
|
+
"## Next Steps\n\n"
|
|
492
|
+
f"{steps}\n\n"
|
|
493
|
+
"## Apply Policy\n\n"
|
|
494
|
+
"- Use `workspace/shared/artifacts/handoffs/` for real cross-agent or cross-runtime handoffs.\n"
|
|
495
|
+
"- Use `workspace/shared/artifacts/session-briefs/` for same-agent next-session briefs.\n"
|
|
496
|
+
"- The receiving agent reads this file first and decides which referenced source files to inspect.\n"
|
|
497
|
+
"- The receiving agent updates its own memory after review; do not blindly copy target files.\n"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _render_path_list(records):
|
|
502
|
+
if not records:
|
|
503
|
+
return "- No paths declared."
|
|
504
|
+
lines = []
|
|
505
|
+
for record in records:
|
|
506
|
+
detail = record["kind"]
|
|
507
|
+
if record["kind"] == "file":
|
|
508
|
+
detail = f"file, sha256 `{record['sha256']}`"
|
|
509
|
+
elif record["kind"] == "directory":
|
|
510
|
+
detail = f"directory, {record['file_count']} files"
|
|
511
|
+
lines.append(f"- `{record['path']}` ({detail})")
|
|
512
|
+
return "\n".join(lines)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _render_steps(steps):
|
|
516
|
+
if not steps:
|
|
517
|
+
return "- No next steps declared."
|
|
518
|
+
return "\n".join(f"- {step}" for step in steps)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _path_stays_in_root(path, root_dir):
|
|
522
|
+
try:
|
|
523
|
+
path.resolve().relative_to(root_dir.resolve())
|
|
524
|
+
except ValueError:
|
|
525
|
+
return False
|
|
526
|
+
return True
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
raise SystemExit(main())
|
|
531
|
+
'''
|