@instruktlabs/kiln 1.0.0 → 1.2.0

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 (92) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +60 -3
  5. package/CONTRIBUTING.md +16 -7
  6. package/README.md +95 -381
  7. package/dist/agent-run.mjs +220 -150
  8. package/dist/build.json +40 -28
  9. package/dist/cli.mjs +242 -163
  10. package/dist/mcp-engine.mjs +412 -154
  11. package/dist/mcp-server.mjs +1 -1
  12. package/docs/architecture.md +6 -3
  13. package/docs/chatgpt.md +20 -5
  14. package/docs/examples.md +40 -170
  15. package/docs/install.md +100 -35
  16. package/docs/migration.md +20 -0
  17. package/docs/sdk.md +49 -5
  18. package/docs/tools-operations.md +1529 -0
  19. package/lib/asset-widget-uri.d.ts +9 -0
  20. package/lib/asset-widget-uri.js +9 -0
  21. package/lib/cli-render-mode.js +10 -3
  22. package/lib/discovery/index.d.ts +1 -1
  23. package/lib/discovery/index.js +2 -2
  24. package/lib/discovery/portable.d.ts +9 -0
  25. package/lib/discovery/portable.js +6 -0
  26. package/lib/discovery/service.d.ts +3 -1
  27. package/lib/discovery/service.js +4 -2
  28. package/lib/engine-identity.d.ts +1 -1
  29. package/lib/engine-identity.js +1 -1
  30. package/lib/evaluator/isolation.js +19 -5
  31. package/lib/evaluator/probe-invariants.d.ts +11 -0
  32. package/lib/evaluator/probe-invariants.js +36 -0
  33. package/lib/evaluator/probe-worker.js +16 -4
  34. package/lib/loopback.d.ts +6 -0
  35. package/lib/loopback.js +6 -0
  36. package/lib/mcp-core.d.ts +210 -0
  37. package/lib/mcp-core.js +279 -0
  38. package/lib/mcp-engine.d.ts +59 -0
  39. package/lib/mcp-engine.js +289 -0
  40. package/lib/mcp-manifest.d.ts +15 -0
  41. package/lib/mcp-manifest.js +65 -0
  42. package/lib/mcp.d.ts +5 -0
  43. package/lib/mcp.js +2 -0
  44. package/lib/render-service-host.js +1 -1
  45. package/lib/tools/discovery.d.ts +1 -1
  46. package/lib/tools/discovery.js +11 -9
  47. package/lib/tools/materials.d.ts +1 -1
  48. package/lib/tools/materials.js +44 -14
  49. package/lib/tools/operation-contract.d.ts +8 -0
  50. package/lib/tools/operation-contract.js +37 -0
  51. package/lib/tools/operations.d.ts +3 -0
  52. package/lib/tools/operations.js +154 -0
  53. package/lib/tools/registry.d.ts +6 -2
  54. package/lib/tools/registry.js +71 -23
  55. package/package.json +23 -1
  56. package/plugin.json +1 -1
  57. package/plugins/kiln-engine/.claude-plugin/icon.png +0 -0
  58. package/plugins/kiln-engine/.claude-plugin/plugin.json +9 -3
  59. package/plugins/kiln-engine/PRIVACY.md +100 -0
  60. package/plugins/kiln-engine/README.md +103 -18
  61. package/plugins/kiln-engine/{scripts/setup-workspace.mjs → bin/kiln-setup-workspace.mjs} +37 -9
  62. package/plugins/kiln-engine/package-provenance.json +15 -8
  63. package/plugins/kiln-engine/plugin.json +10 -4
  64. package/plugins/kiln-engine/runtime.json +10 -1
  65. package/plugins/kiln-engine/skills/kiln-setup-workspace/SKILL.md +17 -18
  66. package/plugins/kiln-engine/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
  67. package/plugins/kiln-engine/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
  68. package/scripts/create-workspace.mjs +71 -13
  69. package/scripts/workspace-config.mjs +239 -0
  70. package/scripts/workspace-project.mjs +384 -0
  71. package/scripts/workspace-transaction.mjs +504 -0
  72. package/skills/kiln-setup-workspace/SKILL.md +17 -18
  73. package/skills/kiln-setup-workspace/references/plugin-install.md +28 -8
  74. package/skills/kiln-setup-workspace/references/renderer-setup.md +11 -0
  75. package/src/cli-render-mode.ts +9 -9
  76. package/src/cli.ts +3 -3
  77. package/src/discovery/index.ts +2 -0
  78. package/src/discovery/portable.ts +9 -0
  79. package/src/discovery/service.ts +3 -1
  80. package/src/engine-identity.ts +1 -1
  81. package/src/evaluator/isolation.ts +27 -6
  82. package/src/evaluator/probe-invariants.ts +50 -0
  83. package/src/evaluator/probe-worker.ts +22 -4
  84. package/src/mcp-core.ts +3 -0
  85. package/src/mcp-engine.ts +11 -1
  86. package/src/mcp.ts +5 -0
  87. package/src/render-service-host.ts +1 -1
  88. package/src/tools/discovery.ts +20 -10
  89. package/src/tools/materials.ts +59 -15
  90. package/src/tools/operation-contract.ts +51 -0
  91. package/src/tools/operations.ts +177 -0
  92. package/src/tools/registry.ts +100 -20
@@ -0,0 +1,504 @@
1
+ /** Recoverable setup writes. Private recovery data never enters diagnostics. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { constants } from 'node:fs';
4
+ import {
5
+ chmod,
6
+ link,
7
+ lstat as nativeLstat,
8
+ mkdir,
9
+ open,
10
+ readFile,
11
+ readdir,
12
+ realpath,
13
+ rename,
14
+ rmdir,
15
+ unlink,
16
+ } from 'node:fs/promises';
17
+ import { homedir } from 'node:os';
18
+ import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
19
+
20
+ const active = new Set();
21
+ const lstat = (path) => nativeLstat(path, { bigint: true });
22
+ const maxFile = 2 * 1024 * 1024;
23
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
24
+ const key = (value) => (process.platform === 'win32' ? value.toLowerCase() : value);
25
+ const inside = (parent, child) => {
26
+ const rel = relative(key(parent), key(child));
27
+ return !rel || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
28
+ };
29
+ const equal = (a, b) => (a === undefined ? b === undefined : b !== undefined && a.equals(b));
30
+ const maybeStat = async (path) => {
31
+ try {
32
+ return await lstat(path);
33
+ } catch (error) {
34
+ if (error.code === 'ENOENT') return undefined;
35
+ throw error;
36
+ }
37
+ };
38
+
39
+ function safePath(value) {
40
+ if (
41
+ typeof value !== 'string' ||
42
+ !value ||
43
+ value.length > 1024 ||
44
+ value
45
+ .split('/')
46
+ .some(
47
+ (part) =>
48
+ !part ||
49
+ part === '.' ||
50
+ part === '..' ||
51
+ /[\\:*?"<>|]/.test(part) ||
52
+ [...part].some((character) => character.charCodeAt(0) < 32) ||
53
+ /[. ]$/.test(part) ||
54
+ /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(part) ||
55
+ /^\.git$/i.test(part),
56
+ )
57
+ )
58
+ throw new Error('Unsafe project path.');
59
+ return value;
60
+ }
61
+
62
+ async function inspectPath(root, name) {
63
+ safePath(name);
64
+ const rootInfo = await lstat(root);
65
+ if (rootInfo.isSymbolicLink()) throw new Error('Refusing symbolic link at the project root.');
66
+ if (!rootInfo.isDirectory()) throw new Error('Project root must remain a directory.');
67
+ let path = root;
68
+ const parts = name.split('/');
69
+ for (let i = 0; i < parts.length; i++) {
70
+ path = join(path, parts[i]);
71
+ const info = await maybeStat(path);
72
+ if (!info) return undefined;
73
+ if (info.isSymbolicLink()) throw new Error(`Refusing symbolic link in project path: ${name}`);
74
+ if (i !== parts.length - 1 && !info.isDirectory())
75
+ throw new Error(`Not a directory in project path: ${name}`);
76
+ if (i === parts.length - 1) return info;
77
+ }
78
+ }
79
+
80
+ async function readSafe(root, name) {
81
+ const info = await inspectPath(root, name);
82
+ if (!info) return undefined;
83
+ if (!info.isFile() || info.nlink !== 1n)
84
+ throw new Error(`Expected a single regular file: ${name}`);
85
+ if (info.size > maxFile) throw new Error(`Project file exceeds setup size limit: ${name}`);
86
+ const handle = await open(join(root, name), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
87
+ try {
88
+ const current = await handle.stat({ bigint: true });
89
+ if (current.dev !== info.dev || current.ino !== info.ino || current.size > maxFile)
90
+ throw new Error(`Project file changed: ${name}`);
91
+ const data = await handle.readFile();
92
+ if (data.length > maxFile) throw new Error(`Project file exceeds setup size limit: ${name}`);
93
+ return data;
94
+ } finally {
95
+ await handle.close();
96
+ }
97
+ }
98
+
99
+ export async function readProjectFile(root, name) {
100
+ if ((await lstat(root)).isSymbolicLink())
101
+ throw new Error('Refusing symbolic link at the project root.');
102
+ return readSafe(await realpath(root), name);
103
+ }
104
+
105
+ async function paths(root, stateDirectory) {
106
+ if ((await lstat(root)).isSymbolicLink())
107
+ throw new Error('Refusing symbolic link at the project root.');
108
+ root = await realpath(root);
109
+ const rootInfo = await lstat(root);
110
+ if (!rootInfo.isDirectory()) throw new Error('Project root must be a directory.');
111
+ const base =
112
+ process.platform === 'win32'
113
+ ? join(
114
+ process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'),
115
+ 'InstruktLabs',
116
+ 'Kiln',
117
+ )
118
+ : join(
119
+ process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'),
120
+ 'instruktlabs',
121
+ 'kiln',
122
+ );
123
+ const state = resolve(stateDirectory ?? join(base, 'setup-transactions'));
124
+ // A junction in any existing ancestor must not redirect private backups.
125
+ let current = parse(state).root;
126
+ for (const part of relative(current, state).split(sep).filter(Boolean)) {
127
+ current = join(current, part);
128
+ const info = await maybeStat(current);
129
+ if (info?.isSymbolicLink()) throw new Error('Refusing symbolic link in recovery storage.');
130
+ if (info && !info.isDirectory()) throw new Error('Recovery storage must be a directory.');
131
+ }
132
+ if (inside(root, state) || inside(state, root))
133
+ throw new Error('Recovery storage must be outside the project.');
134
+ const directory = join(state, digest(key(root)));
135
+ return { root, state, directory, rootInfo };
136
+ }
137
+
138
+ async function assertRoot(root, journal) {
139
+ const info = await lstat(root);
140
+ if (
141
+ !info.isDirectory() ||
142
+ info.isSymbolicLink() ||
143
+ String(info.dev) !== journal.rootDev ||
144
+ String(info.ino) !== journal.rootIno
145
+ )
146
+ throw new Error('Project root changed during setup.');
147
+ }
148
+
149
+ async function durableWrite(path, value, mode = 0o600) {
150
+ const handle = await open(path, 'wx', mode);
151
+ try {
152
+ await handle.writeFile(value);
153
+ await handle.sync();
154
+ } finally {
155
+ await handle.close();
156
+ }
157
+ }
158
+
159
+ async function saveJournal(directory, journal) {
160
+ const pending = join(directory, 'journal.pending');
161
+ await unlink(pending).catch((error) => {
162
+ if (error.code !== 'ENOENT') throw error;
163
+ });
164
+ await durableWrite(pending, JSON.stringify(journal));
165
+ await rename(pending, join(directory, 'journal.json'));
166
+ }
167
+
168
+ function bytes(value) {
169
+ if (value === undefined) return undefined;
170
+ if (typeof value !== 'string' && !Buffer.isBuffer(value))
171
+ throw new Error('Setup edits require text or bytes.');
172
+ const result = Buffer.from(value);
173
+ if (result.length > maxFile) throw new Error('Setup edit exceeds size limit.');
174
+ return result;
175
+ }
176
+
177
+ async function ensureParents(root, name, journal, directory) {
178
+ const parts = name.split('/').slice(0, -1);
179
+ for (let i = 1; i <= parts.length; i++) {
180
+ const path = parts.slice(0, i).join('/');
181
+ const info = await inspectPath(root, path);
182
+ if (info) {
183
+ if (!info.isDirectory()) throw new Error(`Not a directory in project path: ${path}`);
184
+ continue;
185
+ }
186
+ await mkdir(join(root, path));
187
+ const created = await lstat(join(root, path));
188
+ journal.directories.push({ path, dev: String(created.dev), ino: String(created.ino) });
189
+ await saveJournal(directory, journal);
190
+ }
191
+ }
192
+
193
+ function stageName(journal, entry) {
194
+ return [
195
+ ...entry.path.split('/').slice(0, -1),
196
+ `.kiln-setup-${journal.id}-${entry.index}.tmp`,
197
+ ].join('/');
198
+ }
199
+
200
+ async function replaceFile(root, entry, before, after, journal, directory) {
201
+ await assertRoot(root, journal);
202
+ if (!equal(await readSafe(root, entry.path), before))
203
+ throw new Error(`Project file changed: ${entry.path}`);
204
+ if (after === undefined) {
205
+ await unlink(join(root, entry.path));
206
+ return;
207
+ }
208
+ await ensureParents(root, entry.path, journal, directory);
209
+ const temporary = stageName(journal, entry);
210
+ await inspectPath(root, temporary);
211
+ await durableWrite(join(root, temporary), after);
212
+ if (entry.mode !== undefined) await chmod(join(root, temporary), entry.mode);
213
+ // Recheck after staging. A new destination must not replace a concurrent arrival.
214
+ if (!equal(await readSafe(root, entry.path), before))
215
+ throw new Error(`Project file changed: ${entry.path}`);
216
+ if (before === undefined) {
217
+ await link(join(root, temporary), join(root, entry.path));
218
+ await unlink(join(root, temporary));
219
+ } else {
220
+ await rename(join(root, temporary), join(root, entry.path));
221
+ }
222
+ }
223
+
224
+ async function storedBytes(directory, entry, side) {
225
+ if (entry[side] === null) return undefined;
226
+ const value = await readSafe(directory, `${entry.index}.${side}`);
227
+ if (value === undefined || digest(value) !== entry[side])
228
+ throw new Error('Recovery data failed integrity verification.');
229
+ return value;
230
+ }
231
+
232
+ async function removeTemporary(root, journal, entry) {
233
+ const name = stageName(journal, entry);
234
+ const info = await inspectPath(root, name);
235
+ if (!info) return;
236
+ // Two links are possible if a process stopped between exclusive publication and unlink.
237
+ if (!info.isFile() || info.size > maxFile) throw new Error(`Recovery conflict at ${name}`);
238
+ const value = await readFile(join(root, name));
239
+ if (![entry.before, entry.after].includes(digest(value)))
240
+ throw new Error(`Recovery conflict at ${name}`);
241
+ await unlink(join(root, name));
242
+ }
243
+
244
+ async function cleanState(directory, journal, recovery = false) {
245
+ // Never recursively delete private storage.
246
+ const known = new Set([
247
+ 'journal.pending',
248
+ 'journal.json',
249
+ ...(recovery ? ['recovery.lock'] : []),
250
+ ]);
251
+ for (const entry of journal.entries)
252
+ for (const side of ['before', 'after']) known.add(`${entry.index}.${side}`);
253
+ if ((await readdir(directory)).some((name) => !known.has(name)))
254
+ throw new Error('Unexpected recovery file; private recovery data was retained.');
255
+ for (const entry of journal.entries) {
256
+ for (const side of ['before', 'after']) {
257
+ await unlink(join(directory, `${entry.index}.${side}`)).catch((error) => {
258
+ if (error.code !== 'ENOENT') throw error;
259
+ });
260
+ }
261
+ }
262
+ for (const name of ['journal.pending', 'journal.json', ...(recovery ? ['recovery.lock'] : [])]) {
263
+ await unlink(join(directory, name)).catch((error) => {
264
+ if (error.code !== 'ENOENT') throw error;
265
+ });
266
+ }
267
+ await rmdir(directory);
268
+ }
269
+
270
+ async function rollback(root, directory, journal, recovery = false) {
271
+ await assertRoot(root, journal);
272
+ const restore = [];
273
+ for (const entry of journal.entries.filter((item) => item.started)) {
274
+ const before = await storedBytes(directory, entry, 'before');
275
+ const after = await storedBytes(directory, entry, 'after');
276
+ await removeTemporary(root, journal, entry);
277
+ const current = await readSafe(root, entry.path);
278
+ if (!equal(current, before) && !equal(current, after))
279
+ throw new Error(`Recovery conflict at ${entry.path}`);
280
+ restore.push({ entry, before, after, current });
281
+ }
282
+ // Check all target conflicts before restoring any target.
283
+ for (const { entry, before, after, current } of restore.reverse()) {
284
+ if (!equal(current, before)) await replaceFile(root, entry, after, before, journal, directory);
285
+ }
286
+ for (const created of [...journal.directories].reverse()) {
287
+ const info = await inspectPath(root, created.path);
288
+ if (!info || String(info.dev) !== created.dev || String(info.ino) !== created.ino) continue;
289
+ await rmdir(join(root, created.path)).catch((error) => {
290
+ if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
291
+ });
292
+ }
293
+ journal.phase = 'recovered';
294
+ await saveJournal(directory, journal);
295
+ await cleanState(directory, journal, recovery);
296
+ }
297
+
298
+ export async function applyProjectEdits(
299
+ root,
300
+ edits,
301
+ { stateDirectory, check = false, onStep } = {},
302
+ ) {
303
+ const location = await paths(root, stateDirectory);
304
+ root = location.root;
305
+ const { directory, state } = location;
306
+ if (!Array.isArray(edits) || edits.length > 1024) throw new Error('Invalid setup edit list.');
307
+ const names = new Set();
308
+ let size = 0;
309
+ const planned = [];
310
+ for (const edit of edits) {
311
+ const path = safePath(edit?.path).toLowerCase();
312
+ if (names.has(path)) throw new Error(`Duplicate setup path: ${edit.path}`);
313
+ names.add(path);
314
+ }
315
+ for (const path of names) {
316
+ const parts = path.split('/');
317
+ for (let i = 1; i < parts.length; i++)
318
+ if (names.has(parts.slice(0, i).join('/'))) throw new Error('Overlapping setup paths.');
319
+ }
320
+ for (const edit of edits) {
321
+ const path = safePath(edit.path);
322
+ const before = bytes(edit.before);
323
+ const after = bytes(edit.after);
324
+ size += (before?.length ?? 0) + (after?.length ?? 0);
325
+ if (size > 32 * maxFile) throw new Error('Setup transaction exceeds size limit.');
326
+ if (!equal(await readSafe(root, path), before))
327
+ throw new Error(`Project file changed: ${path}`);
328
+ const info = await inspectPath(root, path);
329
+ if (!equal(before, after))
330
+ planned.push({ path, before, after, mode: info ? Number(info.mode & 0o777n) : undefined });
331
+ }
332
+ planned.sort(
333
+ (a, b) => Number(a.path === '.kiln/workspace.json') - Number(b.path === '.kiln/workspace.json'),
334
+ );
335
+ if (await maybeStat(directory))
336
+ throw new Error(
337
+ 'Project setup is locked; finish the active setup or recover the interrupted update.',
338
+ );
339
+ if (check) return { status: 'planned', paths: planned.map((entry) => entry.path) };
340
+ if (!planned.length) return { status: 'applied', paths: [] };
341
+ await mkdir(state, { recursive: true, mode: 0o700 });
342
+ try {
343
+ await mkdir(directory, { mode: 0o700 });
344
+ } catch (error) {
345
+ if (error.code === 'EEXIST') throw new Error('Project setup is locked.');
346
+ throw error;
347
+ }
348
+ active.add(directory);
349
+ const { rootInfo } = location;
350
+ const journal = {
351
+ version: 1,
352
+ root,
353
+ rootDev: String(rootInfo.dev),
354
+ rootIno: String(rootInfo.ino),
355
+ pid: process.pid,
356
+ id: randomUUID(),
357
+ phase: 'preparing',
358
+ directories: [],
359
+ entries: [],
360
+ };
361
+ try {
362
+ await assertRoot(root, journal);
363
+ for (const [index, entry] of planned.entries()) {
364
+ journal.entries.push({
365
+ index,
366
+ path: entry.path,
367
+ mode: entry.mode,
368
+ before: entry.before === undefined ? null : digest(entry.before),
369
+ after: entry.after === undefined ? null : digest(entry.after),
370
+ started: false,
371
+ });
372
+ for (const side of ['before', 'after'])
373
+ if (entry[side] !== undefined)
374
+ await durableWrite(join(directory, `${index}.${side}`), entry[side]);
375
+ }
376
+ journal.phase = 'prepared';
377
+ await saveJournal(directory, journal);
378
+ await onStep?.({ phase: 'prepared' });
379
+ for (const [index, entry] of planned.entries()) {
380
+ if (!equal(await readSafe(root, entry.path), entry.before))
381
+ throw new Error(`Project file changed: ${entry.path}`);
382
+ journal.entries[index].started = true;
383
+ await saveJournal(directory, journal);
384
+ await replaceFile(
385
+ root,
386
+ journal.entries[index],
387
+ entry.before,
388
+ entry.after,
389
+ journal,
390
+ directory,
391
+ );
392
+ await onStep?.({ phase: 'written', path: entry.path });
393
+ }
394
+ journal.phase = 'committed';
395
+ await saveJournal(directory, journal);
396
+ await cleanState(directory, journal);
397
+ return { status: 'applied', paths: planned.map((entry) => entry.path) };
398
+ } catch {
399
+ if (journal.phase === 'committed')
400
+ throw new Error('Setup completed but private recovery cleanup is required.');
401
+ try {
402
+ await rollback(root, directory, journal);
403
+ } catch {
404
+ throw new Error(
405
+ 'Setup stopped; recovery required. Existing user edits and private recovery data were retained.',
406
+ );
407
+ }
408
+ throw new Error('Setup stopped; previous files were restored.');
409
+ } finally {
410
+ active.delete(directory);
411
+ }
412
+ }
413
+
414
+ function validateJournal(journal, root) {
415
+ const identity = (value) => typeof value === 'string' && /^[0-9]{1,40}$/.test(value);
416
+ if (
417
+ journal?.version !== 1 ||
418
+ journal.root !== root ||
419
+ !Number.isSafeInteger(journal.pid) ||
420
+ journal.pid < 1 ||
421
+ !identity(journal.rootDev) ||
422
+ !identity(journal.rootIno) ||
423
+ !/^[a-f0-9-]{36}$/.test(journal.id) ||
424
+ !['prepared', 'committed', 'recovered'].includes(journal.phase) ||
425
+ !Array.isArray(journal.entries) ||
426
+ journal.entries.length > 1024 ||
427
+ !Array.isArray(journal.directories) ||
428
+ journal.directories.length > 8192
429
+ )
430
+ throw new Error('Invalid recovery journal.');
431
+ const names = new Set();
432
+ for (const [index, entry] of journal.entries.entries()) {
433
+ safePath(entry.path);
434
+ if (
435
+ entry.index !== index ||
436
+ names.has(entry.path.toLowerCase()) ||
437
+ typeof entry.started !== 'boolean' ||
438
+ (entry.mode !== undefined &&
439
+ (!Number.isSafeInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777)) ||
440
+ ![entry.before, entry.after].every(
441
+ (value) => value === null || (typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)),
442
+ )
443
+ )
444
+ throw new Error('Invalid recovery journal.');
445
+ names.add(entry.path.toLowerCase());
446
+ }
447
+ for (const dir of journal.directories) {
448
+ safePath(dir.path);
449
+ if (
450
+ !identity(dir.dev) ||
451
+ !identity(dir.ino) ||
452
+ !journal.entries.some((entry) => entry.path.startsWith(`${dir.path}/`))
453
+ )
454
+ throw new Error('Invalid recovery journal.');
455
+ }
456
+ }
457
+
458
+ export async function recoverProjectEdits(root, { stateDirectory } = {}) {
459
+ const location = await paths(root, stateDirectory);
460
+ const { directory } = location;
461
+ root = location.root;
462
+ if (active.has(directory)) throw new Error('Project setup is still active.');
463
+ const info = await maybeStat(directory);
464
+ if (!info) return { status: 'unchanged' };
465
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Invalid recovery storage.');
466
+ let journal;
467
+ try {
468
+ journal = JSON.parse((await readSafe(directory, 'journal.json')).toString('utf8'));
469
+ } catch {
470
+ throw new Error(
471
+ 'Incomplete recovery journal; manual inspection is required. No project files were changed.',
472
+ );
473
+ }
474
+ validateJournal(journal, root);
475
+ await assertRoot(root, journal);
476
+ if (journal.pid !== process.pid) {
477
+ try {
478
+ process.kill(journal.pid, 0);
479
+ throw new Error('Project setup process is still active.');
480
+ } catch (error) {
481
+ if (error.code !== 'ESRCH') throw error;
482
+ }
483
+ }
484
+ const recoveryLock = join(directory, 'recovery.lock');
485
+ try {
486
+ await durableWrite(recoveryLock, String(process.pid));
487
+ } catch (error) {
488
+ if (error.code === 'EEXIST')
489
+ throw new Error('Project recovery is already active or requires manual inspection.');
490
+ throw error;
491
+ }
492
+ active.add(directory);
493
+ try {
494
+ if (journal.phase === 'committed' || journal.phase === 'recovered')
495
+ await cleanState(directory, journal, true);
496
+ else await rollback(root, directory, journal, true);
497
+ return { status: 'recovered' };
498
+ } finally {
499
+ active.delete(directory);
500
+ await unlink(recoveryLock).catch((error) => {
501
+ if (error.code !== 'ENOENT') throw error;
502
+ });
503
+ }
504
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: kiln-setup-workspace
3
- description: Create and verify a Kiln asset workspace for a chosen coding-agent harness. Use before authoring when the current directory is the engine repository, an empty folder, or any project without a working kiln_workspace server.
3
+ description: Configure and verify Kiln in an existing project or a new asset workspace for the selected coding agent. Use before authoring when the project has no working kiln_workspace server or needs another agent integration; engine development stays in the engine checkout.
4
4
  license: MIT
5
5
  metadata:
6
6
  kiln-workflow: workspace
@@ -18,15 +18,18 @@ Do not author assets inside the engine checkout. The authoring skills assume a w
18
18
 
19
19
  ## Create the workspace
20
20
 
21
- When this skill comes from the **Kiln Engine local plugin**, its plugin root contains `runtime.json` and `scripts/setup-workspace.mjs`. Follow [plugin installation and upgrades](references/plugin-install.md) to install the pinned engine and configure a separate workspace. Do not run the checkout command below from a plugin cache. The plugin registers setup only; the workspace supplies its authoring skills and `kiln_workspace` server.
21
+ When this skill comes from the **Kiln local plugin**, its plugin root contains `runtime.json` and `bin/kiln-setup-workspace.mjs` (plugin 1.0.0 used `scripts/setup-workspace.mjs`). Follow [plugin installation and upgrades](references/plugin-install.md) to install the pinned engine and configure a separate workspace. Do not run the checkout command below from a plugin cache. The plugin registers setup only; the workspace supplies its authoring skills and `kiln_workspace` server.
22
22
 
23
- Choose an absolute path to an empty directory outside the checkout.
23
+ Choose the user's existing project or an absolute path for a new workspace outside the engine installation. Check the installed initializer's `--help`: this setup version supports `--adopt`; older engines support only empty destinations. Upgrade an older installation explicitly before adopting a populated project.
24
24
 
25
25
  ```bash
26
- node scripts/create-workspace.mjs /absolute/empty-workspace --harness claude
26
+ node scripts/create-workspace.mjs /absolute/project --adopt --harness claude --check
27
+ node scripts/create-workspace.mjs /absolute/project --adopt --harness claude
27
28
  ```
28
29
 
29
- From an installed package, the equivalent is `kiln-init /absolute/empty-workspace --harness claude`. Author, refine and QA skills are installed by default; `--skills compose,batch` adds the optional scene-composition and trial-dispatch workflows. Use the harness already selected by the user or current host; ask only when it is unknown. The choice writes different configuration and cannot be changed afterwards without a fresh workspace.
30
+ From an installed package, use `kiln-init` with the same arguments. Check reports planned changes and exits 1 when changes are needed; it writes nothing. Adoption preserves existing instruction files, unrelated settings, assets and skills. Read the returned `instructions` file (`.kiln/AGENTS.md`) and `.kiln/START.md`, alongside the project's existing instructions. Conflicts stop setup before writes; do not delete or replace owner configuration to make the command pass.
31
+
32
+ Author, refine and QA skills are installed by default; `--skills compose,batch` adds the optional workflows. Use the agent selected by the user or current host; ask only when it is unknown. Repeat `--adopt --harness codex` (or another supported adapter) to add that integration around the same program store. It explicitly migrates a legacy managed workspace while preserving assets. A combination whose config formats conflict is reported before changes; do not force one client's configuration into another. The plain empty-directory setup command remains compatible with older workflows.
30
33
 
31
34
  CLI/MCP workspaces use the shared skills under `skills/`. Kiln's optional built-in Strands agent adds its workflow internally, outside that directory. Do not copy its system prompt, native workflow skill or completion protocol into another harness's skills or instructions. This applies to manual setup and plugin installs too; the engine's `src/agent/` directory is not a skill source.
32
35
 
@@ -37,24 +40,18 @@ CLI/MCP workspaces use the shared skills under `skills/`. Kiln's optional built-
37
40
  | `cursor-agent` | `cursor-agent` |
38
41
  | `opencode` | `opencode` |
39
42
  | `agy` | `node agy.mjs` |
40
- | `codex` | `node codex.mjs` |
43
+ | `codex` | `codex` after project trust; `node codex.mjs "TASK"` for a headless run |
41
44
  | `hermes` | `node hermes.mjs` |
42
45
 
43
- **Use the launcher where the table names one, and read the workspace's own START.md over this table.** Antigravity, Codex and Hermes keep all configuration in a user-level home and read nothing from a project directory, so their workspace configuration is applied per invocation by the generated launcher. Running their bare command inside the workspace reaches no Kiln tools at all. None of the three writes outside the workspace, and none touches the user's existing configuration or authentication.
46
+ Read `.kiln/START.md` for an adopted project, or `START.md` in a legacy workspace. Codex reads trusted project configuration from `.codex/config.toml`; its optional headless launcher supplies the same settings per invocation without relocating authentication. Antigravity and Hermes use their generated launchers for the measured workspace flow. Setup does not grant project trust or edit global credentials. Skill registration is client-specific: Claude uses `.claude/skills`, Codex uses `.agents/skills`, and OpenCode uses its configured skills path, all copied from the same maintained Kiln skills.
44
47
 
45
48
  Hermes needs one user-level registration for the MCP server, because it has no project-scoped equivalent and no per-invocation flag for one. Its START.md prints the exact `hermes mcp add` command; offer to run it, and verify with `hermes mcp list`. Never redirect `HERMES_HOME` at the workspace: that one variable resolves the configuration path and the credential path together, so pointing it at a workspace leaves the run with no provider.
46
49
 
47
- ## Start the GPU render service
48
-
49
- Setup asks for render mode `auto`: textured or metallic scenes use a compatible GPU service, starting one lazily when available. Ordinary untextured scenes with zero metalness and no advanced material extensions use CPU views, even with a GPU available. CPU views support silhouette, proportion and contact review, but cannot confirm PBR appearance. Use CLI `--render gpu` when material review is required, including roughness on nonmetallic surfaces.
50
-
51
- The renderer code is included with Kiln; its native GPU dependency is optional at the Kiln package root. For material-dependent tasks, check `node kiln.mjs service status` before starting the authoring session. If dependencies are missing or incompatible, reinstall the official Kiln package with optional dependencies enabled. npm 11 may print an `install-scripts` warning for the optional `webgpu` package; its postinstall only clears the macOS download quarantine from the prebuilt Dawn binary the package ships, so Windows and Linux need nothing and the warning is not an install failure. Read `runtime` from `.kiln/workspace.json` to identify the installation. A source checkout uses `bun install --frozen-lockfile` at that root. Setup checks dependency resolution without loading native code; ready dependencies alone do not prove the device can render.
50
+ ## Check rendering readiness
52
51
 
53
- Once dependencies are available, local CLI/MCP auto mode starts a managed service lazily for a view that needs PBR shading. Compatible clients share it; it survives the initiating session and exits after five minutes without admitted uploads, queued jobs or active renders. From the installation root, `node --import ./render-service/src/register-hooks.mjs render-service/src/server.mjs` starts a manual service that remains running while idle. It binds loopback; widening `HOST` requires `RENDER_SERVICE_TOKEN`.
52
+ Setup selects render mode `auto`. Textured or metallic scenes use a compatible render service; plain scenes use CPU geometry views. CPU views cannot confirm PBR appearance. Check `node kiln.mjs service status` before material-dependent work and require a real `viewFidelity` result before claiming material evidence.
54
53
 
55
- `node kiln.mjs service status` reports installation, verified health and lifetime. `node kiln.mjs service start` joins a compatible service or starts a managed one the way an on-demand view does, and prints its port, renderer, pid, lifetime and build; it starts nothing when a stale, foreign or unknown listener holds the socket. After dependency repair or a cached startup failure, call `kiln_renderer({action:"reprobe"})` inside the MCP session to refresh its existing route. It neither installs dependencies nor starts a renderer; the next view starts one if needed. CLI `service reprobe` checks only its own process. Restart the session after changing Kiln, environment variables or credentials. Unknown or incompatible listeners are reported and left alone. `service stop` explicitly stops a verified local service. If no usable GPU is available, report the material-review limitation and continue on CPU views when that fits the task.
56
-
57
- A `KILN_RENDER_PORT_URL` whose origin is exactly the shared local socket (`http://127.0.0.1:8000` by default) takes the local route: it starts on demand and restarts after an idle exit. For rendering on another device, configure the MCP server process's `KILN_RENDER_PORT_URL` and, when needed, `KILN_RENDER_TOKEN` through the chosen harness. CLI accepts `--render-port URL` and reads `KILN_RENDER_TOKEN`. Start a compatible Kiln renderer on that device; its wider bind requires `RENDER_SERVICE_TOKEN` unless an authenticated proxy is explicitly configured. Manage the remote service on its own device. Verify the selected route with capabilities and require an actual `viewFidelity` receipt for material evidence.
54
+ Read [local renderer setup](references/renderer-setup.md) when dependencies are missing, a service is unavailable, or the task uses a renderer on another device. It covers dependency repair, shared-service lifetime, reprobe and authenticated remote rendering. Do not replace an unknown listener or install dependencies merely to clear a diagnostic.
58
55
 
59
56
  ## Verify the loadout before authoring
60
57
 
@@ -97,7 +94,9 @@ node /current/kiln/scripts/create-workspace.mjs /absolute/empty-workspace --repa
97
94
 
98
95
  Repair rewrites generated runtime paths only. It preserves copied skills, saved revisions and assets, refuses configuration that was edited by hand, and does not upgrade the skills.
99
96
 
100
- For an existing workspace, stop its harness/MCP session and run setup from the desired installation with `--check`, then `--upgrade`. Check returns JSON and exits 1 when an update is required. Upgrade refreshes unchanged configuration, instructions and all registered skill copies while preserving assets and source stores. Conflicting local edits stop the whole upgrade before writing. Older instruction files without ownership hashes also require explicit resolution: preserve them separately, compare against a fresh temporary workspace, then move them aside or supply the current version before retrying. Reapply compatible customizations after upgrading. Do not overwrite an edited file merely to clear a conflict.
97
+ For an existing workspace, stop its harness/MCP session and run setup from the desired installation with `--check`, then `--upgrade`. Check returns JSON and exits 1 when an update is required. Upgrade refreshes unchanged owned configuration, instructions and registered skill copies while preserving assets and source stores. Conflicting local edits stop the whole upgrade before writing. Adopted projects preserve unrelated configuration changes and leave pre-existing instruction files alone. Legacy instruction files without ownership hashes can require explicit resolution; preserve them before resolving the named conflict. Do not overwrite an edited file merely to clear a diagnostic.
98
+
99
+ If adopted-project setup stops partway through writing, it attempts rollback. When it reports recovery required, preserve the project and run `kiln-init /absolute/project --recover` after confirming setup has stopped. Recovery refuses conflicting edits and retains its private snapshots outside the project. Resolve named conflicts explicitly; do not delete a lock or journal to force another setup. Recovery is for interrupted setup, not a general asset backup.
101
100
 
102
101
  New managed launchers reject runtime/skill mismatches before serving tools; older workspaces need this explicit check first. Restart the harness/MCP session after upgrading so cached tool schemas and instructions match. `--repair` alone does not refresh copied skills or a running session. For independent model evaluations, create a fresh workspace from the candidate.
103
102
 
@@ -105,4 +104,4 @@ Discovery replaces the removed `kiln_list_primitives` tool; its old `name`, `nam
105
104
 
106
105
  ## What isolation means
107
106
 
108
- A separate workspace limits task context. It is not an operating-system sandbox, and it is not a git repository. User-level instructions, memory, authentication and filesystem permissions still apply. For comparing harnesses or models, read `skills/kiln-batch-dispatch/references/clean-room-evaluation.md` in the engine checkout, which covers recording inherited context and reporting trials.
107
+ A separate workspace limits task context; an adopted project retains its existing application context and Git repository. Neither is an operating-system sandbox. User-level instructions, memory, authentication and filesystem permissions still apply. For comparing harnesses or models, use a fresh workspace and read `skills/kiln-batch-dispatch/references/clean-room-evaluation.md`, which covers inherited context and reporting trials.
@@ -4,19 +4,27 @@ The local `kiln-engine` plugin installs Kiln's exact pinned npm version and crea
4
4
  an asset workspace. It carries setup guidance and a small installer; the engine,
5
5
  source stores and authored assets live outside the plugin cache. It needs a
6
6
  supported Node.js installation with npm, network access for first installation,
7
- and no separate model API key.
7
+ and no separate model API key. Use it in Claude Code or Codex; it requires a local
8
+ shell and persistent filesystem and is not a Claude chat or Cowork plugin.
8
9
 
9
10
  Resolve the plugin root from this installed skill's absolute path: it is two
10
11
  directories above `skills/kiln-setup-workspace`. Confirm `runtime.json` and
11
- `scripts/setup-workspace.mjs` exist there. Do not assume plugin path variables are
12
+ `bin/kiln-setup-workspace.mjs` exist there. Do not assume plugin path variables are
12
13
  available in an ordinary shell, and do not substitute an engine checkout.
13
14
 
15
+ Plugin version 1.0.0 used `scripts/setup-workspace.mjs`; if using that older
16
+ installation, confirm and use that file instead. Plugin and engine versions are
17
+ independent. `runtime.json` always names the exact engine to install.
18
+
14
19
  For a fresh workspace, use an absolute empty destination outside both the plugin
15
- and its runtime data directory. Select `claude` or `codex` from the current host
16
- or the user's stated choice:
20
+ and its runtime data directory. Select the current host or the user's stated
21
+ adapter. New bundles list the engine's supported adapters in `runtime.json`;
22
+ older bundles without that list accept `claude` or `codex` only. The plugin itself
23
+ is installed in Claude Code or Codex, but its helper can configure another adapter
24
+ supported by the pinned engine:
17
25
 
18
26
  ```sh
19
- node "/absolute/plugin/scripts/setup-workspace.mjs" "/absolute/my-assets" --harness codex
27
+ node "/absolute/plugin/bin/kiln-setup-workspace.mjs" "/absolute/my-assets" --harness codex
20
28
  ```
21
29
 
22
30
  The installer verifies the package name and exact version from `runtime.json`.
@@ -27,11 +35,23 @@ or `$XDG_DATA_HOME/instruktlabs/kiln` on other systems, falling back to
27
35
  Do not place it or the asset workspace inside a removable plugin version directory.
28
36
 
29
37
  Read the returned workspace's `START.md`. Open Claude Code in that workspace, or
30
- launch Codex there with `node codex.mjs`. Accept the host's ordinary project/MCP
38
+ launch Codex there with `codex`. The `node codex.mjs "TASK"` launcher is for
39
+ headless execution with an explicit prompt. Accept the host's ordinary project/MCP
31
40
  trust prompts, then call `kiln_discover({capabilities:true})` on `kiln_workspace`.
32
41
  Compare its installation identity to `.kiln/workspace.json`; a config file alone
33
42
  does not prove the server loaded. The plugin itself starts no global MCP server.
34
43
 
44
+ For an existing project, use a plugin whose helper advertises `--adopt` and whose
45
+ pinned engine supports project adoption. The published engine 1.0.0 predates this
46
+ feature. The helper checks the engine capability and refuses unsupported adoption
47
+ instead of silently falling back to empty-folder setup. Preview with
48
+ `--adopt --check --harness codex`, then apply with `--adopt --harness codex`.
49
+ Use the same path with another `--harness` to add an integration around the same
50
+ assets. Read the returned `instructions` path and `.kiln/START.md`; existing project
51
+ instruction files remain intact. The helper still leaves normal client trust
52
+ prompts to the host. No manual changes to a plugin's runtime pin are needed or
53
+ appropriate.
54
+
35
55
  If the current workspace already has a working `kiln_workspace`, keep it. Do not
36
56
  create a second server. Use its installed authoring skills, which come from its
37
57
  engine version. The plugin supplies only setup, so authoring instructions are not
@@ -43,8 +63,8 @@ For an existing managed workspace, stop its harness session and run the new
43
63
  plugin's helper with `--check`, then `--upgrade` when an update is intended:
44
64
 
45
65
  ```sh
46
- node "/absolute/plugin/scripts/setup-workspace.mjs" "/absolute/my-assets" --check
47
- node "/absolute/plugin/scripts/setup-workspace.mjs" "/absolute/my-assets" --upgrade
66
+ node "/absolute/plugin/bin/kiln-setup-workspace.mjs" "/absolute/my-assets" --check
67
+ node "/absolute/plugin/bin/kiln-setup-workspace.mjs" "/absolute/my-assets" --upgrade
48
68
  ```
49
69
 
50
70
  Check is read-only and reports a missing pinned engine without installing it.