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,645 @@
|
|
|
1
|
+
from collections.abc import Mapping, Sequence
|
|
2
|
+
from .errors import PackwrightValidationError
|
|
3
|
+
from .handoff import HANDOFF_ARTIFACTS
|
|
4
|
+
from .knowledge_contract import knowledge_artifacts
|
|
5
|
+
from .naming import character_slug, is_valid_slug, normalize_slug, save_context_skill_path
|
|
6
|
+
from .path_safety import resolve_mechanism_file
|
|
7
|
+
from .workspace_contract import (
|
|
8
|
+
WORKSPACE_DOMAIN_TEMPLATE_DIR,
|
|
9
|
+
WORKSPACE_INDEX_OWNER,
|
|
10
|
+
WORKSPACE_LAYOUT,
|
|
11
|
+
WORKSPACE_LIFECYCLE_DIRS,
|
|
12
|
+
WORKSPACE_ROOT,
|
|
13
|
+
WORKSPACE_SHARED_DIR,
|
|
14
|
+
workspace_artifacts,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SUPPORTED_KINDS = {"AtlasMechanismSpec", "CharacterMechanismSpec"}
|
|
19
|
+
SUPPORTED_VERSIONS = {"0.5", "0.6"}
|
|
20
|
+
MVP_ADAPTER = "codex"
|
|
21
|
+
SUPPORTED_ADAPTERS = {"codex", "claude-code", "cursor"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_mechanism(data):
|
|
25
|
+
"""Validate the platform-neutral character mechanism spec."""
|
|
26
|
+
issues = []
|
|
27
|
+
if not isinstance(data, Mapping):
|
|
28
|
+
raise PackwrightValidationError(["mechanism root must be a mapping"])
|
|
29
|
+
|
|
30
|
+
_validate_top_level(data, issues)
|
|
31
|
+
_validate_metadata(data.get("metadata"), issues)
|
|
32
|
+
_validate_parameters(data.get("parameters", {}), issues)
|
|
33
|
+
_validate_run(data.get("run"), issues)
|
|
34
|
+
_validate_archetype(data.get("archetype"), issues)
|
|
35
|
+
_validate_targets(data.get("targets"), issues)
|
|
36
|
+
_validate_implementation_scope(data.get("implementation_scope"), issues)
|
|
37
|
+
_validate_identity(data, issues)
|
|
38
|
+
_validate_operating(data, issues)
|
|
39
|
+
_validate_mechanism_refs(data, issues)
|
|
40
|
+
_validate_projection(data, issues)
|
|
41
|
+
_validate_emotion(data, issues)
|
|
42
|
+
_validate_session_start(data.get("session_start"), issues)
|
|
43
|
+
_validate_memory(data, issues)
|
|
44
|
+
_validate_workspace(data.get("workspace"), issues)
|
|
45
|
+
_validate_skills(data, issues)
|
|
46
|
+
_validate_outputs(data, issues)
|
|
47
|
+
_validate_checker(data.get("checker"), issues)
|
|
48
|
+
_validate_coverage(data, issues)
|
|
49
|
+
_validate_reserved_specs(data, issues)
|
|
50
|
+
|
|
51
|
+
if issues:
|
|
52
|
+
raise PackwrightValidationError(issues)
|
|
53
|
+
return data
|
|
54
|
+
def path_exists(data, dotted_path):
|
|
55
|
+
current = data
|
|
56
|
+
for part in dotted_path.split("."):
|
|
57
|
+
if isinstance(current, Mapping) and part in current:
|
|
58
|
+
current = current[part]
|
|
59
|
+
else:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def file_exists(data, path):
|
|
65
|
+
try:
|
|
66
|
+
resolve_mechanism_file(data, path)
|
|
67
|
+
except PackwrightValidationError:
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_top_level(data, issues):
|
|
73
|
+
required = (
|
|
74
|
+
"version",
|
|
75
|
+
"kind",
|
|
76
|
+
"metadata",
|
|
77
|
+
"parameters",
|
|
78
|
+
"run",
|
|
79
|
+
"archetype",
|
|
80
|
+
"targets",
|
|
81
|
+
"implementation_scope",
|
|
82
|
+
"identity",
|
|
83
|
+
"operating",
|
|
84
|
+
"mechanism",
|
|
85
|
+
"projection",
|
|
86
|
+
"emotion",
|
|
87
|
+
"session_start",
|
|
88
|
+
"memory",
|
|
89
|
+
"workspace",
|
|
90
|
+
"skills",
|
|
91
|
+
"outputs",
|
|
92
|
+
"checker",
|
|
93
|
+
"coverage",
|
|
94
|
+
"reserved_specs",
|
|
95
|
+
)
|
|
96
|
+
for key in required:
|
|
97
|
+
if key not in data:
|
|
98
|
+
issues.append(f"missing top-level key: {key}")
|
|
99
|
+
|
|
100
|
+
if data.get("kind") not in SUPPORTED_KINDS:
|
|
101
|
+
issues.append(f"kind must be one of {sorted(SUPPORTED_KINDS)}")
|
|
102
|
+
if str(data.get("version")) not in SUPPORTED_VERSIONS:
|
|
103
|
+
issues.append(f"version must be one of {sorted(SUPPORTED_VERSIONS)}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_metadata(metadata, issues):
|
|
107
|
+
if not _is_mapping(metadata):
|
|
108
|
+
issues.append("metadata must be a mapping")
|
|
109
|
+
return
|
|
110
|
+
for key in ("name", "title", "description"):
|
|
111
|
+
if not _non_empty_string(metadata.get(key)):
|
|
112
|
+
issues.append(f"metadata.{key} must be a non-empty string")
|
|
113
|
+
if "archetype" in metadata and not _non_empty_string(metadata.get("archetype")):
|
|
114
|
+
issues.append("metadata.archetype must be a non-empty string when provided")
|
|
115
|
+
_validate_optional_slug(metadata, "metadata.slug", issues)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_parameters(parameters, issues):
|
|
119
|
+
if not _is_mapping(parameters):
|
|
120
|
+
issues.append("parameters must be a mapping")
|
|
121
|
+
return
|
|
122
|
+
for name, spec in parameters.items():
|
|
123
|
+
if not _non_empty_string(name):
|
|
124
|
+
issues.append("parameter keys must be non-empty strings")
|
|
125
|
+
continue
|
|
126
|
+
if not _is_mapping(spec):
|
|
127
|
+
issues.append(f"parameters.{name} must be a mapping")
|
|
128
|
+
continue
|
|
129
|
+
if "required" in spec and not isinstance(spec["required"], bool):
|
|
130
|
+
issues.append(f"parameters.{name}.required must be a boolean")
|
|
131
|
+
if "description" in spec and not _non_empty_string(spec["description"]):
|
|
132
|
+
issues.append(f"parameters.{name}.description must be a non-empty string")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_run(run, issues):
|
|
136
|
+
if not _is_mapping(run):
|
|
137
|
+
issues.append("run must be a mapping")
|
|
138
|
+
return
|
|
139
|
+
for key in ("objective", "scope", "source"):
|
|
140
|
+
if not _non_empty_string(run.get(key)):
|
|
141
|
+
issues.append(f"run.{key} must be a non-empty string")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _validate_archetype(archetype, issues):
|
|
145
|
+
if not _is_mapping(archetype):
|
|
146
|
+
issues.append("archetype must be a mapping")
|
|
147
|
+
return
|
|
148
|
+
for key in ("id", "label", "description", "profile_scope", "workstream_scope"):
|
|
149
|
+
if not _non_empty_string(archetype.get(key)):
|
|
150
|
+
issues.append(f"archetype.{key} must be a non-empty string")
|
|
151
|
+
promotion = archetype.get("promotion")
|
|
152
|
+
if not _is_mapping(promotion):
|
|
153
|
+
issues.append("archetype.promotion must be a mapping")
|
|
154
|
+
return
|
|
155
|
+
for key in ("from", "to", "rule"):
|
|
156
|
+
if not _non_empty_string(promotion.get(key)):
|
|
157
|
+
issues.append(f"archetype.promotion.{key} must be a non-empty string")
|
|
158
|
+
if not _is_non_empty_list(promotion.get("signals")):
|
|
159
|
+
issues.append("archetype.promotion.signals must be a non-empty list")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _validate_targets(targets, issues):
|
|
163
|
+
if not _is_mapping(targets):
|
|
164
|
+
issues.append("targets must be a mapping")
|
|
165
|
+
return
|
|
166
|
+
if targets.get("mvp_adapter") != MVP_ADAPTER:
|
|
167
|
+
issues.append(f"targets.mvp_adapter must be {MVP_ADAPTER}")
|
|
168
|
+
supported = targets.get("supported")
|
|
169
|
+
if not _is_non_empty_list(supported) or not SUPPORTED_ADAPTERS.issubset(set(supported)):
|
|
170
|
+
issues.append("targets.supported must include codex, claude-code, and cursor")
|
|
171
|
+
reserved = targets.get("reserved", {})
|
|
172
|
+
if not _is_mapping(reserved):
|
|
173
|
+
issues.append("targets.reserved must be a mapping")
|
|
174
|
+
return
|
|
175
|
+
for name, spec in reserved.items():
|
|
176
|
+
if not _is_mapping(spec):
|
|
177
|
+
issues.append(f"targets.reserved.{name} must be a mapping")
|
|
178
|
+
continue
|
|
179
|
+
if spec.get("status") != "reserved":
|
|
180
|
+
issues.append(f"targets.reserved.{name}.status must be reserved")
|
|
181
|
+
if not _non_empty_string(spec.get("reason")):
|
|
182
|
+
issues.append(f"targets.reserved.{name}.reason must be a non-empty string")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _validate_implementation_scope(implementation_scope, issues):
|
|
186
|
+
if not _is_mapping(implementation_scope):
|
|
187
|
+
issues.append("implementation_scope must be a mapping")
|
|
188
|
+
return
|
|
189
|
+
if not _non_empty_string(implementation_scope.get("applies_to")):
|
|
190
|
+
issues.append("implementation_scope.applies_to must be a non-empty string")
|
|
191
|
+
boundaries = implementation_scope.get("boundaries")
|
|
192
|
+
if not _is_non_empty_list(boundaries):
|
|
193
|
+
issues.append("implementation_scope.boundaries must be a non-empty list")
|
|
194
|
+
return
|
|
195
|
+
_validate_id_list(boundaries, "implementation_scope.boundaries", issues)
|
|
196
|
+
for index, boundary in enumerate(_as_list(boundaries)):
|
|
197
|
+
if _is_mapping(boundary) and not _non_empty_string(boundary.get("text")):
|
|
198
|
+
issues.append(f"implementation_scope.boundaries[{index}].text must be a non-empty string")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_identity(data, issues):
|
|
202
|
+
identity = data.get("identity")
|
|
203
|
+
if not _is_mapping(identity):
|
|
204
|
+
issues.append("identity must be a mapping")
|
|
205
|
+
return
|
|
206
|
+
for key in ("name", "role", "positioning", "persona_path", "voice_path", "relationship_path"):
|
|
207
|
+
if not _non_empty_string(identity.get(key)):
|
|
208
|
+
issues.append(f"identity.{key} must be a non-empty string")
|
|
209
|
+
for key in ("user_name", "voice_summary", "mission"):
|
|
210
|
+
if key in identity and not _non_empty_string(identity.get(key)):
|
|
211
|
+
issues.append(f"identity.{key} must be a non-empty string when provided")
|
|
212
|
+
_validate_optional_slug(identity, "identity.slug", issues)
|
|
213
|
+
if not _is_non_empty_list(identity.get("stable_traits")):
|
|
214
|
+
issues.append("identity.stable_traits must be a non-empty list")
|
|
215
|
+
if not _is_non_empty_list(identity.get("work_focus")):
|
|
216
|
+
issues.append("identity.work_focus must be a non-empty list")
|
|
217
|
+
if not _is_non_empty_list(identity.get("personality")):
|
|
218
|
+
issues.append("identity.personality must be a non-empty list")
|
|
219
|
+
for key in ("persona_path", "voice_path", "relationship_path"):
|
|
220
|
+
_validate_file_ref(data, identity.get(key), f"identity.{key}", issues)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _validate_operating(data, issues):
|
|
224
|
+
operating = data.get("operating")
|
|
225
|
+
if not _is_mapping(operating):
|
|
226
|
+
issues.append("operating must be a mapping")
|
|
227
|
+
return
|
|
228
|
+
for key in ("principles_path", "boundaries_path"):
|
|
229
|
+
if not _non_empty_string(operating.get(key)):
|
|
230
|
+
issues.append(f"operating.{key} must be a non-empty string")
|
|
231
|
+
else:
|
|
232
|
+
_validate_file_ref(data, operating.get(key), f"operating.{key}", issues)
|
|
233
|
+
if not _is_non_empty_list(operating.get("hot_rules")):
|
|
234
|
+
issues.append("operating.hot_rules must be a non-empty list")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _validate_mechanism_refs(data, issues):
|
|
238
|
+
mechanism = data.get("mechanism")
|
|
239
|
+
if not _is_mapping(mechanism):
|
|
240
|
+
issues.append("mechanism must be a mapping")
|
|
241
|
+
return
|
|
242
|
+
for key in ("context_loading_path", "session_guards_path", "memory_policy_path"):
|
|
243
|
+
if not _non_empty_string(mechanism.get(key)):
|
|
244
|
+
issues.append(f"mechanism.{key} must be a non-empty string")
|
|
245
|
+
else:
|
|
246
|
+
_validate_file_ref(data, mechanism.get(key), f"mechanism.{key}", issues)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _validate_projection(data, issues):
|
|
250
|
+
projection = data.get("projection")
|
|
251
|
+
if not _is_mapping(projection):
|
|
252
|
+
issues.append("projection must be a mapping")
|
|
253
|
+
return
|
|
254
|
+
for key in ("platform_capabilities_path", "ownership_contract_path"):
|
|
255
|
+
if not _non_empty_string(projection.get(key)):
|
|
256
|
+
issues.append(f"projection.{key} must be a non-empty string")
|
|
257
|
+
else:
|
|
258
|
+
_validate_file_ref(data, projection.get(key), f"projection.{key}", issues)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _validate_emotion(data, issues):
|
|
262
|
+
emotion = data.get("emotion")
|
|
263
|
+
if not _is_mapping(emotion):
|
|
264
|
+
issues.append("emotion must be a mapping")
|
|
265
|
+
return
|
|
266
|
+
if emotion.get("status") != "structured_reserved":
|
|
267
|
+
issues.append("emotion.status must be structured_reserved")
|
|
268
|
+
if emotion.get("runtime") != "not_implemented":
|
|
269
|
+
issues.append("emotion.runtime must be not_implemented")
|
|
270
|
+
if emotion.get("default_mode") != "light":
|
|
271
|
+
issues.append("emotion.default_mode must be light")
|
|
272
|
+
recommended = emotion.get("recommended_mode")
|
|
273
|
+
if recommended is not None and recommended not in {"light", "always", "paused"}:
|
|
274
|
+
issues.append("emotion.recommended_mode must be light, always, or paused when provided")
|
|
275
|
+
modes = emotion.get("user_visible_modes")
|
|
276
|
+
if modes is not None and modes != ["light", "always", "paused"]:
|
|
277
|
+
issues.append("emotion.user_visible_modes must be [light, always, paused]")
|
|
278
|
+
overhead = emotion.get("estimated_overhead", {})
|
|
279
|
+
if overhead is not None:
|
|
280
|
+
if not _is_mapping(overhead):
|
|
281
|
+
issues.append("emotion.estimated_overhead must be a mapping when provided")
|
|
282
|
+
else:
|
|
283
|
+
for mode in ("light", "always", "paused"):
|
|
284
|
+
if mode not in overhead or not _non_empty_string(overhead.get(mode)):
|
|
285
|
+
issues.append(f"emotion.estimated_overhead.{mode} must be a non-empty string")
|
|
286
|
+
if not _non_empty_string(emotion.get("role")):
|
|
287
|
+
issues.append("emotion.role must be a non-empty string")
|
|
288
|
+
if "direct_interaction" in emotion and emotion.get("direct_interaction") not in {
|
|
289
|
+
"work_only",
|
|
290
|
+
"some_direct_emotional_interaction",
|
|
291
|
+
"decide_later",
|
|
292
|
+
}:
|
|
293
|
+
issues.append("emotion.direct_interaction must be work_only, some_direct_emotional_interaction, or decide_later")
|
|
294
|
+
if "relationship_continuity" in emotion and emotion.get("relationship_continuity") not in {
|
|
295
|
+
"task_only",
|
|
296
|
+
"warm_selective",
|
|
297
|
+
"close_continuous",
|
|
298
|
+
}:
|
|
299
|
+
issues.append("emotion.relationship_continuity must be task_only, warm_selective, or close_continuous")
|
|
300
|
+
for key in (
|
|
301
|
+
"model_path",
|
|
302
|
+
"state_schema_path",
|
|
303
|
+
"update_policy_path",
|
|
304
|
+
"voice_modulation_path",
|
|
305
|
+
"memory_events_path",
|
|
306
|
+
):
|
|
307
|
+
if not _non_empty_string(emotion.get(key)):
|
|
308
|
+
issues.append(f"emotion.{key} must be a non-empty string")
|
|
309
|
+
else:
|
|
310
|
+
_validate_file_ref(data, emotion.get(key), f"emotion.{key}", issues)
|
|
311
|
+
projection = emotion.get("projection")
|
|
312
|
+
if not _is_mapping(projection):
|
|
313
|
+
issues.append("emotion.projection must be a mapping")
|
|
314
|
+
else:
|
|
315
|
+
allowed = {
|
|
316
|
+
"codex": {"optional_sidecar_when_explicitly_enabled", "spec_guided_behavior_only"},
|
|
317
|
+
"claude-code": {"spec_guided_behavior_only"},
|
|
318
|
+
"cursor": {"spec_guided_behavior_only"},
|
|
319
|
+
}
|
|
320
|
+
for adapter in SUPPORTED_ADAPTERS:
|
|
321
|
+
if projection.get(adapter) not in allowed[adapter]:
|
|
322
|
+
issues.append(f"emotion.projection.{adapter} must be one of {sorted(allowed[adapter])}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_session_start(session_start, issues):
|
|
326
|
+
if not _is_mapping(session_start):
|
|
327
|
+
issues.append("session_start must be a mapping")
|
|
328
|
+
return
|
|
329
|
+
if session_start.get("hook") != "SessionStart":
|
|
330
|
+
issues.append("session_start.hook must be SessionStart")
|
|
331
|
+
if session_start.get("injects_facts_only") is not True:
|
|
332
|
+
issues.append("session_start.injects_facts_only must be true")
|
|
333
|
+
facts = session_start.get("facts")
|
|
334
|
+
if not _is_non_empty_list(facts):
|
|
335
|
+
issues.append("session_start.facts must be a non-empty list")
|
|
336
|
+
return
|
|
337
|
+
_validate_id_list(facts, "session_start.facts", issues)
|
|
338
|
+
for index, fact in enumerate(_as_list(facts)):
|
|
339
|
+
if _is_mapping(fact) and not _non_empty_string(fact.get("source")):
|
|
340
|
+
issues.append(f"session_start.facts[{index}].source must be a non-empty string")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _validate_memory(data, issues):
|
|
344
|
+
memory = data.get("memory")
|
|
345
|
+
if not _is_mapping(memory):
|
|
346
|
+
issues.append("memory must be a mapping")
|
|
347
|
+
return
|
|
348
|
+
local_files = memory.get("local_files")
|
|
349
|
+
if not _is_non_empty_list(local_files):
|
|
350
|
+
issues.append("memory.local_files must be a non-empty list")
|
|
351
|
+
else:
|
|
352
|
+
_validate_id_list(local_files, "memory.local_files", issues)
|
|
353
|
+
ids = set()
|
|
354
|
+
for index, item in enumerate(_as_list(local_files)):
|
|
355
|
+
if not _is_mapping(item):
|
|
356
|
+
continue
|
|
357
|
+
ids.add(item.get("id"))
|
|
358
|
+
for key in ("path", "track"):
|
|
359
|
+
if not _non_empty_string(item.get(key)):
|
|
360
|
+
issues.append(f"memory.local_files[{index}].{key} must be a non-empty string")
|
|
361
|
+
_validate_file_ref(data, item.get("path"), f"memory.local_files[{index}].path", issues)
|
|
362
|
+
for required in (
|
|
363
|
+
"memory_index",
|
|
364
|
+
"profile",
|
|
365
|
+
"session_index",
|
|
366
|
+
"source_map",
|
|
367
|
+
"collaboration",
|
|
368
|
+
"pinned_memory",
|
|
369
|
+
"workstreams",
|
|
370
|
+
"workstream_template",
|
|
371
|
+
"todos",
|
|
372
|
+
"project_template",
|
|
373
|
+
"relationship_state",
|
|
374
|
+
"emotion_state",
|
|
375
|
+
):
|
|
376
|
+
if required not in ids:
|
|
377
|
+
issues.append(f"memory.local_files must include {required}")
|
|
378
|
+
if not _is_non_empty_list(memory.get("durable_dirs")):
|
|
379
|
+
issues.append("memory.durable_dirs must be a non-empty list")
|
|
380
|
+
_validate_memory_limits(memory.get("limits"), issues)
|
|
381
|
+
if not _non_empty_string(memory.get("scratch_dir")):
|
|
382
|
+
issues.append("memory.scratch_dir must be a non-empty string")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _validate_memory_limits(limits, issues):
|
|
386
|
+
if not _is_mapping(limits):
|
|
387
|
+
issues.append("memory.limits must be a mapping")
|
|
388
|
+
return
|
|
389
|
+
expected = {
|
|
390
|
+
"pinned_items": 20,
|
|
391
|
+
"recent_activity_hot_entries": 20,
|
|
392
|
+
"session_index_entries": 20,
|
|
393
|
+
"workstream_summary_bullets": 7,
|
|
394
|
+
"project_summary_lines": 12,
|
|
395
|
+
"workspace_artifact_index_entries": 50,
|
|
396
|
+
}
|
|
397
|
+
for key, value in expected.items():
|
|
398
|
+
if limits.get(key) != value:
|
|
399
|
+
issues.append(f"memory.limits.{key} must be {value}")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _validate_workspace(workspace, issues):
|
|
403
|
+
if not _is_mapping(workspace):
|
|
404
|
+
issues.append("workspace must be a mapping")
|
|
405
|
+
return
|
|
406
|
+
for key in ("root", "layout", "domain_template_dir", "shared_dir", "index_owner"):
|
|
407
|
+
if not _non_empty_string(workspace.get(key)):
|
|
408
|
+
issues.append(f"workspace.{key} must be a non-empty string")
|
|
409
|
+
if workspace.get("root") != WORKSPACE_ROOT:
|
|
410
|
+
issues.append(f"workspace.root must be {WORKSPACE_ROOT}")
|
|
411
|
+
if workspace.get("layout") != WORKSPACE_LAYOUT:
|
|
412
|
+
issues.append(f"workspace.layout must be {WORKSPACE_LAYOUT}")
|
|
413
|
+
if workspace.get("domain_template_dir") != WORKSPACE_DOMAIN_TEMPLATE_DIR:
|
|
414
|
+
issues.append(f"workspace.domain_template_dir must be {WORKSPACE_DOMAIN_TEMPLATE_DIR}")
|
|
415
|
+
if workspace.get("shared_dir") != WORKSPACE_SHARED_DIR:
|
|
416
|
+
issues.append(f"workspace.shared_dir must be {WORKSPACE_SHARED_DIR}")
|
|
417
|
+
if workspace.get("index_owner") != WORKSPACE_INDEX_OWNER:
|
|
418
|
+
issues.append(f"workspace.index_owner must be {WORKSPACE_INDEX_OWNER}")
|
|
419
|
+
if workspace.get("lifecycle_dirs") != list(WORKSPACE_LIFECYCLE_DIRS):
|
|
420
|
+
expected = ", ".join(WORKSPACE_LIFECYCLE_DIRS)
|
|
421
|
+
issues.append(f"workspace.lifecycle_dirs must be [{expected}]")
|
|
422
|
+
if not _is_non_empty_list(workspace.get("rules")):
|
|
423
|
+
issues.append("workspace.rules must be a non-empty list")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _validate_skills(data, issues):
|
|
427
|
+
skills = data.get("skills")
|
|
428
|
+
if not _is_non_empty_list(skills):
|
|
429
|
+
issues.append("skills must be a non-empty list")
|
|
430
|
+
return
|
|
431
|
+
_validate_id_list(skills, "skills", issues)
|
|
432
|
+
for index, skill in enumerate(_as_list(skills)):
|
|
433
|
+
if not _is_mapping(skill):
|
|
434
|
+
continue
|
|
435
|
+
for key in ("path", "layer", "trigger"):
|
|
436
|
+
if not _non_empty_string(skill.get(key)):
|
|
437
|
+
issues.append(f"skills[{index}].{key} must be a non-empty string")
|
|
438
|
+
_validate_file_ref(data, skill.get("path"), f"skills[{index}].path", issues)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _validate_outputs(data, issues):
|
|
442
|
+
outputs = data.get("outputs")
|
|
443
|
+
if not _is_mapping(outputs):
|
|
444
|
+
issues.append("outputs must be a mapping")
|
|
445
|
+
return
|
|
446
|
+
for adapter in SUPPORTED_ADAPTERS:
|
|
447
|
+
config = outputs.get(adapter)
|
|
448
|
+
if not _is_mapping(config):
|
|
449
|
+
issues.append(f"outputs.{adapter} must be a mapping")
|
|
450
|
+
continue
|
|
451
|
+
if config.get("kind") != "adapter_pack":
|
|
452
|
+
issues.append(f"outputs.{adapter}.kind must be adapter_pack")
|
|
453
|
+
artifacts = config.get("artifacts")
|
|
454
|
+
if not _is_non_empty_list(artifacts):
|
|
455
|
+
issues.append(f"outputs.{adapter}.artifacts must be a non-empty list")
|
|
456
|
+
continue
|
|
457
|
+
skill_path = save_context_skill_path(data, adapter)
|
|
458
|
+
if adapter == "codex":
|
|
459
|
+
required = (
|
|
460
|
+
"AGENTS.md",
|
|
461
|
+
skill_path,
|
|
462
|
+
"manifest.json",
|
|
463
|
+
"memory/index.md",
|
|
464
|
+
"memory/profile.md",
|
|
465
|
+
"memory/session-index.md",
|
|
466
|
+
"memory/source-map.md",
|
|
467
|
+
"memory/collaboration.md",
|
|
468
|
+
"memory/pinned.md",
|
|
469
|
+
"memory/workstreams.md",
|
|
470
|
+
"memory/workstreams/_template.md",
|
|
471
|
+
"memory/projects/_template.md",
|
|
472
|
+
"memory/todos.md",
|
|
473
|
+
"memory/relationship-state.md",
|
|
474
|
+
"memory/emotion-state.json.example",
|
|
475
|
+
*knowledge_artifacts(),
|
|
476
|
+
*workspace_artifacts(),
|
|
477
|
+
)
|
|
478
|
+
elif adapter == "claude-code":
|
|
479
|
+
required = (
|
|
480
|
+
"CLAUDE.md",
|
|
481
|
+
skill_path,
|
|
482
|
+
".claude/settings.local.json.example",
|
|
483
|
+
"manifest.json",
|
|
484
|
+
"memory/index.md",
|
|
485
|
+
"memory/profile.md",
|
|
486
|
+
"memory/session-index.md",
|
|
487
|
+
"memory/source-map.md",
|
|
488
|
+
"memory/collaboration.md",
|
|
489
|
+
"memory/pinned.md",
|
|
490
|
+
"memory/workstreams.md",
|
|
491
|
+
"memory/workstreams/_template.md",
|
|
492
|
+
"memory/projects/_template.md",
|
|
493
|
+
"memory/todos.md",
|
|
494
|
+
"memory/relationship-state.md",
|
|
495
|
+
"memory/emotion-state.json.example",
|
|
496
|
+
*knowledge_artifacts(),
|
|
497
|
+
*workspace_artifacts(),
|
|
498
|
+
)
|
|
499
|
+
else:
|
|
500
|
+
slug = character_slug(data)
|
|
501
|
+
required = (
|
|
502
|
+
"manifest.json",
|
|
503
|
+
f".cursor/rules/{slug}.mdc",
|
|
504
|
+
f".cursor/rules/{slug}-memory.mdc",
|
|
505
|
+
skill_path,
|
|
506
|
+
"memory/index.md",
|
|
507
|
+
"memory/profile.md",
|
|
508
|
+
"memory/session-index.md",
|
|
509
|
+
"memory/source-map.md",
|
|
510
|
+
"memory/collaboration.md",
|
|
511
|
+
"memory/pinned.md",
|
|
512
|
+
"memory/workstreams.md",
|
|
513
|
+
"memory/workstreams/_template.md",
|
|
514
|
+
"memory/projects/_template.md",
|
|
515
|
+
"memory/todos.md",
|
|
516
|
+
"memory/relationship-state.md",
|
|
517
|
+
"memory/emotion-state.json.example",
|
|
518
|
+
*knowledge_artifacts(),
|
|
519
|
+
*HANDOFF_ARTIFACTS,
|
|
520
|
+
*workspace_artifacts(),
|
|
521
|
+
)
|
|
522
|
+
for artifact in required:
|
|
523
|
+
if artifact not in artifacts:
|
|
524
|
+
issues.append(f"outputs.{adapter}.artifacts must include {artifact}")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _validate_checker(checker, issues):
|
|
528
|
+
if not _is_mapping(checker):
|
|
529
|
+
issues.append("checker must be a mapping")
|
|
530
|
+
return
|
|
531
|
+
threshold = checker.get("threshold")
|
|
532
|
+
if not isinstance(threshold, int) or threshold < 0 or threshold > 100:
|
|
533
|
+
issues.append("checker.threshold must be an integer from 0 to 100")
|
|
534
|
+
if not _is_non_empty_list(checker.get("required_checks")):
|
|
535
|
+
issues.append("checker.required_checks must be a non-empty list")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _validate_coverage(data, issues):
|
|
539
|
+
coverage = data.get("coverage")
|
|
540
|
+
if not _is_mapping(coverage):
|
|
541
|
+
issues.append("coverage must be a mapping")
|
|
542
|
+
return
|
|
543
|
+
required = coverage.get("required_mechanisms")
|
|
544
|
+
implemented_by = coverage.get("implemented_by")
|
|
545
|
+
if not _is_non_empty_list(required):
|
|
546
|
+
issues.append("coverage.required_mechanisms must be a non-empty list")
|
|
547
|
+
required = []
|
|
548
|
+
if not _is_mapping(implemented_by):
|
|
549
|
+
issues.append("coverage.implemented_by must be a mapping")
|
|
550
|
+
implemented_by = {}
|
|
551
|
+
|
|
552
|
+
seen = set()
|
|
553
|
+
for mechanism in _as_list(required):
|
|
554
|
+
if not _non_empty_string(mechanism):
|
|
555
|
+
issues.append("coverage mechanisms must be non-empty strings")
|
|
556
|
+
continue
|
|
557
|
+
if mechanism in seen:
|
|
558
|
+
issues.append(f"duplicate character mechanism: {mechanism}")
|
|
559
|
+
seen.add(mechanism)
|
|
560
|
+
|
|
561
|
+
paths = implemented_by.get(mechanism)
|
|
562
|
+
if not _is_non_empty_list(paths):
|
|
563
|
+
issues.append(f"coverage.implemented_by.{mechanism} must be a non-empty list")
|
|
564
|
+
continue
|
|
565
|
+
for path in paths:
|
|
566
|
+
if not _non_empty_string(path):
|
|
567
|
+
issues.append(f"coverage path for {mechanism} must be a non-empty string")
|
|
568
|
+
elif not path_exists(data, path):
|
|
569
|
+
issues.append(f"coverage path for {mechanism} does not exist: {path}")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _validate_reserved_specs(data, issues):
|
|
573
|
+
reserved_specs = data.get("reserved_specs", {})
|
|
574
|
+
if not _is_mapping(reserved_specs):
|
|
575
|
+
issues.append("reserved_specs must be a mapping")
|
|
576
|
+
return
|
|
577
|
+
for name, spec in reserved_specs.items():
|
|
578
|
+
if not _is_mapping(spec):
|
|
579
|
+
issues.append(f"reserved_specs.{name} must be a mapping")
|
|
580
|
+
continue
|
|
581
|
+
if spec.get("status") not in {"reserved", "structured_reserved"}:
|
|
582
|
+
issues.append(f"reserved_specs.{name}.status must be reserved or structured_reserved")
|
|
583
|
+
if spec.get("runtime") != "not_implemented":
|
|
584
|
+
issues.append(f"reserved_specs.{name}.runtime must be not_implemented")
|
|
585
|
+
if not _non_empty_string(spec.get("spec_path")):
|
|
586
|
+
issues.append(f"reserved_specs.{name}.spec_path must be a non-empty string")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _validate_id_list(items, path, issues):
|
|
590
|
+
seen = set()
|
|
591
|
+
for index, item in enumerate(_as_list(items)):
|
|
592
|
+
item_path = f"{path}[{index}]"
|
|
593
|
+
if not _is_mapping(item):
|
|
594
|
+
issues.append(f"{item_path} must be a mapping")
|
|
595
|
+
continue
|
|
596
|
+
item_id = item.get("id")
|
|
597
|
+
if not _non_empty_string(item_id):
|
|
598
|
+
issues.append(f"{item_path}.id must be a non-empty string")
|
|
599
|
+
elif item_id in seen:
|
|
600
|
+
issues.append(f"duplicate id in {path}: {item_id}")
|
|
601
|
+
else:
|
|
602
|
+
seen.add(item_id)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _validate_optional_slug(mapping, label, issues):
|
|
606
|
+
if not _is_mapping(mapping) or "slug" not in mapping:
|
|
607
|
+
return
|
|
608
|
+
value = mapping.get("slug")
|
|
609
|
+
if not _non_empty_string(value):
|
|
610
|
+
issues.append(f"{label} must be a non-empty string when provided")
|
|
611
|
+
return
|
|
612
|
+
normalized = normalize_slug(value, default="")
|
|
613
|
+
if not normalized or not is_valid_slug(normalized):
|
|
614
|
+
issues.append(f"{label} must normalize to a lowercase ASCII slug")
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _validate_file_ref(data, rel_path, label, issues):
|
|
618
|
+
if not _non_empty_string(rel_path):
|
|
619
|
+
return
|
|
620
|
+
try:
|
|
621
|
+
resolve_mechanism_file(data, rel_path, label)
|
|
622
|
+
except PackwrightValidationError as exc:
|
|
623
|
+
issues.extend(exc.issues)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _is_mapping(value):
|
|
627
|
+
return isinstance(value, Mapping)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _is_list(value):
|
|
631
|
+
return isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray))
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _is_non_empty_list(value):
|
|
635
|
+
return _is_list(value) and len(value) > 0
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _as_list(value):
|
|
639
|
+
if _is_list(value):
|
|
640
|
+
return value
|
|
641
|
+
return []
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _non_empty_string(value):
|
|
645
|
+
return isinstance(value, str) and bool(value.strip())
|