specrails-core 4.11.2 → 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.
- package/README.md +56 -20
- package/bin/specrails-core.mjs +343 -20
- package/bin/tui-installer.mjs +103 -52
- package/dist/installer/cli.js +1 -1
- package/dist/installer/cli.js.map +1 -1
- package/dist/installer/commands/doctor.js +486 -24
- package/dist/installer/commands/doctor.js.map +1 -1
- package/dist/installer/commands/framework.js +49 -7
- package/dist/installer/commands/framework.js.map +1 -1
- package/dist/installer/commands/init.js +423 -25
- package/dist/installer/commands/init.js.map +1 -1
- package/dist/installer/commands/update.js +36 -9
- package/dist/installer/commands/update.js.map +1 -1
- package/dist/installer/phases/framework-lifecycle.js +125 -0
- package/dist/installer/phases/framework-lifecycle.js.map +1 -0
- package/dist/installer/phases/install-config.js +157 -5
- package/dist/installer/phases/install-config.js.map +1 -1
- package/dist/installer/phases/manifest.js +27 -2
- package/dist/installer/phases/manifest.js.map +1 -1
- package/dist/installer/phases/prereqs.js +57 -2
- package/dist/installer/phases/prereqs.js.map +1 -1
- package/dist/installer/phases/provider-detect.js +116 -6
- package/dist/installer/phases/provider-detect.js.map +1 -1
- package/dist/installer/phases/scaffold.js +1222 -12
- package/dist/installer/phases/scaffold.js.map +1 -1
- package/dist/installer/runtime/kimi.js +255 -0
- package/dist/installer/runtime/kimi.js.map +1 -0
- package/dist/installer/util/paths.js +12 -0
- package/dist/installer/util/paths.js.map +1 -1
- package/dist/installer/util/registry.js +234 -14
- package/dist/installer/util/registry.js.map +1 -1
- package/docs/README.md +1 -0
- package/docs/deployment.md +6 -7
- package/docs/getting-started.md +11 -7
- package/docs/installation.md +34 -16
- package/docs/plugin-architecture.md +11 -8
- package/docs/updating.md +21 -3
- package/docs/user-docs/cli-reference.md +43 -22
- package/docs/user-docs/codex-vs-claude-code.md +11 -9
- package/docs/user-docs/faq.md +1 -1
- package/docs/user-docs/getting-started-codex.md +5 -8
- package/docs/user-docs/getting-started-kimi.md +423 -0
- package/docs/user-docs/installation.md +49 -14
- package/docs/user-docs/quick-start.md +11 -8
- package/docs/windows.md +29 -4
- package/integration-contract.json +85 -13
- package/package.json +9 -5
- package/schemas/profile.v1.json +67 -5
- package/templates/codex-skills/batch-implement/SKILL.md +4 -0
- package/templates/commands/specrails/batch-implement.md +11 -0
- package/templates/gemini-commands/batch-implement.toml +4 -0
- package/templates/kimi/specrails/run-skill.mjs +3005 -0
- package/templates/kimi/specrails/vendor/js-yaml/LICENSE +21 -0
- package/templates/kimi/specrails/vendor/js-yaml/NOTICE.md +16 -0
- package/templates/kimi/specrails/vendor/js-yaml/js-yaml.mjs +3856 -0
- 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('&')
|
|
2758
|
+
.split('<')
|
|
2759
|
+
.join('<')
|
|
2760
|
+
.split('>')
|
|
2761
|
+
.join('>')
|
|
2762
|
+
.split('"')
|
|
2763
|
+
.join('"')
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
function escapeXmlTags(input) {
|
|
2767
|
+
return input.split('<').join('<').split('>').join('>')
|
|
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
|
+
}
|