@playcraft/cli 0.1.56-beta → 0.1.58-beta

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 +6 -4
  2. package/dist/commands/tools-3d.js +15 -0
  3. package/dist/commands/tools-generation.js +3 -2
  4. package/dist/commands/workspace.js +43 -0
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace/messages.js +10 -0
  11. package/dist/workspace-runtime/batch/deliver.js +140 -80
  12. package/dist/workspace-runtime/batch/freeze.js +18 -6
  13. package/dist/workspace-runtime/batch/generate.js +68 -64
  14. package/dist/workspace-runtime/batch/prepare.js +109 -137
  15. package/dist/workspace-runtime/batch/records.js +4 -0
  16. package/dist/workspace-runtime/batch/routes.js +10 -2
  17. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  18. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  19. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  20. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  21. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  22. package/dist/workspace-runtime/codex/app-server.js +1 -0
  23. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  24. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  25. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  26. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  27. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  28. package/dist/workspace-runtime/persistence/journal.js +34 -0
  29. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  30. package/dist/workspace-runtime/previews/output.js +2 -0
  31. package/dist/workspace-runtime/previews/publish.js +20 -5
  32. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  33. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  34. package/dist/workspace-runtime/serve.js +19 -32
  35. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  36. package/dist/workspace-runtime/server/directory-access.js +414 -0
  37. package/dist/workspace-runtime/server/dispatch.js +61 -23
  38. package/dist/workspace-runtime/server/endpoints.js +1 -0
  39. package/dist/workspace-runtime/server/http.js +52 -10
  40. package/dist/workspace-runtime/server/operation-record.js +2 -0
  41. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  42. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  43. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  44. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  45. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  46. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  47. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  48. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  49. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  50. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  51. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  52. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  53. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  54. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  55. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  56. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  57. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  58. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  59. package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
  60. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  61. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  62. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  63. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  64. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  65. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  66. package/dist/workspace-runtime/workspaces/files.js +4 -0
  67. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  68. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  69. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  70. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  71. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  72. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  73. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  74. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  75. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  76. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  77. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  78. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  79. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  80. package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
  81. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  82. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  83. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  84. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  85. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  86. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  87. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  88. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  89. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  90. package/package.json +5 -4
  91. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  92. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  93. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
  94. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  95. package/templates/preview-runtime/v1/README.md +4 -0
  96. package/templates/preview-runtime/v1/manifest.json +6 -6
  97. package/templates/preview-runtime/v1/runtime.mjs +368 -22
  98. package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
  99. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  100. package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
  101. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -1,5 +1,6 @@
1
1
  import { chmod, chown, lstat, readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, sharePathWithGroup, } from './shared-write.js';
3
4
  /** Grant development-group traversal of the workspace container without touching source checkouts. */
4
5
  export async function grantWorkspaceTraversal(root, groupId) {
5
6
  const stat = await lstat(root);
@@ -18,9 +19,7 @@ export async function openScratchToDevelopment(path, groupId) {
18
19
  const stat = await lstat(path);
19
20
  if (!stat.isDirectory() || stat.isSymbolicLink())
20
21
  throw new Error('invalid_scratch_directory');
21
- if (stat.gid !== groupId)
22
- await chown(path, stat.uid, groupId);
23
- await chmod(path, 0o2770);
22
+ await sharePathWithGroup(path, groupId, SHARED_DIRECTORY_MODE);
24
23
  }
25
24
  /** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
26
25
  export async function openDirectoryToDevelopment(root, groupId) {
@@ -29,22 +28,18 @@ export async function openDirectoryToDevelopment(root, groupId) {
29
28
  const stat = await lstat(path);
30
29
  if (stat.isSymbolicLink())
31
30
  return;
31
+ // Entries owned by the other identity cannot be re-shaped from here; the owner repairs them.
32
32
  const canModify = currentUid === undefined || currentUid === 0 || stat.uid === currentUid;
33
- if (canModify && stat.gid !== groupId)
34
- await chown(path, stat.uid, groupId);
35
- if (stat.isDirectory()) {
36
- if (canModify)
37
- await chmod(path, (stat.mode & 0o777) | 0o2770);
38
- const entries = await readdir(path).catch(error => {
39
- if (!canModify && error.code === 'EACCES')
40
- return [];
41
- throw error;
42
- });
43
- for (const entry of entries)
44
- await visit(join(path, entry));
45
- }
46
- else if (stat.isFile() && canModify)
47
- await chmod(path, (stat.mode & 0o777) | 0o660);
33
+ await sharePathWithGroup(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE, stat.isFile());
34
+ if (!stat.isDirectory())
35
+ return;
36
+ const entries = await readdir(path).catch(error => {
37
+ if (!canModify && error.code === 'EACCES')
38
+ return [];
39
+ throw error;
40
+ });
41
+ for (const entry of entries)
42
+ await visit(join(path, entry));
48
43
  };
49
44
  await visit(root);
50
45
  }
@@ -25,6 +25,7 @@ async function safeDirectory(root, relative, create = false) {
25
25
  let directory = root;
26
26
  for (const part of relative.split('/')) {
27
27
  directory = join(directory, part);
28
+ // shared
28
29
  if (create)
29
30
  await mkdir(directory, { mode: 0o755 }).catch((error) => {
30
31
  if (error.code !== 'EEXIST')
@@ -59,10 +60,14 @@ export async function beginWorkspaceDirectoryAttachment(root, body) {
59
60
  const staging = await safeDirectory(root, stagingPath, true);
60
61
  const id = randomUUID();
61
62
  const stage = join(staging, id);
63
+ // shared
62
64
  await mkdir(stage, { mode: 0o755 });
63
65
  try {
66
+ // shared
64
67
  await mkdir(join(stage, 'data'), { mode: 0o755 });
68
+ // shared
65
69
  await mkdir(join(stage, 'data', manifest.name), { mode: 0o755 });
70
+ // shared
66
71
  await writeFile(join(stage, 'manifest.json'), JSON.stringify(manifest), { flag: 'wx', mode: 0o644 });
67
72
  return { uploadId: id };
68
73
  }
@@ -84,6 +89,7 @@ export async function uploadWorkspaceDirectoryFile(root, id, path, request) {
84
89
  throw new Error('invalid_attachment');
85
90
  const relative = `${stagingPath}/${id}/data/${manifest.name}/${path}`;
86
91
  await safeDirectory(root, dirname(relative), true);
92
+ // shared
87
93
  const handle = await open(join(root, relative), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
88
94
  try {
89
95
  let size = 0;
@@ -164,9 +170,11 @@ export async function completeWorkspaceDirectoryAttachment(root, id) {
164
170
  path: `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${manifest.name}`, files };
165
171
  workspaceAttachmentSchema.parse(attachment);
166
172
  const target = join(uploads, id);
173
+ // shared
167
174
  await mkdir(target, { mode: 0o755 });
168
175
  try {
169
176
  await rename(join(stage, 'data', manifest.name), join(target, manifest.name));
177
+ // shared
170
178
  await writeFile(join(metadata, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
171
179
  }
172
180
  catch (error) {
@@ -1,8 +1,9 @@
1
1
  import { constants } from 'node:fs';
2
- import { lstat, mkdir, open, realpath, rmdir } from 'node:fs/promises';
2
+ import { lstat, mkdir, realpath, rmdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { directoryCreatePayloadSchema } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { ensureWorkspaceRoot, InvalidWorkspacePathError } from './files.js';
6
+ import { ensureSharedDirectory, openSharedFile } from './shared-write.js';
6
7
  const RESERVED_DIRECTORIES = new Set(['.git', 'node_modules', '.playcraft-temp']);
7
8
  export class WorkspaceDirectoryCreateError extends Error {
8
9
  code = 'directory_exists';
@@ -18,18 +19,29 @@ export async function createWorkspaceDirectory(root, input) {
18
19
  if (parts.some(part => RESERVED_DIRECTORIES.has(part)))
19
20
  throw new InvalidWorkspacePathError(path);
20
21
  const rootReal = await ensureWorkspaceRoot(root);
22
+ const share = async (path) => {
23
+ try {
24
+ await ensureSharedDirectory(rootReal, path);
25
+ }
26
+ catch (error) {
27
+ if (error.message === 'invalid_shared_directory')
28
+ throw new InvalidWorkspacePathError(path);
29
+ throw error;
30
+ }
31
+ };
21
32
  let parent = rootReal;
22
33
  for (const part of parts.slice(0, -1)) {
23
34
  parent = join(parent, part);
24
- await mkdir(parent, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
25
- throw error; });
35
+ // Created directories are visible to the development identity that runs agents and builds.
36
+ await share(parent);
26
37
  const stat = await lstat(parent);
27
38
  if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(parent) !== parent)
28
39
  throw new InvalidWorkspacePathError(path);
29
40
  }
30
41
  const target = join(parent, parts.at(-1));
42
+ // shared
31
43
  try {
32
- await mkdir(target, { mode: 0o700 });
44
+ await mkdir(target, { mode: 0o2770 });
33
45
  }
34
46
  catch (error) {
35
47
  if (error.code !== 'EEXIST')
@@ -41,7 +53,8 @@ export async function createWorkspaceDirectory(root, input) {
41
53
  try {
42
54
  if (!(await lstat(target)).isDirectory() || await realpath(target) !== target)
43
55
  throw new InvalidWorkspacePathError(path);
44
- const marker = await open(join(target, '.gitkeep'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
56
+ await share(target);
57
+ const marker = await openSharedFile(rootReal, join(target, '.gitkeep'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
45
58
  try {
46
59
  await marker.sync();
47
60
  }
@@ -6,6 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { batchDirectoryHeld } from '../batch/occupancy.js';
8
8
  import { workspaceRoot, workspaceSessions } from './registry.js';
9
+ import { withDirectoryWrite } from '../server/directory-access.js';
9
10
  const execute = promisify(execFile);
10
11
  const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
11
12
  timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
@@ -39,7 +40,7 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
39
40
  const blocked = [];
40
41
  for (const directoryName of names) {
41
42
  try {
42
- await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
43
+ await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
43
44
  if (await workspaceDirectoryOccupied(state, workspaceId, directoryName))
44
45
  throw new Error('workspace_directory_occupied');
45
46
  const root = await workspaceRoot(state, workspaceId, directoryName);
@@ -51,7 +52,7 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
51
52
  await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
52
53
  await rm(root, { recursive: true });
53
54
  cleaned.push(directoryName);
54
- });
55
+ }));
55
56
  }
56
57
  catch (error) {
57
58
  blocked.push({ directoryName, reason: error instanceof Error && /^[a-z0-9_]+$/.test(error.message) ? error.message : 'workspace_cleanup_failed' });
@@ -70,42 +71,52 @@ export async function syncWorkspaceDirectories(state, workspaceId, commitHash, t
70
71
  for (const directoryName of [...new Set(targetDirectoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))]) {
71
72
  if (directoryName === 'basic')
72
73
  throw new Error('workspace_sync_target_invalid');
73
- await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
74
- if (await workspaceDirectoryOccupied(state, workspaceId, directoryName)) {
75
- skipped.push({ directoryName, reason: 'directory_busy' });
76
- return;
77
- }
78
- const root = await workspaceRoot(state, workspaceId, directoryName);
79
- const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
80
- const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
81
- await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
82
- if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
83
- synced.push(directoryName);
84
- return;
85
- }
86
- const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
87
- if (containing) {
88
- synced.push(directoryName);
89
- return;
90
- }
91
- // Import the exact preserved basic commit, without fetching a moving remote branch.
92
- await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
93
- const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
94
- if (!equivalent) {
95
- try {
96
- await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
74
+ try {
75
+ await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
76
+ if (await workspaceDirectoryOccupied(state, workspaceId, directoryName)) {
77
+ skipped.push({ directoryName, reason: 'directory_busy' });
78
+ return;
97
79
  }
98
- catch {
99
- conflicts.push(directoryName);
80
+ const root = await workspaceRoot(state, workspaceId, directoryName);
81
+ const ledger = join(state.config.stateDir, 'directory-sync', workspaceId, `${directoryName}.json`);
82
+ const applied = await readFile(ledger, 'utf8').then(value => JSON.parse(value), () => undefined);
83
+ await assertCleanAndPushed(state, root, applied?.sourceCommit === commitHash ? applied.targetCommit : undefined);
84
+ if (applied?.sourceCommit === commitHash && await git(state, root, 'rev-parse', 'HEAD') === applied.targetCommit) {
85
+ synced.push(directoryName);
100
86
  return;
101
87
  }
102
- }
103
- await mkdir(join(state.config.stateDir, 'directory-sync', workspaceId), { recursive: true, mode: 0o700 });
104
- const temporary = `${ledger}.${randomUUID()}.tmp`;
105
- await writeFile(temporary, JSON.stringify({ sourceCommit: commitHash, targetCommit: await git(state, root, 'rev-parse', 'HEAD') }), { mode: 0o600 });
106
- await rename(temporary, ledger);
107
- synced.push(directoryName);
108
- });
88
+ const containing = await git(state, root, 'merge-base', '--is-ancestor', commitHash, 'HEAD').then(() => true, () => false);
89
+ if (containing) {
90
+ synced.push(directoryName);
91
+ return;
92
+ }
93
+ // Import the exact preserved basic commit, without fetching a moving remote branch.
94
+ await execute('git', ['-c', 'core.hooksPath=/dev/null', '-C', root, 'fetch', '--no-tags', '--', basic, commitHash], { timeout: 30_000, maxBuffer: 1024 * 1024 });
95
+ const equivalent = (await git(state, root, 'cherry', 'HEAD', commitHash)).split('\n').some(line => line === `- ${commitHash}`);
96
+ if (!equivalent) {
97
+ try {
98
+ await git(state, root, '-c', 'user.name=PlayCraft', '-c', 'user.email=playcraft@localhost', 'cherry-pick', commitHash);
99
+ }
100
+ catch {
101
+ conflicts.push(directoryName);
102
+ return;
103
+ }
104
+ }
105
+ // private
106
+ await mkdir(join(state.config.stateDir, 'directory-sync', workspaceId), { recursive: true, mode: 0o700 });
107
+ const temporary = `${ledger}.${randomUUID()}.tmp`;
108
+ // private
109
+ await writeFile(temporary, JSON.stringify({ sourceCommit: commitHash, targetCommit: await git(state, root, 'rev-parse', 'HEAD') }), { mode: 0o600 });
110
+ await rename(temporary, ledger);
111
+ synced.push(directoryName);
112
+ }));
113
+ }
114
+ catch (error) {
115
+ if (error instanceof Error && error.message === 'directory_busy')
116
+ skipped.push({ directoryName, reason: 'directory_busy' });
117
+ else
118
+ throw error;
119
+ }
109
120
  }
110
121
  return { synced, skipped, conflicts, previewStale: true };
111
122
  }
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
- import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises';
3
+ import { lstat, open, readdir, realpath, rename, rm } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { z } from 'zod';
6
6
  import { directoryDeletePayloadSchema, directoryRenamePayloadSchema, hashOperationPayload, isMutableWorkspaceDirectoryPath, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { safeAssetTransferDirectory, syncAssetTransferDirectory } from './asset-transfer-storage.js';
8
+ import { ensureSharedDirectory } from './shared-write.js';
8
9
  export class DirectoryMutationError extends Error {
9
10
  code;
10
11
  constructor(code) {
@@ -60,6 +61,7 @@ async function validateTree(root, path) {
60
61
  async function saveRecord(directory, record) {
61
62
  await safeAssetTransferDirectory(directory);
62
63
  const temporary = join(directory, `${randomUUID()}.tmp`);
64
+ // private
63
65
  const file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
64
66
  try {
65
67
  await file.writeFile(JSON.stringify(record));
@@ -183,8 +185,11 @@ export async function mutateWorkspaceDirectory(root, kind, input, options) {
183
185
  if (newPath) {
184
186
  // Reserve an absent destination atomically; only our recorded empty placeholder may be replaced.
185
187
  if (!destinationIdentity) {
186
- await mkdir(destination, { mode: 0o700 }).catch(error => { if (error.code === 'EEXIST')
187
- return fail('directory_exists'); throw error; });
188
+ if (await lstat(destination).catch(error => { if (missing(error))
189
+ return undefined; throw error; }))
190
+ return fail('directory_exists');
191
+ // A renamed directory is a workspace entry both identities must reach.
192
+ await ensureSharedDirectory(root, destination);
188
193
  record.reservation = await identity(destination);
189
194
  await saveRecord(directory, record);
190
195
  }
@@ -13,6 +13,7 @@ export class InvalidWorkspacePathError extends Error {
13
13
  }
14
14
  }
15
15
  export async function ensureWorkspaceRoot(root) {
16
+ // private
16
17
  await mkdir(root, { recursive: true, mode: 0o700 });
17
18
  return realpath(root);
18
19
  }
@@ -37,8 +38,10 @@ export async function resolveWorkspaceFile(root, relativePath) {
37
38
  }
38
39
  export async function writeWorkspaceFile(root, relativePath, content) {
39
40
  const target = await resolveWorkspaceFile(root, relativePath);
41
+ // private
40
42
  await mkdir(dirname(target), { recursive: true, mode: 0o700 });
41
43
  const bytes = Buffer.from(content, 'utf8');
44
+ // private
42
45
  await writeFile(target, bytes, { mode: 0o600 });
43
46
  return { path: relativePath, contentHash: hashWorkspaceFileContent(content), size: bytes.byteLength };
44
47
  }
@@ -134,6 +137,7 @@ async function assertParentInsideRoot(rootReal, parent) {
134
137
  catch (error) {
135
138
  if (error.code !== 'ENOENT')
136
139
  throw error;
140
+ // private
137
141
  await mkdir(parent, { recursive: true, mode: 0o700 });
138
142
  const parentReal = await realpath(parent);
139
143
  assertInsideRoot(rootReal, parentReal);
@@ -240,6 +240,7 @@ async function configureGitFile(root, stateDir, commands, repositoryUrl) {
240
240
  gitDirectory = await open(join(pinnedRoot, '.git'), directoryFlags);
241
241
  const gitPath = process.platform === 'linux' ? `/proc/self/fd/${gitDirectory.fd}` : join(root, '.git');
242
242
  const configPath = join(gitPath, 'config');
243
+ // private
243
244
  lock = await open(join(gitPath, 'config.lock'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o640);
244
245
  lockPath = join(gitPath, 'config.lock');
245
246
  const source = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
@@ -254,9 +255,11 @@ async function configureGitFile(root, stateDir, commands, repositoryUrl) {
254
255
  finally {
255
256
  await source.close();
256
257
  }
258
+ // private
257
259
  await mkdir(stateDir, { recursive: true, mode: 0o700 });
258
260
  temporary = await mkdtemp(join(stateDir, 'git-config-'));
259
261
  const staging = join(temporary, 'config');
262
+ // private
260
263
  await writeFile(staging, content, { mode: 0o600 });
261
264
  await clearManagedGitConfig(staging, repositoryUrl);
262
265
  for (const args of commands)
@@ -39,6 +39,7 @@ export class WorkspaceGitDirectStore {
39
39
  throw new Error('git_control_directory_rejected');
40
40
  }
41
41
  for (const path of [stateDir, directory]) {
42
+ // private
42
43
  if (path === directory && create)
43
44
  await mkdir(path, { mode: 0o700 }).catch(error => {
44
45
  if (error.code !== 'EEXIST')
@@ -105,6 +106,7 @@ export class WorkspaceGitDirectStore {
105
106
  if (rejectedPassword !== undefined && old?.access?.password !== rejectedPassword)
106
107
  return;
107
108
  const temporary = join(path, `${randomUUID()}.tmp`);
109
+ // private
108
110
  const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
109
111
  try {
110
112
  await handle.writeFile(JSON.stringify({ identity: this.identity(), binding, access }));
@@ -33,6 +33,42 @@ export function editJson5Paths(source, changes) {
33
33
  JSON5.parse(next);
34
34
  return next;
35
35
  }
36
+ /** Replace complete language records while preserving every unrelated authored member. */
37
+ export function editJson5StringRecords(source, changes) {
38
+ const parsed = JSON5.parse(source);
39
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
40
+ throw new ParameterEditError('parameter_document_invalid');
41
+ if (new Set(changes.map(change => change.key)).size !== changes.length)
42
+ throw new ParameterEditError('parameter_changes_overlap');
43
+ let next = source;
44
+ for (const change of changes) {
45
+ if (!change.key || ['__proto__', 'prototype', 'constructor'].includes(change.key))
46
+ throw new ParameterEditError('parameter_value_invalid');
47
+ const object = readJson5Object(next, skipTrivia(next, 0, next.length));
48
+ const memberIndex = object.members.findIndex(member => member.name === change.key);
49
+ const member = object.members[memberIndex];
50
+ if ('op' in change) {
51
+ if (!member)
52
+ continue;
53
+ const comma = member.comma ?? object.members[memberIndex - 1]?.comma;
54
+ const spans = [{ start: member.keyStart, end: member.value.end }, ...(comma === undefined ? [] : [{ start: comma, end: comma + 1 }])].sort((left, right) => right.start - left.start);
55
+ for (const span of spans)
56
+ next = next.slice(0, span.start) + next.slice(span.end);
57
+ }
58
+ else {
59
+ if (!change.value || typeof change.value !== 'object' || Array.isArray(change.value) || Object.values(change.value).some(value => typeof value !== 'string'))
60
+ throw new ParameterEditError('parameter_value_invalid');
61
+ if (!member)
62
+ next = editJson5Paths(next, [{ fieldPath: [change.key], value: '' }]);
63
+ const span = findJson5ValueSpan(next, [change.key]);
64
+ if (!span)
65
+ throw new ParameterEditError('parameter_field_missing');
66
+ next = next.slice(0, span.start) + JSON.stringify(change.value) + next.slice(span.end);
67
+ }
68
+ }
69
+ JSON5.parse(next);
70
+ return next;
71
+ }
36
72
  function editJson5Object(source, braceIndex, change, depth) {
37
73
  const object = readJson5Object(source, braceIndex);
38
74
  const memberIndex = object.members.findIndex((member) => member.name === change.fieldPath[depth]);
@@ -11,6 +11,7 @@ async function safeDirectory(root, path, create = false) {
11
11
  let directory = root;
12
12
  for (const part of path.split('/').filter(Boolean)) {
13
13
  directory = join(directory, part);
14
+ // shared
14
15
  if (create)
15
16
  await mkdir(directory, { mode: 0o755 }).catch((error) => {
16
17
  if (error.code !== 'EEXIST')
@@ -173,12 +174,14 @@ async function importAttachment(root, body) {
173
174
  if (!record) {
174
175
  const temporary = join(uploads, `.import-${randomUUID()}`);
175
176
  const target = join(uploads, id);
177
+ // shared
176
178
  await mkdir(temporary, { mode: 0o755 });
177
179
  try {
178
180
  for (const file of files) {
179
181
  const parent = dirname(file.path);
180
182
  if (parent !== '.')
181
183
  await safeDirectory(temporary, parent, true);
184
+ // shared
182
185
  await writeFile(join(temporary, file.path), file.bytes, { flag: 'wx', mode: 0o644 });
183
186
  }
184
187
  const prefix = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}`;
@@ -236,6 +239,7 @@ async function importAttachment(root, body) {
236
239
  record.clientRequestIds = [...new Set([...record.clientRequestIds, clientRequestId])];
237
240
  record.unbound ||= !sessionId;
238
241
  const tempMetadata = join(metadata, `.import-${randomUUID()}.json`);
242
+ // shared
239
243
  await writeFile(tempMetadata, JSON.stringify(record), { flag: 'wx', mode: 0o644 });
240
244
  await rename(tempMetadata, join(metadata, `${id}.json`));
241
245
  await chmod(join(uploads, id), 0o755);
@@ -38,3 +38,5 @@ Report JSON (JavaScript Object Notation, JavaScript 对象表示法) contract (n
38
38
  - No passed/ready/live/checks fields are accepted in the report. All implementation and validation statements remain claims until platform verification.
39
39
  - If blocked or partial, preserve reviewable changes and list concrete missing work. Do not overwrite later user edits or claim multi-file changes were atomic.
40
40
  `;
41
+ /** Initialization strategy comes from the locked skill; report validation stays platform-owned. */
42
+ export const PARAMETER_AGENT_REPORT_PROTOCOL = PARAMETER_AGENT_PROTOCOL.slice(PARAMETER_AGENT_PROTOCOL.indexOf('Report JSON'));
@@ -17,6 +17,7 @@ async function taskDirectory(state, workspaceId, directoryName, root) {
17
17
  }
18
18
  const scope = [state.config.environment, state.config.userId, state.config.runtimeId, workspaceId, directoryName];
19
19
  const directory = join(parent, parameterDigest(JSON.stringify(scope)));
20
+ // private
20
21
  await mkdir(directory, { recursive: true, mode: 0o700 });
21
22
  if ((await lstat(parent)).isSymbolicLink() || (await lstat(directory)).isSymbolicLink())
22
23
  throw new ParameterAgentError('parameter_agent_state_invalid');
@@ -47,6 +48,7 @@ export async function writePrivateParameterTask(state, task, root) {
47
48
  const directory = await taskDirectory(state, task.record.workspaceId, task.record.directoryName, root);
48
49
  const record = parameterAgentTaskRecordSchema.parse(task.record);
49
50
  const temporary = join(directory, `.${record.taskId}.${randomUUID()}.tmp`);
51
+ // private
50
52
  await writeFile(temporary, JSON.stringify({ ...task, record }), { mode: 0o600 });
51
53
  await rename(temporary, join(directory, `${record.taskId}.json`));
52
54
  }
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { parameterAgentReportSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, parameterCatalog, stableStringify, validateParameterChange, } from '@playcraft/common/cloud-workspace/runtime';
2
+ import { parameterAgentReportSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, parameterI18nCatalog, stableStringify, validateParameterChange, validateParameterI18nChange, } from '@playcraft/common/cloud-workspace/runtime';
3
3
  import { readWorkspaceParameters } from './parameters.js';
4
4
  import { revisionScope } from './registry.js';
5
5
  import { runDeveloperCommand } from './developer-command.js';
6
- import { PARAMETER_AGENT_PROTOCOL } from './parameter-agent-protocol.js';
6
+ import { PARAMETER_AGENT_PROTOCOL, PARAMETER_AGENT_REPORT_PROTOCOL } from './parameter-agent-protocol.js';
7
+ import { inspectParameterInitialization } from './parameter-initialization.js';
8
+ import { inspectParameterProductionCheck, prepareParameterProductionCheck } from './parameter-production.js';
7
9
  import { ParameterAgentError, parameterDigest, parameterInputSnapshot, readParameterAgentFile } from './parameter-agent-inputs.js';
8
10
  import { listPrivateParameterTaskIds, parameterTaskWorkspace, readPrivateParameterTask, writePrivateParameterTask } from './parameter-agent-store.js';
9
11
  export { ParameterAgentError } from './parameter-agent-inputs.js';
@@ -58,14 +60,19 @@ async function inspectReport(root, task, snapshot) {
58
60
  }
59
61
  return report;
60
62
  }
61
- export async function prepareParameterAgentTask(state, operation, value, root) {
63
+ export async function prepareParameterAgentTask(state, operation, value, root, options = {}) {
62
64
  const request = parameterAgentTaskRequestSchema.parse(value);
63
65
  if (operation.kind !== 'session.turn' || operation.clientRequestId !== request.taskId)
64
66
  throw new ParameterAgentError('parameter_agent_task_identity_invalid');
65
67
  const owner = scope(state, operation);
68
+ const skill = request.action === 'initialize' ? options.initializationSkill : undefined;
69
+ if (request.action === 'initialize' && (!skill || !skill.entryKey || !/^[a-f0-9]{64}$/.test(skill.lockHash) || !skill.path))
70
+ throw new ParameterAgentError('parameter_initialization_skill_unavailable');
66
71
  const existing = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, request.taskId, root);
67
72
  if (existing && (existing.record.sessionId !== owner.sessionId || !current(state, existing) || stableStringify(existing.request) !== stableStringify(request)))
68
73
  throw new ParameterAgentError('parameter_agent_task_conflict');
74
+ if (existing && skill && (existing.record.initializationSkill?.entryKey !== skill.entryKey || existing.record.initializationSkill.lockHash !== skill.lockHash))
75
+ throw new ParameterAgentError('parameter_initialization_skill_changed');
69
76
  const document = await readWorkspaceParameters(root, state.journal.fileRevisions(revisionScope(state, operation.workspaceId, owner.directoryName)));
70
77
  if (request.sourceFingerprint && request.sourceFingerprint !== document.sourceFingerprint)
71
78
  throw new ParameterAgentError('parameter_source_conflict');
@@ -84,7 +91,7 @@ export async function prepareParameterAgentTask(state, operation, value, root) {
84
91
  const timestamp = now();
85
92
  const task = existing ?? {
86
93
  request, initialDigest: initial.digest, instanceToken: state.instanceToken,
87
- record: { version: 1, taskId: request.taskId, action: request.action, ...(request.goal ? { goal: request.goal } : {}), ...owner, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch, status: 'running', reportPath: reportPath(request.taskId), checks: [], createdAt: timestamp, updatedAt: timestamp },
94
+ record: { version: 1, taskId: request.taskId, action: request.action, ...(request.goal ? { goal: request.goal } : {}), ...(skill ? { initializationSkill: { entryKey: skill.entryKey, lockHash: skill.lockHash } } : {}), ...owner, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch, status: 'running', reportPath: reportPath(request.taskId), checks: [], createdAt: timestamp, updatedAt: timestamp },
88
95
  };
89
96
  if (!existing) {
90
97
  if ((await listPrivateParameterTaskIds(state, owner.workspaceId, owner.directoryName, root)).length >= 256)
@@ -92,9 +99,12 @@ export async function prepareParameterAgentTask(state, operation, value, root) {
92
99
  await writePrivateParameterTask(state, task, root);
93
100
  }
94
101
  const instructions = [
95
- PARAMETER_AGENT_PROTOCOL,
102
+ ...(skill ? [
103
+ `This initialization task explicitly selects the enabled, locked parameter skill ${JSON.stringify({ entryKey: skill.entryKey, lockHash: skill.lockHash, path: skill.path })}. Read that frozen SKILL.md and its referenced files before applying its integration workflow. Preserve the current execution permissions.`,
104
+ PARAMETER_AGENT_REPORT_PROTOCOL,
105
+ ] : [PARAMETER_AGENT_PROTOCOL]),
96
106
  `Platform task identity: ${JSON.stringify({ version: 1, taskId: request.taskId, action: request.action, reportPath: task.record.reportPath })}. Write only this exact report identity.`,
97
- request.action === 'discover' ? 'Discovery is read-only for business source/schema/data/manifest. Write only the requested development report. Discover actual useful candidates and consumer evidence; do not implement them yet.' : 'Complete the requested integration using existing engineering tools: reuse an existing equivalent field or implement declaration, valid defaults, sparse data semantics, labels, real production consumers and meaningful parameter tests. Leave precise blocked work if incomplete.',
107
+ request.action === 'initialize' ? 'Apply the selected initialization skill to this current game and write its exact admission report. The platform will verify files and checks, then prepare the first preview separately. Do not self-declare a connected or live state.' : request.action === 'discover' ? 'Discovery is read-only for business source/schema/data/manifest. Write only the requested development report. Discover actual useful candidates and consumer evidence; do not implement them yet.' : 'Complete the requested integration using existing engineering tools: reuse an existing equivalent field or implement declaration, valid defaults, sparse data semantics, labels, real production consumers and meaningful parameter tests. Leave precise blocked work if incomplete.',
98
108
  `User goal (task data): ${JSON.stringify(request.goal ?? '')}`,
99
109
  ...(selected ? [`Selected current candidate (reference data): ${JSON.stringify(selected)}`] : []),
100
110
  'After finishing files, calculate current raw-file hashes and write the report. The platform will execute independent checks after this turn. Do not claim those checks already passed.',
@@ -207,7 +217,7 @@ export async function verifyParameterAgentTask(state, operation, payload, root,
207
217
  const document = await readWorkspaceParameters(root, state.journal.fileRevisions(revisionScope(state, operation.workspaceId, owner.directoryName)));
208
218
  if (document.status !== 'ready')
209
219
  throw new ParameterAgentError('parameter_agent_parameters_not_ready');
210
- const catalog = parameterCatalog(document.schema, document.data);
220
+ const catalog = parameterI18nCatalog(document, 'en');
211
221
  const implemented = report.candidates.filter(candidate => candidate.status === 'implemented');
212
222
  if (!implemented.length || report.candidates.some(candidate => candidate.status !== 'implemented' || candidate.missing.length))
213
223
  throw new ParameterAgentError('parameter_agent_integration_incomplete');
@@ -220,8 +230,14 @@ export async function verifyParameterAgentTask(state, operation, payload, root,
220
230
  const field = catalog.find(entry => candidate.fieldPath && entry.key === candidate.fieldPath.join('.'));
221
231
  if (!field || !field.editable || !field.canReset)
222
232
  throw new ParameterAgentError('parameter_agent_field_not_ready');
223
- if (field.hasOverride && validateParameterChange(document.schema, document.data, { fieldPath: field.fieldPath, value: field.value }))
233
+ const invalidValue = field.kind === 'i18n'
234
+ ? validateParameterI18nChange(document, { fieldPath: field.fieldPath, locale: 'en', value: String(field.value ?? '') })
235
+ : field.hasOverride && validateParameterChange(document.schema, document.data, { fieldPath: field.fieldPath, value: field.value });
236
+ if (invalidValue)
224
237
  throw new ParameterAgentError('parameter_agent_field_not_ready');
238
+ if (field.kind === 'i18n' && document.runtimeI18n &&
239
+ (!evidence.has(document.runtimeI18n.globalPath) || (document.runtimeI18n.themeHash && !evidence.has(document.runtimeI18n.themePath))))
240
+ throw new ParameterAgentError('parameter_agent_evidence_missing');
225
241
  if (!candidate.consumers.length)
226
242
  throw new ParameterAgentError('parameter_agent_consumer_missing');
227
243
  }
@@ -230,11 +246,17 @@ export async function verifyParameterAgentTask(state, operation, payload, root,
230
246
  if (typeof packageData.scripts?.[script] !== 'string' || !packageData.scripts[script].trim())
231
247
  throw new ParameterAgentError('parameter_agent_check_script_missing');
232
248
  }
249
+ const initialization = task.request.action === 'initialize'
250
+ ? await inspectParameterInitialization(root, document, report) : undefined;
233
251
  task.instanceToken = state.instanceToken;
234
- await update(state, task, { status: 'checking', checks: [], error: undefined, receiptId: undefined, sourceDigest: before.digest }, root);
252
+ await update(state, task, { status: 'checking', checks: [], error: undefined, receiptId: undefined, productionCheck: undefined, sourceDigest: before.digest }, root);
235
253
  const scripts = ['test:parameters', 'build', ...(typeof packageData.scripts?.typecheck === 'string' ? ['typecheck'] : [])];
254
+ let productionPlan;
255
+ let productionEvidence;
236
256
  for (const script of scripts) {
237
257
  assertActive();
258
+ if (initialization && script === 'build')
259
+ productionPlan = await prepareParameterProductionCheck(root);
238
260
  const controller = new AbortController();
239
261
  const abort = () => controller.abort();
240
262
  options.signal?.addEventListener('abort', abort, { once: true });
@@ -269,16 +291,23 @@ export async function verifyParameterAgentTask(state, operation, payload, root,
269
291
  await writePrivateParameterTask(state, task, root);
270
292
  if (outcome.code !== 0 || outcome.timedOut || outcome.canceled)
271
293
  throw new ParameterAgentError(outcome.timedOut ? 'parameter_agent_check_timeout' : outcome.canceled ? 'parameter_agent_check_canceled' : 'parameter_agent_check_failed');
294
+ if (productionPlan && script === 'build')
295
+ productionEvidence = await inspectParameterProductionCheck(productionPlan, assertActive);
272
296
  }
273
297
  assertActive();
274
298
  const after = await parameterInputSnapshot(root);
275
299
  if (after.digest !== before.digest || parameterDigest(await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024)) !== task.reportHash)
276
300
  throw new ParameterAgentError('parameter_agent_inputs_changed');
277
- return update(state, task, { status: 'verified', sourceDigest: after.digest, receiptId: randomUUID(), error: undefined }, root);
301
+ if (initialization && initialization !== await inspectParameterInitialization(root, document, report))
302
+ throw new ParameterAgentError('parameter_agent_inputs_changed');
303
+ if (productionPlan && stableStringify(productionEvidence) !== stableStringify(await inspectParameterProductionCheck(productionPlan, assertActive)))
304
+ throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
305
+ return update(state, task, { status: 'verified', sourceDigest: after.digest, receiptId: randomUUID(), error: undefined,
306
+ ...(productionEvidence ? { productionCheck: { ...productionEvidence, checkedAt: now() } } : {}) }, root);
278
307
  }
279
308
  catch (cause) {
280
309
  const code = errorCode(cause);
281
310
  const status = code === 'ENOENT' ? 'missing' : /stale|changed|expired/.test(code) ? 'stale' : /(?:not_ready|incomplete|consumer_missing|script_missing|evidence_missing)$/.test(code) ? 'needs-verification' : /report_|evidence_invalid|path_invalid/.test(code) ? 'invalid' : 'failed';
282
- return update(state, task, { status, error: code === 'ENOENT' ? 'parameter_agent_report_missing' : code, receiptId: undefined }, root);
311
+ return update(state, task, { status, error: code === 'ENOENT' ? 'parameter_agent_report_missing' : code, receiptId: undefined, productionCheck: undefined }, root);
283
312
  }
284
313
  }
@@ -159,6 +159,7 @@ export async function writeParameterAssetRegistration(root, plan) {
159
159
  throw new ParameterAssetError('parameter_asset_index_conflict');
160
160
  const parent = await lstat(dirname(target));
161
161
  const bytes = Buffer.from(plan.content);
162
+ // private
162
163
  staged = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
163
164
  // Metadata restoration must succeed before publication; never downgrade to a partial text write.
164
165
  await staged.chown(before.uid, before.gid);