@playcraft/cli 0.1.65 → 0.1.67

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.
Files changed (102) hide show
  1. package/dist/assets/client.js +4 -2
  2. package/dist/assets/messages.js +2 -0
  3. package/dist/audio-source/messages.js +35 -0
  4. package/dist/commands/assets.js +1 -1
  5. package/dist/commands/audio-source.js +109 -0
  6. package/dist/commands/audio.js +11 -4
  7. package/dist/commands/build.js +4 -2
  8. package/dist/commands/tools-context.js +46 -0
  9. package/dist/commands/tools.js +2 -0
  10. package/dist/model-adaptation/project.js +1 -1
  11. package/dist/project-skills/builtin-import.js +69 -22
  12. package/dist/project-skills/commands.js +11 -2
  13. package/dist/project-skills/lifecycle.js +1 -1
  14. package/dist/project-skills/local-store.js +90 -31
  15. package/dist/project-skills/messages.js +16 -0
  16. package/dist/project-skills/migrate-lock.js +166 -0
  17. package/dist/project-skills/remote-cache.js +36 -5
  18. package/dist/utils/agent-api-client.js +18 -1
  19. package/dist/utils/agent-generation-provenance.js +8 -2
  20. package/dist/utils/audio-export.js +249 -0
  21. package/dist/utils/audio-processor.js +22 -99
  22. package/dist/utils/execution-context-bridge.js +28 -0
  23. package/dist/workspace-runtime/batch/control.js +13 -2
  24. package/dist/workspace-runtime/batch/generate.js +43 -2
  25. package/dist/workspace-runtime/batch/prepare.js +4 -0
  26. package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
  27. package/dist/workspace-runtime/batch/source-capture.js +6 -8
  28. package/dist/workspace-runtime/codex/app-server.js +26 -2
  29. package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
  30. package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
  31. package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
  32. package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
  33. package/dist/workspace-runtime/codex/model-broker.js +15 -2
  34. package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
  35. package/dist/workspace-runtime/previews/finalize.js +1 -1
  36. package/dist/workspace-runtime/previews/output.js +4 -1
  37. package/dist/workspace-runtime/previews/publish.js +5 -2
  38. package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
  39. package/dist/workspace-runtime/serve.js +6 -0
  40. package/dist/workspace-runtime/server/accept-operation.js +85 -13
  41. package/dist/workspace-runtime/server/audio-assets.js +211 -0
  42. package/dist/workspace-runtime/server/audio-export.js +136 -0
  43. package/dist/workspace-runtime/server/directory-access.js +222 -67
  44. package/dist/workspace-runtime/server/dispatch.js +252 -29
  45. package/dist/workspace-runtime/server/http.js +97 -3
  46. package/dist/workspace-runtime/server/operation-record.js +1 -1
  47. package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
  48. package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
  49. package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
  50. package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
  51. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
  52. package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
  53. package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
  54. package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
  55. package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
  56. package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
  57. package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
  58. package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
  59. package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
  60. package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
  61. package/dist/workspace-runtime/workspaces/files.js +3 -5
  62. package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
  63. package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
  64. package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
  65. package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
  66. package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
  67. package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
  68. package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
  69. package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
  70. package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
  72. package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
  73. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
  74. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
  75. package/dist/workspace-runtime/workspaces/skills.js +48 -13
  76. package/package.json +6 -4
  77. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  78. package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
  79. package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
  80. package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
  81. package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
  82. package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
  83. package/project-template/vite.config.ts +1 -1
  84. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  85. package/project-template-v2/.playcraft/skills.lock.json +9 -2
  86. package/project-template-v2/vite.config.ts +1 -1
  87. package/project-templates/empty/AGENTS.md +8 -4
  88. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  89. package/project-templates/phaser/vite.config.ts +1 -1
  90. package/project-templates/playable-template-phaser/vite.config.ts +1 -1
  91. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  92. package/project-templates/threejs/vite.config.ts +1 -1
  93. package/templates/preview-runtime/v1/README.md +12 -0
  94. package/templates/preview-runtime/v1/build-preview.mjs +17 -8
  95. package/templates/preview-runtime/v1/manifest.json +9 -6
  96. package/templates/preview-runtime/v1/release-guard.mjs +1 -1
  97. package/templates/preview-runtime/v1/runtime.d.mts +1 -3
  98. package/templates/preview-runtime/v1/runtime.d.ts +1 -3
  99. package/templates/preview-runtime/v1/runtime.mjs +1142 -257
  100. package/templates/preview-runtime/v1/types/host.d.ts +29 -0
  101. package/templates/preview-runtime/v1/types/index.d.ts +6 -0
  102. package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
@@ -0,0 +1,454 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, rename, rm } from 'node:fs/promises';
4
+ import JSON5 from 'json5';
5
+ import { z } from 'zod';
6
+ import { compileLevelEditorDefinition, applyLevelEditorCommand, applyLevelEditorHistory, validateLevelEditorDocuments, levelEditorCanonicalJson, levelEditorSnapshotSchema, levelEditorTransactionRequestSchema, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, LevelEditorError, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { readParameterAgentFile, parameterInputSnapshot } from './parameter-agent-inputs.js';
8
+ import { resolveWorkspaceFile } from './files.js';
9
+ import { openSharedFile } from './shared-write.js';
10
+ import { parseLevelEditorDocument, serializeLevelEditorDocument } from './level-editor-format.js';
11
+ import { levelEditorExtensionPaths, validateLevelEditorExtensions, } from './level-editor-extensions.js';
12
+ import { recoverPendingLevelEditorTransaction, levelEditorContext, registerLevelEditorContext, transactLevelEditorBatch, } from './level-editor-atomic.js';
13
+ import { readLevelEditorHistory, writeLevelEditorHistory, levelEditorHistoryRequestDigest, assertLevelEditorHistoryScope, } from './level-editor-history-store.js';
14
+ export const levelEditorDigest = (value) => createHash('sha256').update(value).digest('hex');
15
+ const location = z
16
+ .object({
17
+ version: z.literal(1),
18
+ definition: z.string().refine((value) => workspaceRelativePathSchema.safeParse(value).success),
19
+ })
20
+ .strict();
21
+ const empty = { documents: {}, schemas: {}, revisions: {}, documentHashes: {}, diagnostics: [] };
22
+ function semanticViews(root) {
23
+ const containers = new Set(['split@1', 'stack@1', 'tabs@1', 'group@1', 'toolbar@1']);
24
+ const result = [];
25
+ const visit = (node) => {
26
+ if (!containers.has(node.component)) {
27
+ const props = { ...node.props };
28
+ delete props.titleKey;
29
+ delete props.descriptionKey;
30
+ if (node.component === 'inspector@1' && Array.isArray(props.fields)) {
31
+ props.fields = props.fields
32
+ .map((item) => {
33
+ if (!item || typeof item !== 'object' || Array.isArray(item))
34
+ return item;
35
+ const field = { ...item };
36
+ delete field.titleKey;
37
+ delete field.descriptionKey;
38
+ return field;
39
+ })
40
+ .sort((a, b) => levelEditorCanonicalJson(a).localeCompare(levelEditorCanonicalJson(b)));
41
+ }
42
+ result.push({
43
+ id: node.id,
44
+ component: node.component,
45
+ bind: node.bind ?? {},
46
+ actions: node.actions ?? {},
47
+ props,
48
+ });
49
+ }
50
+ node.children?.forEach(visit);
51
+ };
52
+ visit(root);
53
+ return result.sort((a, b) => a.id.localeCompare(b.id));
54
+ }
55
+ export async function readLevelEditorFile(root, path, limit = WORKSPACE_FILE_CONTENT_MAX) {
56
+ try {
57
+ return await readParameterAgentFile(root, path, limit);
58
+ }
59
+ catch (error) {
60
+ if (error.code === 'ENOENT')
61
+ throw error;
62
+ throw new LevelEditorError('level_editor_file_invalid', [path]);
63
+ }
64
+ }
65
+ export async function readWorkspaceLevelEditor(root, revisions, context) {
66
+ const access = levelEditorContext(root, context);
67
+ if (access) {
68
+ registerLevelEditorContext(root, access);
69
+ try {
70
+ await recoverPendingLevelEditorTransaction(root, access, () => readLevelEditorSnapshot(root, revisions));
71
+ }
72
+ catch (error) {
73
+ const code = error instanceof LevelEditorError ? error.code : 'level_editor_recovery_required';
74
+ return {
75
+ status: 'invalid',
76
+ ...empty,
77
+ diagnostics: [{ code, message: code, severity: 'error' }],
78
+ };
79
+ }
80
+ }
81
+ const snapshot = await readLevelEditorSnapshot(root, revisions);
82
+ if (snapshot.status !== 'ready' || !snapshot.definition)
83
+ return snapshot;
84
+ try {
85
+ const diagnostics = [
86
+ ...snapshot.diagnostics,
87
+ ...(await validateLevelEditorExtensions(root, snapshot.definition, snapshot.documents)),
88
+ ];
89
+ if (diagnostics.length > 256)
90
+ throw new LevelEditorError('level_editor_extension_output_invalid');
91
+ return {
92
+ ...snapshot,
93
+ status: diagnostics.some((item) => item.severity === 'error') ? 'invalid' : 'ready',
94
+ diagnostics,
95
+ };
96
+ }
97
+ catch (error) {
98
+ const code = error instanceof LevelEditorError ? error.code : 'level_editor_extension_failed';
99
+ return {
100
+ ...snapshot,
101
+ status: 'invalid',
102
+ diagnostics: [
103
+ { code, message: code, severity: 'error' },
104
+ ...snapshot.diagnostics,
105
+ ].slice(0, 256),
106
+ };
107
+ }
108
+ }
109
+ async function readLevelEditorSnapshot(root, revisions) {
110
+ try {
111
+ let manifest;
112
+ try {
113
+ manifest = await readLevelEditorFile(root, '.playcraft/workspace.json');
114
+ }
115
+ catch (error) {
116
+ if (error.code === 'ENOENT')
117
+ return { status: 'absent', ...empty };
118
+ throw error;
119
+ }
120
+ const workspaceManifest = JSON5.parse(manifest.toString('utf8'));
121
+ const raw = workspaceManifest.levelEditor;
122
+ if (raw === undefined)
123
+ return { status: 'absent', ...empty };
124
+ const declared = location.parse(raw);
125
+ const definitionBytes = await readLevelEditorFile(root, declared.definition);
126
+ const initial = compileLevelEditorDefinition(JSON5.parse(definitionBytes.toString('utf8')));
127
+ if (!initial.ok)
128
+ return {
129
+ status: 'invalid',
130
+ ...empty,
131
+ definitionPath: declared.definition,
132
+ diagnostics: initial.diagnostics,
133
+ };
134
+ const definition = initial.definition;
135
+ const documents = {}, schemas = {};
136
+ const documentHashes = {}, dataRevisions = {};
137
+ const sourceHashes = [];
138
+ for (const [id, document] of Object.entries(definition.documents)) {
139
+ const [data, schema] = await Promise.all([
140
+ readLevelEditorFile(root, document.path),
141
+ readLevelEditorFile(root, document.schema),
142
+ ]);
143
+ let text;
144
+ try {
145
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data);
146
+ }
147
+ catch {
148
+ throw new LevelEditorError('level_editor_document_invalid_utf8', [id]);
149
+ }
150
+ documents[id] = await parseLevelEditorDocument(root, document, text);
151
+ schemas[id] = JSON5.parse(schema.toString('utf8'));
152
+ documentHashes[id] = levelEditorDigest(data);
153
+ dataRevisions[id] = revisions[document.path]?.revision ?? 0;
154
+ sourceHashes.push([document.schema, levelEditorDigest(schema)]);
155
+ }
156
+ const checked = compileLevelEditorDefinition(definition, schemas);
157
+ const diagnostics = checked.diagnostics.concat(validateLevelEditorDocuments(definition, documents, schemas));
158
+ const { view: _view, labels: _labels, preview: _preview, ...bindingDefinition } = definition;
159
+ const bindingContractHash = levelEditorDigest(levelEditorCanonicalJson({ ...bindingDefinition, views: semanticViews(definition.view) }));
160
+ const schemaHash = levelEditorDigest(levelEditorCanonicalJson(sourceHashes.sort()));
161
+ const adapterInputs = [];
162
+ for (const path of levelEditorExtensionPaths(definition))
163
+ adapterInputs.push([path, levelEditorDigest(await readLevelEditorFile(root, path))]);
164
+ for (const path of [...new Set(Object.values(definition.preview ?? {}))])
165
+ adapterInputs.push([path, levelEditorDigest(await readLevelEditorFile(root, path))]);
166
+ const authoredData = new Set([
167
+ declared.definition,
168
+ '.playcraft/workspace.json',
169
+ ...Object.values(definition.documents).flatMap((document) => [
170
+ document.path,
171
+ document.schema,
172
+ ]),
173
+ ]);
174
+ // Conservatively cover authored transitive imports, instead of trusting an agent-maintained import list.
175
+ const sourceInputs = (await parameterInputSnapshot(root)).sources.filter((item) => !authoredData.has(item.path));
176
+ for (const path of workspaceManifest.previewRuntime?.gameFilePaths ?? []) {
177
+ workspaceRelativePathSchema.parse(path);
178
+ if (!authoredData.has(path))
179
+ adapterInputs.push([path, levelEditorDigest(await readLevelEditorFile(root, path))]);
180
+ }
181
+ const executionContractHash = levelEditorDigest(levelEditorCanonicalJson({
182
+ bindingContractHash,
183
+ schemaHash,
184
+ adapterInputs,
185
+ sourceInputs,
186
+ previewRuntime: workspaceManifest.previewRuntime ?? null,
187
+ }));
188
+ const sourceFingerprint = levelEditorDigest(levelEditorCanonicalJson({
189
+ definitionPath: declared.definition,
190
+ bindingContractHash,
191
+ schemaHash,
192
+ executionContractHash,
193
+ }));
194
+ return levelEditorSnapshotSchema.parse({
195
+ status: diagnostics.some((item) => item.severity === 'error') ? 'invalid' : 'ready',
196
+ definitionPath: declared.definition,
197
+ definition,
198
+ documents,
199
+ schemas,
200
+ revisions: dataRevisions,
201
+ documentHashes,
202
+ definitionHash: levelEditorDigest(definitionBytes),
203
+ sourceFingerprint,
204
+ bindingContractHash,
205
+ schemaHash,
206
+ executionContractHash,
207
+ diagnostics,
208
+ });
209
+ }
210
+ catch (error) {
211
+ const code = error instanceof LevelEditorError ? error.code : 'level_editor_document_invalid';
212
+ const diagnostics = [{ code, message: code, severity: 'error' }];
213
+ return { status: 'invalid', ...empty, diagnostics };
214
+ }
215
+ }
216
+ export async function transactWorkspaceLevelEditor(root, revisions, value, context) {
217
+ const request = levelEditorTransactionRequestSchema.parse(value);
218
+ if (request.version === 2)
219
+ return transactLevelEditorBatch(root, revisions, request, context, () => readLevelEditorSnapshot(root, revisions));
220
+ if (context) {
221
+ assertLevelEditorHistoryScope(context);
222
+ registerLevelEditorContext(root, context);
223
+ }
224
+ if (context && context.operation.clientRequestId !== request.requestId)
225
+ throw new LevelEditorError('level_editor_history_scope_invalid');
226
+ const snapshot = await readWorkspaceLevelEditor(root, revisions);
227
+ if (snapshot.status !== 'ready' || !snapshot.definition)
228
+ throw new LevelEditorError('level_editor_not_ready');
229
+ if (snapshot.definition.editorId !== request.editorId ||
230
+ snapshot.definition.dataContractVersion !== request.dataContractVersion)
231
+ throw new LevelEditorError('level_editor_contract_conflict');
232
+ for (const key of [
233
+ 'sourceFingerprint',
234
+ 'bindingContractHash',
235
+ 'schemaHash',
236
+ 'executionContractHash',
237
+ ])
238
+ if (snapshot[key] !== request[key])
239
+ throw new LevelEditorError('level_editor_source_conflict');
240
+ const previousReceipt = context && (await readLevelEditorHistory(root, context, request.requestId));
241
+ if (previousReceipt) {
242
+ if (previousReceipt.requestDigest !== levelEditorHistoryRequestDigest(request))
243
+ throw new LevelEditorError('level_editor_history_request_conflict');
244
+ const currentRevision = snapshot.revisions[request.documentId];
245
+ if (snapshot.documentHashes[request.documentId] === previousReceipt.contentHash &&
246
+ (currentRevision === request.expectedRevision ||
247
+ currentRevision === request.expectedRevision + 1)) {
248
+ if (snapshot.definition.documents[request.documentId]?.path !== previousReceipt.path)
249
+ throw new LevelEditorError('level_editor_history_invalid');
250
+ await writeLevelEditorHistory(root, context, { ...previousReceipt, phase: 'confirmed' });
251
+ return {
252
+ path: previousReceipt.path,
253
+ contentHash: previousReceipt.contentHash,
254
+ size: previousReceipt.size,
255
+ revision: request.expectedRevision + 1,
256
+ };
257
+ }
258
+ if (previousReceipt.phase === 'confirmed')
259
+ throw new LevelEditorError('revision_conflict');
260
+ }
261
+ if (snapshot.revisions[request.documentId] !== request.expectedRevision ||
262
+ snapshot.documentHashes[request.documentId] !== request.expectedDocumentHash)
263
+ throw new LevelEditorError('revision_conflict');
264
+ const command = {
265
+ commandId: request.commandId,
266
+ selection: request.selection,
267
+ input: request.input,
268
+ };
269
+ const originalReceipt = request.history &&
270
+ context &&
271
+ (await readLevelEditorHistory(root, context, request.history.requestId));
272
+ if (request.history) {
273
+ if (originalReceipt?.request.version !== 1)
274
+ throw new LevelEditorError('level_editor_history_missing');
275
+ if (!originalReceipt ||
276
+ originalReceipt.phase !== 'confirmed' ||
277
+ originalReceipt.request.history ||
278
+ request.history.requestId === request.requestId)
279
+ throw new LevelEditorError('level_editor_history_missing');
280
+ for (const key of [
281
+ 'editorId',
282
+ 'dataContractVersion',
283
+ 'documentId',
284
+ 'sourceFingerprint',
285
+ 'bindingContractHash',
286
+ 'schemaHash',
287
+ 'executionContractHash',
288
+ ])
289
+ if (originalReceipt.request[key] !==
290
+ request[key])
291
+ throw new LevelEditorError('level_editor_source_conflict');
292
+ if (levelEditorCanonicalJson(originalReceipt.command) !== levelEditorCanonicalJson(command))
293
+ throw new LevelEditorError('level_editor_history_invalid');
294
+ }
295
+ const outcome = request.history
296
+ ? applyLevelEditorHistory(snapshot.definition, snapshot.documents, originalReceipt, request.history.direction, snapshot.schemas)
297
+ : applyLevelEditorCommand(snapshot.definition, snapshot.documents, command, snapshot.schemas);
298
+ if (outcome.documentId !== request.documentId)
299
+ throw new LevelEditorError('level_editor_document_mismatch');
300
+ const document = snapshot.definition.documents[request.documentId];
301
+ const original = await readLevelEditorFile(root, document.path);
302
+ if (levelEditorDigest(original) !== request.expectedDocumentHash)
303
+ throw new LevelEditorError('revision_conflict');
304
+ const candidate = { ...snapshot.documents, [outcome.documentId]: outcome.data };
305
+ if ((await validateLevelEditorExtensions(root, snapshot.definition, candidate)).some((item) => item.severity === 'error'))
306
+ throw new LevelEditorError('level_editor_validation_failed');
307
+ const source = original.toString('utf8');
308
+ const edited = await serializeLevelEditorDocument(root, document, source, outcome.changes, outcome.data);
309
+ if (!document.provider && document.format !== 'yaml' && document.path.endsWith('.json'))
310
+ JSON.parse(edited);
311
+ const bytes = Buffer.from(edited);
312
+ if (bytes.length > WORKSPACE_FILE_CONTENT_MAX)
313
+ throw new LevelEditorError('level_editor_document_budget');
314
+ const path = await resolveWorkspaceFile(root, document.path);
315
+ const previous = await lstat(path);
316
+ if (!previous.isFile() || previous.isSymbolicLink() || previous.nlink !== 1)
317
+ throw new LevelEditorError('level_editor_file_invalid');
318
+ const temporary = `${path}.${randomUUID()}.tmp`;
319
+ const receipt = {
320
+ request,
321
+ requestDigest: levelEditorHistoryRequestDigest(request),
322
+ phase: 'prepared',
323
+ command,
324
+ documentId: request.documentId,
325
+ before: snapshot.documents[request.documentId],
326
+ after: outcome.data,
327
+ path: document.path,
328
+ contentHash: levelEditorDigest(bytes),
329
+ size: bytes.length,
330
+ };
331
+ if (context)
332
+ await writeLevelEditorHistory(root, context, receipt);
333
+ try {
334
+ const handle = await openSharedFile(root, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
335
+ try {
336
+ await handle.writeFile(bytes);
337
+ await handle.sync();
338
+ }
339
+ finally {
340
+ await handle.close();
341
+ }
342
+ if (context)
343
+ assertLevelEditorHistoryScope(context);
344
+ const latest = await readLevelEditorSnapshot(root, revisions);
345
+ if (latest.sourceFingerprint !== snapshot.sourceFingerprint ||
346
+ latest.documentHashes[request.documentId] !== request.expectedDocumentHash)
347
+ throw new LevelEditorError('level_editor_source_conflict');
348
+ if (context) {
349
+ assertLevelEditorHistoryScope(context);
350
+ if (context.state.journal?.destroyed() ||
351
+ (context.operation.leaseFence ?? 0) < context.state.acceptedLeaseFence)
352
+ throw new LevelEditorError('level_editor_source_conflict');
353
+ }
354
+ await rename(temporary, path);
355
+ }
356
+ finally {
357
+ await rm(temporary, { force: true });
358
+ }
359
+ if (context) {
360
+ try {
361
+ await writeLevelEditorHistory(root, context, { ...receipt, phase: 'confirmed' });
362
+ }
363
+ catch {
364
+ throw new LevelEditorError('level_editor_transaction_uncertain');
365
+ }
366
+ }
367
+ return {
368
+ path: document.path,
369
+ contentHash: levelEditorDigest(bytes),
370
+ size: bytes.length,
371
+ revision: request.expectedRevision + 1,
372
+ };
373
+ }
374
+ /** Reconcile private proof; a batch resumes only its previously authorized durable intent. */
375
+ export async function reconcileWorkspaceLevelEditorTransaction(root, revisions, value, context) {
376
+ const request = levelEditorTransactionRequestSchema.parse(value);
377
+ if (context.operation.clientRequestId !== request.requestId)
378
+ return undefined;
379
+ if (request.version === 2) {
380
+ await recoverPendingLevelEditorTransaction(root, context, () => readLevelEditorSnapshot(root, revisions));
381
+ let receipt = await readLevelEditorHistory(root, context, request.requestId);
382
+ if (receipt?.batch &&
383
+ receipt.phase === 'prepared' &&
384
+ receipt.requestDigest === levelEditorHistoryRequestDigest(request)) {
385
+ await transactLevelEditorBatch(root, revisions, request, context, () => readLevelEditorSnapshot(root, revisions));
386
+ receipt = await readLevelEditorHistory(root, context, request.requestId);
387
+ }
388
+ if (!receipt?.batch ||
389
+ receipt.phase !== 'confirmed' ||
390
+ receipt.batch.operationId !== context.operation.operationId ||
391
+ receipt.requestDigest !== levelEditorHistoryRequestDigest(request))
392
+ return undefined;
393
+ const snapshot = await readLevelEditorSnapshot(root, revisions);
394
+ if (snapshot.status !== 'ready' ||
395
+ snapshot.definition?.editorId !== request.editorId ||
396
+ snapshot.definition.dataContractVersion !== request.dataContractVersion ||
397
+ ['sourceFingerprint', 'bindingContractHash', 'schemaHash', 'executionContractHash'].some((key) => snapshot[key] !== request[key]) ||
398
+ JSON.stringify(Object.keys(snapshot.definition.documents).sort()) !==
399
+ JSON.stringify(Object.keys(request.expectedDocuments).sort()) ||
400
+ Object.entries(receipt.batch.baselines).some(([id, baseline]) => {
401
+ const file = receipt.batch.files.find((file) => file.documentId === id), revision = snapshot.revisions[id];
402
+ return (snapshot.definition.documents[id]?.path !== baseline.path ||
403
+ snapshot.documentHashes[id] !== (file?.contentHash ?? baseline.contentHash) ||
404
+ (file
405
+ ? revision !== baseline.revision && revision !== file.revision
406
+ : revision !== baseline.revision));
407
+ }))
408
+ return undefined;
409
+ const results = receipt.batch.files.map(({ documentId, path, contentHash, size, revision }) => ({
410
+ documentId,
411
+ path,
412
+ contentHash,
413
+ size,
414
+ revision,
415
+ }));
416
+ for (const item of results)
417
+ snapshot.revisions[item.documentId] = item.revision;
418
+ return { ...results[0], results, snapshot };
419
+ }
420
+ const receipt = await readLevelEditorHistory(root, context, request.requestId);
421
+ if (!receipt || receipt.requestDigest !== levelEditorHistoryRequestDigest(request))
422
+ return undefined;
423
+ const snapshot = await readWorkspaceLevelEditor(root, revisions);
424
+ if (snapshot.status !== 'ready' ||
425
+ !snapshot.definition ||
426
+ snapshot.definition.editorId !== request.editorId ||
427
+ snapshot.definition.dataContractVersion !== request.dataContractVersion ||
428
+ snapshot.definition.documents[request.documentId]?.path !== receipt.path)
429
+ return undefined;
430
+ for (const key of [
431
+ 'sourceFingerprint',
432
+ 'bindingContractHash',
433
+ 'schemaHash',
434
+ 'executionContractHash',
435
+ ])
436
+ if (snapshot[key] !== request[key])
437
+ return undefined;
438
+ const revision = snapshot.revisions[request.documentId];
439
+ if (snapshot.documentHashes[request.documentId] !== receipt.contentHash ||
440
+ (revision !== request.expectedRevision && revision !== request.expectedRevision + 1) ||
441
+ levelEditorCanonicalJson(snapshot.documents[request.documentId]) !==
442
+ levelEditorCanonicalJson(receipt.after))
443
+ return undefined;
444
+ await writeLevelEditorHistory(root, context, { ...receipt, phase: 'confirmed' });
445
+ const nextRevision = request.expectedRevision + 1;
446
+ snapshot.revisions[request.documentId] = nextRevision;
447
+ return {
448
+ path: receipt.path,
449
+ contentHash: receipt.contentHash,
450
+ size: receipt.size,
451
+ revision: nextRevision,
452
+ snapshot,
453
+ };
454
+ }
@@ -8,8 +8,7 @@ import { excludeFromGit } from './dependencies.js';
8
8
  import { ensureSharedDirectory, openSharedFile, shareIfNeeded } from './shared-write.js';
9
9
  import { acquireParameterSkillLease, readParameterSkillTransactionFile, writeParameterSkillTransaction } from './parameter-initialization-skill-transaction.js';
10
10
  export const PARAMETER_INITIALIZATION_SKILL = 'playcraft-runtime-parameters';
11
- const sourceRoot = `.agents/skills/${PARAMETER_INITIALIZATION_SKILL}`;
12
- const skillRef = `builtin/${PARAMETER_INITIALIZATION_SKILL}`;
11
+ const profile = (name) => ({ sourceRoot: `.agents/skills/${name}`, skillRef: `builtin/${name}` });
13
12
  const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
14
13
  async function safeDirectory(root, path) {
15
14
  let current = root;
@@ -48,7 +47,8 @@ async function optionalFile(path) {
48
47
  throw error;
49
48
  }
50
49
  }
51
- async function installSource(root, files, token) {
50
+ async function installSource(root, files, token, name) {
51
+ const { sourceRoot } = profile(name);
52
52
  const directory = join(root, sourceRoot);
53
53
  const staging = await safeDirectory(root, `.playcraft/parameter-initialization-${token}`);
54
54
  const temporary = join(staging, 'source');
@@ -98,9 +98,10 @@ function snapshot(originalDeclaration, originalLock) {
98
98
  const declaration = { schemaVersion: 1, entries: [] };
99
99
  return { declaration, lock: { lockVersion: 3, declarationHash: declarationDigest(declaration), entries: [] } };
100
100
  }
101
- function selectedSnapshot(previous, templateHash, artifactHash) {
101
+ function selectedSnapshot(previous, templateHash, artifactHash, name) {
102
+ const { sourceRoot, skillRef } = profile(name);
102
103
  const next = structuredClone(previous);
103
- const matches = next.declaration.entries.filter(entry => entry.entryKey === PARAMETER_INITIALIZATION_SKILL
104
+ const matches = next.declaration.entries.filter(entry => entry.entryKey === name
104
105
  || entry.source.skillRef === skillRef || entry.source.kind === 'repository' && entry.source.root === sourceRoot);
105
106
  if (matches.length > 1)
106
107
  throw new Error('workspace_skills_parameter_conflict');
@@ -111,24 +112,52 @@ function selectedSnapshot(previous, templateHash, artifactHash) {
111
112
  if (existing.source.kind !== 'repository' || existing.source.root !== sourceRoot
112
113
  || existing.source.skillRef !== skillRef || existing.surface !== 'sandbox-agent' || existing.projectFiles?.length
113
114
  || locked.dependencies.length || locked.revision.kind !== 'source' || locked.sourceTreeHash !== templateHash
114
- || locked.revision.artifactHash !== artifactHash)
115
+ || (next.lock.lockVersion === 3 && locked.revision.artifactHash !== artifactHash)
116
+ || (next.lock.lockVersion === 4 && locked.revision.contentHash !== templateHash))
115
117
  throw new Error('workspace_skills_parameter_conflict');
116
118
  selected = existing;
117
119
  selected.enabled = true;
118
120
  }
119
121
  else {
120
- selected = { entryKey: PARAMETER_INITIALIZATION_SKILL, source: { kind: 'repository', root: sourceRoot, skillRef },
122
+ selected = { entryKey: name, source: { kind: 'repository', root: sourceRoot, skillRef },
121
123
  enabled: true, surface: 'sandbox-agent' };
122
124
  const locked = { entryKey: selected.entryKey, sourceTreeHash: templateHash,
123
- revision: { kind: 'source', skillRef, sourceRevision: `src-v1-sha256:${templateHash}`, artifactHash },
125
+ revision: { kind: 'source', skillRef, sourceRevision: `src-v1-sha256:${templateHash}`, artifactHash,
126
+ ...(next.lock.lockVersion === 4 ? { contentHash: templateHash } : {}) },
124
127
  dependencies: [], dependencyLockHash: dependencyDigest([], []) };
125
128
  next.declaration.entries.push(selected);
126
- next.lock.entries.push(locked);
129
+ next.lock = { ...next.lock, entries: [...next.lock.entries, locked] };
127
130
  }
128
131
  next.lock.declarationHash = declarationDigest(next.declaration);
129
132
  validateProjectSkillsSnapshot(next.declaration, next.lock);
130
133
  return { next, selected, sourceAdded: !existing };
131
134
  }
135
+ async function preservedSelectionSource(root, previous, name) {
136
+ const { sourceRoot, skillRef } = profile(name);
137
+ const matches = previous.declaration.entries.filter(entry => entry.entryKey === name || entry.source.skillRef === skillRef
138
+ || entry.source.kind === 'repository' && entry.source.root === sourceRoot);
139
+ if (matches.length > 1)
140
+ throw new Error('workspace_skills_parameter_conflict');
141
+ const selected = matches[0];
142
+ if (!selected)
143
+ return undefined;
144
+ const locked = previous.lock.entries.find(entry => entry.entryKey === selected.entryKey);
145
+ if (!locked || selected.source.kind !== 'repository' || selected.source.root !== sourceRoot || selected.source.skillRef !== skillRef
146
+ || selected.surface !== 'sandbox-agent' || selected.projectFiles?.length || locked.dependencies.length
147
+ || locked.revision.kind !== 'source' || !locked.sourceTreeHash
148
+ || locked.revision.sourceRevision !== `src-v1-sha256:${locked.sourceTreeHash}`)
149
+ throw new Error('workspace_skills_parameter_conflict');
150
+ const source = await readProjectSkillSource(root, selected);
151
+ if (source.sourceTreeHash !== locked.sourceTreeHash)
152
+ throw new Error('workspace_skills_source_changed');
153
+ const archive = createCanonicalBundleArchive(source.files.map(file => ({ path: file.path, data: Buffer.from(file.content) })));
154
+ const artifactHash = digest(archive);
155
+ if (previous.lock.lockVersion === 3 && artifactHash !== locked.revision.artifactHash)
156
+ throw new Error('workspace_skills_digest_mismatch');
157
+ if (previous.lock.lockVersion === 4 && source.sourceTreeHash !== locked.revision.contentHash)
158
+ throw new Error('workspace_skills_digest_mismatch');
159
+ return { source, archive, artifactHash };
160
+ }
132
161
  async function cleanStaging(root, transaction, files, archive) {
133
162
  const directory = join(root, '.playcraft', `parameter-initialization-${transaction.owner.token}`);
134
163
  const expected = new Map(files.map(file => [`source/${file.path}`, Buffer.from(file.content)]));
@@ -168,14 +197,15 @@ async function cleanStaging(root, transaction, files, archive) {
168
197
  }
169
198
  }
170
199
  }
171
- async function recoverTransaction(root, transaction, template, artifactHash, archive) {
200
+ async function recoverTransaction(root, transaction, template, artifactHash, archive, name) {
201
+ const { sourceRoot } = profile(name);
172
202
  if (transaction.templateHash !== template.sourceTreeHash || transaction.artifactHash !== artifactHash)
173
203
  throw new Error('workspace_skills_parameter_recovery_conflict');
174
204
  const originalDeclaration = transaction.originalDeclaration === null ? undefined : Buffer.from(transaction.originalDeclaration, 'base64');
175
205
  if (originalDeclaration && originalDeclaration.toString('base64') !== transaction.originalDeclaration)
176
206
  throw new Error('workspace_skills_parameter_recovery_conflict');
177
207
  const previous = snapshot(originalDeclaration, transaction.originalLock);
178
- const { next, selected, sourceAdded } = selectedSnapshot(previous, template.sourceTreeHash, artifactHash);
208
+ const { next, selected, sourceAdded } = selectedSnapshot(previous, template.sourceTreeHash, artifactHash, name);
179
209
  if (sourceAdded !== transaction.sourceAdded || `${canonicalProjectSkillsJson(next.declaration)}\n` !== transaction.nextDeclaration
180
210
  || `${canonicalProjectSkillsJson(next.lock)}\n` !== transaction.nextLock)
181
211
  throw new Error('workspace_skills_parameter_recovery_conflict');
@@ -212,22 +242,24 @@ async function recoverTransaction(root, transaction, template, artifactHash, arc
212
242
  await cleanStaging(root, transaction, template.files, archive);
213
243
  }
214
244
  /** A deliberate initialization selects one versioned project skill without synchronizing other sources. */
215
- export async function ensureParameterInitializationSkill(root) {
245
+ export async function ensureParameterInitializationSkill(root, name = PARAMETER_INITIALIZATION_SKILL, options = {}) {
246
+ const { sourceRoot, skillRef } = profile(name);
216
247
  const rootStat = await lstat(root);
217
248
  if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
218
249
  throw new Error('workspace_skills_invalid');
219
- const templateEntry = { entryKey: PARAMETER_INITIALIZATION_SKILL,
220
- source: { kind: 'repository', root: `project-template/.claude/skills/${PARAMETER_INITIALIZATION_SKILL}`, skillRef },
250
+ const templateEntry = { entryKey: name,
251
+ source: { kind: 'repository', root: `project-template/.claude/skills/${name}`, skillRef },
221
252
  enabled: true, surface: 'sandbox-agent' };
222
- const template = await readProjectSkillSource(resolveCliPackageRoot(), templateEntry);
223
- const archive = createCanonicalBundleArchive(template.files.map(file => ({ path: file.path, data: Buffer.from(file.content) })));
224
- const artifactHash = digest(archive);
253
+ const bundledTemplate = await readProjectSkillSource(resolveCliPackageRoot(), templateEntry);
254
+ const bundledArchive = createCanonicalBundleArchive(bundledTemplate.files.map(file => ({ path: file.path, data: Buffer.from(file.content) })));
255
+ const bundledArtifactHash = digest(bundledArchive);
225
256
  await excludeFromGit(root, '/.playcraft/skills/');
226
257
  await excludeFromGit(root, '/.playcraft/skill-artifacts/');
227
258
  await excludeFromGit(root, '/.playcraft/skills-write.lock');
228
259
  await excludeFromGit(root, '/.playcraft/parameter-initialization-*');
229
260
  const management = await safeDirectory(root, '.playcraft');
230
- const lease = await acquireParameterSkillLease(management, transaction => recoverTransaction(root, transaction, template, artifactHash, archive));
261
+ const lease = await acquireParameterSkillLease(management, transaction => recoverTransaction(root, transaction, bundledTemplate, bundledArtifactHash, bundledArchive, name));
262
+ let template = bundledTemplate, archive = bundledArchive, artifactHash = bundledArtifactHash;
231
263
  const staged = [];
232
264
  let ownedSource = false, committed = false, cleanupAllowed = true;
233
265
  let removeTransaction;
@@ -236,7 +268,15 @@ export async function ensureParameterInitializationSkill(root) {
236
268
  const originalDeclaration = await optionalFile(declarationPath);
237
269
  const originalLock = await readProjectSkillsLockFile(root);
238
270
  const previous = snapshot(originalDeclaration, originalLock);
239
- const { next, selected, sourceAdded } = selectedSnapshot(previous, template.sourceTreeHash, artifactHash);
271
+ if (options.preserveExisting) {
272
+ const preserved = await preservedSelectionSource(root, previous, name);
273
+ if (preserved) {
274
+ template = preserved.source;
275
+ archive = preserved.archive;
276
+ artifactHash = preserved.artifactHash;
277
+ }
278
+ }
279
+ const { next, selected, sourceAdded } = selectedSnapshot(previous, template.sourceTreeHash, artifactHash, name);
240
280
  if (!sourceAdded) {
241
281
  if ((await readProjectSkillSource(root, selected)).sourceTreeHash !== template.sourceTreeHash)
242
282
  throw new Error('workspace_skills_source_changed');
@@ -249,7 +289,7 @@ export async function ensureParameterInitializationSkill(root) {
249
289
  }
250
290
  else {
251
291
  for (const source of PROJECT_SKILL_SOURCE_ROOTS) {
252
- const path = join(root, source, PARAMETER_INITIALIZATION_SKILL);
292
+ const path = join(root, source, name);
253
293
  const exists = await lstat(path).then(() => true, error => {
254
294
  if (error.code === 'ENOENT')
255
295
  return false;
@@ -265,7 +305,7 @@ export async function ensureParameterInitializationSkill(root) {
265
305
  originalDeclaration: originalDeclaration?.toString('base64') ?? null, originalLock,
266
306
  nextDeclaration: `${canonicalProjectSkillsJson(next.declaration)}\n`, nextLock: `${canonicalProjectSkillsJson(next.lock)}\n` });
267
307
  if (sourceAdded) {
268
- await installSource(root, template.files, lease.owner.token);
308
+ await installSource(root, template.files, lease.owner.token, name);
269
309
  ownedSource = true;
270
310
  }
271
311
  await preserveArtifact(root, artifactHash, archive, lease.owner.token);
@@ -313,7 +353,7 @@ export async function ensureParameterInitializationSkill(root) {
313
353
  }
314
354
  finally {
315
355
  if (cleanupAllowed && ownedSource && !committed) {
316
- const entry = { entryKey: PARAMETER_INITIALIZATION_SKILL,
356
+ const entry = { entryKey: name,
317
357
  source: { kind: 'repository', root: sourceRoot }, enabled: true, surface: 'sandbox-agent' };
318
358
  if ((await readProjectSkillSource(root, entry)).sourceTreeHash !== template.sourceTreeHash)
319
359
  throw new Error('workspace_skills_source_changed');
@@ -5,7 +5,7 @@ import { z } from 'zod';
5
5
  import { workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { ParameterAgentError, parameterDigest, readParameterAgentFile } from './parameter-agent-inputs.js';
7
7
  const reportPath = '.playcraft-editor-preview/release-report.json';
8
- const marker = /runtime\.parameters\.|playcraft-preview-storage:|__playcraft_preview_context__/i;
8
+ const marker = /runtime\.(?:parameters|levelEditor|level-editor)\.|playcraft-preview-storage:|__playcraft_(?:preview|level_editor)_context__|playcraft-level-editor-profile/i;
9
9
  const slash = (value) => value.replaceAll('\\', '/').replaceAll('\0', '').split('?')[0];
10
10
  const relativePath = z.string().refine(value => workspaceRelativePathSchema.safeParse(value).success);
11
11
  const reportSchema = z.object({