packwright 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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