@playcraft/cli 0.1.66 → 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 (101) 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/project-skills/builtin-import.js +69 -22
  11. package/dist/project-skills/commands.js +11 -2
  12. package/dist/project-skills/lifecycle.js +1 -1
  13. package/dist/project-skills/local-store.js +90 -31
  14. package/dist/project-skills/messages.js +16 -0
  15. package/dist/project-skills/migrate-lock.js +166 -0
  16. package/dist/project-skills/remote-cache.js +36 -5
  17. package/dist/utils/agent-api-client.js +18 -1
  18. package/dist/utils/agent-generation-provenance.js +8 -2
  19. package/dist/utils/audio-export.js +249 -0
  20. package/dist/utils/audio-processor.js +22 -99
  21. package/dist/utils/execution-context-bridge.js +28 -0
  22. package/dist/workspace-runtime/batch/control.js +13 -2
  23. package/dist/workspace-runtime/batch/generate.js +43 -2
  24. package/dist/workspace-runtime/batch/prepare.js +4 -0
  25. package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
  26. package/dist/workspace-runtime/batch/source-capture.js +6 -8
  27. package/dist/workspace-runtime/codex/app-server.js +26 -2
  28. package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
  29. package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
  30. package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
  31. package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
  32. package/dist/workspace-runtime/codex/model-broker.js +15 -2
  33. package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
  34. package/dist/workspace-runtime/previews/finalize.js +1 -1
  35. package/dist/workspace-runtime/previews/output.js +4 -1
  36. package/dist/workspace-runtime/previews/publish.js +5 -2
  37. package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
  38. package/dist/workspace-runtime/serve.js +6 -0
  39. package/dist/workspace-runtime/server/accept-operation.js +85 -13
  40. package/dist/workspace-runtime/server/audio-assets.js +211 -0
  41. package/dist/workspace-runtime/server/audio-export.js +136 -0
  42. package/dist/workspace-runtime/server/directory-access.js +222 -67
  43. package/dist/workspace-runtime/server/dispatch.js +252 -29
  44. package/dist/workspace-runtime/server/http.js +97 -3
  45. package/dist/workspace-runtime/server/operation-record.js +1 -1
  46. package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
  47. package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
  48. package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
  49. package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
  50. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
  51. package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
  52. package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
  53. package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
  54. package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
  55. package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
  56. package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
  57. package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
  58. package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
  59. package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
  60. package/dist/workspace-runtime/workspaces/files.js +3 -5
  61. package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
  62. package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
  63. package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
  64. package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
  65. package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
  66. package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
  67. package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
  68. package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
  69. package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
  71. package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
  72. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
  73. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
  74. package/dist/workspace-runtime/workspaces/skills.js +48 -13
  75. package/package.json +6 -4
  76. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  77. package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
  78. package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
  79. package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
  80. package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
  81. package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
  82. package/project-template/vite.config.ts +1 -1
  83. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  84. package/project-template-v2/.playcraft/skills.lock.json +9 -2
  85. package/project-template-v2/vite.config.ts +1 -1
  86. package/project-templates/empty/AGENTS.md +2 -0
  87. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  88. package/project-templates/phaser/vite.config.ts +1 -1
  89. package/project-templates/playable-template-phaser/vite.config.ts +1 -1
  90. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  91. package/project-templates/threejs/vite.config.ts +1 -1
  92. package/templates/preview-runtime/v1/README.md +12 -0
  93. package/templates/preview-runtime/v1/build-preview.mjs +17 -8
  94. package/templates/preview-runtime/v1/manifest.json +9 -6
  95. package/templates/preview-runtime/v1/release-guard.mjs +1 -1
  96. package/templates/preview-runtime/v1/runtime.d.mts +1 -3
  97. package/templates/preview-runtime/v1/runtime.d.ts +1 -3
  98. package/templates/preview-runtime/v1/runtime.mjs +1142 -257
  99. package/templates/preview-runtime/v1/types/host.d.ts +29 -0
  100. package/templates/preview-runtime/v1/types/index.d.ts +6 -0
  101. package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
@@ -27,12 +27,34 @@ async function change(directory, action) {
27
27
  await rm(lease);
28
28
  }
29
29
  }
30
- async function writeSnapshot(directory, snapshot) {
30
+ async function snapshotBytes(directory) {
31
+ const declaration = await readFile(join(directory, PROJECT_SKILLS_DECLARATION_PATH))
32
+ .catch((error) => { if (error.code === 'ENOENT')
33
+ return null; throw error; });
34
+ return { declaration, lock: await readProjectSkillsLockFile(directory) };
35
+ }
36
+ async function writeSnapshot(directory, snapshot, expected) {
31
37
  validateProjectSkillsSnapshot(snapshot.declaration, snapshot.lock);
32
- for (const [file, value] of [[PROJECT_SKILLS_DECLARATION_PATH, snapshot.declaration], [PROJECT_SKILLS_LOCK_PATH, snapshot.lock]]) {
33
- const target = join(directory, file), temporary = `${target}.${randomUUID()}.tmp`;
34
- await writeFile(temporary, `${canonicalProjectSkillsJson(value)}\n`, { flag: 'wx', mode: 0o644 });
35
- await rename(temporary, target);
38
+ const staged = [];
39
+ try {
40
+ for (const [file, value] of [[PROJECT_SKILLS_DECLARATION_PATH, snapshot.declaration], [PROJECT_SKILLS_LOCK_PATH, snapshot.lock]]) {
41
+ const target = join(directory, file), temporary = `${target}.${randomUUID()}.tmp`;
42
+ staged.push({ target, temporary });
43
+ await writeFile(temporary, `${canonicalProjectSkillsJson(value)}\n`, { flag: 'wx', mode: 0o644 });
44
+ }
45
+ if (expected) {
46
+ const current = await snapshotBytes(directory);
47
+ if ((current.declaration === null) !== (expected.declaration === null)
48
+ || current.declaration && expected.declaration && !current.declaration.equals(expected.declaration)
49
+ || current.lock !== expected.lock)
50
+ throw new Error('skills_revision_conflict');
51
+ }
52
+ for (const { temporary, target } of staged)
53
+ await rename(temporary, target);
54
+ }
55
+ finally {
56
+ for (const { temporary } of staged)
57
+ await rm(temporary, { force: true });
36
58
  }
37
59
  }
38
60
  /** Initializes an unmanaged checkout with one complete source and release snapshot under the existing writer lease. */
@@ -55,7 +77,7 @@ export async function initializeLocalProjectSkills(directory, cache, options) {
55
77
  let configurationWriteStarted = false;
56
78
  try {
57
79
  await options.prepareSources?.();
58
- const snapshot = buildLocalProjectSkillsSnapshot(await inspectProjectSkills(directory));
80
+ const snapshot = buildLocalProjectSkillsSnapshot(await inspectProjectSkills(directory), undefined, { lockVersion: options.platform?.lock.lockVersion ?? 3 });
59
81
  for (const entry of snapshot.declaration.entries)
60
82
  if (entry.source.kind === 'repository' && options.enabledRoots.has(entry.source.root))
61
83
  entry.enabled = true;
@@ -64,7 +86,9 @@ export async function initializeLocalProjectSkills(directory, cache, options) {
64
86
  if (platform.declaration.entries.some(entry => entry.source.kind !== 'registry'))
65
87
  throw new Error('workspace_skills_invalid');
66
88
  snapshot.declaration.entries.push(...platform.declaration.entries);
67
- snapshot.lock.entries.push(...platform.lock.entries);
89
+ if (snapshot.lock.lockVersion !== platform.lock.lockVersion)
90
+ throw new Error('workspace_skills_lock_version_mismatch');
91
+ snapshot.lock = { ...snapshot.lock, entries: [...snapshot.lock.entries, ...platform.lock.entries] };
68
92
  }
69
93
  snapshot.lock.declarationHash = declarationDigest(snapshot.declaration);
70
94
  validateProjectSkillsSnapshot(snapshot.declaration, snapshot.lock);
@@ -73,13 +97,16 @@ export async function initializeLocalProjectSkills(directory, cache, options) {
73
97
  for (const artifact of [...snapshot.artifacts, ...(options.platform?.artifacts ?? [])]) {
74
98
  if (createHash('sha256').update(artifact.archive).digest('hex') !== artifact.artifactHash)
75
99
  throw new Error('workspace_skills_digest_mismatch');
76
- const target = join(cache, `${artifact.artifactHash}.tar.gz`), temporary = `${target}.${randomUUID()}.tmp`;
77
- try {
78
- await writeFile(temporary, artifact.archive, { flag: 'wx', mode: 0o600 });
79
- await rename(temporary, target);
80
- }
81
- finally {
82
- await rm(temporary, { force: true });
100
+ const contentHash = 'contentHash' in artifact ? artifact.contentHash : undefined;
101
+ for (const name of [`${artifact.artifactHash}.tar.gz`, ...(contentHash ? [`content-${contentHash}.tar.gz`] : [])]) {
102
+ const target = join(cache, name), temporary = `${target}.${randomUUID()}.tmp`;
103
+ try {
104
+ await writeFile(temporary, artifact.archive, { flag: 'wx', mode: 0o600 });
105
+ await rename(temporary, target);
106
+ }
107
+ finally {
108
+ await rm(temporary, { force: true });
109
+ }
83
110
  }
84
111
  }
85
112
  configurationWriteStarted = true;
@@ -107,8 +134,9 @@ export async function initializeLocalProjectSkills(directory, cache, options) {
107
134
  }
108
135
  });
109
136
  }
110
- export async function syncLocalProjectSkills(directory, cache = projectSkillsCache()) {
137
+ export async function syncLocalProjectSkills(directory, cache = projectSkillsCache(), options = {}) {
111
138
  return change(directory, async () => {
139
+ const original = await snapshotBytes(directory);
112
140
  let previous;
113
141
  try {
114
142
  previous = await readProjectSkillsLockFile(directory) ?? undefined;
@@ -117,7 +145,7 @@ export async function syncLocalProjectSkills(directory, cache = projectSkillsCac
117
145
  if (error.code !== 'ENOENT')
118
146
  throw error;
119
147
  }
120
- const snapshot = buildLocalProjectSkillsSnapshot(await inspectProjectSkills(directory), previous);
148
+ const snapshot = buildLocalProjectSkillsSnapshot(await inspectProjectSkills(directory), previous, options);
121
149
  await mkdir(cache, { recursive: true, mode: 0o700 });
122
150
  for (const artifact of snapshot.artifacts) {
123
151
  const target = join(cache, `${artifact.artifactHash}.tar.gz`);
@@ -125,18 +153,19 @@ export async function syncLocalProjectSkills(directory, cache = projectSkillsCac
125
153
  await writeFile(temporary, artifact.archive, { flag: 'wx', mode: 0o600 });
126
154
  await rename(temporary, target);
127
155
  }
128
- await writeSnapshot(directory, snapshot);
156
+ await writeSnapshot(directory, snapshot, original);
129
157
  return { declaration: snapshot.declaration, lock: snapshot.lock };
130
158
  });
131
159
  }
132
160
  export async function enableLocalProjectSkill(directory, entryKey, enabled) {
133
161
  return change(directory, async () => {
162
+ const original = await snapshotBytes(directory);
134
163
  const previous = await readLocalProjectSkillSnapshot(directory);
135
164
  const next = setProjectSkillEnabled(previous.declaration, previous.lock, entryKey, enabled);
136
165
  // Disabled required dependencies are shown immediately, before changing either file.
137
166
  for (const surface of ['portal-agent', 'sandbox-agent', 'build-time'])
138
167
  effectiveProjectSkills(next.declaration, next.lock, surface);
139
- await writeSnapshot(directory, next);
168
+ await writeSnapshot(directory, next, original);
140
169
  return next;
141
170
  });
142
171
  }
@@ -150,27 +179,54 @@ export async function installFrozenProjectSkills(directory, cache = projectSkill
150
179
  for (const entry of effective) {
151
180
  const cached = join(cache, `${entry.revision.artifactHash}.tar.gz`);
152
181
  let archive;
182
+ let contentAddressed = false;
153
183
  try {
154
184
  archive = await readFile(cached);
155
185
  }
156
186
  catch (error) {
157
- if (error.code !== 'ENOENT' || entry.revision.kind !== 'source')
187
+ if (error.code !== 'ENOENT')
158
188
  throw error;
159
- const declaration = snapshot.declaration.entries.find(item => item.entryKey === entry.entryKey);
160
- const source = await readProjectSkillSource(directory, declaration);
161
- if (source.sourceTreeHash !== entry.sourceTreeHash)
162
- throw new Error(`Source digest mismatch: ${entry.entryKey}`);
163
- archive = createCanonicalBundleArchive(source.files.map(file => ({ path: file.path, data: Buffer.from(file.content) })));
164
- if (createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
165
- throw new Error(`Artifact digest mismatch: ${entry.entryKey}`);
166
- await writeFile(cached, archive, { flag: 'wx', mode: 0o600 }).catch(error => { if (error.code !== 'EEXIST')
167
- throw error; });
189
+ if (snapshot.lock.lockVersion === 4) {
190
+ try {
191
+ archive = await readFile(join(cache, `content-${entry.revision.contentHash}.tar.gz`));
192
+ contentAddressed = true;
193
+ }
194
+ catch (contentError) {
195
+ if (contentError.code !== 'ENOENT')
196
+ throw contentError;
197
+ }
198
+ }
199
+ if (!contentAddressed) {
200
+ if (entry.revision.kind !== 'source')
201
+ throw error;
202
+ const declaration = snapshot.declaration.entries.find(item => item.entryKey === entry.entryKey);
203
+ const source = await readProjectSkillSource(directory, declaration);
204
+ if (source.sourceTreeHash !== entry.sourceTreeHash)
205
+ throw new Error(`Source digest mismatch: ${entry.entryKey}`);
206
+ if (snapshot.lock.lockVersion === 4) {
207
+ if (source.sourceTreeHash !== entry.revision.contentHash)
208
+ throw new Error(`Source digest mismatch: ${entry.entryKey}`);
209
+ for (const file of source.files) {
210
+ const target = join(generation, entry.entryKey, file.path);
211
+ await mkdir(join(target, '..'), { recursive: true });
212
+ await writeFile(target, file.content, { flag: 'wx', mode: 0o444 });
213
+ }
214
+ continue;
215
+ }
216
+ archive = createCanonicalBundleArchive(source.files.map(file => ({ path: file.path, data: Buffer.from(file.content) })));
217
+ if (createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
218
+ throw new Error(`Artifact digest mismatch: ${entry.entryKey}`);
219
+ await writeFile(cached, archive, { flag: 'wx', mode: 0o600 }).catch(error => { if (error.code !== 'EEXIST')
220
+ throw error; });
221
+ }
168
222
  }
169
- if (createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
223
+ if (!contentAddressed && createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
170
224
  throw new Error(`Artifact digest mismatch: ${entry.entryKey}`);
171
225
  const extracted = extractBundleArchive(archive);
172
226
  if (extracted.issues.length)
173
227
  throw new Error(JSON.stringify(extracted.issues));
228
+ if (snapshot.lock.lockVersion === 4 && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== entry.revision.contentHash)
229
+ throw new Error(`Content digest mismatch: ${entry.entryKey}`);
174
230
  if (entry.revision.kind === 'source' && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== entry.sourceTreeHash)
175
231
  throw new Error(`Source digest mismatch: ${entry.entryKey}`);
176
232
  for (const file of extracted.files) {
@@ -193,15 +249,18 @@ export async function explainLocalProjectSkills(directory) {
193
249
  export async function adoptServerProjectSkillSnapshot(directory, expected, response, checkpoint) {
194
250
  const next = validateProjectSkillsSnapshot(response.declaration, response.lock);
195
251
  const withoutIdentity = (snapshot) => ({
252
+ lockVersion: snapshot.lock.lockVersion,
196
253
  declaration: { ...snapshot.declaration, entries: snapshot.declaration.entries.map(entry => ({ ...entry,
197
254
  source: entry.source.kind === 'repository' ? { kind: entry.source.kind, root: entry.source.root } : entry.source })) },
198
255
  entries: snapshot.lock.entries.map(entry => ({ entryKey: entry.entryKey, sourceTreeHash: entry.sourceTreeHash,
199
256
  dependencies: entry.dependencies,
200
- revision: entry.revision.kind === 'source' ? { ...entry.revision, skillRef: undefined } : entry.revision })),
257
+ revision: { ...entry.revision, ...(entry.revision.kind === 'source' ? { skillRef: undefined } : {}),
258
+ ...(snapshot.lock.lockVersion === 4 ? { artifactHash: undefined } : {}) } })),
201
259
  });
202
260
  if (canonicalProjectSkillsJson(withoutIdentity(expected)) !== canonicalProjectSkillsJson(withoutIdentity(next)))
203
261
  throw new Error(t('reconcileChanged'));
204
262
  return change(directory, async () => {
263
+ const original = await snapshotBytes(directory);
205
264
  const current = await readLocalProjectSkillSnapshot(directory);
206
265
  if (canonicalProjectSkillsJson(current) !== canonicalProjectSkillsJson(expected))
207
266
  throw new Error(t('reconcileChanged'));
@@ -215,7 +274,7 @@ export async function adoptServerProjectSkillSnapshot(directory, expected, respo
215
274
  checkpoint();
216
275
  const changed = canonicalProjectSkillsJson(current) !== canonicalProjectSkillsJson(next);
217
276
  if (changed)
218
- await writeSnapshot(directory, next);
277
+ await writeSnapshot(directory, next, original);
219
278
  return { changed };
220
279
  });
221
280
  }
@@ -1,5 +1,13 @@
1
1
  const messages = {
2
2
  en: {
3
+ migrateLockV4: 'Upgrade a v3 lock using verified original artifacts and retain a backup',
4
+ migrationMissingArchive: 'Cannot upgrade: the exact locked artifact is missing',
5
+ migrationInvalidArchive: 'Cannot upgrade: the locked artifact is invalid or changed',
6
+ migrationSourceChanged: 'Cannot upgrade: the locked source files changed',
7
+ migrationConcurrentChanged: 'Concurrent lock change detected; original file preserved for recovery',
8
+ lockVersion: 'Lock format for a new project: 3 or 4 (existing locks require explicit migration)',
9
+ invalidLockVersion: 'Lock format must be 3 or 4',
10
+ migrateLockRequired: 'The existing lock cannot be converted by sync; use skills project migrate-lock-v4.',
3
11
  importBuiltins: 'Import published platform skills in Cloud Workspace; preserve exact existing revisions',
4
12
  platformRequired: 'Use Cloud Workspace’s Skills panel to import published platform skills. Local template import is unavailable.',
5
13
  expectedDeclarationHash: 'Expected declaration digest from the current skill lock',
@@ -37,6 +45,14 @@ const messages = {
37
45
  changed: 'Source files have changed since the saved revision',
38
46
  },
39
47
  zh: {
48
+ migrateLockV4: '使用已核验的原始技能包升级 v3 锁并保留备份',
49
+ migrationMissingArchive: '无法升级:缺少锁定的原始技能包',
50
+ migrationInvalidArchive: '无法升级:锁定的技能包无效或已更改',
51
+ migrationSourceChanged: '无法升级:锁定的技能源文件已更改',
52
+ migrationConcurrentChanged: '检测到技能锁并发变更,原文件已保留供恢复',
53
+ lockVersion: '新项目的技能锁格式:3 或 4(已有锁需要显式迁移)',
54
+ invalidLockVersion: '技能锁格式必须是 3 或 4',
55
+ migrateLockRequired: '不能通过 sync 转换已有技能锁,请使用 skills project migrate-lock-v4。',
40
56
  importBuiltins: '在云工作区导入已发布平台技能,保留已有精确修订',
41
57
  platformRequired: '请在云工作区的技能面板导入已发布平台技能;本地模板导入已停用。',
42
58
  expectedDeclarationHash: '当前技能锁中的声明摘要',
@@ -0,0 +1,166 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { link, lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { canonicalProjectSkillsJson, dependencyDigest, extractBundleArchive, readProjectSkillSource, readProjectSkillsLockFile, sourceTreeDigest, validateProjectSkillsSnapshot, PROJECT_SKILLS_DECLARATION_PATH, PROJECT_SKILLS_LOCK_PATH, } from '@playcraft/common/project-skills';
6
+ import { projectSkillsCache } from './local-store.js';
7
+ import { projectSkillsMessage as t } from './messages.js';
8
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ const MAX_ARCHIVE = 80 * 1024 * 1024;
10
+ async function readExactArchive(path, expectedHash) {
11
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
12
+ if (error.code === 'ENOENT')
13
+ return undefined;
14
+ throw error;
15
+ });
16
+ if (!handle)
17
+ return undefined;
18
+ try {
19
+ const stat = await handle.stat();
20
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_ARCHIVE)
21
+ throw new Error(t('migrationInvalidArchive'));
22
+ const bytes = await handle.readFile();
23
+ if (bytes.length !== stat.size || sha256(bytes) !== expectedHash)
24
+ throw new Error(t('migrationInvalidArchive'));
25
+ return bytes;
26
+ }
27
+ finally {
28
+ await handle.close();
29
+ }
30
+ }
31
+ /** Upgrade only from verified v3 bytes. No release is replaced based on name and version alone. */
32
+ export async function migrateProjectSkillsLockV4(directory, cache = projectSkillsCache()) {
33
+ const management = join(directory, '.playcraft');
34
+ const managementStat = await lstat(management);
35
+ if (!managementStat.isDirectory() || managementStat.isSymbolicLink())
36
+ throw new Error('workspace_skills_invalid');
37
+ const lease = join(management, 'skills-write.lock');
38
+ await writeFile(lease, `${process.pid}\n`, { flag: 'wx', mode: 0o600 });
39
+ let temporary;
40
+ try {
41
+ const declarationPath = join(directory, PROJECT_SKILLS_DECLARATION_PATH);
42
+ const declarationStat = await lstat(declarationPath);
43
+ if (!declarationStat.isFile() || declarationStat.isSymbolicLink() || declarationStat.size > 1024 * 1024)
44
+ throw new Error('workspace_skills_invalid');
45
+ const declarationBytes = await readFile(declarationPath);
46
+ const lockPath = join(directory, PROJECT_SKILLS_LOCK_PATH);
47
+ const lockStat = await lstat(lockPath);
48
+ if (!lockStat.isFile() || lockStat.isSymbolicLink() || lockStat.nlink !== 1 || lockStat.size > 1024 * 1024)
49
+ throw new Error('workspace_skills_invalid');
50
+ const lockMode = lockStat.mode & 0o777;
51
+ const lockBytes = await readProjectSkillsLockFile(directory);
52
+ const { declaration, lock } = validateProjectSkillsSnapshot(declarationBytes.toString('utf8'), lockBytes);
53
+ if (lock.lockVersion === 4)
54
+ return { changed: false, lockVersion: 4 };
55
+ const artifactsDir = join(management, 'skill-artifacts');
56
+ await mkdir(artifactsDir, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
57
+ throw error; });
58
+ const artifactsStat = await lstat(artifactsDir);
59
+ if (!artifactsStat.isDirectory() || artifactsStat.isSymbolicLink())
60
+ throw new Error('workspace_skills_invalid');
61
+ const entries = [];
62
+ for (const entry of lock.entries) {
63
+ let contentHash;
64
+ if (entry.revision.kind === 'source') {
65
+ const declared = declaration.entries.find(item => item.entryKey === entry.entryKey);
66
+ const source = await readProjectSkillSource(directory, declared);
67
+ if (source.sourceTreeHash !== entry.sourceTreeHash)
68
+ throw new Error(`${t('migrationSourceChanged')}: ${entry.entryKey}`);
69
+ contentHash = source.sourceTreeHash;
70
+ }
71
+ else {
72
+ const name = `${entry.revision.artifactHash}.tar.gz`;
73
+ const archive = await readExactArchive(join(artifactsDir, name), entry.revision.artifactHash)
74
+ ?? await readExactArchive(join(cache, name), entry.revision.artifactHash);
75
+ if (!archive)
76
+ throw new Error(`${t('migrationMissingArchive')}: ${entry.entryKey}`);
77
+ const extracted = extractBundleArchive(archive);
78
+ if (extracted.issues.length || !extracted.files.some(file => file.path === 'SKILL.md'))
79
+ throw new Error(t('migrationInvalidArchive'));
80
+ contentHash = sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data })));
81
+ }
82
+ entries.push({ ...entry, revision: { ...entry.revision, contentHash } });
83
+ }
84
+ for (const entry of entries)
85
+ entry.dependencyLockHash = dependencyDigest(entries, entry.dependencies);
86
+ const next = validateProjectSkillsSnapshot(declaration, { ...lock, lockVersion: 4, entries }).lock;
87
+ const old = Buffer.from(lockBytes);
88
+ const backup = join(artifactsDir, `skills.lock.v3-${sha256(old)}.json`);
89
+ try {
90
+ await writeFile(backup, old, { flag: 'wx', mode: 0o600 });
91
+ }
92
+ catch (error) {
93
+ if (error.code !== 'EEXIST')
94
+ throw error;
95
+ const saved = await readExactArchive(backup, sha256(old));
96
+ if (!saved || !saved.equals(old))
97
+ throw new Error('skills_revision_conflict');
98
+ }
99
+ temporary = `${lockPath}.${randomUUID()}.tmp`;
100
+ const staged = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, lockMode);
101
+ try {
102
+ await staged.writeFile(`${canonicalProjectSkillsJson(next)}\n`);
103
+ // The process umask may remove group write access on creation.
104
+ await staged.chmod(lockMode);
105
+ }
106
+ finally {
107
+ await staged.close();
108
+ }
109
+ for (const declared of declaration.entries) {
110
+ if (declared.source.kind !== 'repository')
111
+ continue;
112
+ const source = await readProjectSkillSource(directory, declared);
113
+ if (source.sourceTreeHash !== lock.entries.find(item => item.entryKey === declared.entryKey)?.sourceTreeHash) {
114
+ throw new Error(`${t('migrationSourceChanged')}: ${declared.entryKey}`);
115
+ }
116
+ }
117
+ if (!(await readFile(declarationPath)).equals(declarationBytes) || await readProjectSkillsLockFile(directory) !== lockBytes) {
118
+ throw new Error('skills_revision_conflict');
119
+ }
120
+ const target = lockPath;
121
+ const recovery = join(artifactsDir, `skills.lock.v3-recovery-${randomUUID()}.json`);
122
+ // Reserve a unique destination; a cross-device rename fails before the original is moved.
123
+ await writeFile(recovery, '', { flag: 'wx', mode: 0o600 });
124
+ try {
125
+ await rename(target, recovery);
126
+ }
127
+ catch (error) {
128
+ await rm(recovery, { force: true });
129
+ throw error;
130
+ }
131
+ let published = false;
132
+ try {
133
+ // A noncooperative writer may have changed the inode after our previous comparison.
134
+ const moved = await readExactArchive(recovery, sha256(old));
135
+ const movedStat = await lstat(recovery);
136
+ if (!moved?.equals(old) || (movedStat.mode & 0o777) !== lockMode)
137
+ throw new Error('skills_revision_conflict');
138
+ // Linking is atomic and cannot overwrite a concurrently created target.
139
+ await link(temporary, target);
140
+ published = true;
141
+ }
142
+ catch (error) {
143
+ // Restore only into an absent target; always keep the moved inode for recovery.
144
+ try {
145
+ const saved = await lstat(recovery);
146
+ if (saved.isFile() && !saved.isSymbolicLink())
147
+ await link(recovery, target);
148
+ }
149
+ catch (restoreError) {
150
+ if (restoreError.code !== 'EEXIST')
151
+ throw restoreError;
152
+ }
153
+ throw new Error(`${t('migrationConcurrentChanged')}: ${recovery}`, { cause: error });
154
+ }
155
+ if (published)
156
+ await rm(temporary, { force: true });
157
+ temporary = undefined;
158
+ // Keep the moved inode: a process that opened the old file may still write to it later.
159
+ return { changed: true, lockVersion: 4, backup, recovery };
160
+ }
161
+ finally {
162
+ if (temporary)
163
+ await rm(temporary, { force: true });
164
+ await rm(lease, { force: true });
165
+ }
166
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, writeFile, rename, readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { extractBundleArchive, sourceTreeDigest } from '@playcraft/common/project-skills';
4
5
  import { PlayCraftHttpClient } from '../utils/playcraft-http-client.js';
5
6
  import { readRemixLocalMeta } from '../remix/local-git.js';
6
7
  import { readLocalProjectSkillSnapshot, projectSkillsCache } from './local-store.js';
@@ -15,15 +16,39 @@ export async function downloadLocalProjectSkillArtifacts(directory, cache = proj
15
16
  let downloaded = 0;
16
17
  for (const locked of snapshot.lock.entries) {
17
18
  const file = join(cache, `${locked.revision.artifactHash}.tar.gz`);
19
+ const contentHash = snapshot.lock.lockVersion === 4 ? locked.revision.contentHash : undefined;
18
20
  try {
19
- if (createHash('sha256').update(await readFile(file)).digest('hex') === locked.revision.artifactHash)
21
+ const bytes = await readFile(file);
22
+ if (createHash('sha256').update(bytes).digest('hex') === locked.revision.artifactHash) {
23
+ if (contentHash) {
24
+ const extracted = extractBundleArchive(bytes);
25
+ if (extracted.issues.length || sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== contentHash)
26
+ throw new Error(t('downloadMismatch'));
27
+ }
20
28
  continue;
29
+ }
21
30
  }
22
31
  catch (error) {
23
32
  if (error.code !== 'ENOENT')
24
33
  throw error;
25
34
  }
26
- const authorized = view.entries?.find(entry => entry.entryKey === locked.entryKey && entry.artifactHash === locked.revision.artifactHash);
35
+ const contentFile = contentHash ? join(cache, `content-${contentHash}.tar.gz`) : undefined;
36
+ if (contentFile) {
37
+ try {
38
+ const extracted = extractBundleArchive(await readFile(contentFile));
39
+ if (!extracted.issues.length && sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) === contentHash)
40
+ continue;
41
+ throw new Error(t('downloadMismatch'));
42
+ }
43
+ catch (error) {
44
+ if (error.code !== 'ENOENT')
45
+ throw error;
46
+ }
47
+ }
48
+ const authorized = view.entries?.find(entry => entry.entryKey === locked.entryKey && (contentHash
49
+ ? entry.skillRef === locked.revision.skillRef && entry.revisionKind === locked.revision.kind &&
50
+ (locked.revision.kind === 'release' ? entry.version === locked.revision.version : entry.sourceRevision === locked.revision.sourceRevision)
51
+ : entry.artifactHash === locked.revision.artifactHash));
27
52
  if (!authorized)
28
53
  throw new Error(t('downloadMismatch'));
29
54
  const response = await fetch(client.url(`/projects/${encodeURIComponent(meta.projectId)}/skills/versions/${authorized.versionId}/archive?${query}`), {
@@ -40,11 +65,17 @@ export async function downloadLocalProjectSkillArtifacts(directory, cache = proj
40
65
  parts.push(Buffer.from(chunk));
41
66
  }
42
67
  const archive = Buffer.concat(parts);
43
- if (createHash('sha256').update(archive).digest('hex') !== locked.revision.artifactHash)
68
+ if (!/^[a-f0-9]{64}$/.test(authorized.artifactHash) || createHash('sha256').update(archive).digest('hex') !== authorized.artifactHash)
44
69
  throw new Error(t('downloadMismatch'));
45
- const temporary = `${file}.${randomUUID()}.tmp`;
70
+ if (contentHash) {
71
+ const extracted = extractBundleArchive(archive);
72
+ if (extracted.issues.length || sourceTreeDigest(extracted.files.map(file => ({ path: file.path, content: file.data }))) !== contentHash)
73
+ throw new Error(t('downloadMismatch'));
74
+ }
75
+ const target = contentFile ?? file;
76
+ const temporary = `${target}.${randomUUID()}.tmp`;
46
77
  await writeFile(temporary, archive, { mode: 0o600, flag: 'wx' });
47
- await rename(temporary, file);
78
+ await rename(temporary, target);
48
79
  downloaded++;
49
80
  }
50
81
  return { downloaded };
@@ -11,7 +11,7 @@ import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
11
11
  import { AGENT_TOOL_OPERATION_PROTOCOL, AGENT_TOOL_OPERATION_HEADERS, AGENT_TOOL_OPERATION_CAPABILITIES_PATH, } from '@playcraft/common/agent-tools';
12
12
  import { beginToolOperation, findToolOperation } from './tool-operation-journal.js';
13
13
  import { businessEndpoint } from './environment-endpoints.js';
14
- import { PLATFORM_ROUTES } from '@playcraft/common/cloud-workspace/runtime';
14
+ import { PLATFORM_ROUTES, workspaceExecutionContextQuerySchema, workspaceExecutionContextReadSchema } from '@playcraft/common/cloud-workspace/runtime';
15
15
  import { cloudRuntimeCredentialHint, isWorkspaceGenerationPath, withGenerationWorkspaceScope } from './generation-workspace-scope.js';
16
16
  import { agentGenerationProvenanceHeaders } from './agent-generation-provenance.js';
17
17
  import { redactDiagnosticText } from './error-diagnostics.js';
@@ -245,6 +245,23 @@ export class AgentApiClient {
245
245
  }
246
246
  return res.json();
247
247
  }
248
+ /** Context reads are fresh authenticated queries and never durable mutation receipts. */
249
+ async readWorkspaceExecutionContext(scope, request, signal = AbortSignal.timeout(15_000)) {
250
+ const query = workspaceExecutionContextQuerySchema.parse(request);
251
+ const response = await fetch(this.toolsRequestUrl('/execution-context'), {
252
+ method: 'POST', redirect: 'error', signal,
253
+ headers: { ...this.authHeaders(), 'content-type': 'application/json' },
254
+ body: JSON.stringify({ workspaceId: scope.workspaceId, directoryName: scope.directoryName, request: query }),
255
+ });
256
+ if (!response.ok) {
257
+ const text = await response.text().catch(() => '');
258
+ const pending = pendingAccountActivationError(response.status, parseErrorPayload(text));
259
+ if (pending)
260
+ throw pending;
261
+ throw new Error(`execution_context_read_failed:${response.status}: ${responseErrorReason(text)}`);
262
+ }
263
+ return workspaceExecutionContextReadSchema.parse(await response.json());
264
+ }
248
265
  /** Stream authenticated tool content to a new file; credentials never follow redirects. */
249
266
  async download(path, destination, options) {
250
267
  const url = this.toolsRequestUrl(path);
@@ -13,7 +13,13 @@ export async function agentGenerationProvenanceHeaders(path, body, requestId, cl
13
13
  return {};
14
14
  }
15
15
  const assetCreate = normalized === '/workspace-assets' && payload?.action === 'create';
16
- if (!isWorkspaceGenerationPath(normalized) && !assetCreate && normalized !== '/create-batch')
16
+ const audioWrite = normalized === '/workspace-audio';
17
+ const audioRequest = payload?.request;
18
+ const audioRequestId = audioRequest && typeof audioRequest === 'object' && !Array.isArray(audioRequest)
19
+ ? audioRequest.requestId : undefined;
20
+ if (audioWrite && (typeof audioRequestId !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(audioRequestId)))
21
+ return {};
22
+ if (!isWorkspaceGenerationPath(normalized) && !assetCreate && !audioWrite && normalized !== '/create-batch')
17
23
  return {};
18
24
  const endpoint = process.env.PLAYCRAFT_AGENT_GENERATION_ENDPOINT;
19
25
  const capability = process.env.PLAYCRAFT_AGENT_GENERATION_CAPABILITY;
@@ -29,7 +35,7 @@ export async function agentGenerationProvenanceHeaders(path, body, requestId, cl
29
35
  signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
30
36
  headers: { 'content-type': 'application/json', authorization: `Bearer ${capability}` },
31
37
  body: JSON.stringify({ toolPath: `/agent/tools${normalized}`,
32
- toolRequestId: assetCreate ? payload.clientRequestId : requestId, payloadHash: hashOperationPayload(payload) }),
38
+ toolRequestId: audioWrite ? audioRequestId : assetCreate ? payload.clientRequestId : requestId, payloadHash: hashOperationPayload(payload) }),
33
39
  });
34
40
  if (!response.ok) {
35
41
  await response.body?.cancel();