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.
@@ -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
+ '''