specrails-core 4.11.3 → 4.12.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 (53) hide show
  1. package/README.md +56 -20
  2. package/bin/specrails-core.mjs +343 -20
  3. package/bin/tui-installer.mjs +103 -52
  4. package/dist/installer/cli.js +1 -1
  5. package/dist/installer/cli.js.map +1 -1
  6. package/dist/installer/commands/doctor.js +486 -24
  7. package/dist/installer/commands/doctor.js.map +1 -1
  8. package/dist/installer/commands/framework.js +49 -7
  9. package/dist/installer/commands/framework.js.map +1 -1
  10. package/dist/installer/commands/init.js +423 -25
  11. package/dist/installer/commands/init.js.map +1 -1
  12. package/dist/installer/commands/update.js +36 -9
  13. package/dist/installer/commands/update.js.map +1 -1
  14. package/dist/installer/phases/framework-lifecycle.js +125 -0
  15. package/dist/installer/phases/framework-lifecycle.js.map +1 -0
  16. package/dist/installer/phases/install-config.js +157 -5
  17. package/dist/installer/phases/install-config.js.map +1 -1
  18. package/dist/installer/phases/manifest.js +27 -2
  19. package/dist/installer/phases/manifest.js.map +1 -1
  20. package/dist/installer/phases/prereqs.js +57 -2
  21. package/dist/installer/phases/prereqs.js.map +1 -1
  22. package/dist/installer/phases/provider-detect.js +116 -6
  23. package/dist/installer/phases/provider-detect.js.map +1 -1
  24. package/dist/installer/phases/scaffold.js +1222 -12
  25. package/dist/installer/phases/scaffold.js.map +1 -1
  26. package/dist/installer/runtime/kimi.js +255 -0
  27. package/dist/installer/runtime/kimi.js.map +1 -0
  28. package/dist/installer/util/paths.js +12 -0
  29. package/dist/installer/util/paths.js.map +1 -1
  30. package/dist/installer/util/registry.js +234 -14
  31. package/dist/installer/util/registry.js.map +1 -1
  32. package/docs/README.md +1 -0
  33. package/docs/deployment.md +6 -7
  34. package/docs/getting-started.md +11 -7
  35. package/docs/installation.md +34 -16
  36. package/docs/plugin-architecture.md +11 -8
  37. package/docs/updating.md +21 -3
  38. package/docs/user-docs/cli-reference.md +43 -22
  39. package/docs/user-docs/codex-vs-claude-code.md +11 -9
  40. package/docs/user-docs/faq.md +1 -1
  41. package/docs/user-docs/getting-started-codex.md +5 -8
  42. package/docs/user-docs/getting-started-kimi.md +423 -0
  43. package/docs/user-docs/installation.md +49 -14
  44. package/docs/user-docs/quick-start.md +11 -8
  45. package/docs/windows.md +29 -4
  46. package/integration-contract.json +85 -13
  47. package/package.json +9 -5
  48. package/schemas/profile.v1.json +67 -5
  49. package/templates/kimi/specrails/run-skill.mjs +3005 -0
  50. package/templates/kimi/specrails/vendor/js-yaml/LICENSE +21 -0
  51. package/templates/kimi/specrails/vendor/js-yaml/NOTICE.md +16 -0
  52. package/templates/kimi/specrails/vendor/js-yaml/js-yaml.mjs +3856 -0
  53. package/templates/profiles/kimi-default.json +15 -0
@@ -0,0 +1,3005 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { spawn, spawnSync } from 'node:child_process'
5
+ import {
6
+ accessSync,
7
+ copyFileSync,
8
+ cpSync,
9
+ chmodSync,
10
+ constants as fsConstants,
11
+ existsSync,
12
+ lstatSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ readdirSync,
16
+ readlinkSync,
17
+ realpathSync,
18
+ renameSync,
19
+ rmSync,
20
+ statSync,
21
+ symlinkSync,
22
+ unlinkSync,
23
+ writeFileSync,
24
+ } from 'node:fs'
25
+ import os from 'node:os'
26
+ import path from 'node:path'
27
+ import { StringDecoder } from 'node:string_decoder'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ import { load as loadYaml } from './vendor/js-yaml/js-yaml.mjs'
31
+
32
+ const SAFE_SKILL_ID = /^[a-z0-9][a-z0-9-]*$/
33
+ const SAFE_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._/:-]*$/
34
+ const SAFE_SESSION_ID = /^[A-Za-z0-9._-]+$/
35
+ const SAFE_WAVE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
36
+ const SAFE_GIT_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
37
+ const MAX_MODEL_ID_LENGTH = 128
38
+ const MAX_SESSION_ID_LENGTH = 128
39
+ const MAX_ROLE_REQUEST_BYTES = 1_048_576
40
+ const MAX_ROLE_WAVE_SIZE = 32
41
+ const ROLE_REQUEST_RELATIVE_PATH = path.join(
42
+ '.specrails',
43
+ 'kimi-role-request.json',
44
+ )
45
+ const ROLE_WAVE_RELATIVE_PATH = path.join(
46
+ '.specrails',
47
+ 'kimi-role-wave.json',
48
+ )
49
+ const ROLE_MERGE_RELATIVE_PATH = path.join(
50
+ '.specrails',
51
+ 'kimi-role-merge.json',
52
+ )
53
+ const ROLE_WORKTREE_MANIFEST_DIR = path.join(
54
+ '.specrails',
55
+ 'kimi-role-worktrees',
56
+ )
57
+ const WINDOWS_COMMAND_LINE_BUDGET = 30_000
58
+ const MAX_MANAGED_PROMPT_BYTES = 1_048_576
59
+ const PLAIN_PROMPT_STDIN_FLAG = '--plain-prompt-stdin'
60
+ export const WINDOWS_PROMPT_STDIN_TOKEN =
61
+ '__SPECRAILS_KIMI_PROMPT_FROM_STDIN__'
62
+ export const WINDOWS_NPM_STDIN_BOOTSTRAP = [
63
+ "const {readFileSync}=require('node:fs');",
64
+ "const {pathToFileURL}=require('node:url');",
65
+ '(async()=>{',
66
+ 'const entry=process.argv[1];',
67
+ `const marker=${JSON.stringify(WINDOWS_PROMPT_STDIN_TOKEN)};`,
68
+ "const promptFlag=process.argv.lastIndexOf('-p');",
69
+ 'const index=promptFlag+1;',
70
+ "if(promptFlag<2||process.argv[index]!==marker)throw new Error('SpecRails Kimi prompt marker missing');",
71
+ "process.argv[index]=readFileSync(0,'utf8');",
72
+ 'await import(pathToFileURL(entry).href);',
73
+ "})().catch(error=>{console.error(error);process.exitCode=1})",
74
+ ].join('')
75
+ const KNOWN_SKILL_TYPES = new Set(['prompt', 'inline', 'flow', 'reference'])
76
+ const OFFICIAL_SHORT_MODEL_IDS = new Set([
77
+ 'k3',
78
+ 'kimi-for-coding',
79
+ 'kimi-for-coding-highspeed',
80
+ ])
81
+
82
+ const VALUE_FLAGS = new Set([
83
+ '--skill',
84
+ '--model',
85
+ '--args',
86
+ '--session',
87
+ '--add-dir',
88
+ '--attachment',
89
+ '--prompt',
90
+ '--request-file',
91
+ '--role-wave-file',
92
+ '--role-wave-status',
93
+ '--role-wave-cleanup',
94
+ '--role-merge-file',
95
+ ])
96
+
97
+ export class RunnerUsageError extends Error {
98
+ constructor(message) {
99
+ super(message)
100
+ this.name = 'RunnerUsageError'
101
+ }
102
+ }
103
+
104
+ export function normalizeKimiCliModel(model) {
105
+ const value = requireSafeModelId(model)
106
+ return OFFICIAL_SHORT_MODEL_IDS.has(value) ? `kimi-code/${value}` : value
107
+ }
108
+
109
+ export function tokenizeSkillArguments(raw) {
110
+ const out = []
111
+ let current = ''
112
+ let quote
113
+ let hasContent = false
114
+
115
+ for (const char of raw) {
116
+ if (quote !== undefined) {
117
+ if (char === quote) {
118
+ quote = undefined
119
+ } else {
120
+ current += char
121
+ hasContent = true
122
+ }
123
+ continue
124
+ }
125
+ if (char === '"' || char === "'") {
126
+ quote = char
127
+ hasContent = true
128
+ continue
129
+ }
130
+ if (/\s/.test(char)) {
131
+ if (hasContent) {
132
+ out.push(current)
133
+ current = ''
134
+ hasContent = false
135
+ }
136
+ continue
137
+ }
138
+ current += char
139
+ hasContent = true
140
+ }
141
+
142
+ if (hasContent) out.push(current)
143
+ return out
144
+ }
145
+
146
+ export function expandSkillParameters(body, rawArgs, context) {
147
+ const tokens = tokenizeSkillArguments(rawArgs)
148
+ let content = body
149
+
150
+ for (let index = 0; index < (context.argumentNames?.length ?? 0); index++) {
151
+ const name = context.argumentNames[index]
152
+ if (name === undefined) continue
153
+ const escaped = escapeRegExp(name)
154
+ content = content.replace(
155
+ new RegExp(`\\$${escaped}(?![\\[\\w])`, 'g'),
156
+ escapeXmlTags(tokens[index] ?? ''),
157
+ )
158
+ }
159
+
160
+ content = content
161
+ .replace(/\$ARGUMENTS\[(\d+)\]/g, (_match, indexText) => {
162
+ const index = Number.parseInt(indexText, 10)
163
+ return escapeXmlTags(tokens[index] ?? '')
164
+ })
165
+ .replace(/\$(\d+)(?!\w)/g, (_match, indexText) => {
166
+ const index = Number.parseInt(indexText, 10)
167
+ return escapeXmlTags(tokens[index] ?? '')
168
+ })
169
+ .split('$ARGUMENTS')
170
+ .join(escapeXmlTags(rawArgs))
171
+
172
+ const hasArgumentPlaceholder = content !== body
173
+ content = content
174
+ .split('${KIMI_SKILL_DIR}')
175
+ .join(context.skillDir)
176
+ .split('${KIMI_SESSION_ID}')
177
+ .join(context.sessionId ?? '')
178
+
179
+ if (!hasArgumentPlaceholder && rawArgs.length > 0) {
180
+ return `${content}\n\nARGUMENTS: ${escapeXmlTags(rawArgs)}`
181
+ }
182
+ return content
183
+ }
184
+
185
+ export function renderUserSlashSkillPrompt(input) {
186
+ return [
187
+ `User activated the skill "${escapeXml(input.skillName)}". Follow the loaded skill instructions.`,
188
+ '',
189
+ `<kimi-skill-loaded name="${escapeXml(input.skillName)}" trigger="user-slash" source="project" dir="${escapeXml(input.skillDir)}" args="${escapeXml(input.skillArgs)}">`,
190
+ input.skillContent,
191
+ '</kimi-skill-loaded>',
192
+ ].join('\n')
193
+ }
194
+
195
+ export function parseSkillDocument(text, options = {}) {
196
+ const lines = text.split(/\r?\n/)
197
+ if (lines[0]?.trim() !== '---') {
198
+ throw new RunnerUsageError(
199
+ `Skill ${options.skillId ?? ''} is missing required frontmatter`.trim(),
200
+ )
201
+ }
202
+ const close = lines.findIndex((line, index) => index > 0 && line.trim() === '---')
203
+ if (close === -1) {
204
+ throw new RunnerUsageError(
205
+ `Skill ${options.skillId ?? ''} has no closing frontmatter fence`.trim(),
206
+ )
207
+ }
208
+
209
+ const yamlText = lines.slice(1, close).join('\n').trim()
210
+ let frontmatter
211
+ try {
212
+ frontmatter = yamlText === '' ? {} : (loadYaml(yamlText) ?? {})
213
+ } catch (error) {
214
+ throw new RunnerUsageError(
215
+ `Invalid frontmatter in skill ${options.skillId ?? ''}: ${errorMessage(error)}`.trim(),
216
+ )
217
+ }
218
+ if (!isRecord(frontmatter)) {
219
+ throw new RunnerUsageError(
220
+ `Frontmatter in skill ${options.skillId ?? ''} must be a mapping`.trim(),
221
+ )
222
+ }
223
+
224
+ const name = nonEmptyString(frontmatter.name)
225
+ const description = nonEmptyString(frontmatter.description)
226
+ const hasType = Object.prototype.hasOwnProperty.call(frontmatter, 'type')
227
+ if (
228
+ hasType &&
229
+ (typeof frontmatter.type !== 'string' || frontmatter.type.trim() === '')
230
+ ) {
231
+ throw new RunnerUsageError(
232
+ `Skill ${options.skillId ?? ''} has an invalid type`.trim(),
233
+ )
234
+ }
235
+ const type = nonEmptyString(frontmatter.type)
236
+ if (name === undefined) {
237
+ throw new RunnerUsageError(`Skill ${options.skillId ?? ''} has no valid name`.trim())
238
+ }
239
+ if (description === undefined) {
240
+ throw new RunnerUsageError(
241
+ `Skill ${options.skillId ?? ''} has no valid description`.trim(),
242
+ )
243
+ }
244
+ if (type !== undefined && !KNOWN_SKILL_TYPES.has(type)) {
245
+ throw new RunnerUsageError(`Skill "${name}" has unsupported type "${type}"`)
246
+ }
247
+ if (type === 'reference') {
248
+ throw new RunnerUsageError(
249
+ `Skill "${name}" has type "reference" and cannot be activated by the user`,
250
+ )
251
+ }
252
+
253
+ return {
254
+ name,
255
+ description,
256
+ type,
257
+ argumentNames: skillArgumentNames(frontmatter.arguments),
258
+ body: lines.slice(close + 1).join('\n').trim(),
259
+ }
260
+ }
261
+
262
+ export function parseRunnerArgs(argv) {
263
+ const parsed = {
264
+ skill: undefined,
265
+ model: undefined,
266
+ rawArgs: '',
267
+ sessionId: undefined,
268
+ additionalDirs: [],
269
+ attachmentPaths: [],
270
+ extraPrompt: undefined,
271
+ requestFile: undefined,
272
+ roleWaveFile: undefined,
273
+ roleWaveStatus: undefined,
274
+ roleWaveCleanup: undefined,
275
+ roleMergeFile: undefined,
276
+ plainPromptStdin: false,
277
+ }
278
+ let positionalArgs
279
+ const seenSingleValueFlags = new Set()
280
+
281
+ for (let index = 0; index < argv.length; index++) {
282
+ const token = argv[index]
283
+ if (token === '--') {
284
+ positionalArgs = argv.slice(index + 1).join(' ')
285
+ break
286
+ }
287
+ if (token.startsWith('--session=')) {
288
+ if (seenSingleValueFlags.has('--session')) {
289
+ throw new RunnerUsageError('--session may be supplied once')
290
+ }
291
+ const value = token.slice('--session='.length)
292
+ assertNoNul(value, '--session')
293
+ seenSingleValueFlags.add('--session')
294
+ parsed.sessionId = value
295
+ continue
296
+ }
297
+ if (token === PLAIN_PROMPT_STDIN_FLAG) {
298
+ if (parsed.plainPromptStdin) {
299
+ throw new RunnerUsageError(
300
+ `${PLAIN_PROMPT_STDIN_FLAG} may be supplied once`,
301
+ )
302
+ }
303
+ parsed.plainPromptStdin = true
304
+ continue
305
+ }
306
+ if (!VALUE_FLAGS.has(token)) {
307
+ throw new RunnerUsageError(`Unknown option: ${token}`)
308
+ }
309
+ if (index + 1 >= argv.length) {
310
+ throw new RunnerUsageError(`${token} requires a value`)
311
+ }
312
+ const value = argv[++index]
313
+ assertNoNul(value, token)
314
+ if (token === '--add-dir') {
315
+ parsed.additionalDirs.push(path.resolve(requireNonEmpty(value, token)))
316
+ } else if (token === '--attachment') {
317
+ if (!path.isAbsolute(value)) {
318
+ throw new RunnerUsageError(`Attachment path must be absolute: ${value}`)
319
+ }
320
+ parsed.attachmentPaths.push(value)
321
+ } else {
322
+ if (seenSingleValueFlags.has(token)) {
323
+ throw new RunnerUsageError(`${token} may be supplied once`)
324
+ }
325
+ seenSingleValueFlags.add(token)
326
+ if (token === '--skill') {
327
+ parsed.skill = value
328
+ } else if (token === '--model') {
329
+ parsed.model = value
330
+ } else if (token === '--args') {
331
+ parsed.rawArgs = value
332
+ } else if (token === '--session') {
333
+ parsed.sessionId = value
334
+ } else if (token === '--prompt') {
335
+ parsed.extraPrompt = value
336
+ } else if (token === '--request-file') {
337
+ parsed.requestFile = value
338
+ } else if (token === '--role-wave-file') {
339
+ parsed.roleWaveFile = value
340
+ } else if (token === '--role-wave-status') {
341
+ parsed.roleWaveStatus = value
342
+ } else if (token === '--role-wave-cleanup') {
343
+ parsed.roleWaveCleanup = value
344
+ } else if (token === '--role-merge-file') {
345
+ parsed.roleMergeFile = value
346
+ }
347
+ }
348
+ }
349
+
350
+ if (positionalArgs !== undefined) {
351
+ if (seenSingleValueFlags.has('--args')) {
352
+ throw new RunnerUsageError('Use either --args or positional arguments after --, not both')
353
+ }
354
+ parsed.rawArgs = positionalArgs
355
+ }
356
+
357
+ if (
358
+ parsed.requestFile !== undefined ||
359
+ parsed.roleWaveFile !== undefined ||
360
+ parsed.roleWaveStatus !== undefined ||
361
+ parsed.roleWaveCleanup !== undefined ||
362
+ parsed.roleMergeFile !== undefined
363
+ ) {
364
+ const modes = [
365
+ parsed.requestFile !== undefined,
366
+ parsed.roleWaveFile !== undefined,
367
+ parsed.roleWaveStatus !== undefined,
368
+ parsed.roleWaveCleanup !== undefined,
369
+ parsed.roleMergeFile !== undefined,
370
+ ].filter(Boolean).length
371
+ if (modes !== 1) {
372
+ throw new RunnerUsageError(
373
+ 'Use only one request, role-wave, status, or merge mode',
374
+ )
375
+ }
376
+ const requestFlag =
377
+ parsed.roleWaveFile !== undefined
378
+ ? '--role-wave-file'
379
+ : parsed.roleWaveStatus !== undefined
380
+ ? '--role-wave-status'
381
+ : parsed.roleWaveCleanup !== undefined
382
+ ? '--role-wave-cleanup'
383
+ : parsed.roleMergeFile !== undefined
384
+ ? '--role-merge-file'
385
+ : '--request-file'
386
+ for (const flag of [
387
+ '--skill',
388
+ '--model',
389
+ '--args',
390
+ '--session',
391
+ '--prompt',
392
+ ]) {
393
+ if (seenSingleValueFlags.has(flag)) {
394
+ throw new RunnerUsageError(
395
+ `${requestFlag} cannot be combined with ${flag}`,
396
+ )
397
+ }
398
+ }
399
+ if (positionalArgs !== undefined) {
400
+ throw new RunnerUsageError(
401
+ `${requestFlag} cannot be combined with positional arguments`,
402
+ )
403
+ }
404
+ if (parsed.plainPromptStdin) {
405
+ throw new RunnerUsageError(
406
+ `${requestFlag} cannot be combined with ${PLAIN_PROMPT_STDIN_FLAG}`,
407
+ )
408
+ }
409
+ if (parsed.requestFile !== undefined) {
410
+ parsed.requestFile = requireNonEmpty(
411
+ parsed.requestFile,
412
+ 'request file',
413
+ )
414
+ } else if (parsed.roleWaveFile !== undefined) {
415
+ parsed.roleWaveFile = requireNonEmpty(
416
+ parsed.roleWaveFile,
417
+ 'role wave file',
418
+ )
419
+ } else if (parsed.roleWaveStatus !== undefined) {
420
+ parsed.roleWaveStatus = requireSafeWaveId(
421
+ parsed.roleWaveStatus,
422
+ 'role wave status run',
423
+ )
424
+ } else if (parsed.roleWaveCleanup !== undefined) {
425
+ parsed.roleWaveCleanup = requireSafeWaveId(
426
+ parsed.roleWaveCleanup,
427
+ 'role wave cleanup run',
428
+ )
429
+ } else {
430
+ parsed.roleMergeFile = requireNonEmpty(
431
+ parsed.roleMergeFile,
432
+ 'role merge file',
433
+ )
434
+ }
435
+ } else if (parsed.plainPromptStdin) {
436
+ for (const flag of ['--skill', '--args', '--prompt', '--attachment']) {
437
+ if (
438
+ seenSingleValueFlags.has(flag) ||
439
+ (flag === '--attachment' && parsed.attachmentPaths.length > 0)
440
+ ) {
441
+ throw new RunnerUsageError(
442
+ `${PLAIN_PROMPT_STDIN_FLAG} cannot be combined with ${flag}`,
443
+ )
444
+ }
445
+ }
446
+ if (positionalArgs !== undefined) {
447
+ throw new RunnerUsageError(
448
+ `${PLAIN_PROMPT_STDIN_FLAG} cannot be combined with positional arguments`,
449
+ )
450
+ }
451
+ parsed.model = requireSafeModelId(parsed.model)
452
+ if (parsed.sessionId !== undefined) {
453
+ parsed.sessionId = requireSafeSessionId(parsed.sessionId)
454
+ }
455
+ } else {
456
+ parsed.skill = requireSafeSkillId(parsed.skill)
457
+ parsed.model = requireSafeModelId(parsed.model)
458
+ parsed.rawArgs = parsed.rawArgs.trim()
459
+ if (parsed.sessionId !== undefined) {
460
+ parsed.sessionId = requireSafeSessionId(parsed.sessionId)
461
+ }
462
+ }
463
+ return parsed
464
+ }
465
+
466
+ export function loadRoleRequest(parsed, cwd) {
467
+ if (parsed.requestFile === undefined) return parsed
468
+ const requestPath = path.resolve(cwd, parsed.requestFile)
469
+ const expectedPath = path.resolve(cwd, ROLE_REQUEST_RELATIVE_PATH)
470
+ if (requestPath !== expectedPath) {
471
+ throw new RunnerUsageError(
472
+ `Role request must use ${ROLE_REQUEST_RELATIVE_PATH}`,
473
+ )
474
+ }
475
+ assertManagedPathParents(cwd, requestPath, 'role request')
476
+
477
+ let metadata
478
+ try {
479
+ metadata = lstatSync(requestPath)
480
+ } catch (error) {
481
+ throw new RunnerUsageError(
482
+ `Cannot inspect role request ${requestPath}: ${errorMessage(error)}`,
483
+ )
484
+ }
485
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
486
+ throw new RunnerUsageError(
487
+ `Role request must be a regular non-symlink file: ${requestPath}`,
488
+ )
489
+ }
490
+ if (metadata.size > MAX_ROLE_REQUEST_BYTES) {
491
+ unlinkSync(requestPath)
492
+ throw new RunnerUsageError(
493
+ `Role request exceeds ${MAX_ROLE_REQUEST_BYTES} bytes`,
494
+ )
495
+ }
496
+
497
+ let source
498
+ try {
499
+ source = readFileSync(requestPath, 'utf8')
500
+ } finally {
501
+ // The fixed request is one-shot. Removing it before spawn prevents stale
502
+ // context from being reused by a later role invocation.
503
+ unlinkSync(requestPath)
504
+ }
505
+ if (Buffer.byteLength(source, 'utf8') > MAX_ROLE_REQUEST_BYTES) {
506
+ throw new RunnerUsageError(
507
+ `Role request exceeds ${MAX_ROLE_REQUEST_BYTES} bytes`,
508
+ )
509
+ }
510
+
511
+ let request
512
+ try {
513
+ request = JSON.parse(source)
514
+ } catch (error) {
515
+ throw new RunnerUsageError(
516
+ `Invalid role request JSON: ${errorMessage(error)}`,
517
+ )
518
+ }
519
+ if (!isRecord(request)) {
520
+ throw new RunnerUsageError('Role request must be a JSON object')
521
+ }
522
+ const keys = Object.keys(request).sort()
523
+ if (
524
+ keys.length !== 3 ||
525
+ keys[0] !== 'args' ||
526
+ keys[1] !== 'model' ||
527
+ keys[2] !== 'skill'
528
+ ) {
529
+ throw new RunnerUsageError(
530
+ 'Role request must contain exactly skill, model, and args',
531
+ )
532
+ }
533
+ if (typeof request.args !== 'string') {
534
+ throw new RunnerUsageError('Role request args must be a string')
535
+ }
536
+ assertNoNul(request.args, 'role request args')
537
+
538
+ return {
539
+ ...parsed,
540
+ skill: requireSafeSkillId(request.skill),
541
+ model: requireSafeModelId(request.model),
542
+ rawArgs: request.args.trim(),
543
+ requestFile: undefined,
544
+ }
545
+ }
546
+
547
+ export function loadRoleWave(parsed, cwd) {
548
+ if (parsed.roleWaveFile === undefined) return undefined
549
+ const requestPath = path.resolve(cwd, parsed.roleWaveFile)
550
+ const expectedPath = path.resolve(cwd, ROLE_WAVE_RELATIVE_PATH)
551
+ if (requestPath !== expectedPath) {
552
+ throw new RunnerUsageError(
553
+ `Role wave must use ${ROLE_WAVE_RELATIVE_PATH}`,
554
+ )
555
+ }
556
+ assertManagedPathParents(cwd, requestPath, 'role wave')
557
+
558
+ let metadata
559
+ try {
560
+ metadata = lstatSync(requestPath)
561
+ } catch (error) {
562
+ throw new RunnerUsageError(
563
+ `Cannot inspect role wave ${requestPath}: ${errorMessage(error)}`,
564
+ )
565
+ }
566
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
567
+ throw new RunnerUsageError(
568
+ `Role wave must be a regular non-symlink file: ${requestPath}`,
569
+ )
570
+ }
571
+ if (metadata.size > MAX_ROLE_REQUEST_BYTES) {
572
+ unlinkSync(requestPath)
573
+ throw new RunnerUsageError(
574
+ `Role wave exceeds ${MAX_ROLE_REQUEST_BYTES} bytes`,
575
+ )
576
+ }
577
+
578
+ let source
579
+ try {
580
+ source = readFileSync(requestPath, 'utf8')
581
+ } finally {
582
+ // A whole wave is one-shot. Deleting before any worktree or process is
583
+ // created prevents stale context and removes the parallel WriteFile race.
584
+ unlinkSync(requestPath)
585
+ }
586
+ if (Buffer.byteLength(source, 'utf8') > MAX_ROLE_REQUEST_BYTES) {
587
+ throw new RunnerUsageError(
588
+ `Role wave exceeds ${MAX_ROLE_REQUEST_BYTES} bytes`,
589
+ )
590
+ }
591
+
592
+ let request
593
+ try {
594
+ request = JSON.parse(source)
595
+ } catch (error) {
596
+ throw new RunnerUsageError(
597
+ `Invalid role wave JSON: ${errorMessage(error)}`,
598
+ )
599
+ }
600
+ if (!isRecord(request)) {
601
+ throw new RunnerUsageError('Role wave must be a JSON object')
602
+ }
603
+ const requestKeys = Object.keys(request).sort()
604
+ if (
605
+ requestKeys.length !== 2 ||
606
+ requestKeys[0] !== 'roles' ||
607
+ requestKeys[1] !== 'run'
608
+ ) {
609
+ throw new RunnerUsageError(
610
+ 'Role wave must contain exactly run and roles',
611
+ )
612
+ }
613
+ const run = requireSafeWaveId(request.run, 'run')
614
+ if (
615
+ !Array.isArray(request.roles) ||
616
+ request.roles.length === 0 ||
617
+ request.roles.length > MAX_ROLE_WAVE_SIZE
618
+ ) {
619
+ throw new RunnerUsageError(
620
+ `Role wave roles must contain 1-${MAX_ROLE_WAVE_SIZE} entries`,
621
+ )
622
+ }
623
+
624
+ const seenKeys = new Set()
625
+ const seenWorktrees = new Set()
626
+ const roles = request.roles.map((value, index) => {
627
+ if (!isRecord(value)) {
628
+ throw new RunnerUsageError(
629
+ `Role wave entry ${index} must be a JSON object`,
630
+ )
631
+ }
632
+ const keys = Object.keys(value).sort()
633
+ if (
634
+ keys.length !== 6 ||
635
+ keys[0] !== 'args' ||
636
+ keys[1] !== 'key' ||
637
+ keys[2] !== 'model' ||
638
+ keys[3] !== 'profile' ||
639
+ keys[4] !== 'skill' ||
640
+ keys[5] !== 'workspace'
641
+ ) {
642
+ throw new RunnerUsageError(
643
+ `Role wave entry ${index} must contain exactly ` +
644
+ 'key, skill, model, profile, args, and workspace',
645
+ )
646
+ }
647
+ const key = requireSafeWaveId(value.key, `role key ${index}`)
648
+ if (seenKeys.has(key)) {
649
+ throw new RunnerUsageError(`Duplicate role wave key: ${key}`)
650
+ }
651
+ seenKeys.add(key)
652
+ if (typeof value.args !== 'string') {
653
+ throw new RunnerUsageError(`Role wave entry ${key} args must be a string`)
654
+ }
655
+ assertNoNul(value.args, `role wave entry ${key} args`)
656
+ if (typeof value.workspace !== 'string') {
657
+ throw new RunnerUsageError(
658
+ `Role wave entry ${key} workspace must be a string`,
659
+ )
660
+ }
661
+ let workspace = value.workspace
662
+ if (workspace !== 'current') {
663
+ if (!workspace.startsWith('worktree:')) {
664
+ throw new RunnerUsageError(
665
+ `Role wave entry ${key} workspace must be current or worktree:<id>`,
666
+ )
667
+ }
668
+ const worktreeId = requireSafeWaveId(
669
+ workspace.slice('worktree:'.length),
670
+ `worktree id for ${key}`,
671
+ )
672
+ workspace = `worktree:${worktreeId}`
673
+ if (seenWorktrees.has(workspace)) {
674
+ throw new RunnerUsageError(
675
+ `Concurrent roles cannot share workspace ${workspace}`,
676
+ )
677
+ }
678
+ seenWorktrees.add(workspace)
679
+ }
680
+ return {
681
+ key,
682
+ skill: requireSafeSkillId(value.skill),
683
+ model: requireSafeModelId(value.model),
684
+ profile:
685
+ value.profile === 'inherit'
686
+ ? 'inherit'
687
+ : requireSafeWaveId(value.profile, `profile for ${key}`),
688
+ rawArgs: value.args.trim(),
689
+ workspace,
690
+ }
691
+ })
692
+
693
+ return {
694
+ run,
695
+ roles,
696
+ additionalDirs: parsed.additionalDirs,
697
+ }
698
+ }
699
+
700
+ export function loadRoleMerge(parsed, cwd) {
701
+ if (parsed.roleMergeFile === undefined) return undefined
702
+ const requestPath = path.resolve(cwd, parsed.roleMergeFile)
703
+ const expectedPath = path.resolve(cwd, ROLE_MERGE_RELATIVE_PATH)
704
+ if (requestPath !== expectedPath) {
705
+ throw new RunnerUsageError(
706
+ `Role merge must use ${ROLE_MERGE_RELATIVE_PATH}`,
707
+ )
708
+ }
709
+ assertManagedPathParents(cwd, requestPath, 'role merge')
710
+ let metadata
711
+ try {
712
+ metadata = lstatSync(requestPath)
713
+ } catch (error) {
714
+ throw new RunnerUsageError(
715
+ `Cannot inspect role merge ${requestPath}: ${errorMessage(error)}`,
716
+ )
717
+ }
718
+ if (
719
+ !metadata.isFile() ||
720
+ metadata.isSymbolicLink() ||
721
+ metadata.size > MAX_ROLE_REQUEST_BYTES
722
+ ) {
723
+ if (metadata.isFile() && !metadata.isSymbolicLink()) {
724
+ unlinkSync(requestPath)
725
+ }
726
+ throw new RunnerUsageError(
727
+ `Role merge must be a regular file at most ${MAX_ROLE_REQUEST_BYTES} bytes`,
728
+ )
729
+ }
730
+ let source
731
+ try {
732
+ source = readFileSync(requestPath, 'utf8')
733
+ } finally {
734
+ unlinkSync(requestPath)
735
+ }
736
+ let request
737
+ try {
738
+ request = JSON.parse(source)
739
+ } catch (error) {
740
+ throw new RunnerUsageError(
741
+ `Invalid role merge JSON: ${errorMessage(error)}`,
742
+ )
743
+ }
744
+ if (
745
+ !isRecord(request) ||
746
+ Object.keys(request).sort().join(',') !== 'actions,run'
747
+ ) {
748
+ throw new RunnerUsageError(
749
+ 'Role merge must contain exactly run and actions',
750
+ )
751
+ }
752
+ const run = requireSafeWaveId(request.run, 'role merge run')
753
+ if (
754
+ !Array.isArray(request.actions) ||
755
+ request.actions.length === 0 ||
756
+ request.actions.length > 4_096
757
+ ) {
758
+ throw new RunnerUsageError(
759
+ 'Role merge actions must contain 1-4096 entries',
760
+ )
761
+ }
762
+ const targets = new Set()
763
+ const actions = request.actions.map((value, index) => {
764
+ if (
765
+ !isRecord(value) ||
766
+ Object.keys(value).sort().join(',') !==
767
+ 'operation,path,worktree'
768
+ ) {
769
+ throw new RunnerUsageError(
770
+ `Role merge action ${index} must contain exactly ` +
771
+ 'worktree, path, and operation',
772
+ )
773
+ }
774
+ const worktree = requireSafeWaveId(
775
+ value.worktree,
776
+ `role merge worktree ${index}`,
777
+ )
778
+ const relativePath = requireSafeRelativePath(
779
+ value.path,
780
+ `role merge path ${index}`,
781
+ )
782
+ if (value.operation !== 'copy' && value.operation !== 'delete') {
783
+ throw new RunnerUsageError(
784
+ `Role merge action ${index} operation must be copy or delete`,
785
+ )
786
+ }
787
+ if (targets.has(relativePath)) {
788
+ throw new RunnerUsageError(
789
+ `Role merge contains duplicate target path: ${relativePath}`,
790
+ )
791
+ }
792
+ targets.add(relativePath)
793
+ return {
794
+ worktree,
795
+ path: relativePath,
796
+ operation: value.operation,
797
+ }
798
+ })
799
+ return { run, actions }
800
+ }
801
+
802
+ export function prepareSkillLaunch(options, dependencies = {}) {
803
+ const readFile = dependencies.readFile ?? ((file) => readFileSync(file, 'utf8'))
804
+ const resolvePath = dependencies.resolvePath ?? realpathSync
805
+ const stat = dependencies.stat ?? statSync
806
+ const attachments = validateAttachmentPaths(
807
+ options.attachmentPaths,
808
+ dependencies,
809
+ )
810
+ const skillId = requireSafeSkillId(options.skill)
811
+ const sessionId =
812
+ options.sessionId === undefined
813
+ ? undefined
814
+ : requireSafeSessionId(options.sessionId)
815
+ const skillDirCandidate = path.join(options.providerRoot, 'skills', skillId)
816
+ const skillPathCandidate = path.join(skillDirCandidate, 'SKILL.md')
817
+
818
+ let text
819
+ try {
820
+ if (!stat(skillPathCandidate).isFile()) {
821
+ throw new Error('not a regular file')
822
+ }
823
+ text = readFile(skillPathCandidate)
824
+ } catch (error) {
825
+ throw new RunnerUsageError(
826
+ `Cannot read skill "${skillId}" at ${skillPathCandidate}: ${errorMessage(error)}`,
827
+ )
828
+ }
829
+
830
+ // Kimi's scanner canonicalizes each discovered skill root. Use the same
831
+ // real path for context placeholders and the activation envelope.
832
+ const skillDir = toPromptPath(resolvePath(skillDirCandidate))
833
+ const parsed = parseSkillDocument(text, { skillId })
834
+ if (parsed.name.toLowerCase() !== skillId.toLowerCase()) {
835
+ throw new RunnerUsageError(
836
+ `Skill directory "${skillId}" declares mismatched name "${parsed.name}"`,
837
+ )
838
+ }
839
+ if (parsed.body.length === 0) {
840
+ throw new RunnerUsageError(`Skill "${skillId}" has an empty body`)
841
+ }
842
+ if (sessionId === undefined && parsed.body.includes('${KIMI_SESSION_ID}')) {
843
+ throw new RunnerUsageError(
844
+ `Skill "${skillId}" requires KIMI_SESSION_ID; start it only with --session=<id>`,
845
+ )
846
+ }
847
+ const expanded = expandSkillParameters(parsed.body, options.rawArgs, {
848
+ skillDir,
849
+ sessionId,
850
+ argumentNames: parsed.argumentNames,
851
+ })
852
+ let prompt = renderUserSlashSkillPrompt({
853
+ skillName: parsed.name,
854
+ skillArgs: options.rawArgs,
855
+ skillContent: expanded,
856
+ skillDir,
857
+ })
858
+
859
+ if (options.extraPrompt?.trim()) {
860
+ prompt += `\n\n${options.extraPrompt.trim()}`
861
+ }
862
+ if (attachments.length > 0) {
863
+ prompt += [
864
+ '',
865
+ '',
866
+ 'Attached files (absolute paths):',
867
+ ...attachments.map((attachment) => `- ${attachment}`),
868
+ '',
869
+ 'Read textual files with ReadFile. For images or other media, use',
870
+ 'ReadMediaFile on the exact path. Kimi prompt mode has no attachment flag.',
871
+ ].join('\n')
872
+ }
873
+
874
+ const kimiArgs = [
875
+ ...(sessionId ? [`--session=${sessionId}`] : []),
876
+ ...Array.from(
877
+ new Set([
878
+ ...options.additionalDirs.map(canonicalizeExistingPath),
879
+ ...attachments.map((attachment) => path.dirname(attachment)),
880
+ ]),
881
+ ).flatMap((dir) => ['--add-dir', dir]),
882
+ '-m',
883
+ normalizeKimiCliModel(options.model),
884
+ '-p',
885
+ prompt,
886
+ '--output-format',
887
+ 'stream-json',
888
+ ]
889
+ return { prompt, kimiArgs, skillDir, skillName: parsed.name }
890
+ }
891
+
892
+ function canonicalizeExistingPath(value) {
893
+ try {
894
+ return canonicalRealpath(value)
895
+ } catch {
896
+ return path.resolve(value)
897
+ }
898
+ }
899
+
900
+ /**
901
+ * Node's legacy realpath implementation can preserve an input's Windows 8.3
902
+ * spelling while Git reports the same directory with its long name (or vice
903
+ * versa). Use the native resolver and a case-folded identity for comparisons;
904
+ * callers still lstat the recomputed managed path so a symlink alias cannot
905
+ * satisfy an ownership check.
906
+ */
907
+ function canonicalRealpath(value) {
908
+ const resolved =
909
+ typeof realpathSync.native === 'function'
910
+ ? realpathSync.native(value)
911
+ : realpathSync(value)
912
+ return path.resolve(resolved)
913
+ }
914
+
915
+ function existingPathIdentity(value) {
916
+ const canonical = canonicalRealpath(value)
917
+ return process.platform === 'win32'
918
+ ? canonical.toLowerCase()
919
+ : canonical
920
+ }
921
+
922
+ function validateAttachmentPaths(attachmentPaths = [], dependencies = {}) {
923
+ const inspect = dependencies.lstat ?? lstatSync
924
+ const resolvePath = dependencies.resolvePath ?? realpathSync
925
+ const assertReadable =
926
+ dependencies.access ??
927
+ ((file) => {
928
+ accessSync(file, fsConstants.R_OK)
929
+ })
930
+ return attachmentPaths.map((attachment) => {
931
+ if (!path.isAbsolute(attachment)) {
932
+ throw new RunnerUsageError(
933
+ `Attachment path must be absolute: ${attachment}`,
934
+ )
935
+ }
936
+ try {
937
+ const metadata = inspect(attachment)
938
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
939
+ throw new Error('not a regular non-symlink file')
940
+ }
941
+ const canonical = resolvePath(attachment)
942
+ assertReadable(canonical)
943
+ return canonical
944
+ } catch (error) {
945
+ throw new RunnerUsageError(
946
+ `Attachment must be a readable regular non-symlink file: ` +
947
+ `${attachment}: ${errorMessage(error)}`,
948
+ )
949
+ }
950
+ })
951
+ }
952
+
953
+ export function parseNpmCmdShimEntry(shimPath, contents) {
954
+ const match = contents.match(
955
+ /%dp0%[\\/]([^"\r\n]*?\.(?:mjs|cjs|js))["']?\s+%\*/i,
956
+ )
957
+ if (!match?.[1]) return null
958
+ return path.win32.join(path.win32.dirname(shimPath), match[1])
959
+ }
960
+
961
+ export function resolveWindowsKimiBinary(env = process.env, fileExists = existsSync) {
962
+ const pathValue = getEnvCaseInsensitive(env, 'PATH') ?? ''
963
+ // npm installs three siblings on Windows: `kimi`, `kimi.cmd`, and
964
+ // `kimi.ps1`. The extensionless sibling is a POSIX shell script and cannot
965
+ // be passed to CreateProcess, while PowerShell scripts cannot be spawned
966
+ // directly with shell:false. Probe only executable-safe forms, with the npm
967
+ // command shim first so large prompts can use the stdin bootstrap.
968
+ const names = ['kimi.cmd', 'kimi.bat', 'kimi.exe', 'kimi.com']
969
+
970
+ for (const rawEntry of pathValue.split(';')) {
971
+ const entry = rawEntry.trim().replace(/^"(.*)"$/, '$1')
972
+ if (entry === '') continue
973
+ for (const name of names) {
974
+ const candidate = path.win32.join(entry, name)
975
+ if (fileExists(candidate)) return candidate
976
+ }
977
+ }
978
+ throw new RunnerUsageError(
979
+ 'No shell-free Kimi executable (.cmd, .bat, .exe, or .com) was found on PATH',
980
+ )
981
+ }
982
+
983
+ export function resolveKimiLaunch(kimiArgs, options = {}) {
984
+ const platform = options.platform ?? process.platform
985
+ if (platform !== 'win32') {
986
+ return { command: 'kimi', args: kimiArgs }
987
+ }
988
+
989
+ const fileExists = options.fileExists ?? existsSync
990
+ const readFile = options.readFile ?? ((file) => readFileSync(file, 'utf8'))
991
+ const binary = options.binary ?? resolveWindowsKimiBinary(options.env, fileExists)
992
+ const extension = path.win32.extname(binary).toLowerCase()
993
+ if (extension !== '.cmd' && extension !== '.bat') {
994
+ assertWindowsCommandLineBudget(binary, kimiArgs)
995
+ return { command: binary, args: kimiArgs }
996
+ }
997
+
998
+ let entry
999
+ try {
1000
+ entry = parseNpmCmdShimEntry(binary, readFile(binary))
1001
+ } catch (error) {
1002
+ throw new RunnerUsageError(
1003
+ `Cannot read the Kimi Windows shim ${binary}: ${errorMessage(error)}`,
1004
+ )
1005
+ }
1006
+ if (entry === null) {
1007
+ throw new RunnerUsageError(
1008
+ `Refusing to execute non-standard Kimi Windows shim through cmd.exe: ${binary}`,
1009
+ )
1010
+ }
1011
+ const localNode = path.win32.join(path.win32.dirname(binary), 'node.exe')
1012
+ const nodeBinary = fileExists(localNode) ? localNode : 'node'
1013
+ const promptFlag = kimiArgs.indexOf('-p')
1014
+ if (promptFlag === -1 || promptFlag + 1 >= kimiArgs.length) {
1015
+ const args = [entry, ...kimiArgs]
1016
+ assertWindowsCommandLineBudget(nodeBinary, args)
1017
+ return { command: nodeBinary, args }
1018
+ }
1019
+
1020
+ const transportedArgs = [...kimiArgs]
1021
+ const prompt = transportedArgs[promptFlag + 1]
1022
+ transportedArgs[promptFlag + 1] = WINDOWS_PROMPT_STDIN_TOKEN
1023
+ const args = [
1024
+ '-e',
1025
+ WINDOWS_NPM_STDIN_BOOTSTRAP,
1026
+ entry,
1027
+ ...transportedArgs,
1028
+ ]
1029
+ assertWindowsCommandLineBudget(nodeBinary, args)
1030
+ return { command: nodeBinary, args, stdinText: prompt }
1031
+ }
1032
+
1033
+ export function materializeRoleWaveWorkspaces(wave, options = {}) {
1034
+ const initialCwd = canonicalRealpath(options.cwd ?? process.cwd())
1035
+ const git = options.spawnSync ?? spawnSync
1036
+ const gitBinary = options.gitBinary ?? 'git'
1037
+ const tempRoot = ensureManagedTempRoot(options.tempRoot ?? os.tmpdir())
1038
+ const providerRoot = canonicalRealpath(options.providerRoot)
1039
+ const runGit = (cwd, args, input, extraEnv = {}) => {
1040
+ const result = git(
1041
+ gitBinary,
1042
+ ['-C', cwd, '-c', 'core.autocrlf=false', ...args],
1043
+ {
1044
+ encoding: 'utf8',
1045
+ env: { ...process.env, ...extraEnv },
1046
+ input,
1047
+ maxBuffer: 256 * 1024 * 1024,
1048
+ shell: false,
1049
+ },
1050
+ )
1051
+ if (result.error || result.status !== 0) {
1052
+ const detail =
1053
+ errorMessage(result.error ?? '').trim() ||
1054
+ String(result.stderr ?? '').trim() ||
1055
+ `exit ${String(result.status)}`
1056
+ throw new RunnerUsageError(
1057
+ `git ${args.join(' ')} failed in ${cwd}: ${detail}`,
1058
+ )
1059
+ }
1060
+ return String(result.stdout ?? '')
1061
+ }
1062
+
1063
+ const baseRepo = canonicalRealpath(
1064
+ runGit(initialCwd, ['rev-parse', '--show-toplevel']).trim(),
1065
+ )
1066
+ const manifestPath = path.join(
1067
+ baseRepo,
1068
+ ROLE_WORKTREE_MANIFEST_DIR,
1069
+ `${wave.run}.json`,
1070
+ )
1071
+ ensureRealDirectoryTree(baseRepo, path.dirname(manifestPath))
1072
+ const existingManifest = readRoleWaveManifest(manifestPath)
1073
+ const head = requireGitOid(
1074
+ runGit(baseRepo, ['rev-parse', 'HEAD']).trim(),
1075
+ 'repository HEAD',
1076
+ )
1077
+ if (
1078
+ existingManifest &&
1079
+ (
1080
+ existingManifest.run !== wave.run ||
1081
+ existingPathIdentity(existingManifest.baseRepo) !==
1082
+ existingPathIdentity(baseRepo)
1083
+ )
1084
+ ) {
1085
+ throw new RunnerUsageError(
1086
+ `Role wave manifest does not belong to ${wave.run} in ${baseRepo}`,
1087
+ )
1088
+ }
1089
+ const worktrees = { ...(existingManifest?.worktrees ?? {}) }
1090
+ const repoKey = createHash('sha256')
1091
+ .update(baseRepo)
1092
+ .digest('hex')
1093
+ .slice(0, 16)
1094
+ const needsWorktrees = wave.roles.some(
1095
+ (role) => role.workspace !== 'current',
1096
+ )
1097
+ let baseCommit = existingManifest?.baseCommit ?? null
1098
+ let sourceHead = existingManifest?.sourceHead ?? null
1099
+ let createdBaseline = false
1100
+ if (baseCommit !== null) {
1101
+ baseCommit = requireGitOid(baseCommit, 'role wave base commit')
1102
+ runGit(baseRepo, ['cat-file', '-e', `${baseCommit}^{commit}`])
1103
+ } else if (needsWorktrees) {
1104
+ sourceHead = head
1105
+ baseCommit = createSyntheticBaselineCommit({
1106
+ baseRepo,
1107
+ head,
1108
+ repoKey,
1109
+ run: wave.run,
1110
+ tempRoot,
1111
+ runGit,
1112
+ })
1113
+ createdBaseline = true
1114
+ }
1115
+ const worktreeRoot = path.join(
1116
+ tempRoot,
1117
+ 'specrails-kimi-worktrees',
1118
+ repoKey,
1119
+ wave.run,
1120
+ )
1121
+ const executionRoot = path.join(
1122
+ tempRoot,
1123
+ 'specrails-kimi-execution',
1124
+ repoKey,
1125
+ wave.run,
1126
+ )
1127
+ ensureRealDirectoryTree(tempRoot, worktreeRoot)
1128
+ ensureRealDirectoryTree(tempRoot, executionRoot)
1129
+ const gitExcludeDirectory = path.join(
1130
+ tempRoot,
1131
+ 'specrails-kimi-git-excludes',
1132
+ repoKey,
1133
+ )
1134
+ ensureRealDirectoryTree(tempRoot, gitExcludeDirectory)
1135
+ const gitExcludeFile = writeRoleGitExclude(
1136
+ path.join(gitExcludeDirectory, `${wave.run}.txt`),
1137
+ )
1138
+
1139
+ const listed = parseGitWorktreeList(
1140
+ runGit(baseRepo, ['worktree', 'list', '--porcelain', '-z']),
1141
+ )
1142
+ if (existingManifest) {
1143
+ validateRoleWaveManifestIntegrity(existingManifest, {
1144
+ baseRepo,
1145
+ run: wave.run,
1146
+ tempRoot,
1147
+ repoKey,
1148
+ registered: listed,
1149
+ runGit: (args) => runGit(baseRepo, args),
1150
+ })
1151
+ }
1152
+ const createdThisCall = []
1153
+ try {
1154
+ const roles = wave.roles.map((role) => {
1155
+ if (role.profile !== 'inherit') {
1156
+ const profilePath = path.join(
1157
+ baseRepo,
1158
+ '.specrails',
1159
+ 'profiles',
1160
+ `${role.profile}.json`,
1161
+ )
1162
+ assertWorkspaceParentSafety(baseRepo, profilePath)
1163
+ let profileMetadata
1164
+ try {
1165
+ profileMetadata = lstatSync(profilePath)
1166
+ } catch (error) {
1167
+ throw new RunnerUsageError(
1168
+ `Cannot read role profile ${role.profile}: ${errorMessage(error)}`,
1169
+ )
1170
+ }
1171
+ if (!profileMetadata.isFile() || profileMetadata.isSymbolicLink()) {
1172
+ throw new RunnerUsageError(
1173
+ `Role profile must be a regular non-symlink file: ${profilePath}`,
1174
+ )
1175
+ }
1176
+ }
1177
+ if (role.workspace === 'current') {
1178
+ const executionCwd = path.join(executionRoot, role.key)
1179
+ ensureExecutionWorkspace(
1180
+ executionCwd,
1181
+ providerRoot,
1182
+ {
1183
+ run: wave.run,
1184
+ roleKey: role.key,
1185
+ repoDir: baseRepo,
1186
+ },
1187
+ options.platform ?? process.platform,
1188
+ )
1189
+ return {
1190
+ ...role,
1191
+ cwd: executionCwd,
1192
+ repoDir: baseRepo,
1193
+ gitExcludeFile,
1194
+ }
1195
+ }
1196
+ const worktreeId = role.workspace.slice('worktree:'.length)
1197
+ const target = path.join(worktreeRoot, worktreeId)
1198
+ const recorded = worktrees[worktreeId]
1199
+ if (recorded !== undefined && path.resolve(recorded) !== target) {
1200
+ throw new RunnerUsageError(
1201
+ `Role worktree mapping changed for ${worktreeId}`,
1202
+ )
1203
+ }
1204
+ const registeredTarget = existsSync(target)
1205
+ ? existingPathIdentity(target)
1206
+ : path.resolve(target)
1207
+ if (!listed.has(registeredTarget)) {
1208
+ if (existsSync(target)) {
1209
+ throw new RunnerUsageError(
1210
+ `Unregistered path blocks role worktree ${worktreeId}: ${target}`,
1211
+ )
1212
+ }
1213
+ mkdirSync(path.dirname(target), { recursive: true })
1214
+ runGit(baseRepo, [
1215
+ 'worktree',
1216
+ 'add',
1217
+ '--detach',
1218
+ target,
1219
+ requireGitOid(baseCommit, 'role wave base commit'),
1220
+ ])
1221
+ createdThisCall.push(target)
1222
+ }
1223
+ ensureProviderOverlay(
1224
+ providerRoot,
1225
+ target,
1226
+ options.platform ?? process.platform,
1227
+ )
1228
+ worktrees[worktreeId] = target
1229
+ return {
1230
+ ...role,
1231
+ cwd: target,
1232
+ repoDir: target,
1233
+ gitExcludeFile,
1234
+ }
1235
+ })
1236
+
1237
+ writeRoleWaveManifest(manifestPath, {
1238
+ schemaVersion: 1,
1239
+ run: wave.run,
1240
+ baseRepo,
1241
+ baseCommit,
1242
+ sourceHead,
1243
+ worktrees,
1244
+ roles: {
1245
+ ...(existingManifest?.roles ?? {}),
1246
+ ...Object.fromEntries(
1247
+ roles.map((role) => [
1248
+ role.key,
1249
+ {
1250
+ executionCwd: role.cwd,
1251
+ repoDir: role.repoDir,
1252
+ workspace: role.workspace,
1253
+ gitExcludeFile: role.gitExcludeFile ?? null,
1254
+ },
1255
+ ]),
1256
+ ),
1257
+ },
1258
+ })
1259
+ return {
1260
+ run: wave.run,
1261
+ roles,
1262
+ baseRepo,
1263
+ baseCommit,
1264
+ manifestPath,
1265
+ }
1266
+ } catch (error) {
1267
+ for (const target of createdThisCall.reverse()) {
1268
+ try {
1269
+ runGit(baseRepo, ['worktree', 'remove', '--force', target])
1270
+ } catch {
1271
+ rmSync(target, { recursive: true, force: true })
1272
+ }
1273
+ }
1274
+ if (createdBaseline) {
1275
+ try {
1276
+ runGit(
1277
+ baseRepo,
1278
+ ['update-ref', '-d', `refs/specrails/kimi/${repoKey}/${wave.run}`],
1279
+ )
1280
+ } catch {
1281
+ // Preserve the original setup error. A stale private ref is harmless
1282
+ // and can be pruned by a later successful cleanup.
1283
+ }
1284
+ }
1285
+ throw error
1286
+ }
1287
+ }
1288
+
1289
+ function parseGitWorktreeList(source) {
1290
+ const paths = new Set()
1291
+ for (const field of source.split('\0')) {
1292
+ if (field.startsWith('worktree ')) {
1293
+ const candidate = field.slice('worktree '.length)
1294
+ paths.add(
1295
+ existsSync(candidate)
1296
+ ? existingPathIdentity(candidate)
1297
+ : path.resolve(candidate),
1298
+ )
1299
+ }
1300
+ }
1301
+ return paths
1302
+ }
1303
+
1304
+ function createSyntheticBaselineCommit({
1305
+ baseRepo,
1306
+ head,
1307
+ repoKey,
1308
+ run,
1309
+ tempRoot,
1310
+ runGit,
1311
+ }) {
1312
+ mkdirSync(tempRoot, { recursive: true })
1313
+ const indexDirectory = path.join(
1314
+ tempRoot,
1315
+ 'specrails-kimi-indexes',
1316
+ repoKey,
1317
+ )
1318
+ ensureRealDirectoryTree(tempRoot, indexDirectory)
1319
+ const indexPath = path.join(indexDirectory, `${run}.index`)
1320
+ if (existsSync(indexPath)) {
1321
+ const metadata = lstatSync(indexPath)
1322
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
1323
+ throw new RunnerUsageError(
1324
+ `Invalid synthetic baseline index: ${indexPath}`,
1325
+ )
1326
+ }
1327
+ unlinkSync(indexPath)
1328
+ }
1329
+ const indexEnv = { GIT_INDEX_FILE: indexPath }
1330
+ try {
1331
+ runGit(baseRepo, ['read-tree', head], undefined, indexEnv)
1332
+ runGit(
1333
+ baseRepo,
1334
+ [
1335
+ 'add',
1336
+ '-A',
1337
+ '--',
1338
+ '.',
1339
+ ':(exclude).kimi-code',
1340
+ ':(exclude).kimi-code/**',
1341
+ `:(exclude)${ROLE_WAVE_RELATIVE_PATH.split(path.sep).join('/')}`,
1342
+ `:(exclude)${ROLE_REQUEST_RELATIVE_PATH.split(path.sep).join('/')}`,
1343
+ `:(exclude)${ROLE_MERGE_RELATIVE_PATH.split(path.sep).join('/')}`,
1344
+ `:(exclude)${ROLE_WORKTREE_MANIFEST_DIR.split(path.sep).join('/')}/**`,
1345
+ ],
1346
+ undefined,
1347
+ indexEnv,
1348
+ )
1349
+ const tree = requireGitOid(
1350
+ runGit(baseRepo, ['write-tree'], undefined, indexEnv).trim(),
1351
+ 'synthetic baseline tree',
1352
+ )
1353
+ const identityEnv = {
1354
+ ...indexEnv,
1355
+ GIT_AUTHOR_NAME: 'SpecRails Kimi',
1356
+ GIT_AUTHOR_EMAIL: 'kimi-baseline@specrails.local',
1357
+ GIT_COMMITTER_NAME: 'SpecRails Kimi',
1358
+ GIT_COMMITTER_EMAIL: 'kimi-baseline@specrails.local',
1359
+ }
1360
+ const commit = requireGitOid(
1361
+ runGit(
1362
+ baseRepo,
1363
+ ['commit-tree', tree, '-p', head],
1364
+ `SpecRails Kimi baseline ${run}\n`,
1365
+ identityEnv,
1366
+ ).trim(),
1367
+ 'synthetic baseline commit',
1368
+ )
1369
+ runGit(
1370
+ baseRepo,
1371
+ ['update-ref', `refs/specrails/kimi/${repoKey}/${run}`, commit],
1372
+ )
1373
+ return commit
1374
+ } finally {
1375
+ rmSync(indexPath, { force: true })
1376
+ }
1377
+ }
1378
+
1379
+ function writeRoleGitExclude(file) {
1380
+ const contents = roleGitExcludeContents()
1381
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
1382
+ if (existsSync(file)) {
1383
+ const metadata = lstatSync(file)
1384
+ if (
1385
+ !metadata.isFile() ||
1386
+ metadata.isSymbolicLink() ||
1387
+ readFileSync(file, 'utf8') !== contents
1388
+ ) {
1389
+ throw new RunnerUsageError(`Invalid role git exclude file: ${file}`)
1390
+ }
1391
+ return file
1392
+ }
1393
+ writeFileSync(file, contents, {
1394
+ encoding: 'utf8',
1395
+ flag: 'wx',
1396
+ mode: 0o600,
1397
+ })
1398
+ return file
1399
+ }
1400
+
1401
+ function roleGitExcludeContents() {
1402
+ return [
1403
+ '/.kimi-code',
1404
+ '/.kimi-code/',
1405
+ '/.specrails/kimi-role-wave.json',
1406
+ '/.specrails/kimi-role-request.json',
1407
+ '/.specrails/kimi-role-merge.json',
1408
+ '/.specrails/kimi-role-worktrees/',
1409
+ '',
1410
+ ].join('\n')
1411
+ }
1412
+
1413
+ function safeWorkspacePath(root, relative) {
1414
+ if (path.isAbsolute(relative) || relative.includes('\0')) {
1415
+ throw new RunnerUsageError(`Unsafe git workspace path: ${relative}`)
1416
+ }
1417
+ const resolved = path.resolve(root, relative)
1418
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
1419
+ if (!resolved.startsWith(prefix)) {
1420
+ throw new RunnerUsageError(`Git workspace path escapes its root: ${relative}`)
1421
+ }
1422
+ return resolved
1423
+ }
1424
+
1425
+ function isManagedRolePath(relative) {
1426
+ return (
1427
+ relative === '.kimi-code' ||
1428
+ relative.startsWith('.kimi-code/') ||
1429
+ relative === '.specrails/kimi-role-wave.json' ||
1430
+ relative === '.specrails/kimi-role-request.json' ||
1431
+ relative === '.specrails/kimi-role-merge.json' ||
1432
+ relative.startsWith('.specrails/kimi-role-worktrees/')
1433
+ )
1434
+ }
1435
+
1436
+ function assertManagedPathParents(root, file, label) {
1437
+ const absoluteRoot = path.resolve(root)
1438
+ const parent = path.dirname(path.resolve(file))
1439
+ const relative = path.relative(absoluteRoot, parent)
1440
+ if (
1441
+ relative === '..' ||
1442
+ relative.startsWith(`..${path.sep}`) ||
1443
+ path.isAbsolute(relative)
1444
+ ) {
1445
+ throw new RunnerUsageError(`${label} escapes its working directory`)
1446
+ }
1447
+ let cursor = absoluteRoot
1448
+ for (const component of relative.split(path.sep).filter(Boolean)) {
1449
+ cursor = path.join(cursor, component)
1450
+ let metadata
1451
+ try {
1452
+ metadata = lstatSync(cursor)
1453
+ } catch (error) {
1454
+ throw new RunnerUsageError(
1455
+ `Cannot inspect ${label} parent ${cursor}: ${errorMessage(error)}`,
1456
+ )
1457
+ }
1458
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1459
+ throw new RunnerUsageError(
1460
+ `${label} parent must be a real directory: ${cursor}`,
1461
+ )
1462
+ }
1463
+ }
1464
+ }
1465
+
1466
+ function ensureRealDirectoryTree(root, directory) {
1467
+ const lexicalRoot = path.resolve(root)
1468
+ const absoluteRoot = canonicalRealpath(lexicalRoot)
1469
+ const lexicalTarget = path.resolve(directory)
1470
+ const relative = path.relative(lexicalRoot, lexicalTarget)
1471
+ if (
1472
+ relative === '..' ||
1473
+ relative.startsWith(`..${path.sep}`) ||
1474
+ path.isAbsolute(relative)
1475
+ ) {
1476
+ throw new RunnerUsageError(
1477
+ `Managed directory escapes its root: ${directory}`,
1478
+ )
1479
+ }
1480
+ let cursor = absoluteRoot
1481
+ for (const component of relative.split(path.sep).filter(Boolean)) {
1482
+ cursor = path.join(cursor, component)
1483
+ if (!existsSync(cursor)) {
1484
+ mkdirSync(cursor, { mode: 0o700 })
1485
+ continue
1486
+ }
1487
+ const metadata = lstatSync(cursor)
1488
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1489
+ throw new RunnerUsageError(
1490
+ `Managed directory component must not be a symlink: ${cursor}`,
1491
+ )
1492
+ }
1493
+ }
1494
+ }
1495
+
1496
+ function ensureManagedTempRoot(candidate) {
1497
+ const absolute = path.resolve(candidate)
1498
+ if (existsSync(absolute)) {
1499
+ const metadata = lstatSync(absolute)
1500
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1501
+ throw new RunnerUsageError(
1502
+ `Managed temp root must be a real directory: ${absolute}`,
1503
+ )
1504
+ }
1505
+ return canonicalRealpath(absolute)
1506
+ }
1507
+ const parent = path.dirname(absolute)
1508
+ const realParent = canonicalRealpath(parent)
1509
+ ensureRealDirectoryTree(realParent, path.join(realParent, path.basename(absolute)))
1510
+ return canonicalRealpath(path.join(realParent, path.basename(absolute)))
1511
+ }
1512
+
1513
+ export function ensureProviderOverlay(
1514
+ providerRoot,
1515
+ workspace,
1516
+ platform,
1517
+ dependencies = {},
1518
+ ) {
1519
+ const createLink = dependencies.symlink ?? symlinkSync
1520
+ const copyTree = dependencies.copyTree ?? cpSync
1521
+ const removeTree = dependencies.removeTree ?? rmSync
1522
+ const expectedRoot = canonicalRealpath(providerRoot)
1523
+ const contentSha256 = hashManagedDirectoryTree(expectedRoot)
1524
+ const markerValue = {
1525
+ schemaVersion: 2,
1526
+ providerRoot: expectedRoot,
1527
+ runnerSha256: createHash('sha256')
1528
+ .update(readFileSync(path.join(expectedRoot, 'specrails', 'run-skill.mjs')))
1529
+ .digest('hex'),
1530
+ contentSha256,
1531
+ }
1532
+ const destination = path.join(workspace, '.kimi-code')
1533
+ if (existsSync(destination)) {
1534
+ const destinationMetadata = lstatSync(destination)
1535
+ if (destinationMetadata.isSymbolicLink()) {
1536
+ if (
1537
+ existingPathIdentity(destination) !==
1538
+ existingPathIdentity(expectedRoot)
1539
+ ) {
1540
+ throw new RunnerUsageError(
1541
+ `Worktree provider link does not target the managed provider: ${destination}`,
1542
+ )
1543
+ }
1544
+ return
1545
+ }
1546
+ if (!destinationMetadata.isDirectory()) {
1547
+ throw new RunnerUsageError(
1548
+ `Worktree provider path is not a directory: ${destination}`,
1549
+ )
1550
+ }
1551
+ const markerPath = path.join(
1552
+ destination,
1553
+ '.specrails-managed-overlay.json',
1554
+ )
1555
+ if (!existsSync(markerPath) || lstatSync(markerPath).isSymbolicLink()) {
1556
+ throw new RunnerUsageError(
1557
+ `Refusing unverified worktree provider directory: ${destination}`,
1558
+ )
1559
+ }
1560
+ let marker
1561
+ try {
1562
+ marker = JSON.parse(readFileSync(markerPath, 'utf8'))
1563
+ } catch (error) {
1564
+ throw new RunnerUsageError(
1565
+ `Cannot verify worktree provider overlay: ${errorMessage(error)}`,
1566
+ )
1567
+ }
1568
+ const isOwnedMarker =
1569
+ isRecord(marker) &&
1570
+ (marker.schemaVersion === 1 || marker.schemaVersion === 2) &&
1571
+ marker.providerRoot === expectedRoot &&
1572
+ typeof marker.runnerSha256 === 'string' &&
1573
+ /^[0-9a-f]{64}$/.test(marker.runnerSha256) &&
1574
+ (
1575
+ marker.schemaVersion === 1 ||
1576
+ (
1577
+ typeof marker.contentSha256 === 'string' &&
1578
+ /^[0-9a-f]{64}$/.test(marker.contentSha256)
1579
+ )
1580
+ )
1581
+ if (!isOwnedMarker) {
1582
+ throw new RunnerUsageError(
1583
+ `Worktree provider overlay does not match the managed provider: ${destination}`,
1584
+ )
1585
+ }
1586
+ const actualContentSha256 = hashManagedDirectoryTree(destination, {
1587
+ ignoredRelativePath: '.specrails-managed-overlay.json',
1588
+ })
1589
+ if (
1590
+ JSON.stringify(marker) === JSON.stringify(markerValue) &&
1591
+ actualContentSha256 === contentSha256
1592
+ ) {
1593
+ return
1594
+ }
1595
+ // A valid ownership marker lets us repair stale/corrupt copied overlays.
1596
+ // Unmarked directories remain user-owned and are never removed.
1597
+ removeTree(destination, { recursive: true, force: true })
1598
+ }
1599
+ try {
1600
+ createLink(
1601
+ expectedRoot,
1602
+ destination,
1603
+ platform === 'win32' ? 'junction' : 'dir',
1604
+ )
1605
+ } catch {
1606
+ removeTree(destination, { recursive: true, force: true })
1607
+ copyTree(providerRoot, destination, {
1608
+ recursive: true,
1609
+ dereference: true,
1610
+ errorOnExist: true,
1611
+ })
1612
+ const copiedContentSha256 = hashManagedDirectoryTree(destination)
1613
+ if (copiedContentSha256 !== contentSha256) {
1614
+ removeTree(destination, { recursive: true, force: true })
1615
+ throw new RunnerUsageError(
1616
+ `Copied worktree provider overlay failed content verification: ${destination}`,
1617
+ )
1618
+ }
1619
+ writeFileSync(
1620
+ path.join(destination, '.specrails-managed-overlay.json'),
1621
+ `${JSON.stringify(markerValue)}\n`,
1622
+ { encoding: 'utf8', flag: 'wx', mode: 0o600 },
1623
+ )
1624
+ }
1625
+ }
1626
+
1627
+ function hashManagedDirectoryTree(
1628
+ root,
1629
+ options = { ignoredRelativePath: undefined },
1630
+ ) {
1631
+ const hash = createHash('sha256')
1632
+ const walk = (directory, prefix) => {
1633
+ const names = readdirSync(directory).sort()
1634
+ for (const name of names) {
1635
+ const relative = prefix === '' ? name : `${prefix}/${name}`
1636
+ if (relative === options.ignoredRelativePath) continue
1637
+ const entry = path.join(directory, name)
1638
+ const metadata = lstatSync(entry)
1639
+ if (metadata.isSymbolicLink()) {
1640
+ throw new RunnerUsageError(
1641
+ `Managed provider tree must not contain symlinks: ${entry}`,
1642
+ )
1643
+ }
1644
+ if (metadata.isDirectory()) {
1645
+ hash.update(`D\0${relative}\0`)
1646
+ walk(entry, relative)
1647
+ } else if (metadata.isFile()) {
1648
+ hash.update(`F\0${relative}\0${metadata.mode & 0o111}\0`)
1649
+ hash.update(readFileSync(entry))
1650
+ hash.update('\0')
1651
+ } else {
1652
+ throw new RunnerUsageError(
1653
+ `Managed provider tree has unsupported entry: ${entry}`,
1654
+ )
1655
+ }
1656
+ }
1657
+ }
1658
+ walk(root, '')
1659
+ return hash.digest('hex')
1660
+ }
1661
+
1662
+ function ensureExecutionWorkspace(
1663
+ executionCwd,
1664
+ providerRoot,
1665
+ identity,
1666
+ platform,
1667
+ ) {
1668
+ mkdirSync(executionCwd, { recursive: true, mode: 0o700 })
1669
+ const metadata = lstatSync(executionCwd)
1670
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1671
+ throw new RunnerUsageError(
1672
+ `Role execution workspace must be a real directory: ${executionCwd}`,
1673
+ )
1674
+ }
1675
+ const marker = path.join(executionCwd, '.specrails-role-workspace.json')
1676
+ if (existsSync(marker)) {
1677
+ const markerMetadata = lstatSync(marker)
1678
+ if (!markerMetadata.isFile() || markerMetadata.isSymbolicLink()) {
1679
+ throw new RunnerUsageError(
1680
+ `Invalid role execution workspace marker: ${marker}`,
1681
+ )
1682
+ }
1683
+ let existing
1684
+ try {
1685
+ existing = JSON.parse(readFileSync(marker, 'utf8'))
1686
+ } catch (error) {
1687
+ throw new RunnerUsageError(
1688
+ `Cannot parse role execution workspace marker: ${errorMessage(error)}`,
1689
+ )
1690
+ }
1691
+ if (JSON.stringify(existing) !== JSON.stringify(identity)) {
1692
+ throw new RunnerUsageError(
1693
+ `Role execution workspace belongs to another run: ${executionCwd}`,
1694
+ )
1695
+ }
1696
+ } else {
1697
+ writeFileSync(marker, `${JSON.stringify(identity)}\n`, {
1698
+ encoding: 'utf8',
1699
+ flag: 'wx',
1700
+ mode: 0o600,
1701
+ })
1702
+ }
1703
+ ensureProviderOverlay(providerRoot, executionCwd, platform)
1704
+ }
1705
+
1706
+ function readRoleWaveManifest(manifestPath) {
1707
+ if (!existsSync(manifestPath)) return undefined
1708
+ const metadata = lstatSync(manifestPath)
1709
+ if (
1710
+ !metadata.isFile() ||
1711
+ metadata.isSymbolicLink() ||
1712
+ metadata.size > MAX_ROLE_REQUEST_BYTES
1713
+ ) {
1714
+ throw new RunnerUsageError(
1715
+ `Invalid role wave manifest: ${manifestPath}`,
1716
+ )
1717
+ }
1718
+ let value
1719
+ try {
1720
+ value = JSON.parse(readFileSync(manifestPath, 'utf8'))
1721
+ } catch (error) {
1722
+ throw new RunnerUsageError(
1723
+ `Cannot parse role wave manifest ${manifestPath}: ${errorMessage(error)}`,
1724
+ )
1725
+ }
1726
+ if (!isRecord(value)) {
1727
+ throw new RunnerUsageError(`Invalid role wave manifest: ${manifestPath}`)
1728
+ }
1729
+ const keys = Object.keys(value).sort()
1730
+ if (
1731
+ keys.join(',') !==
1732
+ 'baseCommit,baseRepo,roles,run,schemaVersion,sourceHead,worktrees' ||
1733
+ value.schemaVersion !== 1 ||
1734
+ typeof value.baseRepo !== 'string' ||
1735
+ !(
1736
+ value.baseCommit === null ||
1737
+ (
1738
+ typeof value.baseCommit === 'string' &&
1739
+ SAFE_GIT_OID.test(value.baseCommit)
1740
+ )
1741
+ ) ||
1742
+ !(
1743
+ value.sourceHead === null ||
1744
+ (
1745
+ typeof value.sourceHead === 'string' &&
1746
+ SAFE_GIT_OID.test(value.sourceHead)
1747
+ )
1748
+ ) ||
1749
+ !SAFE_WAVE_ID.test(value.run) ||
1750
+ !isRecord(value.worktrees) ||
1751
+ !isRecord(value.roles)
1752
+ ) {
1753
+ throw new RunnerUsageError(`Invalid role wave manifest: ${manifestPath}`)
1754
+ }
1755
+ const worktrees = {}
1756
+ for (const [key, target] of Object.entries(value.worktrees)) {
1757
+ requireSafeWaveId(key, 'manifest worktree id')
1758
+ if (typeof target !== 'string' || !path.isAbsolute(target)) {
1759
+ throw new RunnerUsageError(`Invalid role wave manifest: ${manifestPath}`)
1760
+ }
1761
+ worktrees[key] = target
1762
+ }
1763
+ const roles = {}
1764
+ for (const [key, role] of Object.entries(value.roles)) {
1765
+ requireSafeWaveId(key, 'manifest role key')
1766
+ if (
1767
+ !isRecord(role) ||
1768
+ Object.keys(role).sort().join(',') !==
1769
+ 'executionCwd,gitExcludeFile,repoDir,workspace' ||
1770
+ typeof role.executionCwd !== 'string' ||
1771
+ !path.isAbsolute(role.executionCwd) ||
1772
+ typeof role.repoDir !== 'string' ||
1773
+ !path.isAbsolute(role.repoDir) ||
1774
+ !(
1775
+ role.gitExcludeFile === null ||
1776
+ (
1777
+ typeof role.gitExcludeFile === 'string' &&
1778
+ path.isAbsolute(role.gitExcludeFile)
1779
+ )
1780
+ ) ||
1781
+ typeof role.workspace !== 'string'
1782
+ ) {
1783
+ throw new RunnerUsageError(`Invalid role wave manifest: ${manifestPath}`)
1784
+ }
1785
+ roles[key] = role
1786
+ }
1787
+ return { ...value, worktrees, roles }
1788
+ }
1789
+
1790
+ function writeRoleWaveManifest(manifestPath, value) {
1791
+ const directory = path.dirname(manifestPath)
1792
+ ensureRealDirectoryTree(value.baseRepo, directory)
1793
+ const metadata = lstatSync(directory)
1794
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1795
+ throw new RunnerUsageError(
1796
+ `Role worktree manifest directory must not be a symlink: ${directory}`,
1797
+ )
1798
+ }
1799
+ if (existsSync(manifestPath) && lstatSync(manifestPath).isSymbolicLink()) {
1800
+ throw new RunnerUsageError(
1801
+ `Role wave manifest must not be a symlink: ${manifestPath}`,
1802
+ )
1803
+ }
1804
+ const temporary = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
1805
+ try {
1806
+ writeFileSync(
1807
+ temporary,
1808
+ `${JSON.stringify(value, null, 2)}\n`,
1809
+ { encoding: 'utf8', flag: 'wx', mode: 0o600 },
1810
+ )
1811
+ renameSync(temporary, manifestPath)
1812
+ } finally {
1813
+ rmSync(temporary, { force: true })
1814
+ }
1815
+ }
1816
+
1817
+ export function inspectRoleWaveStatus(run, dependencies = {}) {
1818
+ const state = resolveRoleWaveState(run, dependencies)
1819
+ const worktrees = {}
1820
+ for (const [worktree, repoDir] of Object.entries(state.manifest.worktrees)) {
1821
+ const changes = new Map()
1822
+ const tracked = runRoleGit(
1823
+ repoDir,
1824
+ [
1825
+ 'diff',
1826
+ '--name-status',
1827
+ '-z',
1828
+ '--no-renames',
1829
+ state.manifest.baseCommit,
1830
+ '--',
1831
+ ],
1832
+ dependencies,
1833
+ ).split('\0')
1834
+ for (let index = 0; index + 1 < tracked.length; index += 2) {
1835
+ const rawStatus = tracked[index]
1836
+ const relative = tracked[index + 1]
1837
+ if (!rawStatus || !relative || isManagedRolePath(relative)) continue
1838
+ const status =
1839
+ rawStatus.startsWith('A')
1840
+ ? 'A'
1841
+ : rawStatus.startsWith('D')
1842
+ ? 'D'
1843
+ : 'M'
1844
+ changes.set(relative, { status, path: relative })
1845
+ }
1846
+ const untracked = runRoleGit(
1847
+ repoDir,
1848
+ ['ls-files', '--others', '--exclude-standard', '-z'],
1849
+ dependencies,
1850
+ ).split('\0')
1851
+ for (const relative of untracked) {
1852
+ if (
1853
+ relative === '' ||
1854
+ isManagedRolePath(relative) ||
1855
+ changes.has(relative)
1856
+ ) {
1857
+ continue
1858
+ }
1859
+ changes.set(relative, { status: 'A', path: relative })
1860
+ }
1861
+ worktrees[worktree] = {
1862
+ repoDir,
1863
+ changes: Array.from(changes.values()).sort((left, right) =>
1864
+ left.path.localeCompare(right.path),
1865
+ ),
1866
+ }
1867
+ }
1868
+ return {
1869
+ run,
1870
+ baseRepo: state.baseRepo,
1871
+ baseCommit: state.manifest.baseCommit,
1872
+ manifestPath: state.manifestPath,
1873
+ worktrees,
1874
+ }
1875
+ }
1876
+
1877
+ export function applyRoleMerge(merge, dependencies = {}) {
1878
+ const state = resolveRoleWaveState(merge.run, dependencies)
1879
+ const prepared = merge.actions.map((action) => {
1880
+ const workspace = state.manifest.worktrees[action.worktree]
1881
+ if (workspace === undefined) {
1882
+ throw new RunnerUsageError(
1883
+ `Unknown role merge worktree: ${action.worktree}`,
1884
+ )
1885
+ }
1886
+ const source = safeWorkspacePath(workspace, action.path)
1887
+ const target = safeWorkspacePath(state.baseRepo, action.path)
1888
+ if (action.operation === 'copy') {
1889
+ let metadata
1890
+ try {
1891
+ metadata = lstatSync(source)
1892
+ } catch (error) {
1893
+ throw new RunnerUsageError(
1894
+ `Cannot copy missing role output ${action.path}: ${errorMessage(error)}`,
1895
+ )
1896
+ }
1897
+ if (!metadata.isFile() && !metadata.isSymbolicLink()) {
1898
+ throw new RunnerUsageError(
1899
+ `Role merge copy source must be a file or symlink: ${action.path}`,
1900
+ )
1901
+ }
1902
+ assertWorkspaceParentSafety(state.baseRepo, target)
1903
+ return { ...action, source, target, metadata }
1904
+ }
1905
+ if (existsSync(source)) {
1906
+ throw new RunnerUsageError(
1907
+ `Role merge deletion source still exists: ${action.path}`,
1908
+ )
1909
+ }
1910
+ assertWorkspaceParentSafety(state.baseRepo, target)
1911
+ return { ...action, source, target }
1912
+ })
1913
+
1914
+ for (const action of prepared) {
1915
+ ensureWorkspaceParentDirectories(state.baseRepo, action.target)
1916
+ if (action.operation === 'delete') {
1917
+ if (existsSync(action.target) || lstatExists(action.target)) {
1918
+ const metadata = lstatSync(action.target)
1919
+ if (metadata.isDirectory() && !metadata.isSymbolicLink()) {
1920
+ throw new RunnerUsageError(
1921
+ `Role merge refuses to delete directory path: ${action.path}`,
1922
+ )
1923
+ }
1924
+ unlinkSync(action.target)
1925
+ }
1926
+ continue
1927
+ }
1928
+ const existing = lstatExists(action.target)
1929
+ ? lstatSync(action.target)
1930
+ : undefined
1931
+ if (existing?.isDirectory() && !existing.isSymbolicLink()) {
1932
+ throw new RunnerUsageError(
1933
+ `Role merge refuses to replace directory path: ${action.path}`,
1934
+ )
1935
+ }
1936
+ if (action.metadata.isSymbolicLink()) {
1937
+ if (existing) unlinkSync(action.target)
1938
+ symlinkSync(readlinkSync(action.source), action.target)
1939
+ continue
1940
+ }
1941
+ const temporary = path.join(
1942
+ path.dirname(action.target),
1943
+ `.${path.basename(action.target)}.specrails-${process.pid}.tmp`,
1944
+ )
1945
+ try {
1946
+ copyFileSync(action.source, temporary)
1947
+ chmodSync(temporary, action.metadata.mode & 0o777)
1948
+ if (existing) unlinkSync(action.target)
1949
+ renameSync(temporary, action.target)
1950
+ } finally {
1951
+ rmSync(temporary, { force: true })
1952
+ }
1953
+ }
1954
+ return {
1955
+ run: merge.run,
1956
+ baseRepo: state.baseRepo,
1957
+ applied: prepared.length,
1958
+ }
1959
+ }
1960
+
1961
+ export function cleanupRoleWave(run, dependencies = {}) {
1962
+ const state = resolveRoleWaveState(run, dependencies, {
1963
+ requireIsolated: false,
1964
+ })
1965
+ const repoKey = state.repoKey
1966
+ const failures = []
1967
+ for (const [worktree, target] of Object.entries(state.manifest.worktrees)) {
1968
+ try {
1969
+ runRoleGit(
1970
+ state.baseRepo,
1971
+ ['worktree', 'remove', '--force', target],
1972
+ dependencies,
1973
+ )
1974
+ } catch (error) {
1975
+ failures.push(`${worktree}: ${errorMessage(error)}`)
1976
+ }
1977
+ }
1978
+ if (failures.length > 0) {
1979
+ throw new RunnerUsageError(
1980
+ `Role wave cleanup failed; manifest retained: ${failures.join('; ')}`,
1981
+ )
1982
+ }
1983
+ if (state.manifest.baseCommit !== null) {
1984
+ runRoleGit(
1985
+ state.baseRepo,
1986
+ ['update-ref', '-d', `refs/specrails/kimi/${repoKey}/${run}`],
1987
+ dependencies,
1988
+ )
1989
+ }
1990
+ unlinkSync(state.manifestPath)
1991
+
1992
+ const tempRoot = state.tempRoot
1993
+ for (const directory of [
1994
+ path.join(
1995
+ tempRoot,
1996
+ 'specrails-kimi-worktrees',
1997
+ repoKey,
1998
+ run,
1999
+ ),
2000
+ path.join(
2001
+ tempRoot,
2002
+ 'specrails-kimi-execution',
2003
+ repoKey,
2004
+ run,
2005
+ ),
2006
+ ]) {
2007
+ rmSync(directory, { recursive: true, force: true })
2008
+ }
2009
+ rmSync(
2010
+ path.join(
2011
+ tempRoot,
2012
+ 'specrails-kimi-git-excludes',
2013
+ repoKey,
2014
+ `${run}.txt`,
2015
+ ),
2016
+ { force: true },
2017
+ )
2018
+ runRoleGit(state.baseRepo, ['worktree', 'prune'], dependencies)
2019
+ return {
2020
+ run,
2021
+ baseRepo: state.baseRepo,
2022
+ removedWorktrees: Object.keys(state.manifest.worktrees).length,
2023
+ manifestPath: state.manifestPath,
2024
+ }
2025
+ }
2026
+
2027
+ function resolveRoleWaveState(
2028
+ run,
2029
+ dependencies,
2030
+ options = { requireIsolated: true },
2031
+ ) {
2032
+ const sourceEnv = dependencies.env ?? process.env
2033
+ const cwd =
2034
+ nonEmptyString(sourceEnv.SPECRAILS_REPO_DIR) ??
2035
+ dependencies.cwd ??
2036
+ process.cwd()
2037
+ const baseRepo = canonicalRealpath(
2038
+ runRoleGit(cwd, ['rev-parse', '--show-toplevel'], dependencies).trim(),
2039
+ )
2040
+ const manifestPath = path.join(
2041
+ baseRepo,
2042
+ ROLE_WORKTREE_MANIFEST_DIR,
2043
+ `${requireSafeWaveId(run, 'role wave run')}.json`,
2044
+ )
2045
+ assertManagedPathParents(baseRepo, manifestPath, 'role wave manifest')
2046
+ const manifest = readRoleWaveManifest(manifestPath)
2047
+ if (
2048
+ manifest === undefined ||
2049
+ manifest.run !== run ||
2050
+ existingPathIdentity(manifest.baseRepo) !==
2051
+ existingPathIdentity(baseRepo) ||
2052
+ (options.requireIsolated && manifest.baseCommit === null)
2053
+ ) {
2054
+ throw new RunnerUsageError(
2055
+ `No isolated role wave state exists for ${run}`,
2056
+ )
2057
+ }
2058
+ if (manifest.baseCommit !== null) {
2059
+ const baseCommit = requireGitOid(
2060
+ manifest.baseCommit,
2061
+ 'role wave base commit',
2062
+ )
2063
+ runRoleGit(
2064
+ baseRepo,
2065
+ ['cat-file', '-e', `${baseCommit}^{commit}`],
2066
+ dependencies,
2067
+ )
2068
+ } else if (Object.keys(manifest.worktrees).length > 0) {
2069
+ throw new RunnerUsageError(
2070
+ `Role wave ${run} has worktrees without an isolated base commit`,
2071
+ )
2072
+ }
2073
+ const registered = parseGitWorktreeList(
2074
+ runRoleGit(
2075
+ baseRepo,
2076
+ ['worktree', 'list', '--porcelain', '-z'],
2077
+ dependencies,
2078
+ ),
2079
+ )
2080
+ const tempRoot = ensureManagedTempRoot(
2081
+ dependencies.tempRoot ?? os.tmpdir(),
2082
+ )
2083
+ const repoKey = createHash('sha256')
2084
+ .update(baseRepo)
2085
+ .digest('hex')
2086
+ .slice(0, 16)
2087
+ validateRoleWaveManifestIntegrity(manifest, {
2088
+ baseRepo,
2089
+ run,
2090
+ tempRoot,
2091
+ repoKey,
2092
+ registered,
2093
+ runGit: (args) => runRoleGit(baseRepo, args, dependencies),
2094
+ })
2095
+ return { baseRepo, manifestPath, manifest, tempRoot, repoKey }
2096
+ }
2097
+
2098
+ function validateRoleWaveManifestIntegrity(manifest, context) {
2099
+ if (
2100
+ manifest.run !== context.run ||
2101
+ existingPathIdentity(manifest.baseRepo) !==
2102
+ existingPathIdentity(context.baseRepo)
2103
+ ) {
2104
+ throw new RunnerUsageError(
2105
+ `Role wave manifest identity mismatch for ${context.run}`,
2106
+ )
2107
+ }
2108
+ const worktreeRoot = path.join(
2109
+ context.tempRoot,
2110
+ 'specrails-kimi-worktrees',
2111
+ context.repoKey,
2112
+ context.run,
2113
+ )
2114
+ const executionRoot = path.join(
2115
+ context.tempRoot,
2116
+ 'specrails-kimi-execution',
2117
+ context.repoKey,
2118
+ context.run,
2119
+ )
2120
+ const expectedGitExclude = path.join(
2121
+ context.tempRoot,
2122
+ 'specrails-kimi-git-excludes',
2123
+ context.repoKey,
2124
+ `${context.run}.txt`,
2125
+ )
2126
+ let excludeValidated = false
2127
+ const validateExclude = (actual) => {
2128
+ if (path.resolve(actual) !== expectedGitExclude) {
2129
+ throw new RunnerUsageError(
2130
+ `Role wave manifest has an unexpected git exclude path`,
2131
+ )
2132
+ }
2133
+ if (!excludeValidated) {
2134
+ let metadata
2135
+ try {
2136
+ metadata = lstatSync(expectedGitExclude)
2137
+ } catch (error) {
2138
+ throw new RunnerUsageError(
2139
+ `Cannot verify role git exclude file: ${errorMessage(error)}`,
2140
+ )
2141
+ }
2142
+ if (
2143
+ !metadata.isFile() ||
2144
+ metadata.isSymbolicLink() ||
2145
+ readFileSync(expectedGitExclude, 'utf8') !== roleGitExcludeContents()
2146
+ ) {
2147
+ throw new RunnerUsageError(
2148
+ `Invalid role git exclude file: ${expectedGitExclude}`,
2149
+ )
2150
+ }
2151
+ excludeValidated = true
2152
+ }
2153
+ }
2154
+
2155
+ for (const [worktree, target] of Object.entries(manifest.worktrees)) {
2156
+ requireSafeWaveId(worktree, 'manifest worktree id')
2157
+ const expected = path.join(worktreeRoot, worktree)
2158
+ if (path.resolve(target) !== expected) {
2159
+ throw new RunnerUsageError(
2160
+ `Role wave manifest worktree path mismatch: ${worktree}`,
2161
+ )
2162
+ }
2163
+ if (
2164
+ !existsSync(expected) ||
2165
+ lstatSync(expected).isSymbolicLink() ||
2166
+ !lstatSync(expected).isDirectory() ||
2167
+ !context.registered.has(existingPathIdentity(expected))
2168
+ ) {
2169
+ throw new RunnerUsageError(
2170
+ `Role wave worktree is not the expected registered path: ${worktree}`,
2171
+ )
2172
+ }
2173
+ }
2174
+
2175
+ for (const [roleKey, role] of Object.entries(manifest.roles)) {
2176
+ requireSafeWaveId(roleKey, 'manifest role key')
2177
+ validateExclude(role.gitExcludeFile)
2178
+ if (role.workspace === 'current') {
2179
+ if (
2180
+ path.resolve(role.executionCwd) !==
2181
+ path.join(executionRoot, roleKey) ||
2182
+ path.resolve(role.repoDir) !== context.baseRepo
2183
+ ) {
2184
+ throw new RunnerUsageError(
2185
+ `Role wave manifest current workspace mismatch: ${roleKey}`,
2186
+ )
2187
+ }
2188
+ continue
2189
+ }
2190
+ if (!role.workspace.startsWith('worktree:')) {
2191
+ throw new RunnerUsageError(
2192
+ `Role wave manifest has invalid workspace: ${roleKey}`,
2193
+ )
2194
+ }
2195
+ const worktree = requireSafeWaveId(
2196
+ role.workspace.slice('worktree:'.length),
2197
+ 'manifest role worktree id',
2198
+ )
2199
+ const expected = manifest.worktrees[worktree]
2200
+ if (
2201
+ expected === undefined ||
2202
+ path.resolve(role.executionCwd) !== expected ||
2203
+ path.resolve(role.repoDir) !== expected
2204
+ ) {
2205
+ throw new RunnerUsageError(
2206
+ `Role wave manifest isolated workspace mismatch: ${roleKey}`,
2207
+ )
2208
+ }
2209
+ }
2210
+
2211
+ if (manifest.baseCommit !== null) {
2212
+ const baseCommit = requireGitOid(
2213
+ manifest.baseCommit,
2214
+ 'role wave base commit',
2215
+ )
2216
+ const ref = `refs/specrails/kimi/${context.repoKey}/${context.run}`
2217
+ const refCommit = requireGitOid(
2218
+ context.runGit(['rev-parse', '--verify', ref]).trim(),
2219
+ 'role wave private ref',
2220
+ )
2221
+ if (refCommit !== baseCommit) {
2222
+ throw new RunnerUsageError(
2223
+ `Role wave private ref does not match its baseline: ${context.run}`,
2224
+ )
2225
+ }
2226
+ }
2227
+ }
2228
+
2229
+ function runRoleGit(cwd, args, dependencies = {}, input) {
2230
+ const command = dependencies.gitBinary ?? 'git'
2231
+ const execute = dependencies.spawnSync ?? spawnSync
2232
+ const result = execute(
2233
+ command,
2234
+ ['-C', cwd, '-c', 'core.autocrlf=false', ...args],
2235
+ {
2236
+ encoding: 'utf8',
2237
+ env: { ...process.env, ...(dependencies.env ?? {}) },
2238
+ input,
2239
+ maxBuffer: 256 * 1024 * 1024,
2240
+ shell: false,
2241
+ },
2242
+ )
2243
+ if (result.error || result.status !== 0) {
2244
+ const detail =
2245
+ errorMessage(result.error ?? '').trim() ||
2246
+ String(result.stderr ?? '').trim() ||
2247
+ `exit ${String(result.status)}`
2248
+ throw new RunnerUsageError(
2249
+ `git ${args.join(' ')} failed in ${cwd}: ${detail}`,
2250
+ )
2251
+ }
2252
+ return String(result.stdout ?? '')
2253
+ }
2254
+
2255
+ function lstatExists(file) {
2256
+ try {
2257
+ lstatSync(file)
2258
+ return true
2259
+ } catch {
2260
+ return false
2261
+ }
2262
+ }
2263
+
2264
+ function assertWorkspaceParentSafety(root, file) {
2265
+ let cursor = realpathSync(root)
2266
+ const relative = path.relative(path.resolve(root), path.dirname(file))
2267
+ for (const component of relative.split(path.sep).filter(Boolean)) {
2268
+ cursor = path.join(cursor, component)
2269
+ if (!existsSync(cursor) && !lstatExists(cursor)) break
2270
+ const metadata = lstatSync(cursor)
2271
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
2272
+ throw new RunnerUsageError(
2273
+ `Role merge target parent must not be a symlink: ${cursor}`,
2274
+ )
2275
+ }
2276
+ }
2277
+ }
2278
+
2279
+ function ensureWorkspaceParentDirectories(root, file) {
2280
+ const parent = path.dirname(file)
2281
+ const relative = path.relative(path.resolve(root), parent)
2282
+ let cursor = realpathSync(root)
2283
+ for (const component of relative.split(path.sep).filter(Boolean)) {
2284
+ cursor = path.join(cursor, component)
2285
+ if (!existsSync(cursor) && !lstatExists(cursor)) {
2286
+ mkdirSync(cursor)
2287
+ continue
2288
+ }
2289
+ const metadata = lstatSync(cursor)
2290
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
2291
+ throw new RunnerUsageError(
2292
+ `Role merge target parent must not be a symlink: ${cursor}`,
2293
+ )
2294
+ }
2295
+ }
2296
+ }
2297
+
2298
+ export async function runSkillCli(argv, dependencies = {}) {
2299
+ const cwd = dependencies.cwd ?? process.cwd()
2300
+ const parsedArgs = parseRunnerArgs(argv)
2301
+ const scriptPath = dependencies.scriptPath ?? process.argv[1]
2302
+ const providerRoot = resolveProviderRoot(scriptPath)
2303
+ const writeOutput =
2304
+ dependencies.writeOutput ??
2305
+ ((line) => {
2306
+ process.stdout.write(line)
2307
+ })
2308
+ if (parsedArgs.roleWaveStatus !== undefined) {
2309
+ emitRoleFrame(
2310
+ writeOutput,
2311
+ {
2312
+ type: 'specrails.merge.inventory',
2313
+ ...inspectRoleWaveStatus(parsedArgs.roleWaveStatus, {
2314
+ ...dependencies,
2315
+ cwd,
2316
+ }),
2317
+ },
2318
+ )
2319
+ return 0
2320
+ }
2321
+ if (parsedArgs.roleWaveCleanup !== undefined) {
2322
+ emitRoleFrame(
2323
+ writeOutput,
2324
+ {
2325
+ type: 'specrails.role.cleanup',
2326
+ ...cleanupRoleWave(parsedArgs.roleWaveCleanup, {
2327
+ ...dependencies,
2328
+ cwd,
2329
+ }),
2330
+ },
2331
+ )
2332
+ return 0
2333
+ }
2334
+ if (parsedArgs.roleMergeFile !== undefined) {
2335
+ const merge = loadRoleMerge(parsedArgs, cwd)
2336
+ emitRoleFrame(
2337
+ writeOutput,
2338
+ {
2339
+ type: 'specrails.merge.applied',
2340
+ ...applyRoleMerge(merge, {
2341
+ ...dependencies,
2342
+ cwd,
2343
+ }),
2344
+ },
2345
+ )
2346
+ return 0
2347
+ }
2348
+ if (parsedArgs.roleWaveFile !== undefined) {
2349
+ const wave = loadRoleWave(parsedArgs, cwd)
2350
+ return runRoleWave(wave, {
2351
+ ...dependencies,
2352
+ cwd,
2353
+ providerRoot,
2354
+ })
2355
+ }
2356
+ if (parsedArgs.plainPromptStdin) {
2357
+ const readStdin =
2358
+ dependencies.readStdin ??
2359
+ (() => readFileSync(0, 'utf8'))
2360
+ const prompt = String(readStdin())
2361
+ assertManagedPrompt(prompt)
2362
+ const kimiArgs = [
2363
+ ...(parsedArgs.sessionId
2364
+ ? [`--session=${parsedArgs.sessionId}`]
2365
+ : []),
2366
+ ...parsedArgs.additionalDirs.flatMap((dir) => ['--add-dir', dir]),
2367
+ '-m',
2368
+ normalizeKimiCliModel(parsedArgs.model),
2369
+ '-p',
2370
+ prompt,
2371
+ '--output-format',
2372
+ 'stream-json',
2373
+ ]
2374
+ return runPreparedPrompt(kimiArgs, prompt, {
2375
+ ...dependencies,
2376
+ cwd,
2377
+ model: parsedArgs.model,
2378
+ })
2379
+ }
2380
+ const parsed = loadRoleRequest(parsedArgs, cwd)
2381
+ return runPreparedSkill(parsed, {
2382
+ ...dependencies,
2383
+ cwd,
2384
+ providerRoot,
2385
+ })
2386
+ }
2387
+
2388
+ async function runRoleWave(wave, dependencies) {
2389
+ const sourceEnv = dependencies.env ?? process.env
2390
+ const repositoryCwd =
2391
+ nonEmptyString(sourceEnv.SPECRAILS_REPO_DIR) ?? dependencies.cwd
2392
+ const inheritedProfile = nonEmptyString(
2393
+ sourceEnv.SPECRAILS_PROFILE_PATH,
2394
+ )
2395
+ const materialized = materializeRoleWaveWorkspaces(wave, {
2396
+ cwd: repositoryCwd,
2397
+ providerRoot: dependencies.providerRoot,
2398
+ platform: dependencies.platform,
2399
+ spawnSync: dependencies.spawnSync,
2400
+ gitBinary: dependencies.gitBinary,
2401
+ tempRoot: dependencies.tempRoot,
2402
+ })
2403
+ const writeOutput =
2404
+ dependencies.writeOutput ??
2405
+ ((line) => {
2406
+ process.stdout.write(line)
2407
+ })
2408
+ const children = new Set()
2409
+ const aggregateChild = {
2410
+ kill(signal) {
2411
+ for (const child of children) child.kill?.(signal)
2412
+ },
2413
+ }
2414
+ const removeSignalForwarding = forwardTerminationSignals(
2415
+ aggregateChild,
2416
+ dependencies.signalSource ?? process,
2417
+ )
2418
+
2419
+ try {
2420
+ const results = await Promise.all(
2421
+ materialized.roles.map(async (role) => {
2422
+ emitRoleFrame(writeOutput, {
2423
+ type: 'specrails.role.workspace',
2424
+ run: materialized.run,
2425
+ roleKey: role.key,
2426
+ workspace: role.workspace,
2427
+ executionCwd: role.cwd,
2428
+ repoDir: role.repoDir,
2429
+ baseCommit: materialized.baseCommit,
2430
+ manifestPath: materialized.manifestPath,
2431
+ })
2432
+ try {
2433
+ const code = await runPreparedSkill(
2434
+ {
2435
+ skill: role.skill,
2436
+ model: role.model,
2437
+ rawArgs: role.rawArgs,
2438
+ sessionId: undefined,
2439
+ additionalDirs: Array.from(
2440
+ new Set([
2441
+ ...wave.additionalDirs,
2442
+ materialized.baseRepo,
2443
+ ]),
2444
+ ),
2445
+ attachmentPaths: [],
2446
+ extraPrompt: undefined,
2447
+ },
2448
+ {
2449
+ ...dependencies,
2450
+ cwd: role.cwd,
2451
+ providerRoot: dependencies.providerRoot,
2452
+ env: {
2453
+ ...sourceEnv,
2454
+ SPECRAILS_REPO_DIR: role.repoDir,
2455
+ SPECRAILS_MERGE_TARGET: materialized.baseRepo,
2456
+ SPECRAILS_KIMI_WORKTREE_MANIFEST:
2457
+ materialized.manifestPath,
2458
+ ...(role.profile === 'inherit'
2459
+ ? inheritedProfile === undefined
2460
+ ? {}
2461
+ : {
2462
+ SPECRAILS_PROFILE_PATH: path.isAbsolute(
2463
+ inheritedProfile,
2464
+ )
2465
+ ? inheritedProfile
2466
+ : path.resolve(
2467
+ materialized.baseRepo,
2468
+ inheritedProfile,
2469
+ ),
2470
+ }
2471
+ : {
2472
+ SPECRAILS_PROFILE_PATH: path.join(
2473
+ materialized.baseRepo,
2474
+ '.specrails',
2475
+ 'profiles',
2476
+ `${role.profile}.json`,
2477
+ ),
2478
+ }),
2479
+ GIT_CONFIG_COUNT: '2',
2480
+ GIT_CONFIG_KEY_0: 'core.excludesFile',
2481
+ GIT_CONFIG_VALUE_0: role.gitExcludeFile,
2482
+ GIT_CONFIG_KEY_1: 'core.autocrlf',
2483
+ GIT_CONFIG_VALUE_1: 'false',
2484
+ },
2485
+ captureRoleKey: role.key,
2486
+ writeOutput,
2487
+ childRegistry: children,
2488
+ forwardSignals: false,
2489
+ },
2490
+ )
2491
+ emitRoleFrame(writeOutput, {
2492
+ type: 'specrails.role.completed',
2493
+ run: materialized.run,
2494
+ roleKey: role.key,
2495
+ status: code === 0 ? 'succeeded' : 'failed',
2496
+ exitCode: code,
2497
+ })
2498
+ return code
2499
+ } catch (error) {
2500
+ emitRoleFrame(writeOutput, {
2501
+ type: 'specrails.role.completed',
2502
+ run: materialized.run,
2503
+ roleKey: role.key,
2504
+ status: 'failed',
2505
+ exitCode: 1,
2506
+ error: errorMessage(error),
2507
+ })
2508
+ return 1
2509
+ }
2510
+ }),
2511
+ )
2512
+ return results.find((code) => code !== 0) ?? 0
2513
+ } finally {
2514
+ removeSignalForwarding()
2515
+ }
2516
+ }
2517
+
2518
+ async function runPreparedSkill(parsed, dependencies) {
2519
+ const prepared = prepareSkillLaunch(
2520
+ { ...parsed, providerRoot: dependencies.providerRoot },
2521
+ dependencies,
2522
+ )
2523
+ return runPreparedPrompt(
2524
+ prepared.kimiArgs,
2525
+ prepared.prompt,
2526
+ {
2527
+ ...dependencies,
2528
+ model: parsed.model,
2529
+ },
2530
+ )
2531
+ }
2532
+
2533
+ async function runPreparedPrompt(kimiArgs, prompt, dependencies) {
2534
+ assertManagedPrompt(prompt)
2535
+ let child
2536
+ let removeSignalForwarding = () => {}
2537
+ try {
2538
+ // Kimi 0.27 exposes only `-p <prompt>` for an exact non-interactive user
2539
+ // turn. The managed runner receives plain prompts over stdin so the host
2540
+ // process does not expose them, but a native Kimi binary must still receive
2541
+ // the exact prompt in its own argv. npm Windows shims use the exact stdin
2542
+ // bootstrap in resolveKimiLaunch instead. Do not replace this with a
2543
+ // prompt-file instruction: that changes the first user turn and telemetry.
2544
+ const launch = resolveKimiLaunch(kimiArgs, dependencies)
2545
+ const spawnChild = dependencies.spawnChild ?? spawn
2546
+ const env = stableKimiEnvironment(
2547
+ dependencies.env ?? process.env,
2548
+ dependencies.model,
2549
+ )
2550
+ child = spawnChild(launch.command, launch.args, {
2551
+ cwd: dependencies.cwd,
2552
+ env,
2553
+ shell: false,
2554
+ stdio:
2555
+ dependencies.captureRoleKey === undefined
2556
+ ? launch.stdinText === undefined
2557
+ ? 'inherit'
2558
+ : ['pipe', 'inherit', 'inherit']
2559
+ : launch.stdinText === undefined
2560
+ ? ['ignore', 'pipe', 'pipe']
2561
+ : ['pipe', 'pipe', 'pipe'],
2562
+ })
2563
+ dependencies.childRegistry?.add(child)
2564
+ const completion = waitForChild(child)
2565
+ const outputCompletion =
2566
+ dependencies.captureRoleKey === undefined
2567
+ ? Promise.resolve()
2568
+ : captureRoleOutput(
2569
+ child,
2570
+ dependencies.captureRoleKey,
2571
+ dependencies.writeOutput,
2572
+ )
2573
+ removeSignalForwarding =
2574
+ dependencies.forwardSignals === false
2575
+ ? () => {}
2576
+ : forwardTerminationSignals(
2577
+ child,
2578
+ dependencies.signalSource ?? process,
2579
+ )
2580
+ if (launch.stdinText === undefined) {
2581
+ const [code] = await Promise.all([completion, outputCompletion])
2582
+ return code
2583
+ }
2584
+ try {
2585
+ const [code] = await Promise.all([
2586
+ completion,
2587
+ writePromptToChild(child, launch.stdinText),
2588
+ outputCompletion,
2589
+ ])
2590
+ return code
2591
+ } catch (error) {
2592
+ child.kill?.('SIGTERM')
2593
+ throw error
2594
+ }
2595
+ } finally {
2596
+ if (child !== undefined) dependencies.childRegistry?.delete(child)
2597
+ removeSignalForwarding()
2598
+ }
2599
+ }
2600
+
2601
+ function assertManagedPrompt(prompt) {
2602
+ if (typeof prompt !== 'string' || prompt.trim() === '') {
2603
+ throw new RunnerUsageError('Kimi prompt must not be empty')
2604
+ }
2605
+ const byteLength = Buffer.byteLength(prompt, 'utf8')
2606
+ if (byteLength > MAX_MANAGED_PROMPT_BYTES) {
2607
+ throw new RunnerUsageError(
2608
+ `Kimi prompt exceeds ${MAX_MANAGED_PROMPT_BYTES} UTF-8 bytes`,
2609
+ )
2610
+ }
2611
+ }
2612
+
2613
+ function captureRoleOutput(child, roleKey, writeOutput) {
2614
+ const streams = [
2615
+ ['stdout', child.stdout],
2616
+ ['stderr', child.stderr],
2617
+ ].filter(([, stream]) => stream?.on)
2618
+ if (streams.length === 0) return Promise.resolve()
2619
+ return Promise.all(
2620
+ streams.map(([streamName, stream]) =>
2621
+ new Promise((resolve) => {
2622
+ const decoder = new StringDecoder('utf8')
2623
+ let pending = ''
2624
+ let finished = false
2625
+ const emitLines = (final) => {
2626
+ const parts = pending.split('\n')
2627
+ pending = final ? '' : (parts.pop() ?? '')
2628
+ for (const line of parts) {
2629
+ emitCapturedRoleLine(
2630
+ writeOutput,
2631
+ roleKey,
2632
+ streamName,
2633
+ line.replace(/\r$/, ''),
2634
+ )
2635
+ }
2636
+ if (final && pending !== '') {
2637
+ emitCapturedRoleLine(
2638
+ writeOutput,
2639
+ roleKey,
2640
+ streamName,
2641
+ pending.replace(/\r$/, ''),
2642
+ )
2643
+ pending = ''
2644
+ }
2645
+ }
2646
+ const finish = () => {
2647
+ if (finished) return
2648
+ finished = true
2649
+ pending += decoder.end()
2650
+ emitLines(true)
2651
+ resolve()
2652
+ }
2653
+ stream.on('data', (chunk) => {
2654
+ pending += decoder.write(
2655
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)),
2656
+ )
2657
+ emitLines(false)
2658
+ })
2659
+ stream.once('end', finish)
2660
+ stream.once('close', finish)
2661
+ stream.once('error', (error) => {
2662
+ emitRoleFrame(writeOutput, {
2663
+ type: 'specrails.role.output-error',
2664
+ roleKey,
2665
+ stream: streamName,
2666
+ error: errorMessage(error),
2667
+ })
2668
+ finish()
2669
+ })
2670
+ }),
2671
+ ),
2672
+ ).then(() => undefined)
2673
+ }
2674
+
2675
+ function emitCapturedRoleLine(writeOutput, roleKey, stream, line) {
2676
+ if (line === '') return
2677
+ if (stream === 'stdout') {
2678
+ try {
2679
+ emitRoleFrame(writeOutput, {
2680
+ type: 'specrails.role.event',
2681
+ roleKey,
2682
+ event: JSON.parse(line),
2683
+ })
2684
+ return
2685
+ } catch {
2686
+ // Preserve non-JSON output as an attributed frame.
2687
+ }
2688
+ }
2689
+ emitRoleFrame(writeOutput, {
2690
+ type: 'specrails.role.output',
2691
+ roleKey,
2692
+ stream,
2693
+ data: line,
2694
+ })
2695
+ }
2696
+
2697
+ function emitRoleFrame(writeOutput, value) {
2698
+ writeOutput(`${JSON.stringify({ role: 'meta', ...value })}\n`)
2699
+ }
2700
+
2701
+ export function forwardTerminationSignals(child, signalSource = process) {
2702
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']
2703
+ const handlers = new Map()
2704
+ for (const signal of signals) {
2705
+ const handler = () => {
2706
+ try {
2707
+ child.kill?.(signal)
2708
+ } catch {
2709
+ // The child may already have exited; waitForChild owns final status.
2710
+ }
2711
+ }
2712
+ handlers.set(signal, handler)
2713
+ signalSource.on(signal, handler)
2714
+ }
2715
+ return () => {
2716
+ for (const [signal, handler] of handlers) {
2717
+ signalSource.off(signal, handler)
2718
+ }
2719
+ }
2720
+ }
2721
+
2722
+ export function resolveProviderRoot(scriptPath) {
2723
+ const absolute = path.resolve(requireNonEmpty(scriptPath, 'runner path'))
2724
+ const runnerDir = path.dirname(absolute)
2725
+ const providerRoot = path.dirname(runnerDir)
2726
+ if (
2727
+ path.basename(runnerDir) !== 'specrails' ||
2728
+ path.basename(providerRoot) !== '.kimi-code'
2729
+ ) {
2730
+ throw new RunnerUsageError(
2731
+ 'run-skill.mjs must be invoked from .kimi-code/specrails/run-skill.mjs',
2732
+ )
2733
+ }
2734
+ return providerRoot
2735
+ }
2736
+
2737
+ function skillArgumentNames(value) {
2738
+ const isValidName = (name) => name.trim() !== '' && !/^\d+$/.test(name)
2739
+ if (typeof value === 'string') return value.split(/\s+/).filter(isValidName)
2740
+ if (!Array.isArray(value)) return []
2741
+ return value.filter((item) => typeof item === 'string' && isValidName(item))
2742
+ }
2743
+
2744
+ function isRecord(value) {
2745
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
2746
+ }
2747
+
2748
+ function nonEmptyString(value) {
2749
+ return typeof value === 'string' && value.trim() !== ''
2750
+ ? value.trim()
2751
+ : undefined
2752
+ }
2753
+
2754
+ function escapeXml(input) {
2755
+ return input
2756
+ .split('&')
2757
+ .join('&amp;')
2758
+ .split('<')
2759
+ .join('&lt;')
2760
+ .split('>')
2761
+ .join('&gt;')
2762
+ .split('"')
2763
+ .join('&quot;')
2764
+ }
2765
+
2766
+ function escapeXmlTags(input) {
2767
+ return input.split('<').join('&lt;').split('>').join('&gt;')
2768
+ }
2769
+
2770
+ function escapeRegExp(input) {
2771
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2772
+ }
2773
+
2774
+ function toPromptPath(nativePath) {
2775
+ const slashPath = nativePath.split('\\').join('/').split(path.sep).join('/')
2776
+ return /^[a-z]:\//.test(slashPath)
2777
+ ? slashPath[0].toUpperCase() + slashPath.slice(1)
2778
+ : slashPath
2779
+ }
2780
+
2781
+ function requireSafeSkillId(value) {
2782
+ const skill = requireNonEmpty(value, 'skill')
2783
+ if (!SAFE_SKILL_ID.test(skill)) {
2784
+ throw new RunnerUsageError(`Invalid skill id: ${skill}`)
2785
+ }
2786
+ return skill
2787
+ }
2788
+
2789
+ function requireSafeModelId(value) {
2790
+ if (
2791
+ typeof value !== 'string' ||
2792
+ value.length === 0 ||
2793
+ value.length > MAX_MODEL_ID_LENGTH ||
2794
+ !SAFE_MODEL_ID.test(value)
2795
+ ) {
2796
+ throw new RunnerUsageError(
2797
+ 'Invalid model id: expected 1-128 characters matching ' +
2798
+ '[A-Za-z0-9][A-Za-z0-9._/:-]*',
2799
+ )
2800
+ }
2801
+ return value
2802
+ }
2803
+
2804
+ function requireSafeSessionId(value) {
2805
+ if (
2806
+ typeof value !== 'string' ||
2807
+ value.length === 0 ||
2808
+ value.length > MAX_SESSION_ID_LENGTH ||
2809
+ !SAFE_SESSION_ID.test(value) ||
2810
+ value === '.' ||
2811
+ value === '..'
2812
+ ) {
2813
+ throw new RunnerUsageError(
2814
+ 'Invalid session id: expected 1-128 characters matching ' +
2815
+ '[A-Za-z0-9._-]+, excluding "." and ".."',
2816
+ )
2817
+ }
2818
+ return value
2819
+ }
2820
+
2821
+ function requireSafeWaveId(value, label) {
2822
+ if (typeof value !== 'string' || !SAFE_WAVE_ID.test(value)) {
2823
+ throw new RunnerUsageError(
2824
+ `Invalid ${label}: expected 1-64 lowercase letters, digits, or hyphens`,
2825
+ )
2826
+ }
2827
+ return value
2828
+ }
2829
+
2830
+ function requireGitOid(value, label) {
2831
+ if (typeof value !== 'string' || !SAFE_GIT_OID.test(value)) {
2832
+ throw new RunnerUsageError(
2833
+ `Invalid ${label}: expected a 40- or 64-character lowercase hex commit id`,
2834
+ )
2835
+ }
2836
+ return value
2837
+ }
2838
+
2839
+ function requireSafeRelativePath(value, label) {
2840
+ if (
2841
+ typeof value !== 'string' ||
2842
+ value.length === 0 ||
2843
+ Buffer.byteLength(value, 'utf8') > 4_096 ||
2844
+ value.includes('\0') ||
2845
+ value.includes('\\') ||
2846
+ path.posix.isAbsolute(value) ||
2847
+ path.posix.normalize(value) !== value ||
2848
+ value.split('/').some((part) => part === '' || part === '.' || part === '..') ||
2849
+ isManagedRolePath(value)
2850
+ ) {
2851
+ throw new RunnerUsageError(`Invalid ${label}`)
2852
+ }
2853
+ return value
2854
+ }
2855
+
2856
+ function requireNonEmpty(value, label) {
2857
+ if (typeof value !== 'string' || value.trim() === '') {
2858
+ throw new RunnerUsageError(`Missing or empty ${label}`)
2859
+ }
2860
+ assertNoNul(value, label)
2861
+ return value.trim()
2862
+ }
2863
+
2864
+ function assertNoNul(value, label) {
2865
+ if (value.includes('\0')) throw new RunnerUsageError(`${label} contains a NUL byte`)
2866
+ }
2867
+
2868
+ function getEnvCaseInsensitive(env, key) {
2869
+ const found = Object.entries(env ?? {}).find(
2870
+ ([candidate]) => candidate.toUpperCase() === key,
2871
+ )
2872
+ return found?.[1]
2873
+ }
2874
+
2875
+ export function windowsCommandLineLength(command, args) {
2876
+ return [command, ...args].map(quoteWindowsArgument).join(' ').length
2877
+ }
2878
+
2879
+ function assertWindowsCommandLineBudget(command, args) {
2880
+ const length = windowsCommandLineLength(command, args)
2881
+ if (length > WINDOWS_COMMAND_LINE_BUDGET) {
2882
+ throw new RunnerUsageError(
2883
+ `Kimi Windows command line requires ${length} UTF-16 code units, above ` +
2884
+ `${WINDOWS_COMMAND_LINE_BUDGET}. Use the standard npm kimi.cmd shim so ` +
2885
+ 'SpecRails can transport the materialized prompt over stdin.',
2886
+ )
2887
+ }
2888
+ }
2889
+
2890
+ function quoteWindowsArgument(value) {
2891
+ if (value !== '' && !/[\s"]/u.test(value)) return value
2892
+ return (
2893
+ '"' +
2894
+ value
2895
+ .replace(/(\\*)"/g, '$1$1\\"')
2896
+ .replace(/(\\+)$/g, '$1$1') +
2897
+ '"'
2898
+ )
2899
+ }
2900
+
2901
+ export function stableKimiEnvironment(source, model) {
2902
+ const env = { ...source }
2903
+ let validThinkingEffort
2904
+ for (const key of Object.keys(env)) {
2905
+ const normalizedKey = key.toUpperCase()
2906
+ if (normalizedKey === 'KIMI_CODE_EXPERIMENTAL_FLAG') {
2907
+ delete env[key]
2908
+ } else if (
2909
+ normalizedKey === 'KIMI_DISABLE_CRON' ||
2910
+ normalizedKey === 'KIMI_CODE_NO_AUTO_UPDATE'
2911
+ ) {
2912
+ delete env[key]
2913
+ } else if (normalizedKey === 'KIMI_MODEL_THINKING_EFFORT') {
2914
+ const value = env[key]
2915
+ if (
2916
+ validThinkingEffort === undefined &&
2917
+ (value === 'low' || value === 'high' || value === 'max')
2918
+ ) {
2919
+ validThinkingEffort = value
2920
+ }
2921
+ delete env[key]
2922
+ }
2923
+ }
2924
+ // A managed `-p` run owns one bounded foreground invocation. It must not
2925
+ // create persistent schedules or mutate the external CLI during startup.
2926
+ env.KIMI_DISABLE_CRON = '1'
2927
+ env.KIMI_CODE_NO_AUTO_UPDATE = '1'
2928
+ if (
2929
+ normalizeKimiCliModel(model) === 'kimi-code/k3' &&
2930
+ validThinkingEffort !== undefined
2931
+ ) {
2932
+ env.KIMI_MODEL_THINKING_EFFORT = validThinkingEffort
2933
+ }
2934
+ return env
2935
+ }
2936
+
2937
+ function errorMessage(error) {
2938
+ return error instanceof Error ? error.message : String(error)
2939
+ }
2940
+
2941
+ function waitForChild(child) {
2942
+ return new Promise((resolve, reject) => {
2943
+ child.once('error', reject)
2944
+ child.once('exit', (code, signal) => {
2945
+ if (code !== null) {
2946
+ resolve(code)
2947
+ return
2948
+ }
2949
+ resolve(signal === 'SIGINT' ? 130 : signal === 'SIGHUP' ? 129 : 143)
2950
+ })
2951
+ })
2952
+ }
2953
+
2954
+ function writePromptToChild(child, prompt) {
2955
+ if (!child.stdin || typeof child.stdin.end !== 'function') {
2956
+ return Promise.reject(new RunnerUsageError(
2957
+ 'Cannot transport the Kimi prompt: child stdin is unavailable',
2958
+ ))
2959
+ }
2960
+
2961
+ return new Promise((resolve, reject) => {
2962
+ let settled = false
2963
+ const finish = (error, keepErrorListener = false) => {
2964
+ if (settled) return
2965
+ settled = true
2966
+ // Node's Writable.end callback receives a write error before the stream
2967
+ // emits its corresponding `error` event. Keep the once-listener in that
2968
+ // case so the later event is consumed instead of becoming unhandled.
2969
+ if (!keepErrorListener) child.stdin.off?.('error', onError)
2970
+ if (error) {
2971
+ reject(
2972
+ new RunnerUsageError(
2973
+ `Cannot transport the Kimi prompt: ${errorMessage(error)}`,
2974
+ ),
2975
+ )
2976
+ } else {
2977
+ resolve()
2978
+ }
2979
+ }
2980
+ const onError = (error) => finish(error)
2981
+ child.stdin.once('error', onError)
2982
+ child.stdin.end(prompt, 'utf8', (error) =>
2983
+ finish(error, Boolean(error)),
2984
+ )
2985
+ })
2986
+ }
2987
+
2988
+ function isDirectExecution() {
2989
+ if (!process.argv[1]) return false
2990
+ try {
2991
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
2992
+ } catch {
2993
+ return false
2994
+ }
2995
+ }
2996
+
2997
+ if (isDirectExecution()) {
2998
+ try {
2999
+ process.exitCode = await runSkillCli(process.argv.slice(2))
3000
+ } catch (error) {
3001
+ const prefix = error instanceof RunnerUsageError ? 'usage error' : 'error'
3002
+ process.stderr.write(`specrails Kimi skill runner ${prefix}: ${errorMessage(error)}\n`)
3003
+ process.exitCode = error instanceof RunnerUsageError ? 2 : 1
3004
+ }
3005
+ }