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,2114 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
import warnings
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .emotion_engine_contract import (
|
|
14
|
+
EMOTION_ENGINE_CODEX_ARTIFACTS,
|
|
15
|
+
EMOTION_ENGINE_CODEX_SCRIPT_PATH,
|
|
16
|
+
EMOTION_ENGINE_CODEX_SIDECAR,
|
|
17
|
+
EMOTION_ENGINE_CODEX_SKILL_DIR,
|
|
18
|
+
EMOTION_ENGINE_CODEX_LEGACY_SKILL_DIR,
|
|
19
|
+
EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
20
|
+
EMOTION_ENGINE_CODEX_WRAPPER_PATH,
|
|
21
|
+
EMOTION_ENGINE_MODES,
|
|
22
|
+
EMOTION_ENGINE_RUNTIME,
|
|
23
|
+
emotion_engine_codex_expected,
|
|
24
|
+
emotion_engine_codex_manifest_diagnostics,
|
|
25
|
+
emotion_engine_codex_sidecar_record,
|
|
26
|
+
emotion_engine_feature,
|
|
27
|
+
)
|
|
28
|
+
from .adapter_layout import adapter_entry
|
|
29
|
+
from .errors import PackwrightValidationError
|
|
30
|
+
from .handoff import HANDOFF_ARTIFACTS, HANDOFF_EXECUTABLE_ARTIFACTS, target_handoff_artifacts
|
|
31
|
+
from .knowledge_contract import (
|
|
32
|
+
KNOWLEDGE_ROOT,
|
|
33
|
+
SOURCES_ROOT,
|
|
34
|
+
knowledge_artifacts,
|
|
35
|
+
knowledge_files,
|
|
36
|
+
knowledge_manifest_diagnostics,
|
|
37
|
+
knowledge_required_dirs,
|
|
38
|
+
)
|
|
39
|
+
from .loader import load_mechanism
|
|
40
|
+
from .memory_projection import project_memory_file
|
|
41
|
+
from .pack_metadata import LOCK_PATH, SPEC_PATH, embed_pack_metadata, load_embedded_spec
|
|
42
|
+
from .path_safety import resolve_destination_path, resolve_source_path, validate_relative_path
|
|
43
|
+
from .naming import (
|
|
44
|
+
character_slug,
|
|
45
|
+
is_valid_slug,
|
|
46
|
+
normalize_slug,
|
|
47
|
+
reference_prefix,
|
|
48
|
+
save_context_skill_path,
|
|
49
|
+
)
|
|
50
|
+
from .resolver import resolve_mechanism
|
|
51
|
+
from .workspace_contract import workspace_artifacts, workspace_readme, workspace_required_dirs
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SUPPORTED_INSTALL_ADAPTERS = {"codex", "claude-code", "cursor"}
|
|
55
|
+
PORTABLE_STATE_DIRS = ("memory", "workspace", KNOWLEDGE_ROOT, SOURCES_ROOT)
|
|
56
|
+
MIGRATION_SCHEMA = "packwright-migration/v1"
|
|
57
|
+
COMPATIBILITY_MEMORY_FILES = (
|
|
58
|
+
"memory/pinned.md",
|
|
59
|
+
"memory/recent-activity.md",
|
|
60
|
+
"memory/knowledge_map.md",
|
|
61
|
+
"memory/relationship-state.md",
|
|
62
|
+
)
|
|
63
|
+
EMOTION_ENGINE_SECTION = """## Emotion Engine
|
|
64
|
+
- Default mode: `{mode}`. The Codex sidecar is installed, but normal work should use it only according to this mode's loading policy.
|
|
65
|
+
- Use `.agents/skills/emotion-engine-codex/SKILL.md` for Emotion Engine controls and `.emotion-engine/codex-state.json` for project-local runtime state.
|
|
66
|
+
- Use `scripts/codex_emotion.sh` as the project-local wrapper when present; it forwards to `.agents/skills/emotion-engine-codex/scripts/codex_emotion.sh`.
|
|
67
|
+
- Use `record_policy` before deciding whether an interaction should be persisted; it is deterministic, side-effect free, and returns compact `reply_bias` rather than rewriting `AGENTS.md`.
|
|
68
|
+
- `light` mode target: <1% global token overhead; use the sidecar only when tone continuity, emotional interaction, relationship dynamics, concrete feedback, repair, boundary pressure, or milestone settlement matter.
|
|
69
|
+
- `always` mode target: ~3% global token overhead, capped at <=5%; it may track each meaningful turn, but still respects salience, habituation, low-value duplicate compaction, and compact summaries.
|
|
70
|
+
- `paused` mode keeps local state available but should not record or modulate turns until resumed.
|
|
71
|
+
- Generic praise should usually affect the current reply only; repeated generic praise habituates, while concrete feedback, milestones, repair, and stable preferences may be recorded.
|
|
72
|
+
- At meaningful session or milestone close, use the sidecar's `settle_trust` command to conservatively settle agent-to-user trust from recent evidence; praise alone must not directly grow trust.
|
|
73
|
+
- Keep it internal: do not expose PAD/trust numbers, state JSON, or step-by-step status unless asked.
|
|
74
|
+
- Do not mix Emotion Engine state into memory files; keep durable facts in `memory/*` and dynamic state in `.emotion-engine/codex-state.json`.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class MigrationPlan:
|
|
80
|
+
source_target_dir: Path
|
|
81
|
+
target_dir: Path
|
|
82
|
+
from_adapter: str
|
|
83
|
+
to_adapter: str
|
|
84
|
+
mechanism_file: Path
|
|
85
|
+
resolved: dict
|
|
86
|
+
pack: dict
|
|
87
|
+
source_manifest: dict
|
|
88
|
+
pack_dir: Optional[Path]
|
|
89
|
+
force: bool
|
|
90
|
+
include_emotion_state: bool
|
|
91
|
+
emotion_engine_codex_source: object
|
|
92
|
+
emotion_style: object
|
|
93
|
+
emotion_engine_mode: object
|
|
94
|
+
report: dict
|
|
95
|
+
|
|
96
|
+
def to_dict(self):
|
|
97
|
+
return copy.deepcopy(self.report)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def install_pack(
|
|
101
|
+
pack_dir,
|
|
102
|
+
target_dir,
|
|
103
|
+
adapter="codex",
|
|
104
|
+
force=False,
|
|
105
|
+
include_emotion_engine_codex=None,
|
|
106
|
+
emotion_engine_codex_source=None,
|
|
107
|
+
emotion_style=None,
|
|
108
|
+
emotion_engine_mode=None,
|
|
109
|
+
):
|
|
110
|
+
"""Install an adapter pack into a local agent runtime working directory."""
|
|
111
|
+
pack_dir = Path(pack_dir)
|
|
112
|
+
target_dir = Path(target_dir)
|
|
113
|
+
manifest = _load_manifest(pack_dir)
|
|
114
|
+
manifest_adapter = manifest.get("adapter")
|
|
115
|
+
|
|
116
|
+
if adapter not in SUPPORTED_INSTALL_ADAPTERS:
|
|
117
|
+
raise PackwrightValidationError([f"unsupported adapter: {adapter}"])
|
|
118
|
+
if manifest_adapter != adapter:
|
|
119
|
+
raise PackwrightValidationError([f"pack adapter is {manifest_adapter!r}, expected {adapter!r}"])
|
|
120
|
+
resolved_emotion_engine_mode = emotion_engine_mode or _manifest_emotion_engine_mode(manifest)
|
|
121
|
+
if resolved_emotion_engine_mode not in EMOTION_ENGINE_MODES:
|
|
122
|
+
raise PackwrightValidationError([f"emotion_engine_mode must be one of {sorted(EMOTION_ENGINE_MODES)}"])
|
|
123
|
+
if include_emotion_engine_codex is None:
|
|
124
|
+
include_emotion_engine_codex = False
|
|
125
|
+
|
|
126
|
+
artifacts = _manifest_artifacts(manifest)
|
|
127
|
+
source_paths = [
|
|
128
|
+
(artifact, resolve_source_path(pack_dir, artifact, "adapter pack artifact"))
|
|
129
|
+
for artifact in artifacts
|
|
130
|
+
]
|
|
131
|
+
destinations = {
|
|
132
|
+
artifact: resolve_destination_path(target_dir, artifact, "installed artifact destination")
|
|
133
|
+
for artifact in artifacts
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
existing = [artifact for artifact, path in destinations.items() if path.exists()]
|
|
137
|
+
if existing and not force:
|
|
138
|
+
raise PackwrightValidationError(
|
|
139
|
+
[
|
|
140
|
+
"target already contains files that would be overwritten; rerun with --force after reviewing them",
|
|
141
|
+
*[f"existing target artifact: {artifact}" for artifact in existing],
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
sidecar_plan = None
|
|
145
|
+
if include_emotion_engine_codex:
|
|
146
|
+
if adapter != "codex":
|
|
147
|
+
raise PackwrightValidationError(["--include-emotion-engine-codex is only supported for the codex adapter"])
|
|
148
|
+
sidecar_plan = _prepare_emotion_engine_codex_install(
|
|
149
|
+
target_dir,
|
|
150
|
+
emotion_engine_codex_source,
|
|
151
|
+
force=force,
|
|
152
|
+
emotion_style=emotion_style,
|
|
153
|
+
emotion_engine_mode=resolved_emotion_engine_mode,
|
|
154
|
+
manifest=manifest,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
stale_removed = []
|
|
158
|
+
if force:
|
|
159
|
+
next_artifacts = set(artifacts)
|
|
160
|
+
if sidecar_plan:
|
|
161
|
+
next_artifacts.update(EMOTION_ENGINE_CODEX_ARTIFACTS)
|
|
162
|
+
stale_removed = _remove_stale_manifest_artifacts(target_dir, next_artifacts, preserve_portable=True)
|
|
163
|
+
|
|
164
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
installed = []
|
|
166
|
+
preserved_portable = []
|
|
167
|
+
for artifact, source_path in source_paths:
|
|
168
|
+
destination = destinations[artifact]
|
|
169
|
+
if force and _is_portable_path(artifact) and destination.exists():
|
|
170
|
+
preserved_portable.append(artifact)
|
|
171
|
+
continue
|
|
172
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
shutil.copy2(source_path, destination)
|
|
174
|
+
if artifact in HANDOFF_EXECUTABLE_ARTIFACTS:
|
|
175
|
+
_make_executable(destination)
|
|
176
|
+
installed.append(artifact)
|
|
177
|
+
|
|
178
|
+
sidecars = {}
|
|
179
|
+
if sidecar_plan:
|
|
180
|
+
sidecars[EMOTION_ENGINE_CODEX_SIDECAR] = _install_emotion_engine_codex(target_dir, sidecar_plan)
|
|
181
|
+
_mark_emotion_engine_codex_installed(target_dir, sidecars[EMOTION_ENGINE_CODEX_SIDECAR], resolved_emotion_engine_mode)
|
|
182
|
+
|
|
183
|
+
_refresh_artifact_lock(target_dir)
|
|
184
|
+
|
|
185
|
+
result = {
|
|
186
|
+
"adapter": adapter,
|
|
187
|
+
"pack_dir": str(pack_dir),
|
|
188
|
+
"target_dir": str(target_dir),
|
|
189
|
+
"installed_artifacts": installed,
|
|
190
|
+
}
|
|
191
|
+
if stale_removed:
|
|
192
|
+
result["stale_removed"] = stale_removed
|
|
193
|
+
if preserved_portable:
|
|
194
|
+
result["preserved_portable_state"] = sorted(preserved_portable)
|
|
195
|
+
if sidecars:
|
|
196
|
+
result["sidecars"] = sidecars
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def refresh_emotion_engine_codex(
|
|
201
|
+
target_dir,
|
|
202
|
+
emotion_engine_codex_source=None,
|
|
203
|
+
emotion_style=None,
|
|
204
|
+
emotion_engine_mode=None,
|
|
205
|
+
):
|
|
206
|
+
"""Refresh the installed Codex Emotion Engine sidecar projection.
|
|
207
|
+
|
|
208
|
+
This is the repair path for targets whose installed sidecar drifted from
|
|
209
|
+
the canonical Emotion Engine source. It rewrites projected sidecar files,
|
|
210
|
+
the project wrapper, AGENTS.md Emotion Engine section, and manifest sidecar
|
|
211
|
+
bookkeeping while preserving the project-local runtime state file.
|
|
212
|
+
"""
|
|
213
|
+
target_dir = Path(target_dir)
|
|
214
|
+
manifest = _load_manifest(target_dir)
|
|
215
|
+
manifest_adapter = manifest.get("adapter")
|
|
216
|
+
if manifest_adapter != "codex":
|
|
217
|
+
raise PackwrightValidationError([f"target adapter is {manifest_adapter!r}, expected 'codex'"])
|
|
218
|
+
|
|
219
|
+
resolved_emotion_engine_mode = emotion_engine_mode or _manifest_emotion_engine_mode(manifest)
|
|
220
|
+
if resolved_emotion_engine_mode not in EMOTION_ENGINE_MODES:
|
|
221
|
+
raise PackwrightValidationError([f"emotion_engine_mode must be one of {sorted(EMOTION_ENGINE_MODES)}"])
|
|
222
|
+
|
|
223
|
+
plan = _prepare_emotion_engine_codex_install(
|
|
224
|
+
target_dir,
|
|
225
|
+
emotion_engine_codex_source,
|
|
226
|
+
force=True,
|
|
227
|
+
emotion_style=emotion_style,
|
|
228
|
+
emotion_engine_mode=resolved_emotion_engine_mode,
|
|
229
|
+
manifest=manifest,
|
|
230
|
+
)
|
|
231
|
+
sidecar = _install_emotion_engine_codex(target_dir, plan)
|
|
232
|
+
_mark_emotion_engine_codex_installed(target_dir, sidecar, resolved_emotion_engine_mode)
|
|
233
|
+
updated_lock_paths = ["manifest.json", *_existing_sidecar_artifacts(target_dir)]
|
|
234
|
+
if sidecar.get("agents_section_added"):
|
|
235
|
+
updated_lock_paths.append("AGENTS.md")
|
|
236
|
+
_update_artifact_lock_paths(target_dir, updated_lock_paths)
|
|
237
|
+
return {
|
|
238
|
+
"adapter": "codex",
|
|
239
|
+
"target_dir": str(target_dir),
|
|
240
|
+
"refreshed_artifacts": _existing_sidecar_artifacts(target_dir),
|
|
241
|
+
"sidecars": {EMOTION_ENGINE_CODEX_SIDECAR: sidecar},
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def doctor_target(
|
|
246
|
+
target_dir,
|
|
247
|
+
fix=False,
|
|
248
|
+
emotion_engine_codex_source=None,
|
|
249
|
+
emotion_style=None,
|
|
250
|
+
emotion_engine_mode=None,
|
|
251
|
+
):
|
|
252
|
+
"""Inspect and optionally repair installed target projection drift."""
|
|
253
|
+
target_dir = Path(target_dir)
|
|
254
|
+
manifest = _load_manifest(target_dir)
|
|
255
|
+
adapter = manifest.get("adapter")
|
|
256
|
+
result = {
|
|
257
|
+
"target_dir": str(target_dir),
|
|
258
|
+
"adapter": adapter,
|
|
259
|
+
"ok": True,
|
|
260
|
+
"issues": [],
|
|
261
|
+
"warnings": _target_layout_doctor_warnings(target_dir),
|
|
262
|
+
"fixes": [],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
layout_issues = _target_layout_doctor_issues(target_dir, manifest)
|
|
266
|
+
if layout_issues and fix:
|
|
267
|
+
fixed_paths = _fix_target_layout(target_dir, layout_issues)
|
|
268
|
+
if fixed_paths:
|
|
269
|
+
result["fixes"].append({
|
|
270
|
+
"id": "target_layout_repaired",
|
|
271
|
+
"paths": fixed_paths,
|
|
272
|
+
})
|
|
273
|
+
manifest = _load_manifest(target_dir)
|
|
274
|
+
layout_issues = _target_layout_doctor_issues(target_dir, manifest)
|
|
275
|
+
result["issues"].extend(layout_issues)
|
|
276
|
+
|
|
277
|
+
lock_issues = _artifact_lock_doctor_issues(target_dir, manifest)
|
|
278
|
+
if lock_issues and fix:
|
|
279
|
+
fixed_paths = _repair_managed_artifact_drift(target_dir, manifest, lock_issues)
|
|
280
|
+
if fixed_paths:
|
|
281
|
+
result["fixes"].append({
|
|
282
|
+
"id": "managed_artifact_drift_repaired",
|
|
283
|
+
"paths": fixed_paths,
|
|
284
|
+
})
|
|
285
|
+
manifest = _load_manifest(target_dir)
|
|
286
|
+
lock_issues = _artifact_lock_doctor_issues(target_dir, manifest)
|
|
287
|
+
result["issues"].extend(lock_issues)
|
|
288
|
+
|
|
289
|
+
if adapter != "codex":
|
|
290
|
+
result["ok"] = not result["issues"]
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
if not _emotion_engine_codex_expected_in_target(manifest, target_dir):
|
|
294
|
+
result["ok"] = not result["issues"]
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
mode = emotion_engine_mode or _manifest_emotion_engine_mode(manifest)
|
|
298
|
+
plan = _prepare_emotion_engine_codex_install(
|
|
299
|
+
target_dir,
|
|
300
|
+
emotion_engine_codex_source,
|
|
301
|
+
force=True,
|
|
302
|
+
emotion_style=emotion_style,
|
|
303
|
+
emotion_engine_mode=mode,
|
|
304
|
+
manifest=manifest,
|
|
305
|
+
)
|
|
306
|
+
issues = _emotion_engine_codex_doctor_issues(target_dir, manifest, plan)
|
|
307
|
+
result["issues"].extend(issues)
|
|
308
|
+
result["ok"] = not result["issues"]
|
|
309
|
+
if issues and fix:
|
|
310
|
+
refresh_result = refresh_emotion_engine_codex(
|
|
311
|
+
target_dir,
|
|
312
|
+
emotion_engine_codex_source=emotion_engine_codex_source,
|
|
313
|
+
emotion_style=emotion_style,
|
|
314
|
+
emotion_engine_mode=mode,
|
|
315
|
+
)
|
|
316
|
+
refreshed_manifest = _load_manifest(target_dir)
|
|
317
|
+
refreshed_plan = _prepare_emotion_engine_codex_install(
|
|
318
|
+
target_dir,
|
|
319
|
+
emotion_engine_codex_source,
|
|
320
|
+
force=True,
|
|
321
|
+
emotion_style=emotion_style,
|
|
322
|
+
emotion_engine_mode=mode,
|
|
323
|
+
manifest=refreshed_manifest,
|
|
324
|
+
)
|
|
325
|
+
after_issues = _emotion_engine_codex_doctor_issues(target_dir, refreshed_manifest, refreshed_plan)
|
|
326
|
+
result["fixes"].append({
|
|
327
|
+
"id": "emotion_engine_codex_refreshed",
|
|
328
|
+
"result": refresh_result,
|
|
329
|
+
})
|
|
330
|
+
result["after_issues"] = after_issues
|
|
331
|
+
result["issues"] = (
|
|
332
|
+
_target_layout_doctor_issues(target_dir, refreshed_manifest)
|
|
333
|
+
+ _artifact_lock_doctor_issues(target_dir, refreshed_manifest)
|
|
334
|
+
+ after_issues
|
|
335
|
+
)
|
|
336
|
+
result["warnings"] = _target_layout_doctor_warnings(target_dir)
|
|
337
|
+
result["ok"] = not result["issues"]
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def migrate_target(
|
|
342
|
+
source_target_dir,
|
|
343
|
+
target_dir,
|
|
344
|
+
to_adapter,
|
|
345
|
+
mechanism_path=None,
|
|
346
|
+
parameters=None,
|
|
347
|
+
pack_dir=None,
|
|
348
|
+
force=False,
|
|
349
|
+
include_emotion_state=True,
|
|
350
|
+
slug=None,
|
|
351
|
+
upgrade_adapter_support=True,
|
|
352
|
+
emotion_engine_codex_source=None,
|
|
353
|
+
emotion_style=None,
|
|
354
|
+
emotion_engine_mode=None,
|
|
355
|
+
):
|
|
356
|
+
"""Plan and apply an installed-target migration for programmatic callers."""
|
|
357
|
+
plan = plan_migration(
|
|
358
|
+
source_target_dir,
|
|
359
|
+
target_dir,
|
|
360
|
+
to_adapter,
|
|
361
|
+
mechanism_path=mechanism_path,
|
|
362
|
+
parameters=parameters,
|
|
363
|
+
pack_dir=pack_dir,
|
|
364
|
+
force=force,
|
|
365
|
+
include_emotion_state=include_emotion_state,
|
|
366
|
+
slug=slug,
|
|
367
|
+
upgrade_adapter_support=upgrade_adapter_support,
|
|
368
|
+
emotion_engine_codex_source=emotion_engine_codex_source,
|
|
369
|
+
emotion_style=emotion_style,
|
|
370
|
+
emotion_engine_mode=emotion_engine_mode,
|
|
371
|
+
)
|
|
372
|
+
return apply_migration(plan)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def plan_migration(
|
|
376
|
+
source_target_dir,
|
|
377
|
+
target_dir,
|
|
378
|
+
to_adapter,
|
|
379
|
+
mechanism_path=None,
|
|
380
|
+
parameters=None,
|
|
381
|
+
pack_dir=None,
|
|
382
|
+
force=False,
|
|
383
|
+
include_emotion_state=True,
|
|
384
|
+
slug=None,
|
|
385
|
+
upgrade_adapter_support=True,
|
|
386
|
+
emotion_engine_codex_source=None,
|
|
387
|
+
emotion_style=None,
|
|
388
|
+
emotion_engine_mode=None,
|
|
389
|
+
):
|
|
390
|
+
"""Build a deterministic migration plan without writing files."""
|
|
391
|
+
source_target_dir = Path(source_target_dir)
|
|
392
|
+
target_dir = Path(target_dir)
|
|
393
|
+
resolved_pack_dir = Path(pack_dir) if pack_dir else None
|
|
394
|
+
if to_adapter not in SUPPORTED_INSTALL_ADAPTERS:
|
|
395
|
+
raise PackwrightValidationError([f"unsupported adapter: {to_adapter}"])
|
|
396
|
+
_validate_migration_locations(source_target_dir, target_dir, resolved_pack_dir)
|
|
397
|
+
|
|
398
|
+
source_manifest = _load_manifest(source_target_dir)
|
|
399
|
+
from_adapter = source_manifest.get("adapter")
|
|
400
|
+
if from_adapter not in SUPPORTED_INSTALL_ADAPTERS:
|
|
401
|
+
raise PackwrightValidationError([f"source target adapter is unsupported: {from_adapter!r}"])
|
|
402
|
+
|
|
403
|
+
mechanism_file = _resolve_migration_mechanism_path(source_target_dir, source_manifest, mechanism_path)
|
|
404
|
+
embedded_mechanism = mechanism_file == source_target_dir / SPEC_PATH
|
|
405
|
+
if embedded_mechanism:
|
|
406
|
+
mechanism = load_embedded_spec(source_target_dir)
|
|
407
|
+
else:
|
|
408
|
+
mechanism = load_mechanism(mechanism_file)
|
|
409
|
+
mechanism_changes = _prepare_migration_mechanism(
|
|
410
|
+
mechanism,
|
|
411
|
+
to_adapter=to_adapter,
|
|
412
|
+
slug=slug,
|
|
413
|
+
upgrade_adapter_support=upgrade_adapter_support,
|
|
414
|
+
)
|
|
415
|
+
resolved_parameters = _migration_resolved_parameters(source_manifest, parameters)
|
|
416
|
+
resolved = mechanism if embedded_mechanism and not parameters else resolve_mechanism(mechanism, resolved_parameters)
|
|
417
|
+
pack = _compile_pack_for_adapter(
|
|
418
|
+
to_adapter,
|
|
419
|
+
resolved,
|
|
420
|
+
references={
|
|
421
|
+
"source_mechanism": str(mechanism_file),
|
|
422
|
+
"migration_source_target": str(source_target_dir),
|
|
423
|
+
"migration_from_adapter": from_adapter,
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
initial_score = _score_migration_pack(resolved, pack, to_adapter)
|
|
427
|
+
pack = embed_pack_metadata(pack, resolved, initial_score)
|
|
428
|
+
|
|
429
|
+
changes, warnings = _plan_migration_changes(
|
|
430
|
+
source_target_dir,
|
|
431
|
+
target_dir,
|
|
432
|
+
source_manifest,
|
|
433
|
+
pack,
|
|
434
|
+
resolved,
|
|
435
|
+
from_adapter,
|
|
436
|
+
to_adapter,
|
|
437
|
+
include_emotion_state=include_emotion_state,
|
|
438
|
+
emotion_engine_codex_source=emotion_engine_codex_source,
|
|
439
|
+
emotion_style=emotion_style,
|
|
440
|
+
emotion_engine_mode=emotion_engine_mode,
|
|
441
|
+
)
|
|
442
|
+
planned_score = _score_migration_pack(resolved, pack, to_adapter)
|
|
443
|
+
conflicts = _migration_plan_conflicts(target_dir, resolved_pack_dir)
|
|
444
|
+
ready = planned_score["passed"] and (force or not conflicts)
|
|
445
|
+
report = {
|
|
446
|
+
"schema": MIGRATION_SCHEMA,
|
|
447
|
+
"status": "planned",
|
|
448
|
+
"ready": ready,
|
|
449
|
+
"force": bool(force),
|
|
450
|
+
"source": {
|
|
451
|
+
"target_dir": str(source_target_dir),
|
|
452
|
+
"adapter": from_adapter,
|
|
453
|
+
"mechanism": str(mechanism_file),
|
|
454
|
+
},
|
|
455
|
+
"destination": {
|
|
456
|
+
"target_dir": str(target_dir),
|
|
457
|
+
"adapter": to_adapter,
|
|
458
|
+
"pack_dir": str(resolved_pack_dir) if resolved_pack_dir else None,
|
|
459
|
+
},
|
|
460
|
+
"character": {
|
|
461
|
+
"name": resolved.get("identity", {}).get("name"),
|
|
462
|
+
"slug": character_slug(resolved),
|
|
463
|
+
},
|
|
464
|
+
"changes": changes,
|
|
465
|
+
"summary": {name: len(items) for name, items in changes.items()},
|
|
466
|
+
"conflicts": conflicts,
|
|
467
|
+
"mechanism_changes": mechanism_changes,
|
|
468
|
+
"score": {
|
|
469
|
+
"planned": planned_score,
|
|
470
|
+
"installed": None,
|
|
471
|
+
},
|
|
472
|
+
"warnings": warnings,
|
|
473
|
+
}
|
|
474
|
+
return MigrationPlan(
|
|
475
|
+
source_target_dir=source_target_dir,
|
|
476
|
+
target_dir=target_dir,
|
|
477
|
+
from_adapter=from_adapter,
|
|
478
|
+
to_adapter=to_adapter,
|
|
479
|
+
mechanism_file=mechanism_file,
|
|
480
|
+
resolved=resolved,
|
|
481
|
+
pack=pack,
|
|
482
|
+
source_manifest=source_manifest,
|
|
483
|
+
pack_dir=resolved_pack_dir,
|
|
484
|
+
force=force,
|
|
485
|
+
include_emotion_state=include_emotion_state,
|
|
486
|
+
emotion_engine_codex_source=emotion_engine_codex_source,
|
|
487
|
+
emotion_style=emotion_style,
|
|
488
|
+
emotion_engine_mode=emotion_engine_mode,
|
|
489
|
+
report=report,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def apply_migration(plan):
|
|
494
|
+
"""Apply a previously prepared MigrationPlan and return its receipt."""
|
|
495
|
+
if not isinstance(plan, MigrationPlan):
|
|
496
|
+
raise TypeError("apply_migration expects a MigrationPlan")
|
|
497
|
+
planned_score = plan.report["score"]["planned"]
|
|
498
|
+
if not planned_score["passed"]:
|
|
499
|
+
raise PackwrightValidationError(["destination adapter pack failed its planned checker score"])
|
|
500
|
+
|
|
501
|
+
source_integrity = _verify_migration_source(plan.report["changes"], plan.source_target_dir)
|
|
502
|
+
if not source_integrity["passed"]:
|
|
503
|
+
raise PackwrightValidationError(
|
|
504
|
+
[
|
|
505
|
+
"migration source changed after the plan was prepared; prepare a new plan before writing",
|
|
506
|
+
*[issue["message"] for issue in source_integrity["issues"]],
|
|
507
|
+
]
|
|
508
|
+
)
|
|
509
|
+
conflicts = _migration_plan_conflicts(plan.target_dir, plan.pack_dir)
|
|
510
|
+
if conflicts and not plan.force:
|
|
511
|
+
raise PackwrightValidationError(
|
|
512
|
+
[
|
|
513
|
+
"migration destination contains files that would be overwritten; rerun with --force after reviewing them",
|
|
514
|
+
*[f"existing {item['location']} artifact: {item['path']}" for item in conflicts],
|
|
515
|
+
]
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
temp_pack = None
|
|
519
|
+
pack_stale_removed = []
|
|
520
|
+
if plan.pack_dir:
|
|
521
|
+
install_pack_dir = plan.pack_dir
|
|
522
|
+
pack_stale_removed = _write_pack_to_dir(plan.pack, install_pack_dir, force=plan.force)
|
|
523
|
+
else:
|
|
524
|
+
temp_pack = tempfile.TemporaryDirectory()
|
|
525
|
+
install_pack_dir = Path(temp_pack.name)
|
|
526
|
+
_write_pack_to_dir(plan.pack, install_pack_dir, force=True)
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
install_result = install_pack(
|
|
530
|
+
install_pack_dir,
|
|
531
|
+
plan.target_dir,
|
|
532
|
+
adapter=plan.to_adapter,
|
|
533
|
+
force=plan.force,
|
|
534
|
+
include_emotion_engine_codex=_migrate_should_include_emotion_engine_codex(
|
|
535
|
+
plan.to_adapter,
|
|
536
|
+
plan.emotion_engine_codex_source,
|
|
537
|
+
),
|
|
538
|
+
emotion_engine_codex_source=plan.emotion_engine_codex_source,
|
|
539
|
+
emotion_style=plan.emotion_style,
|
|
540
|
+
emotion_engine_mode=plan.emotion_engine_mode,
|
|
541
|
+
)
|
|
542
|
+
portable_result = _copy_migrated_portable_state(
|
|
543
|
+
plan.source_target_dir,
|
|
544
|
+
plan.target_dir,
|
|
545
|
+
plan.resolved,
|
|
546
|
+
plan.to_adapter,
|
|
547
|
+
)
|
|
548
|
+
state_snapshots = _copy_emotion_state_snapshot(
|
|
549
|
+
plan.source_target_dir,
|
|
550
|
+
plan.target_dir,
|
|
551
|
+
plan.include_emotion_state,
|
|
552
|
+
)
|
|
553
|
+
finally:
|
|
554
|
+
if temp_pack is not None:
|
|
555
|
+
temp_pack.cleanup()
|
|
556
|
+
|
|
557
|
+
integrity = _verify_migration_integrity(plan.report["changes"], plan.target_dir)
|
|
558
|
+
installed_pack = _read_installed_pack(plan.target_dir)
|
|
559
|
+
installed_score = _score_migration_pack(plan.resolved, installed_pack, plan.to_adapter)
|
|
560
|
+
receipt = plan.to_dict()
|
|
561
|
+
receipt.update(
|
|
562
|
+
{
|
|
563
|
+
"status": "applied",
|
|
564
|
+
"ready": True,
|
|
565
|
+
"ok": integrity["passed"] and installed_score["passed"],
|
|
566
|
+
"integrity": integrity,
|
|
567
|
+
"source_target_dir": str(plan.source_target_dir),
|
|
568
|
+
"target_dir": str(plan.target_dir),
|
|
569
|
+
"from_adapter": plan.from_adapter,
|
|
570
|
+
"to_adapter": plan.to_adapter,
|
|
571
|
+
"mechanism": str(plan.mechanism_file),
|
|
572
|
+
"pack_dir": str(install_pack_dir) if plan.pack_dir else None,
|
|
573
|
+
"installed_artifacts": install_result["installed_artifacts"],
|
|
574
|
+
"stale_removed": sorted(set(pack_stale_removed + install_result.get("stale_removed", []))),
|
|
575
|
+
"portable_state": portable_result["copied"],
|
|
576
|
+
"memory_projection": portable_result["rewritten"],
|
|
577
|
+
"state_snapshots": state_snapshots,
|
|
578
|
+
"runtime_exclusions": _migration_runtime_exclusions(
|
|
579
|
+
plan.source_target_dir,
|
|
580
|
+
plan.source_manifest,
|
|
581
|
+
plan.from_adapter,
|
|
582
|
+
plan.to_adapter,
|
|
583
|
+
state_snapshots,
|
|
584
|
+
),
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
receipt["score"]["installed"] = installed_score
|
|
588
|
+
return receipt
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _plan_migration_changes(
|
|
592
|
+
source_target_dir,
|
|
593
|
+
target_dir,
|
|
594
|
+
source_manifest,
|
|
595
|
+
pack,
|
|
596
|
+
resolved,
|
|
597
|
+
from_adapter,
|
|
598
|
+
to_adapter,
|
|
599
|
+
include_emotion_state,
|
|
600
|
+
emotion_engine_codex_source,
|
|
601
|
+
emotion_style,
|
|
602
|
+
emotion_engine_mode,
|
|
603
|
+
):
|
|
604
|
+
carried = []
|
|
605
|
+
rewritten = []
|
|
606
|
+
source_files = _portable_source_files(source_target_dir)
|
|
607
|
+
for rel_path, source_path in source_files.items():
|
|
608
|
+
source_bytes = source_path.read_bytes()
|
|
609
|
+
if rel_path in {"memory/index.md", "memory/pinned.md", "memory/source-map.md"}:
|
|
610
|
+
source_text = source_bytes.decode("utf-8")
|
|
611
|
+
projected = project_memory_file(resolved, to_adapter, rel_path, source_text)
|
|
612
|
+
projected_bytes = projected.encode("utf-8")
|
|
613
|
+
if projected_bytes != source_bytes:
|
|
614
|
+
rewritten.append(
|
|
615
|
+
{
|
|
616
|
+
"path": rel_path,
|
|
617
|
+
"source_sha256": _sha256_bytes(source_bytes),
|
|
618
|
+
"destination_sha256": _sha256_bytes(projected_bytes),
|
|
619
|
+
"reason": f"adapter routing lines projected for {to_adapter}",
|
|
620
|
+
}
|
|
621
|
+
)
|
|
622
|
+
continue
|
|
623
|
+
carried.append(
|
|
624
|
+
{
|
|
625
|
+
"path": rel_path,
|
|
626
|
+
"sha256": _sha256_bytes(source_bytes),
|
|
627
|
+
"reason": "copied without content changes",
|
|
628
|
+
}
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
state_path = source_target_dir / EMOTION_ENGINE_CODEX_STATE_PATH
|
|
632
|
+
warnings = []
|
|
633
|
+
if include_emotion_state and state_path.is_file():
|
|
634
|
+
carried.append(
|
|
635
|
+
{
|
|
636
|
+
"path": EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
637
|
+
"sha256": _file_sha256(state_path),
|
|
638
|
+
"reason": "copied as project-local runtime state snapshot",
|
|
639
|
+
}
|
|
640
|
+
)
|
|
641
|
+
if to_adapter != "codex":
|
|
642
|
+
warnings.append(
|
|
643
|
+
{
|
|
644
|
+
"id": "emotion_state_snapshot_inert",
|
|
645
|
+
"path": EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
646
|
+
"message": f"snapshot is carried but inactive in the {to_adapter} target",
|
|
647
|
+
}
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
carried_paths = {item["path"] for item in carried}
|
|
651
|
+
rewritten_paths = {item["path"] for item in rewritten}
|
|
652
|
+
target_manifest = json.loads(pack["manifest.json"])
|
|
653
|
+
generated_by_path = {}
|
|
654
|
+
for rel_path in _manifest_artifacts(target_manifest):
|
|
655
|
+
if rel_path in carried_paths or rel_path in rewritten_paths:
|
|
656
|
+
continue
|
|
657
|
+
entry = {
|
|
658
|
+
"path": rel_path,
|
|
659
|
+
"reason": (
|
|
660
|
+
"generated portable scaffold because the source target has no corresponding file"
|
|
661
|
+
if _is_portable_path(rel_path)
|
|
662
|
+
else f"compiled for the {to_adapter} adapter"
|
|
663
|
+
),
|
|
664
|
+
}
|
|
665
|
+
generated_by_path[rel_path] = entry
|
|
666
|
+
|
|
667
|
+
if _migrate_should_include_emotion_engine_codex(to_adapter, emotion_engine_codex_source):
|
|
668
|
+
sidecar_plan = _prepare_emotion_engine_codex_install(
|
|
669
|
+
target_dir,
|
|
670
|
+
emotion_engine_codex_source,
|
|
671
|
+
force=True,
|
|
672
|
+
emotion_style=emotion_style,
|
|
673
|
+
emotion_engine_mode=emotion_engine_mode or _manifest_emotion_engine_mode(target_manifest),
|
|
674
|
+
manifest=target_manifest,
|
|
675
|
+
)
|
|
676
|
+
for rel_path, _, _ in _emotion_engine_codex_projection_files(sidecar_plan):
|
|
677
|
+
generated_by_path[rel_path] = {
|
|
678
|
+
"path": rel_path,
|
|
679
|
+
"reason": "generated Codex sidecar projection",
|
|
680
|
+
}
|
|
681
|
+
generated_by_path[EMOTION_ENGINE_CODEX_WRAPPER_PATH] = {
|
|
682
|
+
"path": EMOTION_ENGINE_CODEX_WRAPPER_PATH,
|
|
683
|
+
"reason": "generated Codex sidecar wrapper",
|
|
684
|
+
}
|
|
685
|
+
if EMOTION_ENGINE_CODEX_STATE_PATH not in carried_paths:
|
|
686
|
+
generated_by_path[EMOTION_ENGINE_CODEX_STATE_PATH] = {
|
|
687
|
+
"path": EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
688
|
+
"reason": "initialized Codex sidecar runtime state",
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
excluded = _plan_migration_exclusions(
|
|
692
|
+
source_target_dir,
|
|
693
|
+
source_manifest,
|
|
694
|
+
from_adapter,
|
|
695
|
+
to_adapter,
|
|
696
|
+
carried_paths | rewritten_paths,
|
|
697
|
+
include_emotion_state,
|
|
698
|
+
)
|
|
699
|
+
return (
|
|
700
|
+
{
|
|
701
|
+
"generated": sorted(generated_by_path.values(), key=lambda item: item["path"]),
|
|
702
|
+
"carried": sorted(carried, key=lambda item: item["path"]),
|
|
703
|
+
"rewritten": sorted(rewritten, key=lambda item: item["path"]),
|
|
704
|
+
"excluded": excluded,
|
|
705
|
+
},
|
|
706
|
+
warnings,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _portable_source_files(source_target_dir):
|
|
711
|
+
result = {}
|
|
712
|
+
for root_name in PORTABLE_STATE_DIRS:
|
|
713
|
+
root = source_target_dir / root_name
|
|
714
|
+
if not root.exists():
|
|
715
|
+
continue
|
|
716
|
+
resolve_source_path(source_target_dir, root_name, "portable state root", require_file=False)
|
|
717
|
+
if not root.is_dir():
|
|
718
|
+
raise PackwrightValidationError([f"source portable state path is not a directory: {root}"])
|
|
719
|
+
for path in sorted(root.rglob("*")):
|
|
720
|
+
rel_path = str(path.relative_to(source_target_dir))
|
|
721
|
+
resolved = resolve_source_path(
|
|
722
|
+
source_target_dir,
|
|
723
|
+
rel_path,
|
|
724
|
+
"portable state source",
|
|
725
|
+
require_file=False,
|
|
726
|
+
)
|
|
727
|
+
if resolved.is_file():
|
|
728
|
+
result[rel_path] = resolved
|
|
729
|
+
return result
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _plan_migration_exclusions(
|
|
733
|
+
source_target_dir,
|
|
734
|
+
source_manifest,
|
|
735
|
+
from_adapter,
|
|
736
|
+
to_adapter,
|
|
737
|
+
handled_paths,
|
|
738
|
+
include_emotion_state,
|
|
739
|
+
):
|
|
740
|
+
source_artifacts = set(_manifest_artifacts(source_manifest))
|
|
741
|
+
source_artifacts.update(
|
|
742
|
+
artifact for artifact in EMOTION_ENGINE_CODEX_ARTIFACTS if (source_target_dir / artifact).is_file()
|
|
743
|
+
)
|
|
744
|
+
source_entry = _adapter_entry_artifact(source_manifest, from_adapter)
|
|
745
|
+
excluded = []
|
|
746
|
+
for rel_path in sorted(source_artifacts - set(handled_paths)):
|
|
747
|
+
if rel_path == source_entry:
|
|
748
|
+
item = {
|
|
749
|
+
"id": "source_runtime_entry_replaced",
|
|
750
|
+
"path": rel_path,
|
|
751
|
+
"reason": f"replaced by the {to_adapter} adapter entry",
|
|
752
|
+
}
|
|
753
|
+
elif rel_path == "manifest.json":
|
|
754
|
+
item = {
|
|
755
|
+
"id": "source_manifest_replaced",
|
|
756
|
+
"path": rel_path,
|
|
757
|
+
"reason": "replaced by the destination adapter manifest",
|
|
758
|
+
}
|
|
759
|
+
elif rel_path == EMOTION_ENGINE_CODEX_STATE_PATH and not include_emotion_state:
|
|
760
|
+
item = {
|
|
761
|
+
"id": "emotion_state_excluded",
|
|
762
|
+
"path": rel_path,
|
|
763
|
+
"reason": "excluded by --no-emotion-state",
|
|
764
|
+
}
|
|
765
|
+
elif rel_path.startswith(f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/") or rel_path == EMOTION_ENGINE_CODEX_WRAPPER_PATH:
|
|
766
|
+
item = {
|
|
767
|
+
"id": "codex_runtime_sidecar_excluded",
|
|
768
|
+
"path": rel_path,
|
|
769
|
+
"reason": f"the {to_adapter} target receives its own runtime projection",
|
|
770
|
+
}
|
|
771
|
+
else:
|
|
772
|
+
item = {
|
|
773
|
+
"id": "source_runtime_artifact_excluded",
|
|
774
|
+
"path": rel_path,
|
|
775
|
+
"reason": f"source {from_adapter} projection is not copied; destination files are generated",
|
|
776
|
+
}
|
|
777
|
+
excluded.append(item)
|
|
778
|
+
return excluded
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _migration_plan_conflicts(target_dir, pack_dir):
|
|
782
|
+
conflicts = _migration_directory_conflicts(target_dir, "target")
|
|
783
|
+
if pack_dir:
|
|
784
|
+
conflicts.extend(_migration_directory_conflicts(pack_dir, "pack"))
|
|
785
|
+
return conflicts
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _migration_directory_conflicts(path, location):
|
|
789
|
+
if not path.exists():
|
|
790
|
+
return []
|
|
791
|
+
if not path.is_dir():
|
|
792
|
+
return [{"location": location, "path": "."}]
|
|
793
|
+
return [{"location": location, "path": child.name} for child in sorted(path.iterdir())]
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _validate_migration_locations(source_target_dir, target_dir, pack_dir):
|
|
797
|
+
if _paths_overlap(source_target_dir, target_dir):
|
|
798
|
+
raise PackwrightValidationError(["source and destination targets must be separate, non-nested directories"])
|
|
799
|
+
if pack_dir and (
|
|
800
|
+
_paths_overlap(source_target_dir, pack_dir)
|
|
801
|
+
or _paths_overlap(target_dir, pack_dir)
|
|
802
|
+
):
|
|
803
|
+
raise PackwrightValidationError(["migration pack directory must be separate from source and destination targets"])
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _paths_overlap(first, second):
|
|
807
|
+
return _path_is_within(first, second) or _path_is_within(second, first)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _path_is_within(path, root):
|
|
811
|
+
try:
|
|
812
|
+
path.resolve().relative_to(root.resolve())
|
|
813
|
+
except ValueError:
|
|
814
|
+
return False
|
|
815
|
+
return True
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _verify_migration_source(changes, source_target_dir):
|
|
819
|
+
checks = []
|
|
820
|
+
issues = []
|
|
821
|
+
for item in changes["carried"]:
|
|
822
|
+
path = source_target_dir / item["path"]
|
|
823
|
+
actual = _file_sha256(path) if path.is_file() else None
|
|
824
|
+
passed = actual == item["sha256"]
|
|
825
|
+
checks.append({"path": item["path"], "passed": passed})
|
|
826
|
+
if not passed:
|
|
827
|
+
issues.append({"path": item["path"], "message": f"source changed: {item['path']}"})
|
|
828
|
+
for item in changes["rewritten"]:
|
|
829
|
+
path = source_target_dir / item["path"]
|
|
830
|
+
actual = _file_sha256(path) if path.is_file() else None
|
|
831
|
+
passed = actual == item["source_sha256"]
|
|
832
|
+
checks.append({"path": item["path"], "passed": passed})
|
|
833
|
+
if not passed:
|
|
834
|
+
issues.append({"path": item["path"], "message": f"source changed: {item['path']}"})
|
|
835
|
+
return {"passed": not issues, "checked": len(checks), "issues": issues}
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _verify_migration_integrity(changes, target_dir):
|
|
839
|
+
checks = []
|
|
840
|
+
issues = []
|
|
841
|
+
expected = [
|
|
842
|
+
(item["path"], item["sha256"], "carried")
|
|
843
|
+
for item in changes["carried"]
|
|
844
|
+
] + [
|
|
845
|
+
(item["path"], item["destination_sha256"], "rewritten")
|
|
846
|
+
for item in changes["rewritten"]
|
|
847
|
+
]
|
|
848
|
+
for rel_path, expected_hash, category in expected:
|
|
849
|
+
path = target_dir / rel_path
|
|
850
|
+
actual = _file_sha256(path) if path.is_file() else None
|
|
851
|
+
passed = actual == expected_hash
|
|
852
|
+
checks.append({"path": rel_path, "category": category, "passed": passed})
|
|
853
|
+
if not passed:
|
|
854
|
+
issues.append(
|
|
855
|
+
{
|
|
856
|
+
"path": rel_path,
|
|
857
|
+
"category": category,
|
|
858
|
+
"message": f"destination hash does not match the planned {category} content",
|
|
859
|
+
}
|
|
860
|
+
)
|
|
861
|
+
return {"passed": not issues, "checked": len(checks), "issues": issues}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _read_installed_pack(target_dir):
|
|
865
|
+
manifest = _load_manifest(target_dir)
|
|
866
|
+
pack = {}
|
|
867
|
+
for rel_path in _manifest_artifacts(manifest):
|
|
868
|
+
path = resolve_source_path(target_dir, rel_path, "installed artifact")
|
|
869
|
+
pack[rel_path] = path.read_text(encoding="utf-8")
|
|
870
|
+
return pack
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _score_migration_pack(resolved, pack, adapter):
|
|
874
|
+
from packwright.checker import score_mechanism
|
|
875
|
+
|
|
876
|
+
return score_mechanism(resolved, pack, adapter=adapter)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _is_portable_path(rel_path):
|
|
880
|
+
return any(rel_path == root or rel_path.startswith(f"{root}/") for root in PORTABLE_STATE_DIRS)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _sha256_bytes(data):
|
|
884
|
+
return hashlib.sha256(data).hexdigest()
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _file_sha256(path):
|
|
888
|
+
return _sha256_bytes(path.read_bytes())
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _artifact_lock_enabled(manifest):
|
|
892
|
+
metadata = manifest.get("packwright", {}) if isinstance(manifest, dict) else {}
|
|
893
|
+
artifacts = manifest.get("artifacts", []) if isinstance(manifest, dict) else []
|
|
894
|
+
return metadata.get("lock") == LOCK_PATH or (isinstance(artifacts, list) and LOCK_PATH in artifacts)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _load_artifact_lock(target_dir):
|
|
898
|
+
path = resolve_source_path(target_dir, LOCK_PATH, "artifact lock")
|
|
899
|
+
try:
|
|
900
|
+
lock = json.loads(path.read_text(encoding="utf-8"))
|
|
901
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
902
|
+
raise PackwrightValidationError([f"invalid artifact lock {path}: {exc}"])
|
|
903
|
+
if not isinstance(lock, dict) or lock.get("schema") != "packwright-lock/v1":
|
|
904
|
+
raise PackwrightValidationError([f"artifact lock has an unexpected schema: {path}"])
|
|
905
|
+
artifacts = lock.get("artifacts")
|
|
906
|
+
if not isinstance(artifacts, dict) or not artifacts:
|
|
907
|
+
raise PackwrightValidationError([f"artifact lock must contain a non-empty artifacts mapping: {path}"])
|
|
908
|
+
normalized = {}
|
|
909
|
+
issues = []
|
|
910
|
+
for rel_path, digest in artifacts.items():
|
|
911
|
+
try:
|
|
912
|
+
relative = validate_relative_path(rel_path, "artifact lock path").as_posix()
|
|
913
|
+
except PackwrightValidationError as exc:
|
|
914
|
+
issues.extend(exc.issues)
|
|
915
|
+
continue
|
|
916
|
+
if not isinstance(digest, str) or len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest.lower()):
|
|
917
|
+
issues.append(f"artifact lock digest must be a SHA-256 hex string: {rel_path}")
|
|
918
|
+
continue
|
|
919
|
+
normalized[relative] = digest.lower()
|
|
920
|
+
if issues:
|
|
921
|
+
raise PackwrightValidationError(issues)
|
|
922
|
+
return normalized
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _refresh_artifact_lock(target_dir):
|
|
926
|
+
lock_path = target_dir / LOCK_PATH
|
|
927
|
+
if not lock_path.is_file():
|
|
928
|
+
return False
|
|
929
|
+
manifest = _load_manifest(target_dir)
|
|
930
|
+
artifacts = {}
|
|
931
|
+
for rel_path in _manifest_artifacts(manifest):
|
|
932
|
+
if rel_path == LOCK_PATH:
|
|
933
|
+
continue
|
|
934
|
+
path = resolve_source_path(target_dir, rel_path, "installed artifact")
|
|
935
|
+
artifacts[rel_path] = _file_sha256(path)
|
|
936
|
+
destination = resolve_destination_path(target_dir, LOCK_PATH, "artifact lock destination")
|
|
937
|
+
destination.write_text(
|
|
938
|
+
json.dumps({"schema": "packwright-lock/v1", "artifacts": artifacts}, indent=2, sort_keys=True) + "\n",
|
|
939
|
+
encoding="utf-8",
|
|
940
|
+
)
|
|
941
|
+
return True
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _update_artifact_lock_paths(target_dir, rel_paths):
|
|
945
|
+
lock_path = target_dir / LOCK_PATH
|
|
946
|
+
if not lock_path.is_file():
|
|
947
|
+
return False
|
|
948
|
+
locked = _load_artifact_lock(target_dir)
|
|
949
|
+
for rel_path in rel_paths:
|
|
950
|
+
if rel_path == LOCK_PATH or _is_portable_path(rel_path) or rel_path == EMOTION_ENGINE_CODEX_STATE_PATH:
|
|
951
|
+
continue
|
|
952
|
+
path = resolve_source_path(target_dir, rel_path, "managed artifact")
|
|
953
|
+
locked[rel_path] = _file_sha256(path)
|
|
954
|
+
destination = resolve_destination_path(target_dir, LOCK_PATH, "artifact lock destination")
|
|
955
|
+
destination.write_text(
|
|
956
|
+
json.dumps({"schema": "packwright-lock/v1", "artifacts": locked}, indent=2, sort_keys=True) + "\n",
|
|
957
|
+
encoding="utf-8",
|
|
958
|
+
)
|
|
959
|
+
return True
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _artifact_lock_doctor_issues(target_dir, manifest):
|
|
963
|
+
if not _artifact_lock_enabled(manifest):
|
|
964
|
+
return []
|
|
965
|
+
try:
|
|
966
|
+
locked = _load_artifact_lock(target_dir)
|
|
967
|
+
except PackwrightValidationError as exc:
|
|
968
|
+
return [_doctor_issue("artifact_lock_invalid", LOCK_PATH, "; ".join(exc.issues))]
|
|
969
|
+
|
|
970
|
+
issues = []
|
|
971
|
+
for rel_path, expected_hash in sorted(locked.items()):
|
|
972
|
+
if rel_path == LOCK_PATH or _is_portable_path(rel_path) or rel_path == EMOTION_ENGINE_CODEX_STATE_PATH:
|
|
973
|
+
continue
|
|
974
|
+
try:
|
|
975
|
+
path = resolve_source_path(target_dir, rel_path, "managed artifact")
|
|
976
|
+
except PackwrightValidationError as exc:
|
|
977
|
+
issues.append(_doctor_issue("managed_artifact_missing_or_unsafe", rel_path, "; ".join(exc.issues)))
|
|
978
|
+
continue
|
|
979
|
+
try:
|
|
980
|
+
actual_hash = _file_sha256(path)
|
|
981
|
+
except OSError as exc:
|
|
982
|
+
issues.append(_doctor_issue("managed_artifact_unreadable", rel_path, f"cannot read managed artifact: {exc}"))
|
|
983
|
+
continue
|
|
984
|
+
if actual_hash != expected_hash:
|
|
985
|
+
issues.append(_doctor_issue("managed_artifact_drift", rel_path, "managed artifact hash differs from .packwright/lock.json"))
|
|
986
|
+
|
|
987
|
+
try:
|
|
988
|
+
manifest_artifacts = _manifest_artifacts(manifest)
|
|
989
|
+
except PackwrightValidationError:
|
|
990
|
+
return issues
|
|
991
|
+
for rel_path in manifest_artifacts:
|
|
992
|
+
if (
|
|
993
|
+
rel_path == LOCK_PATH
|
|
994
|
+
or _is_portable_path(rel_path)
|
|
995
|
+
or rel_path == EMOTION_ENGINE_CODEX_STATE_PATH
|
|
996
|
+
or rel_path in EMOTION_ENGINE_CODEX_ARTIFACTS
|
|
997
|
+
):
|
|
998
|
+
continue
|
|
999
|
+
if rel_path not in locked:
|
|
1000
|
+
issues.append(_doctor_issue("managed_artifact_untracked", rel_path, "managed artifact is not recorded in .packwright/lock.json"))
|
|
1001
|
+
return issues
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _repair_managed_artifact_drift(target_dir, manifest, issues):
|
|
1005
|
+
repairable_ids = {"managed_artifact_drift", "managed_artifact_missing_or_unsafe"}
|
|
1006
|
+
candidates = [issue["path"] for issue in issues if issue.get("id") in repairable_ids]
|
|
1007
|
+
if not candidates:
|
|
1008
|
+
return []
|
|
1009
|
+
canonical_inputs = {SPEC_PATH}
|
|
1010
|
+
canonical_inputs.update(path for path in candidates if path.startswith(".packwright/source/"))
|
|
1011
|
+
if canonical_inputs.intersection(candidates):
|
|
1012
|
+
return []
|
|
1013
|
+
|
|
1014
|
+
try:
|
|
1015
|
+
locked = _load_artifact_lock(target_dir)
|
|
1016
|
+
resolved = load_embedded_spec(target_dir)
|
|
1017
|
+
adapter = manifest.get("adapter")
|
|
1018
|
+
expected = _compile_pack_for_adapter(adapter, resolved, {"source_mechanism": SPEC_PATH})
|
|
1019
|
+
receipt = _score_migration_pack(resolved, expected, adapter)
|
|
1020
|
+
expected = embed_pack_metadata(expected, resolved, receipt)
|
|
1021
|
+
except (OSError, ValueError, PackwrightValidationError, json.JSONDecodeError):
|
|
1022
|
+
return []
|
|
1023
|
+
|
|
1024
|
+
fixed = []
|
|
1025
|
+
for rel_path in candidates:
|
|
1026
|
+
if rel_path.startswith(".packwright/source/") or rel_path == SPEC_PATH:
|
|
1027
|
+
continue
|
|
1028
|
+
content = expected.get(rel_path)
|
|
1029
|
+
expected_hash = locked.get(rel_path)
|
|
1030
|
+
if content is None or expected_hash != _sha256_bytes(content.encode("utf-8")):
|
|
1031
|
+
continue
|
|
1032
|
+
destination = resolve_destination_path(target_dir, rel_path, "managed artifact repair destination")
|
|
1033
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1034
|
+
destination.write_text(content, encoding="utf-8")
|
|
1035
|
+
if rel_path in HANDOFF_EXECUTABLE_ARTIFACTS:
|
|
1036
|
+
_make_executable(destination)
|
|
1037
|
+
fixed.append(rel_path)
|
|
1038
|
+
return sorted(set(fixed))
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _load_manifest(pack_dir):
|
|
1042
|
+
try:
|
|
1043
|
+
manifest_path = resolve_source_path(pack_dir, "manifest.json", "adapter pack manifest")
|
|
1044
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
1045
|
+
except OSError as exc:
|
|
1046
|
+
raise PackwrightValidationError([f"cannot read adapter pack manifest {manifest_path}: {exc}"])
|
|
1047
|
+
except json.JSONDecodeError as exc:
|
|
1048
|
+
raise PackwrightValidationError([f"invalid adapter pack manifest {manifest_path}: {exc}"])
|
|
1049
|
+
if not isinstance(manifest, dict):
|
|
1050
|
+
raise PackwrightValidationError([f"adapter pack manifest must be a mapping: {manifest_path}"])
|
|
1051
|
+
return manifest
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _resolve_migration_mechanism_path(source_target_dir, source_manifest, mechanism_path):
|
|
1055
|
+
embedded = source_target_dir / SPEC_PATH
|
|
1056
|
+
if mechanism_path is None and embedded.is_file():
|
|
1057
|
+
return embedded
|
|
1058
|
+
raw = mechanism_path or source_manifest.get("source_mechanism")
|
|
1059
|
+
if not raw:
|
|
1060
|
+
raise PackwrightValidationError([
|
|
1061
|
+
"source target manifest does not include source_mechanism; pass --mechanism explicitly"
|
|
1062
|
+
])
|
|
1063
|
+
path = Path(raw)
|
|
1064
|
+
candidates = [path] if path.is_absolute() else [
|
|
1065
|
+
source_target_dir / path,
|
|
1066
|
+
source_target_dir.parent / path,
|
|
1067
|
+
Path.cwd() / path,
|
|
1068
|
+
]
|
|
1069
|
+
for candidate in candidates:
|
|
1070
|
+
resolved = candidate.resolve()
|
|
1071
|
+
if resolved.is_file() or resolved.is_dir():
|
|
1072
|
+
return resolved
|
|
1073
|
+
checked = ", ".join(str(candidate) for candidate in candidates)
|
|
1074
|
+
raise PackwrightValidationError([f"cannot resolve migration mechanism {raw!r}; checked {checked}"])
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _prepare_migration_mechanism(data, to_adapter, slug=None, upgrade_adapter_support=True):
|
|
1078
|
+
changes = []
|
|
1079
|
+
if slug:
|
|
1080
|
+
normalized = normalize_slug(slug, default="")
|
|
1081
|
+
if not normalized or not is_valid_slug(normalized):
|
|
1082
|
+
raise PackwrightValidationError(["--slug must normalize to a lowercase ASCII slug"])
|
|
1083
|
+
data.setdefault("metadata", {})["slug"] = normalized
|
|
1084
|
+
data.setdefault("identity", {})["slug"] = normalized
|
|
1085
|
+
changes.append({"id": "slug_override", "slug": normalized})
|
|
1086
|
+
if upgrade_adapter_support:
|
|
1087
|
+
changes.extend(_ensure_current_adapter_contract(data, to_adapter))
|
|
1088
|
+
return changes
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _ensure_current_adapter_contract(data, to_adapter):
|
|
1092
|
+
changes = []
|
|
1093
|
+
targets = data.setdefault("targets", {})
|
|
1094
|
+
supported = targets.setdefault("supported", [])
|
|
1095
|
+
for adapter in sorted(SUPPORTED_INSTALL_ADAPTERS):
|
|
1096
|
+
if adapter not in supported:
|
|
1097
|
+
supported.append(adapter)
|
|
1098
|
+
changes.append({"id": "target_supported_added", "adapter": adapter})
|
|
1099
|
+
|
|
1100
|
+
projection = data.setdefault("emotion", {}).setdefault("projection", {})
|
|
1101
|
+
for adapter in sorted(SUPPORTED_INSTALL_ADAPTERS):
|
|
1102
|
+
if adapter not in projection:
|
|
1103
|
+
projection[adapter] = (
|
|
1104
|
+
"optional_sidecar_when_explicitly_enabled" if adapter == "codex" else "spec_guided_behavior_only"
|
|
1105
|
+
)
|
|
1106
|
+
changes.append({"id": "emotion_projection_added", "adapter": adapter})
|
|
1107
|
+
|
|
1108
|
+
outputs = data.setdefault("outputs", {})
|
|
1109
|
+
for adapter in sorted(SUPPORTED_INSTALL_ADAPTERS):
|
|
1110
|
+
expected = _adapter_output_artifacts(data, adapter)
|
|
1111
|
+
if adapter not in outputs:
|
|
1112
|
+
outputs[adapter] = {"kind": "adapter_pack", "artifacts": expected}
|
|
1113
|
+
changes.append({"id": "outputs_added", "adapter": adapter})
|
|
1114
|
+
continue
|
|
1115
|
+
config = outputs[adapter]
|
|
1116
|
+
if not isinstance(config, dict):
|
|
1117
|
+
outputs[adapter] = {"kind": "adapter_pack", "artifacts": expected}
|
|
1118
|
+
changes.append({"id": "outputs_replaced", "adapter": adapter})
|
|
1119
|
+
continue
|
|
1120
|
+
config.setdefault("kind", "adapter_pack")
|
|
1121
|
+
artifacts = config.setdefault("artifacts", [])
|
|
1122
|
+
if not isinstance(artifacts, list):
|
|
1123
|
+
config["artifacts"] = expected
|
|
1124
|
+
changes.append({"id": "outputs_artifacts_replaced", "adapter": adapter})
|
|
1125
|
+
continue
|
|
1126
|
+
missing = [artifact for artifact in expected if artifact not in artifacts]
|
|
1127
|
+
if missing:
|
|
1128
|
+
artifacts.extend(missing)
|
|
1129
|
+
changes.append({"id": "outputs_artifacts_added", "adapter": adapter, "count": len(missing)})
|
|
1130
|
+
|
|
1131
|
+
coverage = data.setdefault("coverage", {}).setdefault("implemented_by", {})
|
|
1132
|
+
adapter_projection = coverage.setdefault("adapter_projection", [])
|
|
1133
|
+
output_ref = f"outputs.{to_adapter}"
|
|
1134
|
+
if isinstance(adapter_projection, list) and output_ref not in adapter_projection:
|
|
1135
|
+
adapter_projection.append(output_ref)
|
|
1136
|
+
changes.append({"id": "coverage_adapter_projection_added", "adapter": to_adapter})
|
|
1137
|
+
return changes
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _adapter_output_artifacts(mechanism, adapter):
|
|
1141
|
+
skill_path = save_context_skill_path(mechanism, adapter)
|
|
1142
|
+
prefix = reference_prefix(mechanism, adapter)
|
|
1143
|
+
slug = character_slug(mechanism)
|
|
1144
|
+
entry_file = "AGENTS.md" if adapter == "codex" else "CLAUDE.md"
|
|
1145
|
+
if adapter == "cursor":
|
|
1146
|
+
entry_file = f".cursor/rules/{slug}.mdc"
|
|
1147
|
+
artifacts = [
|
|
1148
|
+
entry_file,
|
|
1149
|
+
skill_path,
|
|
1150
|
+
f"{prefix}/identity/persona.md",
|
|
1151
|
+
f"{prefix}/identity/voice.md",
|
|
1152
|
+
f"{prefix}/identity/relationship.md",
|
|
1153
|
+
f"{prefix}/operating/principles.md",
|
|
1154
|
+
f"{prefix}/operating/boundaries.md",
|
|
1155
|
+
f"{prefix}/mechanism/context-loading.yaml",
|
|
1156
|
+
f"{prefix}/mechanism/session-guards.yaml",
|
|
1157
|
+
f"{prefix}/mechanism/memory-policy.yaml",
|
|
1158
|
+
f"{prefix}/projection/platform-capabilities.yaml",
|
|
1159
|
+
f"{prefix}/projection/ownership-contract.yaml",
|
|
1160
|
+
f"{prefix}/emotion/model.yaml",
|
|
1161
|
+
f"{prefix}/emotion/state-schema.yaml",
|
|
1162
|
+
f"{prefix}/emotion/update-policy.yaml",
|
|
1163
|
+
f"{prefix}/emotion/voice-modulation.yaml",
|
|
1164
|
+
f"{prefix}/emotion/memory-events.yaml",
|
|
1165
|
+
f"{prefix}/source-skills/save-context/SKILL.md",
|
|
1166
|
+
]
|
|
1167
|
+
if adapter == "claude-code":
|
|
1168
|
+
artifacts.append(".claude/settings.local.json.example")
|
|
1169
|
+
if adapter == "cursor":
|
|
1170
|
+
artifacts.append(f".cursor/rules/{slug}-memory.mdc")
|
|
1171
|
+
artifacts.extend(HANDOFF_ARTIFACTS)
|
|
1172
|
+
artifacts.extend(
|
|
1173
|
+
[
|
|
1174
|
+
"memory/index.md",
|
|
1175
|
+
"memory/profile.md",
|
|
1176
|
+
"memory/session-index.md",
|
|
1177
|
+
"memory/source-map.md",
|
|
1178
|
+
"memory/collaboration.md",
|
|
1179
|
+
"memory/recent-activity.md",
|
|
1180
|
+
"memory/pinned.md",
|
|
1181
|
+
"memory/workstreams.md",
|
|
1182
|
+
"memory/workstreams/_template.md",
|
|
1183
|
+
"memory/projects/_template.md",
|
|
1184
|
+
"memory/todos.md",
|
|
1185
|
+
"memory/knowledge_map.md",
|
|
1186
|
+
"memory/relationship-state.md",
|
|
1187
|
+
"memory/emotion-state.json.example",
|
|
1188
|
+
*knowledge_artifacts(),
|
|
1189
|
+
*workspace_artifacts(),
|
|
1190
|
+
"manifest.json",
|
|
1191
|
+
]
|
|
1192
|
+
)
|
|
1193
|
+
return artifacts
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def _migration_resolved_parameters(source_manifest, parameters):
|
|
1197
|
+
resolved = source_manifest.get("resolved_parameters", {})
|
|
1198
|
+
result = dict(resolved) if isinstance(resolved, dict) else {}
|
|
1199
|
+
result.update(parameters or {})
|
|
1200
|
+
return result
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _migrate_should_include_emotion_engine_codex(to_adapter, emotion_engine_codex_source):
|
|
1204
|
+
if to_adapter != "codex":
|
|
1205
|
+
return False
|
|
1206
|
+
return bool(emotion_engine_codex_source or os.environ.get("PACKWRIGHT_EMOTION_ENGINE_CODEX_DIR"))
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _compile_pack_for_adapter(adapter, resolved, references):
|
|
1210
|
+
if adapter == "codex":
|
|
1211
|
+
from packwright.adapters import compile_to_codex_pack
|
|
1212
|
+
|
|
1213
|
+
return compile_to_codex_pack(resolved, references=references)
|
|
1214
|
+
if adapter == "claude-code":
|
|
1215
|
+
from packwright.adapters import compile_to_claude_code_pack
|
|
1216
|
+
|
|
1217
|
+
return compile_to_claude_code_pack(resolved, references=references)
|
|
1218
|
+
if adapter == "cursor":
|
|
1219
|
+
from packwright.adapters import compile_to_cursor_pack
|
|
1220
|
+
|
|
1221
|
+
return compile_to_cursor_pack(resolved, references=references)
|
|
1222
|
+
raise PackwrightValidationError([f"unsupported adapter: {adapter}"])
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _write_pack_to_dir(pack, out_dir, force=False):
|
|
1226
|
+
out_dir = Path(out_dir)
|
|
1227
|
+
destinations = {
|
|
1228
|
+
rel_path: resolve_destination_path(out_dir, rel_path, "pack artifact destination")
|
|
1229
|
+
for rel_path in pack
|
|
1230
|
+
}
|
|
1231
|
+
existing = [rel_path for rel_path, path in destinations.items() if path.exists()]
|
|
1232
|
+
if existing and not force:
|
|
1233
|
+
raise PackwrightValidationError(
|
|
1234
|
+
[
|
|
1235
|
+
"pack directory already contains files that would be overwritten; rerun with --force after reviewing them",
|
|
1236
|
+
*[f"existing pack artifact: {artifact}" for artifact in existing],
|
|
1237
|
+
]
|
|
1238
|
+
)
|
|
1239
|
+
stale_removed = []
|
|
1240
|
+
if force:
|
|
1241
|
+
stale_removed = _remove_stale_manifest_artifacts(out_dir, set(pack))
|
|
1242
|
+
for rel_path, content in pack.items():
|
|
1243
|
+
path = destinations[rel_path]
|
|
1244
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1245
|
+
path.write_text(content, encoding="utf-8")
|
|
1246
|
+
if rel_path in HANDOFF_EXECUTABLE_ARTIFACTS:
|
|
1247
|
+
_make_executable(path)
|
|
1248
|
+
return stale_removed
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _remove_stale_manifest_artifacts(root_dir, next_artifacts, preserve_portable=False):
|
|
1252
|
+
manifest_path = root_dir / "manifest.json"
|
|
1253
|
+
if not manifest_path.is_file():
|
|
1254
|
+
return []
|
|
1255
|
+
try:
|
|
1256
|
+
previous_manifest = _load_manifest(root_dir)
|
|
1257
|
+
previous_artifacts = _manifest_artifacts(previous_manifest)
|
|
1258
|
+
except PackwrightValidationError:
|
|
1259
|
+
return []
|
|
1260
|
+
removed = []
|
|
1261
|
+
for artifact in sorted(set(previous_artifacts) - set(next_artifacts), key=lambda item: len(Path(item).parts), reverse=True):
|
|
1262
|
+
if preserve_portable and _is_portable_path(artifact):
|
|
1263
|
+
continue
|
|
1264
|
+
path = resolve_destination_path(root_dir, artifact, "stale artifact destination")
|
|
1265
|
+
if not path.exists():
|
|
1266
|
+
continue
|
|
1267
|
+
if path.is_dir():
|
|
1268
|
+
continue
|
|
1269
|
+
path.unlink()
|
|
1270
|
+
removed.append(artifact)
|
|
1271
|
+
_remove_empty_parents(path.parent, root_dir)
|
|
1272
|
+
return removed
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _path_stays_in_root(path, root_dir):
|
|
1276
|
+
try:
|
|
1277
|
+
path.resolve().relative_to(root_dir.resolve())
|
|
1278
|
+
except ValueError:
|
|
1279
|
+
return False
|
|
1280
|
+
return True
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _remove_empty_parents(path, root_dir):
|
|
1284
|
+
root = root_dir.resolve()
|
|
1285
|
+
current = path
|
|
1286
|
+
while current.resolve() != root:
|
|
1287
|
+
try:
|
|
1288
|
+
current.rmdir()
|
|
1289
|
+
except OSError:
|
|
1290
|
+
return
|
|
1291
|
+
current = current.parent
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _target_layout_doctor_issues(target_dir, manifest):
|
|
1295
|
+
issues = []
|
|
1296
|
+
seen = set()
|
|
1297
|
+
if manifest.get("adapter") == "codex":
|
|
1298
|
+
_append_legacy_codex_skill_issues(target_dir, manifest, issues, seen)
|
|
1299
|
+
for rel_dir in workspace_required_dirs():
|
|
1300
|
+
if not (target_dir / rel_dir).is_dir():
|
|
1301
|
+
_append_doctor_issue(
|
|
1302
|
+
issues,
|
|
1303
|
+
seen,
|
|
1304
|
+
"workspace_layout_missing_directory",
|
|
1305
|
+
rel_dir,
|
|
1306
|
+
"required workspace directory is missing",
|
|
1307
|
+
)
|
|
1308
|
+
for rel_path in workspace_artifacts():
|
|
1309
|
+
if not (target_dir / rel_path).is_file():
|
|
1310
|
+
_append_doctor_issue(
|
|
1311
|
+
issues,
|
|
1312
|
+
seen,
|
|
1313
|
+
"workspace_layout_missing_file",
|
|
1314
|
+
rel_path,
|
|
1315
|
+
"required workspace scaffold file is missing",
|
|
1316
|
+
)
|
|
1317
|
+
for rel_dir in knowledge_required_dirs():
|
|
1318
|
+
if not (target_dir / rel_dir).is_dir():
|
|
1319
|
+
_append_doctor_issue(
|
|
1320
|
+
issues,
|
|
1321
|
+
seen,
|
|
1322
|
+
"knowledge_scaffold_missing_directory",
|
|
1323
|
+
rel_dir,
|
|
1324
|
+
"required knowledge directory is missing",
|
|
1325
|
+
)
|
|
1326
|
+
for rel_path in knowledge_artifacts():
|
|
1327
|
+
if not (target_dir / rel_path).is_file():
|
|
1328
|
+
_append_doctor_issue(
|
|
1329
|
+
issues,
|
|
1330
|
+
seen,
|
|
1331
|
+
"knowledge_scaffold_missing_file",
|
|
1332
|
+
rel_path,
|
|
1333
|
+
"required knowledge scaffold file is missing",
|
|
1334
|
+
)
|
|
1335
|
+
for issue in knowledge_manifest_diagnostics(target_dir):
|
|
1336
|
+
_append_doctor_issue(
|
|
1337
|
+
issues,
|
|
1338
|
+
seen,
|
|
1339
|
+
issue.get("id", "knowledge_issue"),
|
|
1340
|
+
issue.get("path", ""),
|
|
1341
|
+
issue.get("message", "knowledge issue"),
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
if manifest.get("adapter") == "cursor":
|
|
1345
|
+
for rel_path, expected_text in target_handoff_artifacts().items():
|
|
1346
|
+
try:
|
|
1347
|
+
path = resolve_source_path(target_dir, rel_path, "handoff artifact")
|
|
1348
|
+
except PackwrightValidationError:
|
|
1349
|
+
_append_doctor_issue(
|
|
1350
|
+
issues,
|
|
1351
|
+
seen,
|
|
1352
|
+
"handoff_tool_missing_file",
|
|
1353
|
+
rel_path,
|
|
1354
|
+
"target-local handoff helper file is missing",
|
|
1355
|
+
)
|
|
1356
|
+
else:
|
|
1357
|
+
if path.read_text(encoding="utf-8") != expected_text:
|
|
1358
|
+
_append_doctor_issue(
|
|
1359
|
+
issues,
|
|
1360
|
+
seen,
|
|
1361
|
+
"handoff_tool_file_drift",
|
|
1362
|
+
rel_path,
|
|
1363
|
+
"target-local handoff helper differs from expected projection",
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
try:
|
|
1367
|
+
artifacts = _manifest_artifacts(manifest)
|
|
1368
|
+
except PackwrightValidationError as exc:
|
|
1369
|
+
_append_doctor_issue(
|
|
1370
|
+
issues,
|
|
1371
|
+
seen,
|
|
1372
|
+
"manifest_artifacts_invalid",
|
|
1373
|
+
"manifest.json",
|
|
1374
|
+
"; ".join(exc.issues),
|
|
1375
|
+
)
|
|
1376
|
+
return issues
|
|
1377
|
+
for artifact in artifacts:
|
|
1378
|
+
try:
|
|
1379
|
+
resolve_source_path(target_dir, artifact, "manifest artifact")
|
|
1380
|
+
except PackwrightValidationError:
|
|
1381
|
+
_append_doctor_issue(
|
|
1382
|
+
issues,
|
|
1383
|
+
seen,
|
|
1384
|
+
"manifest_artifact_missing",
|
|
1385
|
+
artifact,
|
|
1386
|
+
"manifest artifact is missing",
|
|
1387
|
+
)
|
|
1388
|
+
return issues
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _target_layout_doctor_warnings(target_dir):
|
|
1392
|
+
warnings = []
|
|
1393
|
+
for rel_path in COMPATIBILITY_MEMORY_FILES:
|
|
1394
|
+
if (target_dir / rel_path).is_file():
|
|
1395
|
+
warnings.append({
|
|
1396
|
+
"id": "compatibility_memory_file_present",
|
|
1397
|
+
"path": rel_path,
|
|
1398
|
+
"message": "compatibility-only memory file is present; keep for legacy reads but do not use as an active memory owner",
|
|
1399
|
+
})
|
|
1400
|
+
return warnings
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _append_doctor_issue(issues, seen, issue_id, path, message):
|
|
1404
|
+
key = (issue_id, path)
|
|
1405
|
+
if key in seen:
|
|
1406
|
+
return
|
|
1407
|
+
seen.add(key)
|
|
1408
|
+
issues.append(_doctor_issue(issue_id, path, message))
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _fix_target_layout(target_dir, issues):
|
|
1412
|
+
handoff_artifacts = target_handoff_artifacts()
|
|
1413
|
+
fixed = []
|
|
1414
|
+
legacy_fixes = _fix_legacy_codex_skills(target_dir, issues)
|
|
1415
|
+
fixed.extend(legacy_fixes)
|
|
1416
|
+
for issue in issues:
|
|
1417
|
+
rel_path = issue.get("path")
|
|
1418
|
+
issue_id = issue.get("id")
|
|
1419
|
+
if issue_id == "workspace_layout_missing_directory" and rel_path in workspace_required_dirs():
|
|
1420
|
+
resolve_destination_path(target_dir, rel_path, "workspace repair destination").mkdir(parents=True, exist_ok=True)
|
|
1421
|
+
fixed.append(rel_path)
|
|
1422
|
+
continue
|
|
1423
|
+
if rel_path == "workspace/README.md" and issue_id in {"workspace_layout_missing_file", "manifest_artifact_missing"}:
|
|
1424
|
+
path = resolve_destination_path(target_dir, rel_path, "workspace repair destination")
|
|
1425
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1426
|
+
path.write_text(workspace_readme(), encoding="utf-8")
|
|
1427
|
+
fixed.append(rel_path)
|
|
1428
|
+
continue
|
|
1429
|
+
if rel_path in workspace_artifacts() and rel_path.endswith("/.gitkeep"):
|
|
1430
|
+
path = resolve_destination_path(target_dir, rel_path, "workspace repair destination")
|
|
1431
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1432
|
+
if not path.exists():
|
|
1433
|
+
path.write_text("", encoding="utf-8")
|
|
1434
|
+
fixed.append(rel_path)
|
|
1435
|
+
continue
|
|
1436
|
+
if issue_id == "knowledge_scaffold_missing_directory" and rel_path in knowledge_required_dirs():
|
|
1437
|
+
resolve_destination_path(target_dir, rel_path, "knowledge repair destination").mkdir(parents=True, exist_ok=True)
|
|
1438
|
+
fixed.append(rel_path)
|
|
1439
|
+
continue
|
|
1440
|
+
if rel_path in knowledge_files() and issue_id in {
|
|
1441
|
+
"knowledge_scaffold_missing_file",
|
|
1442
|
+
"manifest_artifact_missing",
|
|
1443
|
+
}:
|
|
1444
|
+
path = resolve_destination_path(target_dir, rel_path, "knowledge repair destination")
|
|
1445
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1446
|
+
path.write_text(knowledge_files()[rel_path], encoding="utf-8")
|
|
1447
|
+
fixed.append(rel_path)
|
|
1448
|
+
continue
|
|
1449
|
+
if rel_path in handoff_artifacts and issue_id in {
|
|
1450
|
+
"handoff_tool_missing_file",
|
|
1451
|
+
"handoff_tool_file_drift",
|
|
1452
|
+
"manifest_artifact_missing",
|
|
1453
|
+
}:
|
|
1454
|
+
path = resolve_destination_path(target_dir, rel_path, "handoff repair destination")
|
|
1455
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1456
|
+
path.write_text(handoff_artifacts[rel_path], encoding="utf-8")
|
|
1457
|
+
if rel_path in HANDOFF_EXECUTABLE_ARTIFACTS:
|
|
1458
|
+
_make_executable(path)
|
|
1459
|
+
fixed.append(rel_path)
|
|
1460
|
+
return sorted(set(fixed))
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _append_legacy_codex_skill_issues(target_dir, manifest, issues, seen):
|
|
1464
|
+
slug = manifest.get("character", {}).get("slug")
|
|
1465
|
+
pairs = []
|
|
1466
|
+
if slug:
|
|
1467
|
+
pairs.append((f".codex/skills/{slug}-save-context", f".agents/skills/{slug}-save-context"))
|
|
1468
|
+
pairs.append((EMOTION_ENGINE_CODEX_LEGACY_SKILL_DIR, EMOTION_ENGINE_CODEX_SKILL_DIR))
|
|
1469
|
+
for legacy, canonical in pairs:
|
|
1470
|
+
legacy_path = target_dir / legacy
|
|
1471
|
+
if not legacy_path.exists():
|
|
1472
|
+
continue
|
|
1473
|
+
if (target_dir / canonical).exists():
|
|
1474
|
+
_append_doctor_issue(
|
|
1475
|
+
issues, seen, "legacy_codex_skill_conflict", legacy,
|
|
1476
|
+
f"legacy Codex skill conflicts with canonical {canonical}; review before removing either copy",
|
|
1477
|
+
)
|
|
1478
|
+
else:
|
|
1479
|
+
_append_doctor_issue(
|
|
1480
|
+
issues, seen, "legacy_codex_skill_layout", legacy,
|
|
1481
|
+
f"legacy Codex skill should be moved to {canonical}",
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def _fix_legacy_codex_skills(target_dir, issues):
|
|
1486
|
+
fixed = []
|
|
1487
|
+
migrations = []
|
|
1488
|
+
for issue in issues:
|
|
1489
|
+
if issue.get("id") != "legacy_codex_skill_layout":
|
|
1490
|
+
continue
|
|
1491
|
+
legacy = issue["path"]
|
|
1492
|
+
canonical = legacy.replace(".codex/skills/", ".agents/skills/", 1)
|
|
1493
|
+
source = resolve_destination_path(target_dir, legacy, "legacy skill source")
|
|
1494
|
+
destination = resolve_destination_path(target_dir, canonical, "canonical skill destination")
|
|
1495
|
+
if not source.exists() or destination.exists():
|
|
1496
|
+
continue
|
|
1497
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1498
|
+
shutil.move(str(source), str(destination))
|
|
1499
|
+
_remove_empty_parents(source.parent, target_dir)
|
|
1500
|
+
migrations.append((legacy, canonical))
|
|
1501
|
+
fixed.extend((legacy, canonical))
|
|
1502
|
+
if not migrations:
|
|
1503
|
+
return fixed
|
|
1504
|
+
manifest_path = resolve_destination_path(target_dir, "manifest.json", "manifest repair destination")
|
|
1505
|
+
manifest_text = manifest_path.read_text(encoding="utf-8")
|
|
1506
|
+
for legacy, canonical in migrations:
|
|
1507
|
+
manifest_text = manifest_text.replace(legacy, canonical)
|
|
1508
|
+
manifest_path.write_text(manifest_text, encoding="utf-8")
|
|
1509
|
+
for rel_path in ("AGENTS.md", "memory/index.md", "memory/pinned.md", "memory/source-map.md"):
|
|
1510
|
+
path = resolve_destination_path(target_dir, rel_path, "memory projection repair destination")
|
|
1511
|
+
if not path.is_file():
|
|
1512
|
+
continue
|
|
1513
|
+
text = path.read_text(encoding="utf-8")
|
|
1514
|
+
updated = text.replace(".codex/skills/", ".agents/skills/")
|
|
1515
|
+
if updated != text:
|
|
1516
|
+
path.write_text(updated, encoding="utf-8")
|
|
1517
|
+
fixed.append(rel_path)
|
|
1518
|
+
return fixed
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _copy_migrated_portable_state(source_target_dir, target_dir, resolved, to_adapter):
|
|
1522
|
+
_portable_source_files(source_target_dir)
|
|
1523
|
+
copied = []
|
|
1524
|
+
for rel_path in PORTABLE_STATE_DIRS:
|
|
1525
|
+
source = source_target_dir / rel_path
|
|
1526
|
+
if not source.exists():
|
|
1527
|
+
continue
|
|
1528
|
+
if not source.is_dir():
|
|
1529
|
+
raise PackwrightValidationError([f"source portable state path is not a directory: {source}"])
|
|
1530
|
+
destination = target_dir / rel_path
|
|
1531
|
+
if destination.exists():
|
|
1532
|
+
shutil.rmtree(destination)
|
|
1533
|
+
shutil.copytree(source, destination)
|
|
1534
|
+
copied.append(rel_path)
|
|
1535
|
+
rewritten = _rewrite_migrated_memory_files(target_dir, resolved, to_adapter)
|
|
1536
|
+
return {"copied": copied, "rewritten": rewritten}
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _rewrite_migrated_memory_files(target_dir, resolved, to_adapter):
|
|
1540
|
+
rewritten = []
|
|
1541
|
+
for rel_path in ("memory/index.md", "memory/pinned.md", "memory/source-map.md"):
|
|
1542
|
+
path = target_dir / rel_path
|
|
1543
|
+
if not path.is_file():
|
|
1544
|
+
continue
|
|
1545
|
+
original = path.read_text(encoding="utf-8")
|
|
1546
|
+
projected = project_memory_file(resolved, to_adapter, rel_path, original)
|
|
1547
|
+
if projected != original:
|
|
1548
|
+
path.write_text(projected, encoding="utf-8")
|
|
1549
|
+
rewritten.append(rel_path)
|
|
1550
|
+
return rewritten
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def _copy_emotion_state_snapshot(source_target_dir, target_dir, include_emotion_state):
|
|
1554
|
+
if not include_emotion_state:
|
|
1555
|
+
return []
|
|
1556
|
+
source = source_target_dir / EMOTION_ENGINE_CODEX_STATE_PATH
|
|
1557
|
+
if not source.is_file():
|
|
1558
|
+
return []
|
|
1559
|
+
destination = target_dir / EMOTION_ENGINE_CODEX_STATE_PATH
|
|
1560
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1561
|
+
shutil.copy2(source, destination)
|
|
1562
|
+
return [EMOTION_ENGINE_CODEX_STATE_PATH]
|
|
1563
|
+
|
|
1564
|
+
|
|
1565
|
+
def _migration_runtime_exclusions(source_target_dir, source_manifest, from_adapter, to_adapter, state_snapshots):
|
|
1566
|
+
exclusions = []
|
|
1567
|
+
source_entry = _adapter_entry_artifact(source_manifest, from_adapter)
|
|
1568
|
+
target_entry = _adapter_entry_by_adapter(to_adapter)
|
|
1569
|
+
if from_adapter != to_adapter and source_entry and source_entry != target_entry:
|
|
1570
|
+
exclusions.append({
|
|
1571
|
+
"id": "source_runtime_entry_replaced",
|
|
1572
|
+
"path": source_entry,
|
|
1573
|
+
"reason": f"replaced by {to_adapter} adapter entry",
|
|
1574
|
+
})
|
|
1575
|
+
if from_adapter == "codex" and to_adapter != "codex":
|
|
1576
|
+
for rel_path in (EMOTION_ENGINE_CODEX_SKILL_DIR, EMOTION_ENGINE_CODEX_WRAPPER_PATH):
|
|
1577
|
+
if (source_target_dir / rel_path).exists():
|
|
1578
|
+
exclusions.append({
|
|
1579
|
+
"id": "codex_runtime_sidecar_excluded",
|
|
1580
|
+
"path": rel_path,
|
|
1581
|
+
"reason": f"{to_adapter} target does not install the Codex sidecar",
|
|
1582
|
+
})
|
|
1583
|
+
if state_snapshots:
|
|
1584
|
+
exclusions.append({
|
|
1585
|
+
"id": "emotion_state_snapshot_inert",
|
|
1586
|
+
"path": EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
1587
|
+
"reason": f"copied as a snapshot; {to_adapter} has no active Codex sidecar",
|
|
1588
|
+
})
|
|
1589
|
+
return exclusions
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _adapter_entry_artifact(manifest, adapter):
|
|
1593
|
+
artifacts = manifest.get("artifacts", []) if isinstance(manifest, dict) else []
|
|
1594
|
+
if adapter == "cursor":
|
|
1595
|
+
cursor_rules = manifest.get("features", {}).get("cursor_rules", {}) if isinstance(manifest, dict) else {}
|
|
1596
|
+
main_rule = cursor_rules.get("main_rule") if isinstance(cursor_rules, dict) else None
|
|
1597
|
+
if main_rule:
|
|
1598
|
+
return main_rule
|
|
1599
|
+
preferred = _adapter_entry_by_adapter(adapter)
|
|
1600
|
+
if preferred in artifacts:
|
|
1601
|
+
return preferred
|
|
1602
|
+
if adapter == "cursor":
|
|
1603
|
+
for artifact in artifacts:
|
|
1604
|
+
if (
|
|
1605
|
+
artifact.startswith(".cursor/rules/")
|
|
1606
|
+
and artifact.endswith(".mdc")
|
|
1607
|
+
and not artifact.endswith("-memory.mdc")
|
|
1608
|
+
and not artifact.endswith("-save-context.mdc")
|
|
1609
|
+
):
|
|
1610
|
+
return artifact
|
|
1611
|
+
return preferred
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _adapter_entry_by_adapter(adapter):
|
|
1615
|
+
return adapter_entry(adapter) if adapter in SUPPORTED_INSTALL_ADAPTERS else None
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _manifest_artifacts(manifest):
|
|
1619
|
+
artifacts = manifest.get("artifacts")
|
|
1620
|
+
if not isinstance(artifacts, list) or not artifacts:
|
|
1621
|
+
raise PackwrightValidationError(["adapter pack manifest must contain a non-empty artifacts list"])
|
|
1622
|
+
|
|
1623
|
+
normalized = []
|
|
1624
|
+
issues = []
|
|
1625
|
+
for artifact in artifacts:
|
|
1626
|
+
if not isinstance(artifact, str) or not artifact.strip():
|
|
1627
|
+
issues.append("adapter pack artifact paths must be non-empty strings")
|
|
1628
|
+
continue
|
|
1629
|
+
try:
|
|
1630
|
+
normalized.append(validate_relative_path(artifact, "adapter pack artifact path").as_posix())
|
|
1631
|
+
except PackwrightValidationError as exc:
|
|
1632
|
+
issues.extend(exc.issues)
|
|
1633
|
+
if issues:
|
|
1634
|
+
raise PackwrightValidationError(issues)
|
|
1635
|
+
return normalized
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _manifest_emotion_engine_mode(manifest):
|
|
1639
|
+
if not isinstance(manifest, dict):
|
|
1640
|
+
return "light"
|
|
1641
|
+
feature = manifest.get("features", {}).get("emotion_engine", {})
|
|
1642
|
+
if isinstance(feature, dict) and feature.get("mode") in EMOTION_ENGINE_MODES:
|
|
1643
|
+
return feature["mode"]
|
|
1644
|
+
boundaries = manifest.get("boundaries", {})
|
|
1645
|
+
if isinstance(boundaries, dict) and boundaries.get("emotion_engine_mode") in EMOTION_ENGINE_MODES:
|
|
1646
|
+
return boundaries["emotion_engine_mode"]
|
|
1647
|
+
return "light"
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _prepare_emotion_engine_codex_install(target_dir, source, force, emotion_style, emotion_engine_mode, manifest):
|
|
1651
|
+
source_dir = _resolve_emotion_engine_codex_source(source)
|
|
1652
|
+
required = [
|
|
1653
|
+
source_dir / "SKILL.md",
|
|
1654
|
+
source_dir / "README.md",
|
|
1655
|
+
source_dir / "install.sh",
|
|
1656
|
+
source_dir / "scripts" / "codex_emotion.sh",
|
|
1657
|
+
source_dir / "scripts" / "pulse_demo.py",
|
|
1658
|
+
]
|
|
1659
|
+
missing = [str(path) for path in required if not path.is_file()]
|
|
1660
|
+
shared = {
|
|
1661
|
+
"scripts/emotion_engine_utils.py": _shared_source(source_dir, "scripts/emotion_engine_utils.py"),
|
|
1662
|
+
"scripts/emotion_engine_mcp.py": _shared_source(source_dir, "scripts/emotion_engine_mcp.py"),
|
|
1663
|
+
"scripts/register_mcp_client.py": _shared_source(source_dir, "scripts/register_mcp_client.py"),
|
|
1664
|
+
"emotion-state-template.json": _shared_source(source_dir, "emotion-state-template.json"),
|
|
1665
|
+
"spec/emotion-state.schema.json": _shared_source(source_dir, "spec/emotion-state.schema.json", required=False),
|
|
1666
|
+
"LICENSE": _shared_source(source_dir, "LICENSE", required=False),
|
|
1667
|
+
}
|
|
1668
|
+
if missing:
|
|
1669
|
+
raise PackwrightValidationError([f"Emotion Engine Codex source is missing required file: {path}" for path in missing])
|
|
1670
|
+
_validate_emotion_engine_codex_source(source_dir, shared)
|
|
1671
|
+
|
|
1672
|
+
skill_dir = target_dir / EMOTION_ENGINE_CODEX_SKILL_DIR
|
|
1673
|
+
if skill_dir.exists() and not force:
|
|
1674
|
+
raise PackwrightValidationError(
|
|
1675
|
+
[
|
|
1676
|
+
"target already contains Emotion Engine Codex sidecar; rerun with --force after reviewing it",
|
|
1677
|
+
f"existing target artifact: {skill_dir.relative_to(target_dir)}",
|
|
1678
|
+
]
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
return {
|
|
1682
|
+
"source_dir": source_dir,
|
|
1683
|
+
"shared": shared,
|
|
1684
|
+
"skill_dir": skill_dir,
|
|
1685
|
+
"state_file": target_dir / EMOTION_ENGINE_CODEX_STATE_PATH,
|
|
1686
|
+
"emotion_style": emotion_style or _manifest_emotion_style(manifest),
|
|
1687
|
+
"relationship_continuity": _manifest_relationship_continuity(manifest),
|
|
1688
|
+
"mode": emotion_engine_mode,
|
|
1689
|
+
"force": force,
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def _resolve_emotion_engine_codex_source(source):
|
|
1694
|
+
raw = source or os.environ.get("PACKWRIGHT_EMOTION_ENGINE_CODEX_DIR")
|
|
1695
|
+
if not raw:
|
|
1696
|
+
raise PackwrightValidationError([
|
|
1697
|
+
"Emotion Engine Codex source directory is required; pass --emotion-engine-codex-source "
|
|
1698
|
+
"or set PACKWRIGHT_EMOTION_ENGINE_CODEX_DIR"
|
|
1699
|
+
])
|
|
1700
|
+
source_dir = Path(raw)
|
|
1701
|
+
if not source_dir.is_dir():
|
|
1702
|
+
raise PackwrightValidationError([f"Emotion Engine Codex source directory does not exist: {source_dir}"])
|
|
1703
|
+
return source_dir
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def _shared_source(source_dir, rel_path, required=True):
|
|
1707
|
+
direct = source_dir / rel_path
|
|
1708
|
+
if direct.is_file():
|
|
1709
|
+
return direct
|
|
1710
|
+
repo_root = source_dir.parents[2] if len(source_dir.parents) >= 3 else source_dir
|
|
1711
|
+
fallback = repo_root / rel_path
|
|
1712
|
+
if fallback.is_file():
|
|
1713
|
+
return fallback
|
|
1714
|
+
if required:
|
|
1715
|
+
raise PackwrightValidationError([f"Emotion Engine Codex source is missing required shared file: {fallback}"])
|
|
1716
|
+
return None
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
def _validate_emotion_engine_codex_source(source_dir, shared):
|
|
1720
|
+
issues = []
|
|
1721
|
+
skill_text = (source_dir / "SKILL.md").read_text(encoding="utf-8")
|
|
1722
|
+
wrapper_text = (source_dir / "scripts" / "codex_emotion.sh").read_text(encoding="utf-8")
|
|
1723
|
+
engine_path = shared.get("scripts/emotion_engine_utils.py")
|
|
1724
|
+
mcp_path = shared.get("scripts/emotion_engine_mcp.py")
|
|
1725
|
+
register_path = shared.get("scripts/register_mcp_client.py")
|
|
1726
|
+
engine_text = engine_path.read_text(encoding="utf-8") if engine_path else ""
|
|
1727
|
+
mcp_text = mcp_path.read_text(encoding="utf-8") if mcp_path else ""
|
|
1728
|
+
register_text = register_path.read_text(encoding="utf-8") if register_path else ""
|
|
1729
|
+
if "settle_trust" not in skill_text:
|
|
1730
|
+
issues.append("Emotion Engine Codex skill must document settle_trust")
|
|
1731
|
+
if "record_policy" not in skill_text:
|
|
1732
|
+
issues.append("Emotion Engine Codex skill must document record_policy")
|
|
1733
|
+
if "settle_trust" not in engine_text:
|
|
1734
|
+
issues.append("Emotion Engine helper must implement settle_trust")
|
|
1735
|
+
if "record_policy" not in engine_text or "reply_bias" not in engine_text:
|
|
1736
|
+
issues.append("Emotion Engine helper must implement deterministic record_policy with reply_bias")
|
|
1737
|
+
if "tools/list" not in mcp_text or "emotion_engine_record_policy" not in mcp_text:
|
|
1738
|
+
issues.append("Emotion Engine MCP server must expose record_policy through tools/list")
|
|
1739
|
+
if "emotion_engine_repair" in mcp_text or "doctor_target" in mcp_text:
|
|
1740
|
+
issues.append("Emotion Engine MCP server must not expose Packwright repair commands")
|
|
1741
|
+
if "codex" not in register_text or "state" not in register_text:
|
|
1742
|
+
issues.append("Emotion Engine MCP registration helper must support Codex client registration")
|
|
1743
|
+
if "exec \"$PYTHON\" \"$ENGINE\" \"$COMMAND\" \"$STATE_FILE\"" not in wrapper_text:
|
|
1744
|
+
issues.append("Emotion Engine Codex wrapper must forward commands to the shared helper")
|
|
1745
|
+
if issues:
|
|
1746
|
+
raise PackwrightValidationError(issues)
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def _manifest_emotion_style(manifest):
|
|
1750
|
+
character = manifest.get("character", {}) if isinstance(manifest, dict) else {}
|
|
1751
|
+
return character.get("emotion_style") or "calm, direct, lightly warm, and not over-compliant"
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _manifest_relationship_continuity(manifest):
|
|
1755
|
+
character = manifest.get("character", {}) if isinstance(manifest, dict) else {}
|
|
1756
|
+
continuity = character.get("relationship_continuity")
|
|
1757
|
+
if continuity in {"task_only", "warm_selective", "close_continuous"}:
|
|
1758
|
+
return continuity
|
|
1759
|
+
return "warm_selective"
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def _emotion_engine_codex_projection_files(plan):
|
|
1763
|
+
skill_dir = plan["skill_dir"]
|
|
1764
|
+
source_dir = plan["source_dir"]
|
|
1765
|
+
files = [
|
|
1766
|
+
("SKILL.md", source_dir / "SKILL.md"),
|
|
1767
|
+
("README.md", source_dir / "README.md"),
|
|
1768
|
+
("install.sh", source_dir / "install.sh"),
|
|
1769
|
+
("scripts/codex_emotion.sh", source_dir / "scripts" / "codex_emotion.sh"),
|
|
1770
|
+
("scripts/pulse_demo.py", source_dir / "scripts" / "pulse_demo.py"),
|
|
1771
|
+
]
|
|
1772
|
+
files.extend((rel_path, source_path) for rel_path, source_path in plan["shared"].items() if source_path is not None)
|
|
1773
|
+
return [
|
|
1774
|
+
(
|
|
1775
|
+
f"{EMOTION_ENGINE_CODEX_SKILL_DIR}/{rel_path}",
|
|
1776
|
+
skill_dir / rel_path,
|
|
1777
|
+
source_path,
|
|
1778
|
+
)
|
|
1779
|
+
for rel_path, source_path in files
|
|
1780
|
+
]
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _emotion_engine_codex_expected_in_target(manifest, target_dir):
|
|
1784
|
+
if emotion_engine_codex_expected(manifest):
|
|
1785
|
+
return True
|
|
1786
|
+
return any((target_dir / artifact).exists() for artifact in EMOTION_ENGINE_CODEX_ARTIFACTS)
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def _emotion_engine_codex_doctor_issues(target_dir, manifest, plan):
|
|
1790
|
+
issues = []
|
|
1791
|
+
expected_artifacts = {EMOTION_ENGINE_CODEX_WRAPPER_PATH, EMOTION_ENGINE_CODEX_STATE_PATH}
|
|
1792
|
+
|
|
1793
|
+
for rel_path, target_path, source_path in _emotion_engine_codex_projection_files(plan):
|
|
1794
|
+
expected_artifacts.add(rel_path)
|
|
1795
|
+
if not target_path.is_file():
|
|
1796
|
+
issues.append(_doctor_issue("emotion_engine_codex_missing_file", rel_path, "projected sidecar file is missing"))
|
|
1797
|
+
continue
|
|
1798
|
+
if _read_bytes(target_path) != _read_bytes(source_path):
|
|
1799
|
+
issues.append(_doctor_issue("emotion_engine_codex_file_drift", rel_path, "projected sidecar file differs from source"))
|
|
1800
|
+
|
|
1801
|
+
wrapper_path = target_dir / EMOTION_ENGINE_CODEX_WRAPPER_PATH
|
|
1802
|
+
expected_wrapper = _project_emotion_wrapper_text()
|
|
1803
|
+
if not wrapper_path.is_file():
|
|
1804
|
+
issues.append(_doctor_issue("emotion_engine_codex_missing_file", EMOTION_ENGINE_CODEX_WRAPPER_PATH, "project wrapper is missing"))
|
|
1805
|
+
elif wrapper_path.read_text(encoding="utf-8") != expected_wrapper:
|
|
1806
|
+
issues.append(_doctor_issue("emotion_engine_codex_file_drift", EMOTION_ENGINE_CODEX_WRAPPER_PATH, "project wrapper differs from expected projection"))
|
|
1807
|
+
|
|
1808
|
+
state_issue = _emotion_engine_state_issue(plan["state_file"])
|
|
1809
|
+
if state_issue:
|
|
1810
|
+
issues.append(state_issue)
|
|
1811
|
+
|
|
1812
|
+
mode = plan["mode"]
|
|
1813
|
+
issues.extend(
|
|
1814
|
+
emotion_engine_codex_manifest_diagnostics(
|
|
1815
|
+
manifest,
|
|
1816
|
+
expected_mode=mode,
|
|
1817
|
+
required_artifacts=expected_artifacts,
|
|
1818
|
+
)
|
|
1819
|
+
)
|
|
1820
|
+
return issues
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _doctor_issue(issue_id, path, message):
|
|
1824
|
+
return {"id": issue_id, "path": path, "message": message}
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def _read_bytes(path):
|
|
1828
|
+
try:
|
|
1829
|
+
return path.read_bytes()
|
|
1830
|
+
except OSError:
|
|
1831
|
+
return None
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _emotion_engine_state_issue(state_file):
|
|
1835
|
+
if not state_file.is_file():
|
|
1836
|
+
return _doctor_issue("emotion_engine_codex_missing_file", EMOTION_ENGINE_CODEX_STATE_PATH, "runtime state file is missing")
|
|
1837
|
+
try:
|
|
1838
|
+
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
1839
|
+
except (OSError, json.JSONDecodeError):
|
|
1840
|
+
return _doctor_issue("emotion_engine_codex_state_invalid", EMOTION_ENGINE_CODEX_STATE_PATH, "runtime state file is not valid JSON")
|
|
1841
|
+
if not isinstance(state, dict) or state.get("_schema") != "emotion-engine-state/v2":
|
|
1842
|
+
return _doctor_issue("emotion_engine_codex_state_invalid", EMOTION_ENGINE_CODEX_STATE_PATH, "runtime state file has an unexpected schema")
|
|
1843
|
+
return None
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _install_emotion_engine_codex(target_dir, plan):
|
|
1847
|
+
skill_dir = plan["skill_dir"]
|
|
1848
|
+
if skill_dir.exists() and not skill_dir.is_dir():
|
|
1849
|
+
raise PackwrightValidationError([f"Emotion Engine Codex skill path is not a directory: {skill_dir}"])
|
|
1850
|
+
|
|
1851
|
+
(skill_dir / "scripts").mkdir(parents=True, exist_ok=True)
|
|
1852
|
+
(skill_dir / "spec").mkdir(parents=True, exist_ok=True)
|
|
1853
|
+
for _, target_path, source_path in _emotion_engine_codex_projection_files(plan):
|
|
1854
|
+
_copy_sidecar_file(source_path, target_path)
|
|
1855
|
+
|
|
1856
|
+
_make_executable(skill_dir / "install.sh")
|
|
1857
|
+
_make_executable(skill_dir / "scripts" / "codex_emotion.sh")
|
|
1858
|
+
_make_executable(skill_dir / "scripts" / "emotion_engine_mcp.py")
|
|
1859
|
+
_make_executable(skill_dir / "scripts" / "register_mcp_client.py")
|
|
1860
|
+
wrapper_path = _write_project_emotion_wrapper(target_dir, plan["force"])
|
|
1861
|
+
|
|
1862
|
+
state_created = _ensure_emotion_state(
|
|
1863
|
+
plan["state_file"],
|
|
1864
|
+
plan["emotion_style"],
|
|
1865
|
+
plan["mode"],
|
|
1866
|
+
plan["relationship_continuity"],
|
|
1867
|
+
)
|
|
1868
|
+
agents_section_added = _ensure_emotion_section(target_dir / "AGENTS.md", plan["mode"])
|
|
1869
|
+
|
|
1870
|
+
return {
|
|
1871
|
+
"skill_dir": str(skill_dir),
|
|
1872
|
+
"state_file": str(plan["state_file"]),
|
|
1873
|
+
"wrapper": str(wrapper_path),
|
|
1874
|
+
"mode": plan["mode"],
|
|
1875
|
+
"state_created": state_created,
|
|
1876
|
+
"agents_section_added": agents_section_added,
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def _mark_emotion_engine_codex_installed(target_dir, sidecar, mode):
|
|
1881
|
+
manifest_path = target_dir / "manifest.json"
|
|
1882
|
+
if not manifest_path.exists():
|
|
1883
|
+
return False
|
|
1884
|
+
manifest = _load_manifest(target_dir)
|
|
1885
|
+
manifest.setdefault("features", {})["emotion_engine"] = emotion_engine_feature(
|
|
1886
|
+
mode=mode,
|
|
1887
|
+
adapter="codex",
|
|
1888
|
+
installed=True,
|
|
1889
|
+
)
|
|
1890
|
+
manifest.setdefault("sidecars", {})[EMOTION_ENGINE_CODEX_SIDECAR] = emotion_engine_codex_sidecar_record(mode)
|
|
1891
|
+
boundaries = manifest.setdefault("boundaries", {})
|
|
1892
|
+
boundaries["emotion_engine_runtime"] = EMOTION_ENGINE_RUNTIME
|
|
1893
|
+
boundaries["emotion_engine_mode"] = mode
|
|
1894
|
+
artifacts = set(manifest.get("artifacts", []))
|
|
1895
|
+
artifacts.update(_existing_sidecar_artifacts(target_dir))
|
|
1896
|
+
manifest["artifacts"] = sorted(artifacts)
|
|
1897
|
+
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
1898
|
+
return True
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def _existing_sidecar_artifacts(target_dir):
|
|
1902
|
+
return [artifact for artifact in EMOTION_ENGINE_CODEX_ARTIFACTS if (target_dir / artifact).is_file()]
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
def _copy_sidecar_file(source, destination):
|
|
1906
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1907
|
+
shutil.copy2(source, destination)
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def _project_emotion_wrapper_text():
|
|
1911
|
+
return """#!/usr/bin/env sh
|
|
1912
|
+
set -eu
|
|
1913
|
+
|
|
1914
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
1915
|
+
PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
1916
|
+
exec "$PROJECT_DIR/{script_path}" "$@"
|
|
1917
|
+
""".format(script_path=EMOTION_ENGINE_CODEX_SCRIPT_PATH)
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _write_project_emotion_wrapper(target_dir, force=False):
|
|
1921
|
+
wrapper_path = target_dir / EMOTION_ENGINE_CODEX_WRAPPER_PATH
|
|
1922
|
+
expected = _project_emotion_wrapper_text()
|
|
1923
|
+
if wrapper_path.exists() and wrapper_path.read_text(encoding="utf-8") != expected and not force:
|
|
1924
|
+
raise PackwrightValidationError([
|
|
1925
|
+
"target already contains scripts/codex_emotion.sh; rerun with --force after reviewing it"
|
|
1926
|
+
])
|
|
1927
|
+
wrapper_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1928
|
+
wrapper_path.write_text(expected, encoding="utf-8")
|
|
1929
|
+
_make_executable(wrapper_path)
|
|
1930
|
+
return wrapper_path
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def _make_executable(path):
|
|
1934
|
+
mode = path.stat().st_mode
|
|
1935
|
+
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def _ensure_emotion_state(state_file, emotion_style, mode, relationship_continuity="warm_selective"):
|
|
1939
|
+
if state_file.exists():
|
|
1940
|
+
_update_existing_emotion_state(state_file, mode, emotion_style, relationship_continuity)
|
|
1941
|
+
return False
|
|
1942
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1943
|
+
profile = _infer_emotion_profile(emotion_style, relationship_continuity)
|
|
1944
|
+
state = {
|
|
1945
|
+
"_schema": "emotion-engine-state/v2",
|
|
1946
|
+
"enabled": mode != "paused",
|
|
1947
|
+
"runtime_mode": mode,
|
|
1948
|
+
"volatility_profile": profile["volatility_profile"],
|
|
1949
|
+
"emotion": profile["baseline"],
|
|
1950
|
+
"affective_pulse": {
|
|
1951
|
+
"P": 0.0,
|
|
1952
|
+
"A": 0.0,
|
|
1953
|
+
"D": 0.0,
|
|
1954
|
+
"intensity": 0.0,
|
|
1955
|
+
"label": "none",
|
|
1956
|
+
"source": "packwright-install",
|
|
1957
|
+
"created_at": None,
|
|
1958
|
+
},
|
|
1959
|
+
"personality_baseline": profile["baseline"],
|
|
1960
|
+
"character_profile": profile["character_profile"],
|
|
1961
|
+
"trust": 0.1,
|
|
1962
|
+
"trust_anchor": 0.1,
|
|
1963
|
+
"session_count": 0,
|
|
1964
|
+
"total_turns": 0,
|
|
1965
|
+
"last_interaction_iso": None,
|
|
1966
|
+
"emotion_trajectory": [],
|
|
1967
|
+
"emotion_log": [],
|
|
1968
|
+
"trust_history": [],
|
|
1969
|
+
"log_limit": 200,
|
|
1970
|
+
}
|
|
1971
|
+
state_file.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
1972
|
+
return True
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def _update_existing_emotion_state(state_file, mode, emotion_style, relationship_continuity):
|
|
1976
|
+
try:
|
|
1977
|
+
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
1978
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1979
|
+
warnings.warn(
|
|
1980
|
+
f"could not update existing Emotion Engine state {state_file}: {exc}",
|
|
1981
|
+
RuntimeWarning,
|
|
1982
|
+
stacklevel=2,
|
|
1983
|
+
)
|
|
1984
|
+
return
|
|
1985
|
+
if not isinstance(state, dict):
|
|
1986
|
+
warnings.warn(
|
|
1987
|
+
f"could not update existing Emotion Engine state {state_file}: root must be a JSON object",
|
|
1988
|
+
RuntimeWarning,
|
|
1989
|
+
stacklevel=2,
|
|
1990
|
+
)
|
|
1991
|
+
return
|
|
1992
|
+
changed = False
|
|
1993
|
+
if state.get("runtime_mode") != mode:
|
|
1994
|
+
state["runtime_mode"] = mode
|
|
1995
|
+
changed = True
|
|
1996
|
+
enabled = mode != "paused"
|
|
1997
|
+
if state.get("enabled") is not enabled:
|
|
1998
|
+
state["enabled"] = enabled
|
|
1999
|
+
changed = True
|
|
2000
|
+
if "volatility_profile" not in state:
|
|
2001
|
+
state["volatility_profile"] = "steady"
|
|
2002
|
+
changed = True
|
|
2003
|
+
if "affective_pulse" not in state:
|
|
2004
|
+
state["affective_pulse"] = {
|
|
2005
|
+
"P": 0.0,
|
|
2006
|
+
"A": 0.0,
|
|
2007
|
+
"D": 0.0,
|
|
2008
|
+
"intensity": 0.0,
|
|
2009
|
+
"label": "none",
|
|
2010
|
+
"source": "packwright-install",
|
|
2011
|
+
"created_at": None,
|
|
2012
|
+
}
|
|
2013
|
+
changed = True
|
|
2014
|
+
if changed:
|
|
2015
|
+
state_file.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def _infer_emotion_profile(emotion_style, relationship_continuity):
|
|
2019
|
+
text = str(emotion_style or "")
|
|
2020
|
+
lowered = text.lower()
|
|
2021
|
+
traits = []
|
|
2022
|
+
p = 0.1
|
|
2023
|
+
a = 0.3
|
|
2024
|
+
d = 0.5
|
|
2025
|
+
rules = [
|
|
2026
|
+
("warm", ["温柔", "亲切", "治愈", "关怀", "暖", "陪伴", "warm", "kind", "gentle"], 0.16, -0.03, 0.02),
|
|
2027
|
+
("intimate", ["亲密", "亲近", "贴近", "close", "intimate", "affectionate", "romantic"], 0.18, 0.03, 0.02),
|
|
2028
|
+
("playful", ["活泼", "兴奋", "元气", "热情", "开朗", "调皮", "逗", "playful", "energetic", "lively", "teasing"], 0.16, 0.14, 0.0),
|
|
2029
|
+
("calm", ["冷静", "沉稳", "安静", "可靠", "稳定", "calm", "steady", "reliable"], 0.08, -0.15, 0.12),
|
|
2030
|
+
("bounded", ["边界", "主见", "不讨好", "独立", "自尊", "boundary", "boundaries", "independent"], 0.0, 0.02, 0.18),
|
|
2031
|
+
("assertive", ["强势", "坚定", "掌控", "自信", "assertive", "confident", "dominant"], -0.02, 0.05, 0.22),
|
|
2032
|
+
]
|
|
2033
|
+
for trait, keywords, dp, da, dd in rules:
|
|
2034
|
+
hits = sum(1 for keyword in keywords if keyword in lowered)
|
|
2035
|
+
if not hits:
|
|
2036
|
+
continue
|
|
2037
|
+
weight = min(1.0 + (hits - 1) * 0.25, 1.5)
|
|
2038
|
+
p += dp * weight
|
|
2039
|
+
a += da * weight
|
|
2040
|
+
d += dd * weight
|
|
2041
|
+
traits.append(trait)
|
|
2042
|
+
if not traits:
|
|
2043
|
+
traits = _style_traits(emotion_style)
|
|
2044
|
+
baseline = {
|
|
2045
|
+
"pleasure": _clamp_dimension("pleasure", p),
|
|
2046
|
+
"arousal": _clamp_dimension("arousal", a),
|
|
2047
|
+
"dominance": _clamp_dimension("dominance", d),
|
|
2048
|
+
}
|
|
2049
|
+
volatility_profile = "expressive" if (
|
|
2050
|
+
relationship_continuity == "close_continuous"
|
|
2051
|
+
or any(trait in {"intimate", "playful"} for trait in traits)
|
|
2052
|
+
or any(keyword in lowered for keyword in ["close personal bond", "companion", "亲密", "陪伴"])
|
|
2053
|
+
) else "steady"
|
|
2054
|
+
return {
|
|
2055
|
+
"baseline": baseline,
|
|
2056
|
+
"volatility_profile": volatility_profile,
|
|
2057
|
+
"character_profile": {
|
|
2058
|
+
"source": "packwright-install",
|
|
2059
|
+
"description": emotion_style,
|
|
2060
|
+
"interpretation": _describe_baseline(baseline, traits),
|
|
2061
|
+
"traits": traits[:8],
|
|
2062
|
+
},
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
def _clamp_dimension(dim, value):
|
|
2067
|
+
limits = {
|
|
2068
|
+
"pleasure": (-1.0, 1.0),
|
|
2069
|
+
"arousal": (0.0, 1.0),
|
|
2070
|
+
"dominance": (0.0, 1.0),
|
|
2071
|
+
}
|
|
2072
|
+
lo, hi = limits[dim]
|
|
2073
|
+
return round(max(lo, min(hi, float(value))), 4)
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
def _describe_baseline(baseline, traits):
|
|
2077
|
+
warmth = "warm and affirming" if baseline["pleasure"] >= 0.25 else "mildly warm"
|
|
2078
|
+
arousal = "energetic" if baseline["arousal"] >= 0.55 else ("calm" if baseline["arousal"] <= 0.22 else "steady")
|
|
2079
|
+
dominance = "strongly bounded" if baseline["dominance"] >= 0.65 else ("deferential" if baseline["dominance"] <= 0.38 else "balanced")
|
|
2080
|
+
return f"{warmth}; {arousal}; {dominance}; traits: {', '.join(traits[:5])}."
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
def _style_traits(emotion_style):
|
|
2084
|
+
words = []
|
|
2085
|
+
for part in str(emotion_style or "").replace(",", ",").split(","):
|
|
2086
|
+
word = part.strip().lower()
|
|
2087
|
+
if word and len(words) < 5:
|
|
2088
|
+
words.append(word)
|
|
2089
|
+
return words or ["calm", "direct", "lightly warm"]
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
def _ensure_emotion_section(agents_path, mode):
|
|
2093
|
+
if not agents_path.exists():
|
|
2094
|
+
return False
|
|
2095
|
+
text = agents_path.read_text(encoding="utf-8")
|
|
2096
|
+
section = EMOTION_ENGINE_SECTION.format(mode=mode)
|
|
2097
|
+
for heading in ["## Emotion Engine", "## Optional Emotion Engine"]:
|
|
2098
|
+
marker = text.find(heading)
|
|
2099
|
+
if marker == -1:
|
|
2100
|
+
continue
|
|
2101
|
+
next_heading = text.find("\n## ", marker + 1)
|
|
2102
|
+
if next_heading == -1:
|
|
2103
|
+
updated = text[:marker].rstrip() + "\n\n" + section
|
|
2104
|
+
else:
|
|
2105
|
+
updated = text[:marker].rstrip() + "\n\n" + section.rstrip() + "\n" + text[next_heading:]
|
|
2106
|
+
if updated != text:
|
|
2107
|
+
agents_path.write_text(updated, encoding="utf-8")
|
|
2108
|
+
return True
|
|
2109
|
+
return False
|
|
2110
|
+
if text and not text.endswith("\n"):
|
|
2111
|
+
text += "\n"
|
|
2112
|
+
text += "\n" + section
|
|
2113
|
+
agents_path.write_text(text, encoding="utf-8")
|
|
2114
|
+
return True
|