@playcraft/cli 0.1.60-beta → 0.1.61-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 (32) hide show
  1. package/dist/project-skills/builtin-import.js +2 -2
  2. package/dist/skill-registry/messages.js +2 -2
  3. package/dist/skill-registry/platform-package.js +2 -4
  4. package/dist/workspace-runtime/codex/app-server.js +24 -1
  5. package/dist/workspace-runtime/codex/config-toml.js +9 -5
  6. package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
  7. package/dist/workspace-runtime/codex/launcher-client.js +25 -13
  8. package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
  9. package/dist/workspace-runtime/processes/skill-command.js +130 -0
  10. package/dist/workspace-runtime/serve.js +7 -1
  11. package/dist/workspace-runtime/server/accept-operation.js +10 -1
  12. package/dist/workspace-runtime/server/directory-access.js +10 -3
  13. package/dist/workspace-runtime/server/dispatch.js +39 -1
  14. package/dist/workspace-runtime/server/http.js +25 -3
  15. package/dist/workspace-runtime/server/operation-record.js +4 -1
  16. package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
  17. package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
  18. package/dist/workspace-runtime/workspaces/directories.js +4 -1
  19. package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
  20. package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
  21. package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
  22. package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
  23. package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
  24. package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
  25. package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
  26. package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
  27. package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
  28. package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
  29. package/dist/workspace-runtime/workspaces/skills.js +15 -5
  30. package/package.json +3 -3
  31. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  32. package/project-templates/empty/AGENTS.md +10 -0
@@ -0,0 +1,595 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { lstat, readFile, readdir, rmdir } from 'node:fs/promises';
4
+ import { extname, join, relative } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { z } from 'zod';
7
+ import { ProductionSkillFailure } from '../../codex/production-skill-tools.js';
8
+ import { withDirectoryOwner } from '../../server/directory-access.js';
9
+ import { runSkillCommand } from '../../processes/skill-command.js';
10
+ import { resolveActiveSkillExecutionContext } from '../skill-execution-context.js';
11
+ import { freezeWorkspaceSkills } from '../skills.js';
12
+ import { loadWorkspaceSkillArtifact } from '../skill-artifact-loader.js';
13
+ import { ensureSharedDirectory, writeSharedFile } from '../shared-write.js';
14
+ import { checkedFile, digest, verifyFiles, writeControlJson } from './files.js';
15
+ import { adoptCandidate, freezeCandidate, verifyCandidate, candidateWorkspaceFiles, } from './candidate.js';
16
+ import { requestSchema, captureSchema } from './contracts.js';
17
+ import { captureV2Schema, reviewProfileHash, validateRenderReceipt } from './review-contracts.js';
18
+ function verifyControlledSpecChange(before, after, action, kind) {
19
+ const previous = structuredClone(before), current = structuredClone(after);
20
+ if (action === 'tier1-diagnostics') {
21
+ const oldRows = previous.tier1Results ?? [], newRows = current.tier1Results;
22
+ if (!Array.isArray(oldRows) ||
23
+ !Array.isArray(newRows) ||
24
+ newRows.length !== oldRows.length + 1 ||
25
+ !isDeepStrictEqual(oldRows, newRows.slice(0, -1)))
26
+ throw new Error('procedural_skill_unexpected_diagnostic_change');
27
+ delete previous.tier1Results;
28
+ delete current.tier1Results;
29
+ }
30
+ else if (action === 'request-correction') {
31
+ const oldRows = previous.reviewHistory ?? [], newRows = current.reviewHistory;
32
+ const correction = Array.isArray(newRows) ? newRows.at(-1) : undefined;
33
+ if (!Array.isArray(oldRows) ||
34
+ !Array.isArray(newRows) ||
35
+ newRows.length !== oldRows.length + 1 ||
36
+ !isDeepStrictEqual(oldRows, newRows.slice(0, -1)) ||
37
+ correction?.reviewType !== 'diagnostic-correction' ||
38
+ correction?.action !== kind ||
39
+ correction?.aiVisionScore !== undefined ||
40
+ correction?.estimatedFidelity !== undefined)
41
+ throw new Error('procedural_skill_unexpected_correction_change');
42
+ delete previous.reviewHistory;
43
+ delete current.reviewHistory;
44
+ }
45
+ else if (action === 'pipeline-sync') {
46
+ delete previous.sculptPipeline;
47
+ delete current.sculptPipeline;
48
+ }
49
+ if (!isDeepStrictEqual(previous, current))
50
+ throw new Error('procedural_skill_unexpected_diagnostic_change');
51
+ }
52
+ export function proceduralSkillHandler(state) {
53
+ return (request, context, signal) => execute(state, request, context, signal);
54
+ }
55
+ async function execute(state, raw, context, signal) {
56
+ const parsed = requestSchema.safeParse(raw);
57
+ if (!parsed.success)
58
+ throw new Error('procedural_skill_invalid_arguments');
59
+ const request = parsed.data;
60
+ const binding = await resolveActiveSkillExecutionContext(state, context);
61
+ return withDirectoryOwner(state, binding.operation, `operation:${binding.operation.operationId}`, async () => {
62
+ const root = context.root;
63
+ const frozen = await freezeWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), (entry) => loadWorkspaceSkillArtifact(state, binding.operation.workspaceId, entry, binding.operation.directoryName));
64
+ if (frozen.lockHash !== binding.lockHash)
65
+ throw new Error('context_skills_changed');
66
+ const skillRoot = join(root, '.playcraft/skills', binding.lockHash, binding.entry.entryKey);
67
+ const descriptorBytes = await checkedFile(skillRoot, 'capability.json');
68
+ const descriptor = JSON.parse(descriptorBytes.toString('utf8'));
69
+ if (descriptor.id !== 'playcraft.procedural-3d' ||
70
+ ![1, 2].includes(descriptor.hostProtocolVersion) ||
71
+ descriptor.entrypoint !== 'adapter/entry.mjs')
72
+ throw new Error('procedural_skill_incompatible');
73
+ const runId = request.runId ?? (request.action === 'prepare' ? randomUUID() : undefined);
74
+ if (!runId && !['describe', 'preflight'].includes(request.action))
75
+ throw new Error('procedural_skill_run_required');
76
+ const jobRoot = join(root, '.playcraft/procedural-3d/runs', runId ?? 'preflight');
77
+ const controlPath = join(state.config.stateDir, 'procedural-3d', digest(root), `${runId}.json`);
78
+ const current = async () => {
79
+ if (signal.aborted)
80
+ throw new Error('procedural_skill_cancelled');
81
+ const now = await resolveActiveSkillExecutionContext(state, context);
82
+ if (now.lockHash !== binding.lockHash ||
83
+ now.entry.revision.artifactHash !== binding.entry.revision.artifactHash)
84
+ throw new Error('context_skills_changed');
85
+ };
86
+ const guard = async (run) => {
87
+ for (const [path, hash] of [
88
+ ['adapter-state.json', run.adapterStateHash],
89
+ ['workspace/.img2threejs/state.json', run.upstreamStateHash],
90
+ ])
91
+ if (hash && digest(await checkedFile(jobRoot, path)) !== hash)
92
+ throw new Error('procedural_skill_state_changed');
93
+ };
94
+ const saveGuards = async (run) => {
95
+ run.adapterStateHash = digest(await checkedFile(jobRoot, 'adapter-state.json'));
96
+ run.upstreamStateHash = digest(await checkedFile(jobRoot, 'workspace/.img2threejs/state.json'));
97
+ };
98
+ const adapter = async (action, input = {}) => {
99
+ await current();
100
+ const result = await runSkillCommand(state, {
101
+ scope: binding.operation,
102
+ cwd: root,
103
+ argv: [process.execPath, join(skillRoot, 'adapter/entry.mjs')],
104
+ signal,
105
+ input: {
106
+ protocolVersion: 1,
107
+ action,
108
+ input,
109
+ context: {
110
+ jobRoot,
111
+ skillLockHash: binding.lockHash,
112
+ skillArtifactHash: binding.entry.revision.artifactHash,
113
+ },
114
+ },
115
+ });
116
+ await current();
117
+ let reply;
118
+ try {
119
+ reply = JSON.parse(result.stdout);
120
+ }
121
+ catch {
122
+ throw new Error('procedural_skill_invalid_response');
123
+ }
124
+ if (reply.protocolVersion !== 1 || typeof reply.ok !== 'boolean')
125
+ throw new Error('procedural_skill_invalid_response');
126
+ if (!reply.ok || result.code !== 0) {
127
+ const code = typeof reply.error?.code === 'string' && /^[a-z0-9-]{1,80}$/.test(reply.error.code)
128
+ ? reply.error.code
129
+ : 'adapter-failed';
130
+ const reportPath = reply.error?.reportPath;
131
+ if (typeof reportPath === 'string' &&
132
+ /^reports\/[a-zA-Z0-9_-]+\.json$/.test(reportPath)) {
133
+ await checkedFile(jobRoot, reportPath);
134
+ throw new ProductionSkillFailure(`procedural_skill:${code}`, relative(root, join(jobRoot, reportPath)));
135
+ }
136
+ throw new Error(`procedural_skill:${code}`);
137
+ }
138
+ const failure = reply.result?.diagnosticFailure;
139
+ if (failure) {
140
+ if (typeof failure.reportPath !== 'string' ||
141
+ !/^reports\/[a-zA-Z0-9_-]+\.json$/.test(failure.reportPath))
142
+ throw new Error('procedural_skill_invalid_report');
143
+ if (digest(await checkedFile(jobRoot, failure.reportPath)) !== failure.reportHash)
144
+ throw new Error('procedural_skill_invalid_report');
145
+ return {
146
+ ...reply.result,
147
+ diagnosticFailure: {
148
+ ...failure,
149
+ reportPath: relative(root, join(jobRoot, failure.reportPath)),
150
+ },
151
+ };
152
+ }
153
+ return reply.result;
154
+ };
155
+ if (request.action === 'describe' || request.action === 'preflight')
156
+ return adapter(request.action);
157
+ let run;
158
+ try {
159
+ run = JSON.parse(await readFile(controlPath, 'utf8'));
160
+ }
161
+ catch (error) {
162
+ if (error.code !== 'ENOENT')
163
+ throw error;
164
+ }
165
+ if (request.action === 'prepare') {
166
+ const input = z
167
+ .object({
168
+ referencePath: z.string(),
169
+ name: z.string().min(1).max(160),
170
+ maxPerPass: z.number().int().min(1).max(3).optional(),
171
+ maxTotal: z.number().int().min(1).max(6).optional(),
172
+ })
173
+ .strict()
174
+ .parse(request.input);
175
+ const bytes = await checkedFile(root, input.referencePath);
176
+ const extension = extname(input.referencePath).toLowerCase();
177
+ if (!['.png', '.jpg', '.jpeg'].includes(extension))
178
+ throw new Error('procedural_skill_reference_format');
179
+ if (run) {
180
+ if (run.referenceHash !== digest(bytes) ||
181
+ run.lockHash !== binding.lockHash ||
182
+ run.artifactHash !== binding.entry.revision.artifactHash)
183
+ throw new Error('procedural_skill_run_conflict');
184
+ return { runId, revision: run.revision, status: run.status, jobRoot, resumed: true };
185
+ }
186
+ const environment = await adapter('preflight');
187
+ await ensureSharedDirectory(root, jobRoot);
188
+ const referencePath = `input/reference${extension}`;
189
+ await writeSharedFile(root, join(jobRoot, referencePath), bytes);
190
+ run = {
191
+ schemaVersion: 1,
192
+ runId: runId,
193
+ revision: 0,
194
+ root,
195
+ lockHash: binding.lockHash,
196
+ artifactHash: binding.entry.revision.artifactHash,
197
+ referencePath,
198
+ referenceHash: digest(bytes),
199
+ descriptorHash: digest(descriptorBytes),
200
+ hostProtocolVersion: descriptor.hostProtocolVersion,
201
+ operationId: binding.operation.operationId,
202
+ status: 'preparing',
203
+ environment,
204
+ candidates: [],
205
+ receipts: [],
206
+ };
207
+ await writeControlJson(controlPath, run);
208
+ try {
209
+ const result = await adapter('prepare', {
210
+ ...input,
211
+ referencePath,
212
+ referenceSha256: run.referenceHash,
213
+ });
214
+ run.status = 'prepared';
215
+ run.revision++;
216
+ await saveGuards(run);
217
+ await writeControlJson(controlPath, run);
218
+ return { runId, revision: run.revision, jobRoot, ...result };
219
+ }
220
+ catch (error) {
221
+ run.status = 'uncertain';
222
+ run.lastError = error instanceof Error ? error.message : 'prepare_failed';
223
+ run.lastReportPath =
224
+ error instanceof ProductionSkillFailure ? error.reportPath : undefined;
225
+ await writeControlJson(controlPath, run);
226
+ throw error;
227
+ }
228
+ }
229
+ if (!run || run.root !== root)
230
+ throw new Error('procedural_skill_run_missing');
231
+ if (run.lockHash !== binding.lockHash ||
232
+ run.artifactHash !== binding.entry.revision.artifactHash ||
233
+ run.descriptorHash !== digest(descriptorBytes) ||
234
+ (run.hostProtocolVersion ?? 1) !== descriptor.hostProtocolVersion)
235
+ throw new Error('blocked-lock-mismatch');
236
+ if (digest(await checkedFile(jobRoot, run.referencePath)) !== run.referenceHash)
237
+ throw new Error('procedural_skill_reference_changed');
238
+ await guard(run);
239
+ if (request.action === 'status')
240
+ return {
241
+ runId,
242
+ revision: run.revision,
243
+ jobRoot,
244
+ runStatus: run.status,
245
+ lastError: run.lastError,
246
+ reportPath: run.lastReportPath,
247
+ environment: run.environment,
248
+ candidates: run.candidates,
249
+ ...(await adapter('status')),
250
+ ...(descriptor.hostProtocolVersion === 2 ? { referenceHash: run.referenceHash } : {}),
251
+ };
252
+ if (request.expectedRevision !== run.revision)
253
+ throw new Error('procedural_skill_revision_conflict');
254
+ if (['uncertain', 'running'].includes(run.status) && request.action !== 'recover')
255
+ throw new Error('procedural_skill_recovery_required');
256
+ if (request.action === 'recover' && (!run.adapterStateHash || !run.upstreamStateHash))
257
+ throw new Error('procedural_skill_recovery_required');
258
+ run.revision++;
259
+ run.status = 'running';
260
+ run.operationId = binding.operation.operationId;
261
+ await writeControlJson(controlPath, run);
262
+ try {
263
+ const candidate = run.candidates.at(-1);
264
+ let result;
265
+ if (request.action === 'recover') {
266
+ if (Object.keys(request.input).length)
267
+ throw new Error('procedural_skill_invalid_arguments');
268
+ const scope = state.config.workspaceMode === 'multi'
269
+ ? `${binding.operation.workspaceId}:${binding.operation.directoryName ?? 'basic'}`
270
+ : 'basic';
271
+ if (state.journal
272
+ .listManagedWrites()
273
+ .some((item) => item.workspaceId === scope && !item.stoppedAt))
274
+ throw new Error('procedural_skill_stop_unconfirmed');
275
+ const marker = join(jobRoot, '.adapter-running');
276
+ const stat = await lstat(marker).catch((error) => {
277
+ if (error.code === 'ENOENT')
278
+ return undefined;
279
+ throw error;
280
+ });
281
+ if (stat) {
282
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (await readdir(marker)).length)
283
+ throw new Error('procedural_skill_recovery_required');
284
+ await rmdir(marker);
285
+ }
286
+ // guard() above compared the control-owned checkpoint. A partial upstream commit remains
287
+ // blocked instead of silently accepting new state or resetting its correction budget.
288
+ result = await adapter('status');
289
+ }
290
+ else if (request.action === 'capture') {
291
+ const input = descriptor.hostProtocolVersion === 2
292
+ ? captureV2Schema.parse(request.input)
293
+ : captureSchema.parse(request.input), status = await adapter('status');
294
+ if (status.currentStep !== 'render-capture' || typeof status.currentPass !== 'string')
295
+ throw new Error('procedural_skill_out_of_order');
296
+ const review = 'review' in input ? input.review : undefined;
297
+ if (review &&
298
+ run.reviewProfile &&
299
+ reviewProfileHash(review.profile) !== run.reviewProfile.hash)
300
+ throw new Error('procedural_skill_review_profile_changed');
301
+ const helpers = review
302
+ ? {
303
+ scene: await checkedFile(skillRoot, 'adapter/review-scene.mjs'),
304
+ profile: await checkedFile(skillRoot, 'adapter/review-profile.mjs'),
305
+ }
306
+ : undefined;
307
+ const fresh = await freezeCandidate(root, jobRoot, {
308
+ ...input,
309
+ views: [...new Set(input.views)],
310
+ passId: status.currentPass,
311
+ }, helpers);
312
+ const outputRoot = join(jobRoot, 'candidates', fresh.id, 'captures');
313
+ const render = await runSkillCommand(state, {
314
+ scope: binding.operation,
315
+ cwd: root,
316
+ signal,
317
+ argv: [process.execPath, fileURLToPath(new URL('./render-worker.js', import.meta.url))],
318
+ input: {
319
+ projectRoot: root,
320
+ sourceRoot: join(jobRoot, 'candidates', fresh.id, 'source'),
321
+ outputRoot,
322
+ pagePath: fresh.pagePath,
323
+ views: fresh.views,
324
+ referencePath: join(jobRoot, run.referencePath),
325
+ ...(fresh.hostReview
326
+ ? {
327
+ reviewProfile: {
328
+ profile: fresh.hostReview.profile,
329
+ hash: fresh.hostReview.profileHash,
330
+ },
331
+ }
332
+ : {}),
333
+ },
334
+ });
335
+ if (render.code !== 0) {
336
+ const reportPath = `reports/host-render-${randomUUID()}.json`;
337
+ await writeSharedFile(root, join(jobRoot, reportPath), JSON.stringify({
338
+ kind: 'render-failure',
339
+ ...(fresh.hostReview
340
+ ? {
341
+ candidateId: fresh.id,
342
+ sourceHash: fresh.sourceHash,
343
+ reviewProfileHash: fresh.hostReview.profileHash,
344
+ }
345
+ : {}),
346
+ code: render.code,
347
+ stdout: render.stdout.slice(-65536),
348
+ stderr: render.stderr.slice(-65536),
349
+ }));
350
+ throw new ProductionSkillFailure('procedural_skill_render_failed', relative(root, join(jobRoot, reportPath)));
351
+ }
352
+ try {
353
+ const report = JSON.parse(render.stdout);
354
+ if (report.ok !== true || !Array.isArray(report.result?.captures))
355
+ throw new Error('procedural_skill_render_failed');
356
+ if (fresh.hostReview) {
357
+ const expected = [...fresh.views, 'map-stripped', 'comparison'];
358
+ if (report.result.captures.length !== expected.length ||
359
+ new Set(report.result.captures.map((capture) => capture.view))
360
+ .size !== expected.length ||
361
+ report.result.captures.some((capture) => !expected.includes(capture.view) || capture.file !== `${capture.view}.png`))
362
+ throw new Error('procedural_skill_review_capture_mismatch');
363
+ }
364
+ fresh.captures = await Promise.all(report.result.captures.map(async (item) => {
365
+ const path = `candidates/${fresh.id}/captures/${item.file}`;
366
+ return { path, view: item.view, hash: digest(await checkedFile(jobRoot, path)) };
367
+ }));
368
+ fresh.render = report.result;
369
+ if (fresh.hostReview) {
370
+ const { renderReceipt: receipt, ...summary } = report.result;
371
+ fresh.render = summary;
372
+ if (receipt?.file !== 'render-state.json')
373
+ throw new Error('procedural_skill_review_receipt_missing');
374
+ const path = `candidates/${fresh.id}/captures/render-state.json`;
375
+ const bytes = await checkedFile(jobRoot, path);
376
+ if (receipt.hash !== digest(bytes))
377
+ throw new Error('procedural_skill_review_receipt_changed');
378
+ validateRenderReceipt(JSON.parse(bytes.toString('utf8')), fresh.hostReview.profile, fresh.views);
379
+ fresh.renderReceipt = { path, hash: digest(bytes) };
380
+ }
381
+ await verifyCandidate(root, jobRoot, fresh);
382
+ }
383
+ catch (error) {
384
+ if (!fresh.hostReview)
385
+ throw error;
386
+ const reportPath = `reports/host-render-contract-${randomUUID()}.json`;
387
+ await writeSharedFile(root, join(jobRoot, reportPath), JSON.stringify({
388
+ kind: 'render-review-contract-failure',
389
+ candidateId: fresh.id,
390
+ sourceHash: fresh.sourceHash,
391
+ reviewProfileHash: fresh.hostReview.profileHash,
392
+ reason: 'render-state-or-capture-mismatch',
393
+ }));
394
+ throw new ProductionSkillFailure('procedural_skill_review_state_invalid', relative(root, join(jobRoot, reportPath)));
395
+ }
396
+ await current();
397
+ if (!fresh.hostReview)
398
+ run.candidates.push(fresh);
399
+ await writeSharedFile(root, join(jobRoot, `candidates/${fresh.id}/capture.json`), JSON.stringify(fresh, null, 2));
400
+ result = await adapter('host-receipt', {
401
+ stepId: 'render-capture',
402
+ evidence: [`candidates/${fresh.id}/capture.json`],
403
+ });
404
+ if (fresh.hostReview) {
405
+ run.candidates.push(fresh);
406
+ if (!run.reviewProfile)
407
+ run.reviewProfile = {
408
+ profile: fresh.hostReview.profile,
409
+ hash: fresh.hostReview.profileHash,
410
+ };
411
+ }
412
+ }
413
+ else if (request.action === 'review') {
414
+ if (!candidate)
415
+ throw new Error('procedural_skill_candidate_missing');
416
+ await verifyCandidate(root, jobRoot, candidate);
417
+ const input = z
418
+ .object({
419
+ candidateId: z.string().uuid(),
420
+ review: z.record(z.unknown()),
421
+ uncertainty: z.string().max(8000).default(''),
422
+ })
423
+ .strict()
424
+ .parse(request.input);
425
+ if (input.candidateId !== candidate.id)
426
+ throw new Error('procedural_skill_candidate_changed');
427
+ const review = {
428
+ ...input.review,
429
+ render: candidate.captures.find((item) => item.view === 'hero')?.path ??
430
+ candidate.captures[0].path,
431
+ comparison: candidate.captures.find((item) => item.view === 'comparison')?.path,
432
+ mapStrippedRender: candidate.captures.find((item) => item.view === 'map-stripped')
433
+ ?.path,
434
+ };
435
+ const evidence = `candidates/${candidate.id}/review.json`;
436
+ await writeSharedFile(root, join(jobRoot, evidence), JSON.stringify({
437
+ candidateId: candidate.id,
438
+ sourceHash: candidate.sourceHash,
439
+ specHash: candidate.specHash,
440
+ review,
441
+ uncertainty: input.uncertainty,
442
+ sessionId: context.sessionId,
443
+ operationId: binding.operation.operationId,
444
+ }, null, 2));
445
+ result = await adapter('host-receipt', {
446
+ stepId: 'ai-review-recorded',
447
+ evidence: [evidence],
448
+ review,
449
+ });
450
+ const beforeSpecHash = candidate.expectedSpecHash;
451
+ candidate.expectedSpecHash = digest(await checkedFile(join(jobRoot, 'workspace'), 'object-sculpt-spec.json'));
452
+ run.receipts.push({
453
+ action: 'ai-review-recorded',
454
+ beforeSpecHash,
455
+ afterSpecHash: candidate.expectedSpecHash,
456
+ revision: run.revision,
457
+ });
458
+ candidate.review = {
459
+ decision: input.review.action === 'continue' ? 'accept' : 'revise',
460
+ findings: String(input.review.summary ?? ''),
461
+ uncertainty: input.uncertainty,
462
+ sourceHash: candidate.sourceHash,
463
+ };
464
+ }
465
+ else if (request.action === 'adopt') {
466
+ const input = z
467
+ .object({ candidateId: z.string().uuid(), assetId: z.string() })
468
+ .strict()
469
+ .parse(request.input);
470
+ const status = await adapter('status');
471
+ if (!candidate ||
472
+ candidate.id !== input.candidateId ||
473
+ candidate.passId !== 'optimization-pass' ||
474
+ status.currentStep !== 'emission-target')
475
+ throw new Error('procedural_skill_final_candidate_required');
476
+ const adopted = await adoptCandidate(root, jobRoot, candidate, input.assetId);
477
+ candidate.adoptedPath = adopted.path;
478
+ const evidence = `candidates/${candidate.id}/adoption.json`;
479
+ await writeSharedFile(root, join(jobRoot, evidence), JSON.stringify(adopted));
480
+ await adapter('host-receipt', { stepId: 'emission-target', evidence: [evidence] });
481
+ result = adopted;
482
+ run.status = 'accepted';
483
+ }
484
+ else {
485
+ const metadataStep = request.action === 'request-correction' ||
486
+ (request.action === 'run-step' &&
487
+ [
488
+ 'tier1-diagnostics',
489
+ 'multi-angle-review',
490
+ 'pass-gate-check',
491
+ 'pipeline-sync',
492
+ ].includes(String(request.input.stepId)));
493
+ if (request.action === 'request-correction' && !candidate)
494
+ throw new Error('procedural_skill_candidate_missing');
495
+ if (metadataStep && candidate)
496
+ await verifyCandidate(root, jobRoot, candidate);
497
+ const beforeSpecHash = candidate?.expectedSpecHash;
498
+ const beforeSpec = metadataStep && candidate
499
+ ? JSON.parse((await checkedFile(join(jobRoot, 'workspace'), 'object-sculpt-spec.json')).toString('utf8'))
500
+ : undefined;
501
+ let adapterInput = request.input;
502
+ if (request.action === 'run-step' &&
503
+ ['tier1-diagnostics', 'multi-angle-review'].includes(String(request.input.stepId))) {
504
+ if (!candidate)
505
+ throw new Error('procedural_skill_candidate_missing');
506
+ const parameters = request.input.parameters;
507
+ if (parameters !== undefined &&
508
+ (!parameters || typeof parameters !== 'object' || Object.keys(parameters).length))
509
+ throw new Error('procedural_skill_diagnostics_use_current_capture');
510
+ const paths = new Map();
511
+ for (const capture of candidate.captures) {
512
+ const path = `host-evidence/${candidate.id}/${capture.view}.png`;
513
+ await writeSharedFile(root, join(jobRoot, 'workspace', path), await checkedFile(jobRoot, capture.path));
514
+ paths.set(capture.view, path);
515
+ }
516
+ adapterInput = {
517
+ stepId: request.input.stepId,
518
+ parameters: request.input.stepId === 'tier1-diagnostics'
519
+ ? {
520
+ render: paths.get('hero'),
521
+ mapStrippedRender: paths.get('map-stripped'),
522
+ ...(candidate.hostReview
523
+ ? {
524
+ diagnosticBinding: {
525
+ schemaVersion: 1,
526
+ runId,
527
+ candidateId: candidate.id,
528
+ sourceHash: candidate.sourceHash,
529
+ specHash: candidate.specHash,
530
+ referenceHash: run.referenceHash,
531
+ renderHash: candidate.captures.find((capture) => capture.view === 'hero').hash,
532
+ view: 'hero',
533
+ reviewProfileHash: candidate.hostReview.profileHash,
534
+ renderReceiptPath: candidate.renderReceipt.path,
535
+ renderReceiptHash: candidate.renderReceipt.hash,
536
+ },
537
+ }
538
+ : {}),
539
+ }
540
+ : {
541
+ render: paths.get('hero'),
542
+ orbit: candidate.views
543
+ .filter((view) => view !== 'hero')
544
+ .map((view) => paths.get(view)),
545
+ },
546
+ };
547
+ }
548
+ result = await adapter(request.action, adapterInput);
549
+ if (metadataStep && candidate) {
550
+ await verifyFiles(join(jobRoot, 'workspace'), candidateWorkspaceFiles(candidate));
551
+ const currentSpec = await checkedFile(join(jobRoot, 'workspace'), 'object-sculpt-spec.json');
552
+ verifyControlledSpecChange(beforeSpec, JSON.parse(currentSpec.toString('utf8')), request.action === 'request-correction'
553
+ ? request.action
554
+ : String(request.input.stepId), request.input.kind);
555
+ candidate.expectedSpecHash = digest(currentSpec);
556
+ run.receipts.push({
557
+ action: request.action === 'request-correction'
558
+ ? request.action
559
+ : String(request.input.stepId),
560
+ beforeSpecHash,
561
+ afterSpecHash: candidate.expectedSpecHash,
562
+ revision: run.revision,
563
+ });
564
+ }
565
+ }
566
+ await current();
567
+ const diagnosticFailure = result && typeof result === 'object' && 'diagnosticFailure' in result
568
+ ? result.diagnosticFailure
569
+ : undefined;
570
+ if (run.status !== 'accepted')
571
+ run.status = diagnosticFailure ? 'needs-correction' : 'prepared';
572
+ run.lastError = undefined;
573
+ run.lastReportPath = undefined;
574
+ await saveGuards(run);
575
+ await writeControlJson(controlPath, run);
576
+ return {
577
+ runId,
578
+ revision: run.revision,
579
+ jobRoot,
580
+ candidate: run.candidates.at(-1),
581
+ result,
582
+ ...(descriptor.hostProtocolVersion === 2 ? { referenceHash: run.referenceHash } : {}),
583
+ };
584
+ }
585
+ catch (error) {
586
+ // A failed adapter may have committed only part of an upstream transition. Preserve the
587
+ // last verified state hashes; never bless its new bytes merely because the process exited.
588
+ run.status = signal.aborted ? 'uncertain' : 'needs-input';
589
+ run.lastError = error instanceof Error ? error.message : 'procedural_skill_failed';
590
+ run.lastReportPath = error instanceof ProductionSkillFailure ? error.reportPath : undefined;
591
+ await writeControlJson(controlPath, run);
592
+ throw error;
593
+ }
594
+ });
595
+ }