@playcraft/cli 0.1.23 → 0.1.25

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 (65) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/playable.js +53 -0
  3. package/dist/commands/tools-3d.js +11 -0
  4. package/dist/commands/tools-batch.js +42 -14
  5. package/dist/commands/tools-generation.js +6 -0
  6. package/dist/commands/video.js +111 -0
  7. package/dist/index.js +29 -3
  8. package/dist/project-skills/builtin-import.js +225 -0
  9. package/dist/project-skills/commands.js +6 -1
  10. package/dist/project-skills/local-store.js +73 -1
  11. package/dist/project-skills/messages.js +8 -0
  12. package/dist/recording/messages.js +40 -0
  13. package/dist/recording/project-digest.js +85 -0
  14. package/dist/recording/recorder.js +293 -0
  15. package/dist/recording/scenario.js +50 -0
  16. package/dist/utils/agent-api-client.js +39 -3
  17. package/dist/utils/video-evidence-git.js +81 -0
  18. package/dist/video-replication/client.js +222 -0
  19. package/dist/video-replication/comparison-evidence.js +149 -0
  20. package/dist/video-replication/comparison-files.js +57 -0
  21. package/dist/video-replication/comparison-log.js +72 -0
  22. package/dist/video-replication/comparison-messages.js +104 -0
  23. package/dist/video-replication/comparison-output.js +63 -0
  24. package/dist/video-replication/comparison-preflight.js +66 -0
  25. package/dist/video-replication/comparison.js +216 -0
  26. package/dist/video-replication/evidence.js +165 -0
  27. package/dist/video-replication/media.js +111 -0
  28. package/dist/video-replication/messages.js +89 -0
  29. package/dist/workspace-runtime/codex/app-server.js +65 -12
  30. package/dist/workspace-runtime/codex/config-toml.js +3 -2
  31. package/dist/workspace-runtime/persistence/native-subagents.js +162 -0
  32. package/dist/workspace-runtime/persistence/sqlite-client.js +17 -1
  33. package/dist/workspace-runtime/persistence/sqlite-worker.js +116 -74
  34. package/dist/workspace-runtime/production/native.js +21 -1
  35. package/dist/workspace-runtime/production/save-command.js +5 -1
  36. package/dist/workspace-runtime/production/source-copy.js +91 -0
  37. package/dist/workspace-runtime/production/stages.js +48 -21
  38. package/dist/workspace-runtime/serve.js +3 -0
  39. package/dist/workspace-runtime/server/dispatch.js +34 -6
  40. package/dist/workspace-runtime/server/http.js +60 -3
  41. package/dist/workspace-runtime/server/terminal-host.js +2 -2
  42. package/dist/workspace-runtime/workspaces/artifact-events.js +55 -0
  43. package/dist/workspace-runtime/workspaces/artifact-files.js +195 -0
  44. package/dist/workspace-runtime/workspaces/artifact-receipts.js +89 -0
  45. package/dist/workspace-runtime/workspaces/artifacts.js +260 -0
  46. package/dist/workspace-runtime/workspaces/attachments.js +70 -4
  47. package/dist/workspace-runtime/workspaces/batch-commit.js +1 -1
  48. package/dist/workspace-runtime/workspaces/cli-context.js +23 -0
  49. package/dist/workspace-runtime/workspaces/directories.js +10 -3
  50. package/dist/workspace-runtime/workspaces/directory-access.js +9 -0
  51. package/dist/workspace-runtime/workspaces/disk-snapshot.js +31 -0
  52. package/dist/workspace-runtime/workspaces/empty-template.js +12 -4
  53. package/dist/workspace-runtime/workspaces/files.js +8 -1
  54. package/dist/workspace-runtime/workspaces/prepare.js +2 -1
  55. package/dist/workspace-runtime/workspaces/registry.js +13 -5
  56. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +45 -0
  57. package/dist/workspace-runtime/workspaces/skills.js +28 -18
  58. package/dist/workspace-runtime/workspaces/video-attachments.js +49 -0
  59. package/package.json +7 -4
  60. package/project-template/.claude/skills/playcraft-replica-validation/SKILL.md +25 -0
  61. package/project-template/.claude/skills/playcraft-replica-validation/references/contracts.md +79 -0
  62. package/project-template/.claude/skills/playcraft-video-understanding/SKILL.md +29 -0
  63. package/project-template/.claude/skills/playcraft-video-understanding/references/contracts.md +37 -0
  64. package/project-templates/empty/AGENTS.md +2 -0
  65. package/project-templates/empty/gitignore +2 -0
@@ -0,0 +1,260 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
4
+ import { basename, join } from 'node:path';
5
+ import sharp from 'sharp';
6
+ import { workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
8
+ import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
9
+ const skippedDirectories = new Set(['node_modules', 'dist', 'build', 'coverage', 'vendor', 'tmp', 'temp', 'cache']);
10
+ const serial = new Map();
11
+ function digest(text) { return createHash('sha256').update(text).digest('hex'); }
12
+ function artifactId(workspaceId, path) { return `artifact_${digest(`${workspaceId}\0${path}`).slice(0, 40)}`; }
13
+ /** Catalogs and immutable bytes are control-owned, outside the mutable checkout. */
14
+ export class WorkspaceArtifactStore {
15
+ stateDir;
16
+ workspaceId;
17
+ constructor(stateDir, workspaceId) {
18
+ this.stateDir = stateDir;
19
+ this.workspaceId = workspaceId;
20
+ }
21
+ async directory() {
22
+ await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
23
+ return artifactDirectory(this.stateDir, `artifacts/${digest(this.workspaceId)}`);
24
+ }
25
+ async read(directory) {
26
+ const handle = await open(join(directory, 'catalog.json'), constants.O_RDONLY | constants.O_NOFOLLOW).catch(error => {
27
+ if (error.code === 'ENOENT')
28
+ return undefined;
29
+ throw error;
30
+ });
31
+ if (!handle)
32
+ return { artifacts: [], scannedAt: new Date().toISOString(), receipts: [], observations: {} };
33
+ try {
34
+ const stat = await handle.stat();
35
+ if (!stat.isFile() || stat.size > 32 * 1024 * 1024)
36
+ throw new Error('artifact_catalog_invalid');
37
+ const { receipts, observations = {}, ...body } = JSON.parse(await handle.readFile('utf8'));
38
+ if (!Array.isArray(receipts) || receipts.some(id => typeof id !== 'string'))
39
+ throw new Error('artifact_catalog_invalid');
40
+ return { ...workspaceArtifactListSchema.parse(body), receipts, observations };
41
+ }
42
+ finally {
43
+ await handle.close();
44
+ }
45
+ }
46
+ async write(directory, catalog) {
47
+ const temporary = join(directory, `${randomUUID()}.tmp`);
48
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
49
+ try {
50
+ await handle.writeFile(JSON.stringify(catalog));
51
+ await handle.sync();
52
+ await handle.close();
53
+ await rename(temporary, join(directory, 'catalog.json'));
54
+ }
55
+ finally {
56
+ await handle.close();
57
+ await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
58
+ throw error; });
59
+ }
60
+ }
61
+ async discover(root, source = { kind: 'discovered' }, sourcePaths = []) {
62
+ const key = `${this.stateDir}\0${this.workspaceId}`;
63
+ const previous = serial.get(key) ?? Promise.resolve();
64
+ const task = previous.catch(() => undefined).then(() => this.scan(root, source, sourcePaths));
65
+ serial.set(key, task);
66
+ try {
67
+ return await task;
68
+ }
69
+ finally {
70
+ if (serial.get(key) === task)
71
+ serial.delete(key);
72
+ }
73
+ }
74
+ async list() {
75
+ const catalog = await this.read(await this.directory());
76
+ return workspaceArtifactListSchema.parse({ artifacts: catalog.artifacts, scannedAt: catalog.scannedAt });
77
+ }
78
+ async scan(root, source, sourcePaths) {
79
+ const directory = await this.directory();
80
+ const objects = await artifactDirectory(directory, 'objects');
81
+ const catalog = await this.read(directory);
82
+ const original = JSON.stringify(catalog.artifacts);
83
+ const receipts = await this.receipts(root, catalog);
84
+ for (const receipt of receipts) {
85
+ try {
86
+ for (const file of receipt.files) {
87
+ const copied = await snapshotArtifactFile(root, `${ARTIFACT_RECEIPTS_DIRECTORY}/objects/${file.sha256}`, objects);
88
+ if (copied.sha256 !== file.sha256 || copied.size !== file.size)
89
+ throw new Error('artifact_receipt_digest_mismatch');
90
+ }
91
+ const primary = receipt.files.find(file => file.role === 'primary' && file.path === receipt.path);
92
+ if (!primary || !artifactFormat(receipt.path))
93
+ continue;
94
+ const version = {
95
+ id: `version_${receipt.id}`, createdAt: receipt.source.generatedAt ?? new Date().toISOString(),
96
+ path: receipt.path, mimeType: primary.mimeType, size: primary.size, sha256: primary.sha256,
97
+ files: receipt.files, source: receipt.source,
98
+ ...(receipt.width ? { width: receipt.width } : {}), ...(receipt.height ? { height: receipt.height } : {}),
99
+ ...(receipt.duration !== undefined ? { duration: receipt.duration } : {}),
100
+ };
101
+ this.add(catalog, version);
102
+ catalog.receipts.push(receipt.id);
103
+ }
104
+ catch { /* An incomplete receipt stays pending; the next scan retries its original bytes. */ }
105
+ }
106
+ const candidates = await discoverPaths(root);
107
+ // Models are inspected before images so their explicitly referenced textures stay under the model.
108
+ candidates.sort((a, b) => Number(artifactFormat(b)?.[0] === 'model') - Number(artifactFormat(a)?.[0] === 'model') || a.localeCompare(b));
109
+ const members = new Set(catalog.artifacts.flatMap(artifact => artifact.versions.flatMap(version => version.files.filter(file => file.role !== 'primary').map(file => file.path))));
110
+ for (const path of candidates) {
111
+ if (members.has(path))
112
+ continue;
113
+ try {
114
+ const current = catalog.artifacts.find(artifact => artifact.id === artifactId(this.workspaceId, path));
115
+ const version = current?.versions.find(entry => entry.id === current.currentVersionId);
116
+ const knownWrite = sourcePaths.includes(path) && Boolean(source.operationId) && version?.source.operationId !== source.operationId;
117
+ const observation = catalog.observations[path];
118
+ if (!knownWrite && version && observation?.versionId === version.id && observation.signature === await fileSignature(root, version.files.map(file => file.path)))
119
+ continue;
120
+ const thumbnail = version?.files.find(file => file.role === 'thumbnail');
121
+ const thumbnailPath = thumbnail && await lstat(join(root, thumbnail.path)).then(stat => stat.isFile(), () => false) ? thumbnail.path : undefined;
122
+ const paths = [path, ...await artifactResources(root, path), ...(thumbnailPath ? [thumbnailPath] : [])];
123
+ const signature = await fileSignature(root, paths);
124
+ const files = await snapshotArtifactBundle(root, path, objects, thumbnailPath);
125
+ if (signature !== await fileSignature(root, files.map(file => file.path)))
126
+ continue;
127
+ for (const file of files)
128
+ if (file.role !== 'primary')
129
+ members.add(file.path);
130
+ if (!knownWrite && version && artifactBundleDigest(version.files) === artifactBundleDigest(files)) {
131
+ catalog.observations[path] = { signature, versionId: version.id };
132
+ continue;
133
+ }
134
+ const primary = files[0];
135
+ const next = {
136
+ id: `version_${knownWrite ? source.operationId : randomUUID()}`, createdAt: new Date().toISOString(),
137
+ path, mimeType: primary.mimeType, size: primary.size, sha256: primary.sha256, files,
138
+ source: sourcePaths.includes(path) ? source : { kind: 'discovered' },
139
+ ...await this.dimensions(objects, primary),
140
+ };
141
+ this.add(catalog, next);
142
+ catalog.observations[path] = { signature, versionId: next.id };
143
+ }
144
+ catch { /* Files being written or temporarily missing are retried, never published as complete. */ }
145
+ }
146
+ catalog.artifacts = catalog.artifacts.filter(artifact => {
147
+ const current = artifact.versions.find(version => version.id === artifact.currentVersionId);
148
+ return current.source.kind !== 'discovered' || !members.has(current.path);
149
+ });
150
+ catalog.artifacts.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id));
151
+ catalog.scannedAt = new Date().toISOString();
152
+ const list = workspaceArtifactListSchema.parse({ artifacts: catalog.artifacts, scannedAt: catalog.scannedAt });
153
+ await this.write(directory, catalog);
154
+ return { list, changed: original !== JSON.stringify(catalog.artifacts) };
155
+ }
156
+ add(catalog, version) {
157
+ const id = artifactId(this.workspaceId, version.path);
158
+ let artifact = catalog.artifacts.find(item => item.id === id);
159
+ if (!artifact) {
160
+ artifact = { id, kind: artifactFormat(version.path)[0], name: basename(version.path), currentVersionId: version.id, updatedAt: version.createdAt, versions: [] };
161
+ catalog.artifacts.push(artifact);
162
+ }
163
+ if (!artifact.versions.some(item => item.id === version.id)) {
164
+ // Display ordering is monotonic; the original generation time stays in source.generatedAt.
165
+ const latest = Math.max(0, ...artifact.versions.map(item => Date.parse(item.createdAt)));
166
+ version.createdAt = new Date(Math.max(Date.parse(version.createdAt), latest + 1)).toISOString();
167
+ artifact.versions.unshift(version);
168
+ }
169
+ artifact.currentVersionId = version.id;
170
+ artifact.updatedAt = version.createdAt;
171
+ }
172
+ async receipts(root, catalog) {
173
+ let directory;
174
+ try {
175
+ const target = join(root, ARTIFACT_RECEIPTS_DIRECTORY);
176
+ if (!(await lstat(target)).isDirectory())
177
+ return [];
178
+ directory = await artifactDirectory(root, ARTIFACT_RECEIPTS_DIRECTORY);
179
+ }
180
+ catch {
181
+ return [];
182
+ }
183
+ const seen = new Set(catalog.receipts), receipts = [];
184
+ for (const name of await readdir(directory)) {
185
+ if (!/^[a-f0-9-]{36}\.json$/.test(name) || seen.has(name.slice(0, -5)))
186
+ continue;
187
+ try {
188
+ const receipt = await readArtifactReceipt(join(directory, name));
189
+ if (receipt.workspaceId === this.workspaceId && `${receipt.id}.json` === name)
190
+ receipts.push(receipt);
191
+ }
192
+ catch { /* Malformed output metadata is never trusted as a completed artifact. */ }
193
+ }
194
+ return receipts.sort((a, b) => (a.source.generatedAt ?? '').localeCompare(b.source.generatedAt ?? '') || a.id.localeCompare(b.id));
195
+ }
196
+ async dimensions(objects, file) {
197
+ if (!file.mimeType.startsWith('image/') || file.mimeType === 'image/svg+xml')
198
+ return {};
199
+ try {
200
+ const metadata = await sharp(join(objects, file.sha256), { limitInputPixels: 100_000_000 }).metadata();
201
+ return { ...(metadata.width ? { width: metadata.width } : {}), ...(metadata.height ? { height: metadata.height } : {}) };
202
+ }
203
+ catch {
204
+ return {};
205
+ }
206
+ }
207
+ async content(input) {
208
+ const query = workspaceArtifactContentQuerySchema.parse(input);
209
+ const directory = await this.directory(), catalog = await this.read(directory);
210
+ const artifact = catalog.artifacts.find(item => item.id === query.artifactId);
211
+ const version = artifact?.versions.find(item => item.id === query.versionId);
212
+ const file = version?.files.find(item => item.path === (query.path ?? version.path));
213
+ if (!file)
214
+ throw new Error('artifact_not_found');
215
+ if (query.offset > file.size)
216
+ throw new Error('artifact_range_invalid');
217
+ const handle = await open(join(directory, 'objects', file.sha256), constants.O_RDONLY | constants.O_NOFOLLOW);
218
+ try {
219
+ const stat = await handle.stat();
220
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size !== file.size)
221
+ throw new Error('artifact_snapshot_invalid');
222
+ const bytes = Buffer.alloc(Math.min(query.length, file.size - query.offset));
223
+ let read = 0;
224
+ while (read < bytes.length) {
225
+ const part = await handle.read(bytes, read, bytes.length - read, query.offset + read);
226
+ if (!part.bytesRead)
227
+ throw new Error('artifact_snapshot_invalid');
228
+ read += part.bytesRead;
229
+ }
230
+ return { content: bytes.toString('base64'), mimeType: file.mimeType, totalBytes: file.size, offset: query.offset, sha256: file.sha256 };
231
+ }
232
+ finally {
233
+ await handle.close();
234
+ }
235
+ }
236
+ }
237
+ async function fileSignature(root, paths) {
238
+ const records = [];
239
+ for (const path of [...new Set(paths)].sort()) {
240
+ const stat = await lstat(await artifactPath(root, path));
241
+ if (!stat.isFile() || stat.nlink !== 1)
242
+ throw new Error('artifact_file_unavailable');
243
+ records.push([path, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs]);
244
+ }
245
+ return JSON.stringify(records);
246
+ }
247
+ async function discoverPaths(root, directory = '', result = []) {
248
+ for (const entry of await readdir(join(root, directory), { withFileTypes: true })) {
249
+ if (entry.name.startsWith('.') || entry.isSymbolicLink())
250
+ continue;
251
+ const path = directory ? `${directory}/${entry.name}` : entry.name;
252
+ if (entry.isDirectory()) {
253
+ if (!skippedDirectories.has(entry.name))
254
+ await discoverPaths(root, path, result);
255
+ }
256
+ else if (entry.isFile() && artifactFormat(path))
257
+ result.push(path);
258
+ }
259
+ return result;
260
+ }
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { chmod, lstat, mkdir, open, readFile, writeFile } from 'node:fs/promises';
3
+ import { chmod, lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, WORKSPACE_VIDEO_ATTACHMENT_MIME_TYPES, workspaceVideoAttachmentQuerySchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { NativePreparationError } from '../codex/native-adapter.js';
7
7
  import { excludeFromGit } from './dependencies.js';
8
+ import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
8
9
  async function attachmentRoot(root) {
9
10
  // Reject redirected upload directories, including symlinks that stay inside the workspace.
10
11
  let directory = root;
@@ -74,6 +75,52 @@ export async function uploadWorkspaceAttachment(root, body) {
74
75
  });
75
76
  return attachment;
76
77
  }
78
+ /** Video bodies are streamed into an unpublished attachment and never decoded as base64. */
79
+ export async function uploadWorkspaceVideoAttachment(root, request, query) {
80
+ const { name } = workspaceVideoAttachmentQuerySchema.parse(query);
81
+ const contentType = String(request.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
82
+ const declaredSize = request.headers['content-length'];
83
+ if (!WORKSPACE_VIDEO_ATTACHMENT_MIME_TYPES.includes(contentType)
84
+ || (declaredSize !== undefined && (!/^\d+$/.test(declaredSize) || Number(declaredSize) > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)))
85
+ throw new Error('invalid_attachment');
86
+ await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
87
+ const uploads = await attachmentRoot(root);
88
+ const id = randomUUID(), directory = join(uploads, id), metadataDirectory = join(uploads, '.metadata');
89
+ await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => { if (error.code !== 'EEXIST')
90
+ throw error; });
91
+ if (!(await lstat(metadataDirectory)).isDirectory())
92
+ throw new Error('invalid_attachment');
93
+ await mkdir(directory, { mode: 0o755 });
94
+ const handle = await open(join(directory, name), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
95
+ try {
96
+ let size = 0;
97
+ for await (const data of request) {
98
+ const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data);
99
+ size += bytes.length;
100
+ if (size > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
101
+ throw new Error('invalid_attachment');
102
+ let offset = 0;
103
+ while (offset < bytes.length)
104
+ offset += (await handle.write(bytes, offset, bytes.length - offset, size - bytes.length + offset)).bytesWritten;
105
+ }
106
+ if (!size || (declaredSize !== undefined && Number(declaredSize) !== size))
107
+ throw new Error('invalid_attachment');
108
+ await handle.sync();
109
+ const media = await probeAttachmentVideo(handle);
110
+ if (contentType !== media.mimeType && !(contentType === 'video/quicktime' && media.mimeType === 'video/mp4'))
111
+ throw new Error('invalid_attachment');
112
+ const attachment = { id, name, size, ...media, sha256: await hashAttachment(handle) };
113
+ await writeFile(join(metadataDirectory, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
114
+ return attachment;
115
+ }
116
+ catch (error) {
117
+ await rm(directory, { recursive: true, force: true });
118
+ throw error;
119
+ }
120
+ finally {
121
+ await handle.close();
122
+ }
123
+ }
77
124
  export async function resolveWorkspaceAttachments(root, input) {
78
125
  try {
79
126
  const ids = attachmentIdsSchema.parse(input ?? []);
@@ -85,7 +132,17 @@ export async function resolveWorkspaceAttachments(root, input) {
85
132
  await chmod(directory, 0o755);
86
133
  if (!(await lstat(join(uploads, '.metadata'))).isDirectory())
87
134
  throw new Error('invalid_attachment');
88
- const metadata = JSON.parse(await readFile(join(uploads, '.metadata', `${id}.json`), 'utf8'));
135
+ const metadataHandle = await open(join(uploads, '.metadata', `${id}.json`), constants.O_RDONLY | constants.O_NOFOLLOW);
136
+ let metadata;
137
+ try {
138
+ const stat = await metadataHandle.stat();
139
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192)
140
+ throw new Error('invalid_attachment');
141
+ metadata = JSON.parse(await metadataHandle.readFile('utf8'));
142
+ }
143
+ finally {
144
+ await metadataHandle.close();
145
+ }
89
146
  attachmentUploadSchema.shape.name.parse(metadata.name);
90
147
  if (metadata.id !== id)
91
148
  throw new Error('invalid_attachment');
@@ -93,8 +150,17 @@ export async function resolveWorkspaceAttachments(root, input) {
93
150
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
94
151
  try {
95
152
  const stat = await handle.stat();
96
- if (!stat.isFile() || stat.size > WORKSPACE_ATTACHMENT_MAX_BYTES)
153
+ const video = metadata.mimeType?.startsWith('video/');
154
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size !== metadata.size || stat.size > (video ? WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES : WORKSPACE_ATTACHMENT_MAX_BYTES))
97
155
  throw new Error('invalid_attachment');
156
+ if (video) {
157
+ const media = await probeAttachmentVideo(handle);
158
+ const sha256 = await hashAttachment(handle);
159
+ if (sha256 !== metadata.sha256 || media.mimeType !== metadata.mimeType || media.durationMs !== metadata.durationMs
160
+ || media.width !== metadata.width || media.height !== metadata.height)
161
+ throw new Error('invalid_attachment');
162
+ return { ...metadata, ...media, path };
163
+ }
98
164
  const bytes = await handle.readFile();
99
165
  return { ...metadata, size: bytes.length, mimeType: imageMime(bytes), path };
100
166
  }
@@ -12,7 +12,7 @@ const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
12
12
  const PRIVATE_NAMES = new Set([
13
13
  '.ssh', '.aws', '.azure', '.gnupg', '.codex', '.npm', '.pnpm-store', '.config',
14
14
  '.npmrc', '.yarnrc', '.yarnrc.yml', '.netrc', '.git-credentials', 'credentials.json',
15
- 'credentials', 'secrets', 'id_rsa', 'id_ed25519', 'settings.local.json',
15
+ 'credentials', 'secrets', 'id_rsa', 'id_ed25519', 'settings.local.json', '.playcraft.json', 'playcraft.config.json',
16
16
  ]);
17
17
  const DEPENDENCY_LOCKS = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'bun.lock', 'bun.lockb'];
18
18
  const SKILLS_DECLARATION = '.playcraft/skills.json';
@@ -0,0 +1,23 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { excludeFromGit } from './dependencies.js';
5
+ /** Non-secret routing hint; the business service still authorizes the selected workspace. */
6
+ export async function writeWorkspaceCliContext(root, workspaceId) {
7
+ const management = join(root, '.playcraft');
8
+ await mkdir(management, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
9
+ throw error; });
10
+ const stat = await lstat(management);
11
+ if (!stat.isDirectory() || stat.isSymbolicLink())
12
+ throw new Error('workspace_skills_invalid');
13
+ const temporary = join(management, `workspace-context.${randomUUID()}.tmp`);
14
+ try {
15
+ await writeFile(temporary, `${JSON.stringify({ schemaVersion: 'playcraft.workspace-context.v1', workspaceId })}\n`, { flag: 'wx', mode: 0o644 });
16
+ await rename(temporary, join(management, 'workspace-context.json'));
17
+ await excludeFromGit(root, '/.playcraft/workspace-context.json');
18
+ await excludeFromGit(root, '/.playcraft/video-replication/');
19
+ }
20
+ finally {
21
+ await rm(temporary, { force: true });
22
+ }
23
+ }
@@ -10,9 +10,10 @@ import { prepareWorkspaceCheckout } from './prepare.js';
10
10
  import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
11
11
  import { revisionScope } from './registry.js';
12
12
  import { ensureWorkspaceSkills } from './skills.js';
13
+ import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
13
14
  const execute = promisify(execFile);
14
15
  /** Trusted Runtime registration clones an immutable local commit; it never follows basic's current branch head. */
15
- export async function registerWorkspaceDirectory(state, input, leaseFence) {
16
+ export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName) {
16
17
  const directory = workspaceDirectoryRegistrationSchema.parse(input);
17
18
  if (directory.directoryName === 'basic')
18
19
  throw new WorkspaceContextError('workspace_directory_binding_conflict');
@@ -43,11 +44,17 @@ export async function registerWorkspaceDirectory(state, input, leaseFence) {
43
44
  throw new WorkspaceContextError('workspace_recovery_required');
44
45
  if (!directory.sourceCommit)
45
46
  throw new WorkspaceContextError('workspace_directory_source_commit_required');
46
- const source = await workspaceRoot(state, directory.workspaceId, 'basic');
47
+ const source = await workspaceRoot(state, directory.workspaceId, sourceDirectoryName ?? 'basic');
47
48
  await git(source, 'cat-file', '-e', `${directory.sourceCommit}^{commit}`);
48
49
  const staging = join(parent, `.directory-${randomUUID()}`);
49
50
  try {
50
- await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
51
+ if (sourceDirectoryName !== undefined) {
52
+ const frozen = await freezeBatchCommit({ source: { workspaceId: directory.workspaceId, root: source },
53
+ commitHash: directory.sourceCommit, snapshotId: `directory-${directory.remixId}` });
54
+ await checkoutBatchCommit({ source: { workspaceId: directory.workspaceId, root: source }, frozen, destination: staging });
55
+ }
56
+ else
57
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, staging], { timeout: 90_000, maxBuffer: 1024 * 1024 });
51
58
  await git(staging, 'checkout', '-b', directory.branchName, directory.sourceCommit);
52
59
  await git(staging, 'remote', 'set-url', 'origin', binding.repositoryUrl);
53
60
  await git(staging, 'config', 'user.name', 'PlayCraft');
@@ -1,5 +1,14 @@
1
1
  import { chmod, chown, lstat, readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ /** Grant development-group traversal of the workspace container without touching source checkouts. */
4
+ export async function grantWorkspaceTraversal(root, groupId) {
5
+ const stat = await lstat(root);
6
+ if (!stat.isDirectory() || stat.isSymbolicLink())
7
+ throw new Error('invalid_workspace_directory');
8
+ if (stat.gid !== groupId)
9
+ await chown(root, stat.uid, groupId);
10
+ await chmod(root, (stat.mode & 0o777) | 0o2010);
11
+ }
3
12
  /** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
4
13
  export async function openDirectoryToDevelopment(root, groupId) {
5
14
  const visit = async (path) => {
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES } from '@playcraft/common/cloud-workspace/runtime';
5
+ export async function sampleDiskFile(path) {
6
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
7
+ try {
8
+ const before = await handle.stat();
9
+ if (!before.isFile() || before.size > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
10
+ throw new Error('snapshot_body_missing');
11
+ const digest = createHash('sha256');
12
+ const chunk = Buffer.allocUnsafe(64 * 1024);
13
+ let position = 0;
14
+ for (;;) {
15
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
16
+ if (!bytesRead)
17
+ break;
18
+ digest.update(chunk.subarray(0, bytesRead));
19
+ position += bytesRead;
20
+ if (position > WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES)
21
+ throw new Error('snapshot_body_missing');
22
+ }
23
+ const after = await handle.stat();
24
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || position !== before.size)
25
+ throw new Error('snapshot_body_missing');
26
+ return { hash: digest.digest('hex'), size: position, mode: before.mode & 0o777 };
27
+ }
28
+ finally {
29
+ await handle.close();
30
+ }
31
+ }
@@ -1,16 +1,22 @@
1
1
  import { cp, mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { resolveCliPackageRoot } from '../../scaffold/compose.js';
4
+ import { importBuiltinProjectSkills, selectPlatformSkillSnapshot } from '../../project-skills/builtin-import.js';
5
+ import { createHash } from 'node:crypto';
6
+ import { canonicalProjectSkillsJson } from '@playcraft/common/project-skills';
4
7
  import { syncLocalProjectSkills } from '../../project-skills/local-store.js';
5
- export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [
8
+ export const EMPTY_WORKSPACE_REPOSITORY_SKILLS = [
6
9
  'playcraft-skill-recommender',
7
10
  'playcraft-image-generation',
8
11
  'playcraft-audio-generation',
9
12
  'playcraft-image-processing',
10
13
  'playcraft-build-optimizer',
11
14
  ];
15
+ export const EMPTY_WORKSPACE_PLATFORM_SKILLS = ['playcraft-video-understanding', 'playcraft-replica-validation'];
16
+ export const EMPTY_WORKSPACE_DEFAULT_SKILLS = [...EMPTY_WORKSPACE_REPOSITORY_SKILLS, ...EMPTY_WORKSPACE_PLATFORM_SKILLS];
12
17
  /** Only called in a new, unpublished staging directory; existing workspaces are never scaffolded. */
13
- export async function scaffoldEmptyWorkspace(root, skillCache) {
18
+ export async function scaffoldEmptyWorkspace(root, skillCache, access) {
19
+ const selected = await selectPlatformSkillSnapshot([...EMPTY_WORKSPACE_PLATFORM_SKILLS], access);
14
20
  const cliRoot = resolveCliPackageRoot();
15
21
  const template = join(cliRoot, 'project-templates', 'empty');
16
22
  for (const [source, target] of [
@@ -21,12 +27,14 @@ export async function scaffoldEmptyWorkspace(root, skillCache) {
21
27
  }
22
28
  const skills = join(root, '.agents', 'skills');
23
29
  await mkdir(skills, { recursive: true });
24
- for (const name of EMPTY_WORKSPACE_DEFAULT_SKILLS) {
30
+ for (const name of EMPTY_WORKSPACE_REPOSITORY_SKILLS) {
25
31
  await cp(join(cliRoot, 'project-template', '.claude', 'skills', name), join(skills, name), {
26
32
  recursive: true,
27
33
  force: false,
28
34
  errorOnExist: true,
29
35
  });
30
36
  }
31
- await syncLocalProjectSkills(root, skillCache);
37
+ const snapshot = await syncLocalProjectSkills(root, skillCache);
38
+ await importBuiltinProjectSkills(root, { names: [...EMPTY_WORKSPACE_PLATFORM_SKILLS], expectedDeclarationHash: snapshot.lock.declarationHash,
39
+ expectedLockHash: createHash('sha256').update(canonicalProjectSkillsJson(snapshot.lock)).digest('hex') }, skillCache, access, selected, true);
32
40
  }
@@ -1,7 +1,9 @@
1
- import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
1
+ import { lstat, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, relative, resolve, sep } from 'node:path';
3
3
  import { hashWorkspaceFileContent, workspaceFileContentSchema, workspaceFileListSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { sampleDiskFile } from './disk-snapshot.js';
4
5
  const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
6
+ const LARGE_FILE_STREAM_THRESHOLD = 8 * 1024 * 1024;
5
7
  export class InvalidWorkspacePathError extends Error {
6
8
  code = 'invalid_workspace_path';
7
9
  constructor(path) {
@@ -55,6 +57,11 @@ export async function listWorkspaceFiles(root, revisions) {
55
57
  const files = [];
56
58
  for (const relativePath of await walkWorkspaceFiles(rootReal, rootReal)) {
57
59
  const target = join(rootReal, ...relativePath.split('/'));
60
+ if ((await lstat(target)).size > LARGE_FILE_STREAM_THRESHOLD) {
61
+ const sampled = await sampleDiskFile(target);
62
+ files.push({ path: relativePath, revision: revisions[relativePath]?.revision ?? 0, contentHash: sampled.hash, size: sampled.size });
63
+ continue;
64
+ }
58
65
  const content = await readFile(target, 'utf8');
59
66
  files.push({
60
67
  path: relativePath,
@@ -4,6 +4,7 @@ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs
4
4
  import { join } from 'node:path';
5
5
  import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, workspaceDirectoryNameSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { WorkspaceContextError } from './context-error.js';
7
+ import { workspacePlatformSkillAccess } from './skill-artifact-loader.js';
7
8
  import { scaffoldEmptyWorkspace } from './empty-template.js';
8
9
  /** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
9
10
  export async function prepareWorkspaceCheckout(state, binding, input, leaseFence, directoryName = 'basic', recovery = false) {
@@ -96,7 +97,7 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
96
97
  try {
97
98
  if (standalone) {
98
99
  await runGit(['init', '--initial-branch=main', staging], env, deadline);
99
- await scaffoldEmptyWorkspace(staging, join(state.config.stateDir, 'skill-artifacts'));
100
+ await scaffoldEmptyWorkspace(staging, join(state.config.stateDir, 'skill-artifacts'), workspacePlatformSkillAccess(state, binding.workspaceId));
100
101
  }
101
102
  else {
102
103
  const fork = binding.pendingFork;
@@ -7,7 +7,8 @@ import { prepareWorkspaceCheckout } from './prepare.js';
7
7
  import { WorkspaceContextError } from './context-error.js';
8
8
  import { scheduleDependencyInstall } from './dependencies.js';
9
9
  import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
10
- import { loadWorkspaceSkillArtifact } from './skill-artifact-loader.js';
10
+ import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
11
+ import { writeWorkspaceCliContext } from './cli-context.js';
11
12
  export { WorkspaceContextError } from './context-error.js';
12
13
  const exec = promisify(execFile);
13
14
  export function revisionScope(state, workspaceId, directoryName = 'basic') {
@@ -88,7 +89,7 @@ export async function workspaceContextFromRoot(state, cwd) {
88
89
  throw new WorkspaceContextError('workspace_not_registered');
89
90
  return { workspaceId, directoryName };
90
91
  }
91
- export async function registerWorkspace(state, input, leaseFence, preparation) {
92
+ export async function registerWorkspace(state, input, leaseFence, preparation, readOnlyBasic = false) {
92
93
  if (state.config.workspaceMode !== 'multi')
93
94
  throw new WorkspaceContextError('multi_workspace_unavailable');
94
95
  const parsed = workspaceRegistrationSchema.safeParse(input);
@@ -103,7 +104,9 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
103
104
  state.acceptedLeaseFence = leaseFence;
104
105
  await state.journal.setAcceptedLeaseFence(leaseFence);
105
106
  const existing = state.journal.getWorkspace(binding.workspaceId);
106
- if (existing && !sameWorkspaceRegistration(existing, binding))
107
+ const preservedLocalBasic = binding.targetKind !== 'standalone' && binding.preserveLocalBasic === true;
108
+ const localUpgrade = preservedLocalBasic && binding.targetKind === 'main' && existing?.targetKind === 'standalone';
109
+ if (existing && !sameWorkspaceRegistration(existing, binding) && !localUpgrade)
107
110
  throw new WorkspaceContextError('workspace_binding_conflict');
108
111
  const directories = binding.directories ?? existing?.directories;
109
112
  if (directories?.some(directory => directory.directoryName === 'basic' && directory.status === 'reclaimed')) {
@@ -141,7 +144,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
141
144
  })).stdout.trim();
142
145
  try {
143
146
  let checkoutMatches;
144
- if (binding.targetKind === 'standalone') {
147
+ if (binding.targetKind === 'standalone' || preservedLocalBasic) {
145
148
  const gitDirectory = await lstat(join(root, '.git'));
146
149
  checkoutMatches = gitDirectory.isDirectory() && !gitDirectory.isSymbolicLink()
147
150
  && (await git('rev-parse', '--absolute-git-dir')) === join(root, '.git')
@@ -164,6 +167,10 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
164
167
  throw new WorkspaceContextError('runtime_destroyed');
165
168
  if (leaseFence < state.acceptedLeaseFence)
166
169
  throw new WorkspaceContextError('stale_lease');
170
+ if (readOnlyBasic || preservedLocalBasic) {
171
+ await state.journal.putWorkspace({ ...binding, directories });
172
+ return binding;
173
+ }
167
174
  if (preparation?.mode === 'clone' && (preparation.transport?.kind === 'git-gateway-v2' || isLocalWorkspaceGitAuthMode(preparation.authMode))) {
168
175
  if (!state.gitCredentials)
169
176
  throw new WorkspaceContextError('workspace_git_gateway_unavailable');
@@ -175,11 +182,12 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
175
182
  }
176
183
  }
177
184
  try {
178
- await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
185
+ await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), workspacePlatformSkillAccess(state, binding.workspaceId));
179
186
  }
180
187
  catch {
181
188
  throw new WorkspaceContextError('workspace_skills_unavailable');
182
189
  }
190
+ await writeWorkspaceCliContext(root, binding.workspaceId);
183
191
  await state.journal.putWorkspace({ ...binding, directories });
184
192
  try {
185
193
  await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));