specrails-core 5.5.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/README.md +27 -145
  2. package/bin/specrails-core.mjs +12 -554
  3. package/dist/agent-runtime/capabilities.js +0 -1
  4. package/dist/agent-runtime/cli-executor.d.ts +1 -1
  5. package/dist/agent-runtime/cli-executor.js +4 -3
  6. package/dist/agent-runtime/cli-process.js +0 -1
  7. package/dist/agent-runtime/cli.d.ts +8 -0
  8. package/dist/agent-runtime/cli.js +31 -5
  9. package/dist/agent-runtime/codex-schema.js +0 -1
  10. package/dist/agent-runtime/compact/architect.d.ts +3 -272
  11. package/dist/agent-runtime/compact/architect.js +8 -9
  12. package/dist/agent-runtime/compact/chat-client.d.ts +3 -3
  13. package/dist/agent-runtime/compact/chat-client.js +1 -2
  14. package/dist/agent-runtime/compact/developer.d.ts +3 -70
  15. package/dist/agent-runtime/compact/developer.js +13 -14
  16. package/dist/agent-runtime/compact/environment.d.ts +4 -5
  17. package/dist/agent-runtime/compact/environment.js +1 -2
  18. package/dist/agent-runtime/compact/exit-code-honesty.d.ts +2 -1
  19. package/dist/agent-runtime/compact/exit-code-honesty.js +0 -1
  20. package/dist/agent-runtime/compact/guarded-loop.d.ts +2 -18
  21. package/dist/agent-runtime/compact/guarded-loop.js +7 -8
  22. package/dist/agent-runtime/compact/prompt-inputs.d.ts +0 -2
  23. package/dist/agent-runtime/compact/prompt-inputs.js +1 -2
  24. package/dist/agent-runtime/compact/reviewer.d.ts +0 -13
  25. package/dist/agent-runtime/compact/reviewer.js +5 -6
  26. package/dist/agent-runtime/compact/step.d.ts +0 -1
  27. package/dist/agent-runtime/compact/step.js +1 -2
  28. package/dist/agent-runtime/compact/test-reachability.js +0 -1
  29. package/dist/agent-runtime/config.js +0 -1
  30. package/dist/agent-runtime/core-host.d.ts +1 -1
  31. package/dist/agent-runtime/core-host.js +5 -3
  32. package/dist/agent-runtime/durable-store.d.ts +0 -1
  33. package/dist/agent-runtime/durable-store.js +1 -2
  34. package/dist/agent-runtime/efficiency-summary.d.ts +2 -2
  35. package/dist/agent-runtime/efficiency-summary.js +1 -2
  36. package/dist/agent-runtime/efficiency-types.js +0 -1
  37. package/dist/agent-runtime/efficiency.js +0 -1
  38. package/dist/agent-runtime/evaluation-corpus.js +0 -1
  39. package/dist/agent-runtime/evaluation.d.ts +2 -1
  40. package/dist/agent-runtime/evaluation.js +1 -2
  41. package/dist/agent-runtime/executor-types.d.ts +1 -1
  42. package/dist/agent-runtime/executor-types.js +0 -1
  43. package/dist/agent-runtime/executors.js +0 -1
  44. package/dist/agent-runtime/gemini-policy.js +0 -1
  45. package/dist/agent-runtime/graph/artifacts.d.ts +1 -1
  46. package/dist/agent-runtime/graph/artifacts.js +11 -5
  47. package/dist/agent-runtime/graph/nodes.d.ts +1 -1
  48. package/dist/agent-runtime/graph/nodes.js +1 -2
  49. package/dist/agent-runtime/graph/review-policy.js +0 -1
  50. package/dist/agent-runtime/graph/roles.d.ts +2 -5
  51. package/dist/agent-runtime/graph/roles.js +2 -8
  52. package/dist/agent-runtime/graph/state.d.ts +1 -1
  53. package/dist/agent-runtime/graph/state.js +0 -1
  54. package/dist/agent-runtime/graph-checkpointer.d.ts +2 -1
  55. package/dist/agent-runtime/graph-checkpointer.js +0 -1
  56. package/dist/agent-runtime/guardrails.d.ts +3 -3
  57. package/dist/agent-runtime/guardrails.js +1 -2
  58. package/dist/agent-runtime/index.d.ts +2 -1
  59. package/dist/agent-runtime/index.js +2 -2
  60. package/dist/agent-runtime/kimi-acp.js +1 -2
  61. package/dist/agent-runtime/kimi-model.d.ts +6 -0
  62. package/dist/agent-runtime/kimi-model.js +12 -0
  63. package/dist/agent-runtime/openai-executor.d.ts +0 -9
  64. package/dist/agent-runtime/openai-executor.js +6 -7
  65. package/dist/agent-runtime/openspec-tool-server.js +1 -2
  66. package/dist/agent-runtime/openspec.d.ts +0 -2
  67. package/dist/agent-runtime/openspec.js +3 -7
  68. package/dist/agent-runtime/prompts.d.ts +4 -3
  69. package/dist/agent-runtime/prompts.js +0 -1
  70. package/dist/agent-runtime/provider-diagnostic.js +1 -2
  71. package/dist/agent-runtime/recovery.d.ts +46 -0
  72. package/dist/agent-runtime/recovery.js +169 -0
  73. package/dist/agent-runtime/repository-context.d.ts +1 -1
  74. package/dist/agent-runtime/repository-context.js +0 -1
  75. package/dist/agent-runtime/review-context.d.ts +1 -1
  76. package/dist/agent-runtime/review-context.js +0 -1
  77. package/dist/agent-runtime/role-routing.js +0 -1
  78. package/dist/agent-runtime/role-state.js +0 -1
  79. package/dist/agent-runtime/runtime-identity.js +1 -2
  80. package/dist/agent-runtime/tool-event.js +0 -1
  81. package/dist/agent-runtime/verification-plan.d.ts +5 -4
  82. package/dist/agent-runtime/verification-plan.js +1 -2
  83. package/dist/agent-runtime/workflow-types.js +0 -1
  84. package/dist/agent-runtime/workflow.js +0 -1
  85. package/dist/agent-runtime/workspace-tools.d.ts +2 -2
  86. package/dist/agent-runtime/workspace-tools.js +1 -2
  87. package/dist/installer/cli.d.ts +3 -32
  88. package/dist/installer/cli.js +26 -101
  89. package/dist/installer/commands/framework.d.ts +4 -3
  90. package/dist/installer/commands/framework.js +0 -1
  91. package/dist/installer/commands/init.d.ts +4 -25
  92. package/dist/installer/commands/init.js +5 -8
  93. package/dist/installer/phases/framework-lifecycle.d.ts +3 -17
  94. package/dist/installer/phases/framework-lifecycle.js +2 -3
  95. package/dist/installer/phases/install-config.d.ts +7 -8
  96. package/dist/installer/phases/install-config.js +2 -7
  97. package/dist/installer/phases/manifest.d.ts +6 -7
  98. package/dist/installer/phases/manifest.js +2 -7
  99. package/dist/installer/phases/prereqs.d.ts +6 -7
  100. package/dist/installer/phases/prereqs.js +4 -6
  101. package/dist/installer/phases/provider-detect.d.ts +6 -7
  102. package/dist/installer/phases/provider-detect.js +2 -4
  103. package/dist/installer/phases/scaffold.d.ts +9 -10
  104. package/dist/installer/phases/scaffold.js +82 -805
  105. package/dist/installer/util/errors.js +0 -1
  106. package/dist/installer/util/exec.d.ts +3 -2
  107. package/dist/installer/util/exec.js +0 -1
  108. package/dist/installer/util/fs.d.ts +0 -10
  109. package/dist/installer/util/fs.js +1 -2
  110. package/dist/installer/util/git.js +0 -1
  111. package/dist/installer/util/install-transaction.d.ts +2 -2
  112. package/dist/installer/util/install-transaction.js +1 -2
  113. package/dist/installer/util/logger.d.ts +1 -3
  114. package/dist/installer/util/logger.js +0 -1
  115. package/dist/installer/util/paths.js +0 -1
  116. package/dist/installer/util/registry.d.ts +4 -8
  117. package/dist/installer/util/registry.js +2 -3
  118. package/dist/{installer/runtime → pipeline}/pipeline-state.js +0 -3
  119. package/dist/shared/args.d.ts +18 -0
  120. package/dist/shared/args.js +53 -0
  121. package/integration-contract.json +10 -46
  122. package/package.json +3 -10
  123. package/templates/agents/sr-architect.md +15 -310
  124. package/templates/agents/sr-developer.md +15 -269
  125. package/templates/agents/sr-reviewer.md +13 -367
  126. package/templates/codex-skills/rails/sr-architect/SKILL.md +14 -308
  127. package/templates/codex-skills/rails/sr-developer/SKILL.md +14 -201
  128. package/templates/codex-skills/rails/sr-reviewer/SKILL.md +15 -289
  129. package/templates/kimi/specrails/run-skill.mjs +91 -2173
  130. package/bin/tui-installer.mjs +0 -430
  131. package/commands/doctor.md +0 -62
  132. package/dist/agent-runtime/capabilities.js.map +0 -1
  133. package/dist/agent-runtime/cli-executor.js.map +0 -1
  134. package/dist/agent-runtime/cli-process.js.map +0 -1
  135. package/dist/agent-runtime/cli.js.map +0 -1
  136. package/dist/agent-runtime/codex-schema.js.map +0 -1
  137. package/dist/agent-runtime/compact/architect.js.map +0 -1
  138. package/dist/agent-runtime/compact/chat-client.js.map +0 -1
  139. package/dist/agent-runtime/compact/developer.js.map +0 -1
  140. package/dist/agent-runtime/compact/environment.js.map +0 -1
  141. package/dist/agent-runtime/compact/exit-code-honesty.js.map +0 -1
  142. package/dist/agent-runtime/compact/guarded-loop.js.map +0 -1
  143. package/dist/agent-runtime/compact/prompt-inputs.js.map +0 -1
  144. package/dist/agent-runtime/compact/reviewer.js.map +0 -1
  145. package/dist/agent-runtime/compact/step.js.map +0 -1
  146. package/dist/agent-runtime/compact/test-reachability.js.map +0 -1
  147. package/dist/agent-runtime/config.js.map +0 -1
  148. package/dist/agent-runtime/core-host.js.map +0 -1
  149. package/dist/agent-runtime/durable-store.js.map +0 -1
  150. package/dist/agent-runtime/efficiency-summary.js.map +0 -1
  151. package/dist/agent-runtime/efficiency-types.js.map +0 -1
  152. package/dist/agent-runtime/efficiency.js.map +0 -1
  153. package/dist/agent-runtime/evaluation-corpus.js.map +0 -1
  154. package/dist/agent-runtime/evaluation.js.map +0 -1
  155. package/dist/agent-runtime/executor-types.js.map +0 -1
  156. package/dist/agent-runtime/executors.js.map +0 -1
  157. package/dist/agent-runtime/gemini-policy.js.map +0 -1
  158. package/dist/agent-runtime/graph/artifacts.js.map +0 -1
  159. package/dist/agent-runtime/graph/nodes.js.map +0 -1
  160. package/dist/agent-runtime/graph/review-policy.js.map +0 -1
  161. package/dist/agent-runtime/graph/roles.js.map +0 -1
  162. package/dist/agent-runtime/graph/state.js.map +0 -1
  163. package/dist/agent-runtime/graph-checkpointer.js.map +0 -1
  164. package/dist/agent-runtime/guardrails.js.map +0 -1
  165. package/dist/agent-runtime/index.js.map +0 -1
  166. package/dist/agent-runtime/kimi-acp.js.map +0 -1
  167. package/dist/agent-runtime/openai-executor.js.map +0 -1
  168. package/dist/agent-runtime/openspec-tool-server.js.map +0 -1
  169. package/dist/agent-runtime/openspec.js.map +0 -1
  170. package/dist/agent-runtime/prompts.js.map +0 -1
  171. package/dist/agent-runtime/provider-diagnostic.js.map +0 -1
  172. package/dist/agent-runtime/repository-context.js.map +0 -1
  173. package/dist/agent-runtime/review-context.js.map +0 -1
  174. package/dist/agent-runtime/role-routing.js.map +0 -1
  175. package/dist/agent-runtime/role-state.js.map +0 -1
  176. package/dist/agent-runtime/runtime-identity.js.map +0 -1
  177. package/dist/agent-runtime/tool-event.js.map +0 -1
  178. package/dist/agent-runtime/verification-plan.js.map +0 -1
  179. package/dist/agent-runtime/workflow-types.js.map +0 -1
  180. package/dist/agent-runtime/workflow.js.map +0 -1
  181. package/dist/agent-runtime/workspace-tools.js.map +0 -1
  182. package/dist/installer/cli.js.map +0 -1
  183. package/dist/installer/commands/doctor.d.ts +0 -27
  184. package/dist/installer/commands/doctor.js +0 -615
  185. package/dist/installer/commands/doctor.js.map +0 -1
  186. package/dist/installer/commands/framework.js.map +0 -1
  187. package/dist/installer/commands/init.js.map +0 -1
  188. package/dist/installer/commands/update.d.ts +0 -56
  189. package/dist/installer/commands/update.js +0 -257
  190. package/dist/installer/commands/update.js.map +0 -1
  191. package/dist/installer/commands/v5-migration.d.ts +0 -32
  192. package/dist/installer/commands/v5-migration.js +0 -133
  193. package/dist/installer/commands/v5-migration.js.map +0 -1
  194. package/dist/installer/phases/framework-lifecycle.js.map +0 -1
  195. package/dist/installer/phases/install-config.js.map +0 -1
  196. package/dist/installer/phases/manifest.js.map +0 -1
  197. package/dist/installer/phases/prereqs.js.map +0 -1
  198. package/dist/installer/phases/provider-detect.js.map +0 -1
  199. package/dist/installer/phases/scaffold.js.map +0 -1
  200. package/dist/installer/runtime/kimi.d.ts +0 -84
  201. package/dist/installer/runtime/kimi.js +0 -255
  202. package/dist/installer/runtime/kimi.js.map +0 -1
  203. package/dist/installer/runtime/pipeline-state.js.map +0 -1
  204. package/dist/installer/util/errors.js.map +0 -1
  205. package/dist/installer/util/exec.js.map +0 -1
  206. package/dist/installer/util/fs.js.map +0 -1
  207. package/dist/installer/util/git.js.map +0 -1
  208. package/dist/installer/util/install-transaction.js.map +0 -1
  209. package/dist/installer/util/logger.js.map +0 -1
  210. package/dist/installer/util/paths.js.map +0 -1
  211. package/dist/installer/util/prompts.d.ts +0 -23
  212. package/dist/installer/util/prompts.js +0 -49
  213. package/dist/installer/util/prompts.js.map +0 -1
  214. package/dist/installer/util/registry.js.map +0 -1
  215. package/dist/installer/util/template.d.ts +0 -23
  216. package/dist/installer/util/template.js +0 -60
  217. package/dist/installer/util/template.js.map +0 -1
  218. package/docs/README.md +0 -40
  219. package/docs/agent-runtime-efficiency.md +0 -65
  220. package/docs/agent-runtime.md +0 -347
  221. package/docs/agents.md +0 -315
  222. package/docs/changelog.md +0 -168
  223. package/docs/ci-cd.md +0 -60
  224. package/docs/concepts.md +0 -183
  225. package/docs/customization.md +0 -363
  226. package/docs/deployment.md +0 -234
  227. package/docs/getting-started.md +0 -122
  228. package/docs/installation.md +0 -395
  229. package/docs/local-tickets.md +0 -191
  230. package/docs/migration-guide.md +0 -140
  231. package/docs/playbook-oss-maintainer.md +0 -112
  232. package/docs/playbook-parallel-dev.md +0 -124
  233. package/docs/playbook-product-discovery.md +0 -115
  234. package/docs/plugin-architecture.md +0 -144
  235. package/docs/research/codex-compatibility-analysis.md +0 -365
  236. package/docs/research/mcp-feasibility-analysis.md +0 -382
  237. package/docs/testing/test-matrix-codex.md +0 -130
  238. package/docs/updating.md +0 -89
  239. package/docs/user-docs/cli-reference.md +0 -428
  240. package/docs/user-docs/codex-vs-claude-code.md +0 -23
  241. package/docs/user-docs/core-updates.md +0 -70
  242. package/docs/user-docs/faq.md +0 -146
  243. package/docs/user-docs/getting-started-codex.md +0 -194
  244. package/docs/user-docs/getting-started-kimi.md +0 -423
  245. package/docs/user-docs/installation.md +0 -237
  246. package/docs/user-docs/provider-pipelines.md +0 -96
  247. package/docs/user-docs/quick-start.md +0 -161
  248. package/docs/windows.md +0 -78
  249. package/docs/workflows.md +0 -463
  250. package/schemas/profile.v1.json +0 -207
  251. package/templates/claude-md/CLAUDE-quickstart.md +0 -54
  252. package/templates/claude-md/root.md +0 -50
  253. package/templates/codex-skills/batch-implement/SKILL.md +0 -38
  254. package/templates/codex-skills/implement/SKILL.md +0 -38
  255. package/templates/codex-skills/retry/SKILL.md +0 -16
  256. package/templates/commands/specrails/compat-check.md +0 -271
  257. package/templates/commands/specrails/doctor.md +0 -62
  258. package/templates/commands/specrails/explore-spec.md +0 -173
  259. package/templates/commands/specrails/health-check.md +0 -527
  260. package/templates/commands/specrails/memory-inspect.md +0 -261
  261. package/templates/commands/specrails/opsx-diff.md +0 -419
  262. package/templates/commands/specrails/propose-spec.md +0 -102
  263. package/templates/commands/specrails/refactor-recommender.md +0 -169
  264. package/templates/commands/specrails/telemetry.md +0 -552
  265. package/templates/commands/specrails/why.md +0 -96
  266. package/templates/gemini-commands/batch-implement.toml +0 -37
  267. package/templates/gemini-commands/implement.toml +0 -37
  268. package/templates/gemini-commands/retry.toml +0 -15
  269. package/templates/profiles/default.json +0 -14
  270. package/templates/profiles/kimi-default.json +0 -15
  271. package/templates/rules/layer.md +0 -8
  272. package/templates/runtime/provider-pipeline.md +0 -113
  273. package/templates/security/security-exemptions.yaml +0 -20
  274. package/templates/settings/confidence-config.json +0 -17
  275. package/templates/settings/settings.json +0 -15
  276. /package/dist/{installer/runtime → pipeline}/pipeline-state.d.ts +0 -0
@@ -1,30 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createHash } from 'node:crypto'
4
- import { spawn, spawnSync } from 'node:child_process'
3
+ import { spawn } from 'node:child_process'
5
4
  import {
6
5
  accessSync,
7
- copyFileSync,
8
- cpSync,
9
- chmodSync,
10
6
  constants as fsConstants,
11
7
  existsSync,
12
8
  lstatSync,
13
- mkdirSync,
14
9
  readFileSync,
15
- readdirSync,
16
- readlinkSync,
17
10
  realpathSync,
18
- renameSync,
19
- rmSync,
20
11
  statSync,
21
- symlinkSync,
22
- unlinkSync,
23
- writeFileSync,
24
12
  } from 'node:fs'
25
- import os from 'node:os'
26
13
  import path from 'node:path'
27
- import { StringDecoder } from 'node:string_decoder'
28
14
  import { fileURLToPath } from 'node:url'
29
15
 
30
16
  import { load as loadYaml } from './vendor/js-yaml/js-yaml.mjs'
@@ -32,28 +18,9 @@ import { load as loadYaml } from './vendor/js-yaml/js-yaml.mjs'
32
18
  const SAFE_SKILL_ID = /^[a-z0-9][a-z0-9-]*$/
33
19
  const SAFE_MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._/:-]*$/
34
20
  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
21
  const MAX_MODEL_ID_LENGTH = 128
38
22
  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
- )
23
+ const MAX_EXECUTION_CONTEXT_BYTES = 1_048_576
57
24
  const WINDOWS_COMMAND_LINE_BUDGET = 30_000
58
25
  const MAX_MANAGED_PROMPT_BYTES = 1_048_576
59
26
  const PLAIN_PROMPT_STDIN_FLAG = '--plain-prompt-stdin'
@@ -87,11 +54,6 @@ const VALUE_FLAGS = new Set([
87
54
  '--add-dir',
88
55
  '--attachment',
89
56
  '--prompt',
90
- '--request-file',
91
- '--role-wave-file',
92
- '--role-wave-status',
93
- '--role-wave-cleanup',
94
- '--role-merge-file',
95
57
  ])
96
58
 
97
59
  export class RunnerUsageError extends Error {
@@ -268,11 +230,6 @@ export function parseRunnerArgs(argv) {
268
230
  additionalDirs: [],
269
231
  attachmentPaths: [],
270
232
  extraPrompt: undefined,
271
- requestFile: undefined,
272
- roleWaveFile: undefined,
273
- roleWaveStatus: undefined,
274
- roleWaveCleanup: undefined,
275
- roleMergeFile: undefined,
276
233
  plainPromptStdin: false,
277
234
  }
278
235
  let positionalArgs
@@ -333,16 +290,6 @@ export function parseRunnerArgs(argv) {
333
290
  parsed.sessionId = value
334
291
  } else if (token === '--prompt') {
335
292
  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
293
  }
347
294
  }
348
295
  }
@@ -354,85 +301,7 @@ export function parseRunnerArgs(argv) {
354
301
  parsed.rawArgs = positionalArgs
355
302
  }
356
303
 
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) {
304
+ if (parsed.plainPromptStdin) {
436
305
  for (const flag of ['--skill', '--args', '--prompt', '--attachment']) {
437
306
  if (
438
307
  seenSingleValueFlags.has(flag) ||
@@ -463,342 +332,6 @@ export function parseRunnerArgs(argv) {
463
332
  return parsed
464
333
  }
465
334
 
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
335
  export function prepareSkillLaunch(options, dependencies = {}) {
803
336
  const readFile = dependencies.readFile ?? ((file) => readFileSync(file, 'utf8'))
804
337
  const resolvePath = dependencies.resolvePath ?? realpathSync
@@ -899,10 +432,7 @@ function canonicalizeExistingPath(value) {
899
432
 
900
433
  /**
901
434
  * 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.
435
+ * spelling. Use the native resolver so --add-dir receives the long name.
906
436
  */
907
437
  function canonicalRealpath(value) {
908
438
  const resolved =
@@ -912,13 +442,6 @@ function canonicalRealpath(value) {
912
442
  return path.resolve(resolved)
913
443
  }
914
444
 
915
- function existingPathIdentity(value) {
916
- const canonical = canonicalRealpath(value)
917
- return process.platform === 'win32'
918
- ? canonical.toLowerCase()
919
- : canonical
920
- }
921
-
922
445
  function validateAttachmentPaths(attachmentPaths = [], dependencies = {}) {
923
446
  const inspect = dependencies.lstat ?? lstatSync
924
447
  const resolvePath = dependencies.resolvePath ?? realpathSync
@@ -930,1379 +453,114 @@ function validateAttachmentPaths(attachmentPaths = [], dependencies = {}) {
930
453
  return attachmentPaths.map((attachment) => {
931
454
  if (!path.isAbsolute(attachment)) {
932
455
  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}`,
456
+ `Attachment path must be absolute: ${attachment}`,
1884
457
  )
1885
458
  }
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
- )
459
+ try {
460
+ const metadata = inspect(attachment)
461
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
462
+ throw new Error('not a regular non-symlink file')
1901
463
  }
1902
- assertWorkspaceParentSafety(state.baseRepo, target)
1903
- return { ...action, source, target, metadata }
1904
- }
1905
- if (existsSync(source)) {
464
+ const canonical = resolvePath(attachment)
465
+ assertReadable(canonical)
466
+ return canonical
467
+ } catch (error) {
1906
468
  throw new RunnerUsageError(
1907
- `Role merge deletion source still exists: ${action.path}`,
469
+ `Attachment must be a readable regular non-symlink file: ` +
470
+ `${attachment}: ${errorMessage(error)}`,
1908
471
  )
1909
472
  }
1910
- assertWorkspaceParentSafety(state.baseRepo, target)
1911
- return { ...action, source, target }
1912
473
  })
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
474
  }
1960
475
 
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 },
476
+ export function parseNpmCmdShimEntry(shimPath, contents) {
477
+ const match = contents.match(
478
+ /%dp0%[\\/]([^"\r\n]*?\.(?:mjs|cjs|js))["']?\s+%\*/i,
2017
479
  )
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
- }
480
+ if (!match?.[1]) return null
481
+ return path.win32.join(path.win32.dirname(shimPath), match[1])
2025
482
  }
2026
483
 
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
- }
484
+ export function resolveWindowsKimiBinary(env = process.env, fileExists = existsSync) {
485
+ const pathValue = getEnvCaseInsensitive(env, 'PATH') ?? ''
486
+ // npm installs three siblings on Windows: `kimi`, `kimi.cmd`, and
487
+ // `kimi.ps1`. The extensionless sibling is a POSIX shell script and cannot
488
+ // be passed to CreateProcess, while PowerShell scripts cannot be spawned
489
+ // directly with shell:false. Probe only executable-safe forms, with the npm
490
+ // command shim first so large prompts can use the stdin bootstrap.
491
+ const names = ['kimi.cmd', 'kimi.bat', 'kimi.exe', 'kimi.com']
2097
492
 
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
493
+ for (const rawEntry of pathValue.split(';')) {
494
+ const entry = rawEntry.trim().replace(/^"(.*)"$/, '$1')
495
+ if (entry === '') continue
496
+ for (const name of names) {
497
+ const candidate = path.win32.join(entry, name)
498
+ if (fileExists(candidate)) return candidate
2152
499
  }
2153
500
  }
501
+ throw new RunnerUsageError(
502
+ 'No shell-free Kimi executable (.cmd, .bat, .exe, or .com) was found on PATH',
503
+ )
504
+ }
2154
505
 
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
- }
506
+ export function resolveKimiLaunch(kimiArgs, options = {}) {
507
+ const platform = options.platform ?? process.platform
508
+ if (platform !== 'win32') {
509
+ return { command: 'kimi', args: kimiArgs }
2173
510
  }
2174
511
 
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
- }
512
+ const fileExists = options.fileExists ?? existsSync
513
+ const readFile = options.readFile ?? ((file) => readFileSync(file, 'utf8'))
514
+ const binary = options.binary ?? resolveWindowsKimiBinary(options.env, fileExists)
515
+ const extension = path.win32.extname(binary).toLowerCase()
516
+ if (extension !== '.cmd' && extension !== '.bat') {
517
+ assertWindowsCommandLineBudget(binary, kimiArgs)
518
+ return { command: binary, args: kimiArgs }
2209
519
  }
2210
520
 
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',
521
+ let entry
522
+ try {
523
+ entry = parseNpmCmdShimEntry(binary, readFile(binary))
524
+ } catch (error) {
525
+ throw new RunnerUsageError(
526
+ `Cannot read the Kimi Windows shim ${binary}: ${errorMessage(error)}`,
2220
527
  )
2221
- if (refCommit !== baseCommit) {
2222
- throw new RunnerUsageError(
2223
- `Role wave private ref does not match its baseline: ${context.run}`,
2224
- )
2225
- }
2226
528
  }
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)}`
529
+ if (entry === null) {
2248
530
  throw new RunnerUsageError(
2249
- `git ${args.join(' ')} failed in ${cwd}: ${detail}`,
531
+ `Refusing to execute non-standard Kimi Windows shim through cmd.exe: ${binary}`,
2250
532
  )
2251
533
  }
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
- }
534
+ const localNode = path.win32.join(path.win32.dirname(binary), 'node.exe')
535
+ const nodeBinary = fileExists(localNode) ? localNode : 'node'
536
+ const promptFlag = kimiArgs.indexOf('-p')
537
+ if (promptFlag === -1 || promptFlag + 1 >= kimiArgs.length) {
538
+ const args = [entry, ...kimiArgs]
539
+ assertWindowsCommandLineBudget(nodeBinary, args)
540
+ return { command: nodeBinary, args }
2276
541
  }
2277
- }
2278
542
 
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
- }
543
+ const transportedArgs = [...kimiArgs]
544
+ const prompt = transportedArgs[promptFlag + 1]
545
+ transportedArgs[promptFlag + 1] = WINDOWS_PROMPT_STDIN_TOKEN
546
+ const args = [
547
+ '-e',
548
+ WINDOWS_NPM_STDIN_BOOTSTRAP,
549
+ entry,
550
+ ...transportedArgs,
551
+ ]
552
+ assertWindowsCommandLineBudget(nodeBinary, args)
553
+ return { command: nodeBinary, args, stdinText: prompt }
2296
554
  }
2297
555
 
2298
- /** Keep shared task data independent of each role's private cwd. */
556
+ /** Resolve the shared backlog and pipeline runtime locations for the skill. */
2299
557
  export function resolvePipelineEnvironment(cwd, env = process.env) {
2300
558
  const contextPath = nonEmptyString(env.SPECRAILS_EXECUTION_CONTEXT)
2301
559
  let context
2302
560
  if (contextPath) {
2303
561
  if (!path.isAbsolute(contextPath)) throw new RunnerUsageError('SPECRAILS_EXECUTION_CONTEXT must be absolute')
2304
562
  const metadata = lstatSync(contextPath)
2305
- if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_ROLE_REQUEST_BYTES) {
563
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_EXECUTION_CONTEXT_BYTES) {
2306
564
  throw new RunnerUsageError('Execution context must be a bounded regular non-symlink file')
2307
565
  }
2308
566
  context = JSON.parse(readFileSync(contextPath, 'utf8'))
@@ -2330,68 +588,12 @@ export function resolvePipelineEnvironment(cwd, env = process.env) {
2330
588
  export async function runSkillCli(argv, dependencies = {}) {
2331
589
  const cwd = dependencies.cwd ?? process.cwd()
2332
590
  const parsedArgs = parseRunnerArgs(argv)
2333
- const inputEnv = dependencies.env ?? process.env
2334
- // Only nested waves may adopt the context admitted by the outer workflow.
2335
- // Initial skill invocations must not inherit a previous standalone run by accident.
2336
- const admitted = path.join(cwd, '.specrails', 'pipeline-context.json')
2337
- const env = parsedArgs.roleWaveFile !== undefined && !inputEnv.SPECRAILS_EXECUTION_CONTEXT && existsSync(admitted)
2338
- ? { ...inputEnv, SPECRAILS_EXECUTION_CONTEXT: admitted } : inputEnv
2339
- dependencies = { ...dependencies, env: resolvePipelineEnvironment(cwd, env) }
591
+ dependencies = {
592
+ ...dependencies,
593
+ env: resolvePipelineEnvironment(cwd, dependencies.env ?? process.env),
594
+ }
2340
595
  const scriptPath = dependencies.scriptPath ?? process.argv[1]
2341
596
  const providerRoot = resolveProviderRoot(scriptPath)
2342
- const writeOutput =
2343
- dependencies.writeOutput ??
2344
- ((line) => {
2345
- process.stdout.write(line)
2346
- })
2347
- if (parsedArgs.roleWaveStatus !== undefined) {
2348
- emitRoleFrame(
2349
- writeOutput,
2350
- {
2351
- type: 'specrails.merge.inventory',
2352
- ...inspectRoleWaveStatus(parsedArgs.roleWaveStatus, {
2353
- ...dependencies,
2354
- cwd,
2355
- }),
2356
- },
2357
- )
2358
- return 0
2359
- }
2360
- if (parsedArgs.roleWaveCleanup !== undefined) {
2361
- emitRoleFrame(
2362
- writeOutput,
2363
- {
2364
- type: 'specrails.role.cleanup',
2365
- ...cleanupRoleWave(parsedArgs.roleWaveCleanup, {
2366
- ...dependencies,
2367
- cwd,
2368
- }),
2369
- },
2370
- )
2371
- return 0
2372
- }
2373
- if (parsedArgs.roleMergeFile !== undefined) {
2374
- const merge = loadRoleMerge(parsedArgs, cwd)
2375
- emitRoleFrame(
2376
- writeOutput,
2377
- {
2378
- type: 'specrails.merge.applied',
2379
- ...applyRoleMerge(merge, {
2380
- ...dependencies,
2381
- cwd,
2382
- }),
2383
- },
2384
- )
2385
- return 0
2386
- }
2387
- if (parsedArgs.roleWaveFile !== undefined) {
2388
- const wave = loadRoleWave(parsedArgs, cwd)
2389
- return runRoleWave(wave, {
2390
- ...dependencies,
2391
- cwd,
2392
- providerRoot,
2393
- })
2394
- }
2395
597
  if (parsedArgs.plainPromptStdin) {
2396
598
  const readStdin =
2397
599
  dependencies.readStdin ??
@@ -2416,154 +618,13 @@ export async function runSkillCli(argv, dependencies = {}) {
2416
618
  model: parsedArgs.model,
2417
619
  })
2418
620
  }
2419
- const parsed = loadRoleRequest(parsedArgs, cwd)
2420
- return runPreparedSkill(parsed, {
621
+ return runPreparedSkill(parsedArgs, {
2421
622
  ...dependencies,
2422
623
  cwd,
2423
624
  providerRoot,
2424
625
  })
2425
626
  }
2426
627
 
2427
- async function runRoleWave(wave, dependencies) {
2428
- const sourceEnv = dependencies.env ?? process.env
2429
- const inheritedProfile = nonEmptyString(
2430
- sourceEnv.SPECRAILS_PROFILE_PATH,
2431
- )
2432
- const contextPath = nonEmptyString(sourceEnv.SPECRAILS_EXECUTION_CONTEXT)
2433
- const executionContext = contextPath ? JSON.parse(readFileSync(contextPath, 'utf8')) : undefined
2434
- const repositoryCwd = nonEmptyString(sourceEnv.SPECRAILS_REPO_DIR) ??
2435
- executionContext?.artifactRoot ?? dependencies.cwd
2436
- if (executionContext) {
2437
- if (executionContext.ownership?.worktrees === 'host' && wave.roles.some((role) => role.workspace !== 'current')) {
2438
- throw new RunnerUsageError('Host-owned execution must use current repositories; sibling worktrees are not allowed')
2439
- }
2440
- }
2441
- const materialized = materializeRoleWaveWorkspaces(wave, {
2442
- cwd: repositoryCwd,
2443
- providerRoot: dependencies.providerRoot,
2444
- platform: dependencies.platform,
2445
- spawnSync: dependencies.spawnSync,
2446
- gitBinary: dependencies.gitBinary,
2447
- tempRoot: dependencies.tempRoot,
2448
- })
2449
- const writeOutput =
2450
- dependencies.writeOutput ??
2451
- ((line) => {
2452
- process.stdout.write(line)
2453
- })
2454
- const children = new Set()
2455
- const aggregateChild = {
2456
- kill(signal) {
2457
- for (const child of children) child.kill?.(signal)
2458
- },
2459
- }
2460
- const removeSignalForwarding = forwardTerminationSignals(
2461
- aggregateChild,
2462
- dependencies.signalSource ?? process,
2463
- )
2464
-
2465
- try {
2466
- const results = await Promise.all(
2467
- materialized.roles.map(async (role) => {
2468
- emitRoleFrame(writeOutput, {
2469
- type: 'specrails.role.workspace',
2470
- run: materialized.run,
2471
- roleKey: role.key,
2472
- workspace: role.workspace,
2473
- executionCwd: role.cwd,
2474
- repoDir: role.repoDir,
2475
- baseCommit: materialized.baseCommit,
2476
- manifestPath: materialized.manifestPath,
2477
- })
2478
- try {
2479
- const code = await runPreparedSkill(
2480
- {
2481
- skill: role.skill,
2482
- model: role.model,
2483
- rawArgs: role.rawArgs,
2484
- sessionId: undefined,
2485
- additionalDirs: Array.from(
2486
- new Set([
2487
- ...wave.additionalDirs,
2488
- materialized.baseRepo,
2489
- ...(executionContext?.repositories ?? []).map((repository) => repository.path),
2490
- sourceEnv.SPECRAILS_BACKLOG_ROOT,
2491
- ...(contextPath ? [path.dirname(contextPath)] : []),
2492
- ]),
2493
- ),
2494
- attachmentPaths: [],
2495
- extraPrompt: undefined,
2496
- },
2497
- {
2498
- ...dependencies,
2499
- cwd: role.cwd,
2500
- providerRoot: dependencies.providerRoot,
2501
- env: {
2502
- ...sourceEnv,
2503
- SPECRAILS_REPO_DIR: role.repoDir,
2504
- SPECRAILS_MERGE_TARGET: materialized.baseRepo,
2505
- SPECRAILS_KIMI_WORKTREE_MANIFEST:
2506
- materialized.manifestPath,
2507
- ...(role.profile === 'inherit'
2508
- ? inheritedProfile === undefined
2509
- ? {}
2510
- : {
2511
- SPECRAILS_PROFILE_PATH: path.isAbsolute(
2512
- inheritedProfile,
2513
- )
2514
- ? inheritedProfile
2515
- : path.resolve(
2516
- materialized.baseRepo,
2517
- inheritedProfile,
2518
- ),
2519
- }
2520
- : {
2521
- SPECRAILS_PROFILE_PATH: path.join(
2522
- materialized.baseRepo,
2523
- '.specrails',
2524
- 'profiles',
2525
- `${role.profile}.json`,
2526
- ),
2527
- }),
2528
- GIT_CONFIG_COUNT: '2',
2529
- GIT_CONFIG_KEY_0: 'core.excludesFile',
2530
- GIT_CONFIG_VALUE_0: role.gitExcludeFile,
2531
- GIT_CONFIG_KEY_1: 'core.autocrlf',
2532
- GIT_CONFIG_VALUE_1: 'false',
2533
- },
2534
- captureRoleKey: role.key,
2535
- writeOutput,
2536
- childRegistry: children,
2537
- forwardSignals: false,
2538
- },
2539
- )
2540
- emitRoleFrame(writeOutput, {
2541
- type: 'specrails.role.completed',
2542
- run: materialized.run,
2543
- roleKey: role.key,
2544
- status: code === 0 ? 'succeeded' : 'failed',
2545
- exitCode: code,
2546
- })
2547
- return code
2548
- } catch (error) {
2549
- emitRoleFrame(writeOutput, {
2550
- type: 'specrails.role.completed',
2551
- run: materialized.run,
2552
- roleKey: role.key,
2553
- status: 'failed',
2554
- exitCode: 1,
2555
- error: errorMessage(error),
2556
- })
2557
- return 1
2558
- }
2559
- }),
2560
- )
2561
- return results.find((code) => code !== 0) ?? 0
2562
- } finally {
2563
- removeSignalForwarding()
2564
- }
2565
- }
2566
-
2567
628
  async function runPreparedSkill(parsed, dependencies) {
2568
629
  const prepared = prepareSkillLaunch(
2569
630
  { ...parsed, providerRoot: dependencies.providerRoot },
@@ -2581,7 +642,6 @@ async function runPreparedSkill(parsed, dependencies) {
2581
642
 
2582
643
  async function runPreparedPrompt(kimiArgs, prompt, dependencies) {
2583
644
  assertManagedPrompt(prompt)
2584
- let child
2585
645
  let removeSignalForwarding = () => {}
2586
646
  try {
2587
647
  // Kimi 0.27 exposes only `-p <prompt>` for an exact non-interactive user
@@ -2596,45 +656,27 @@ async function runPreparedPrompt(kimiArgs, prompt, dependencies) {
2596
656
  dependencies.env ?? process.env,
2597
657
  dependencies.model,
2598
658
  )
2599
- child = spawnChild(launch.command, launch.args, {
659
+ const child = spawnChild(launch.command, launch.args, {
2600
660
  cwd: dependencies.cwd,
2601
661
  env,
2602
662
  shell: false,
2603
663
  stdio:
2604
- dependencies.captureRoleKey === undefined
2605
- ? launch.stdinText === undefined
2606
- ? 'inherit'
2607
- : ['pipe', 'inherit', 'inherit']
2608
- : launch.stdinText === undefined
2609
- ? ['ignore', 'pipe', 'pipe']
2610
- : ['pipe', 'pipe', 'pipe'],
664
+ launch.stdinText === undefined
665
+ ? 'inherit'
666
+ : ['pipe', 'inherit', 'inherit'],
2611
667
  })
2612
- dependencies.childRegistry?.add(child)
2613
668
  const completion = waitForChild(child)
2614
- const outputCompletion =
2615
- dependencies.captureRoleKey === undefined
2616
- ? Promise.resolve()
2617
- : captureRoleOutput(
2618
- child,
2619
- dependencies.captureRoleKey,
2620
- dependencies.writeOutput,
2621
- )
2622
- removeSignalForwarding =
2623
- dependencies.forwardSignals === false
2624
- ? () => {}
2625
- : forwardTerminationSignals(
2626
- child,
2627
- dependencies.signalSource ?? process,
2628
- )
669
+ removeSignalForwarding = forwardTerminationSignals(
670
+ child,
671
+ dependencies.signalSource ?? process,
672
+ )
2629
673
  if (launch.stdinText === undefined) {
2630
- const [code] = await Promise.all([completion, outputCompletion])
2631
- return code
674
+ return await completion
2632
675
  }
2633
676
  try {
2634
677
  const [code] = await Promise.all([
2635
678
  completion,
2636
679
  writePromptToChild(child, launch.stdinText),
2637
- outputCompletion,
2638
680
  ])
2639
681
  return code
2640
682
  } catch (error) {
@@ -2642,7 +684,6 @@ async function runPreparedPrompt(kimiArgs, prompt, dependencies) {
2642
684
  throw error
2643
685
  }
2644
686
  } finally {
2645
- if (child !== undefined) dependencies.childRegistry?.delete(child)
2646
687
  removeSignalForwarding()
2647
688
  }
2648
689
  }
@@ -2659,94 +700,6 @@ function assertManagedPrompt(prompt) {
2659
700
  }
2660
701
  }
2661
702
 
2662
- function captureRoleOutput(child, roleKey, writeOutput) {
2663
- const streams = [
2664
- ['stdout', child.stdout],
2665
- ['stderr', child.stderr],
2666
- ].filter(([, stream]) => stream?.on)
2667
- if (streams.length === 0) return Promise.resolve()
2668
- return Promise.all(
2669
- streams.map(([streamName, stream]) =>
2670
- new Promise((resolve) => {
2671
- const decoder = new StringDecoder('utf8')
2672
- let pending = ''
2673
- let finished = false
2674
- const emitLines = (final) => {
2675
- const parts = pending.split('\n')
2676
- pending = final ? '' : (parts.pop() ?? '')
2677
- for (const line of parts) {
2678
- emitCapturedRoleLine(
2679
- writeOutput,
2680
- roleKey,
2681
- streamName,
2682
- line.replace(/\r$/, ''),
2683
- )
2684
- }
2685
- if (final && pending !== '') {
2686
- emitCapturedRoleLine(
2687
- writeOutput,
2688
- roleKey,
2689
- streamName,
2690
- pending.replace(/\r$/, ''),
2691
- )
2692
- pending = ''
2693
- }
2694
- }
2695
- const finish = () => {
2696
- if (finished) return
2697
- finished = true
2698
- pending += decoder.end()
2699
- emitLines(true)
2700
- resolve()
2701
- }
2702
- stream.on('data', (chunk) => {
2703
- pending += decoder.write(
2704
- Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)),
2705
- )
2706
- emitLines(false)
2707
- })
2708
- stream.once('end', finish)
2709
- stream.once('close', finish)
2710
- stream.once('error', (error) => {
2711
- emitRoleFrame(writeOutput, {
2712
- type: 'specrails.role.output-error',
2713
- roleKey,
2714
- stream: streamName,
2715
- error: errorMessage(error),
2716
- })
2717
- finish()
2718
- })
2719
- }),
2720
- ),
2721
- ).then(() => undefined)
2722
- }
2723
-
2724
- function emitCapturedRoleLine(writeOutput, roleKey, stream, line) {
2725
- if (line === '') return
2726
- if (stream === 'stdout') {
2727
- try {
2728
- emitRoleFrame(writeOutput, {
2729
- type: 'specrails.role.event',
2730
- roleKey,
2731
- event: JSON.parse(line),
2732
- })
2733
- return
2734
- } catch {
2735
- // Preserve non-JSON output as an attributed frame.
2736
- }
2737
- }
2738
- emitRoleFrame(writeOutput, {
2739
- type: 'specrails.role.output',
2740
- roleKey,
2741
- stream,
2742
- data: line,
2743
- })
2744
- }
2745
-
2746
- function emitRoleFrame(writeOutput, value) {
2747
- writeOutput(`${JSON.stringify({ role: 'meta', ...value })}\n`)
2748
- }
2749
-
2750
703
  export function forwardTerminationSignals(child, signalSource = process) {
2751
704
  const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']
2752
705
  const handlers = new Map()
@@ -2867,41 +820,6 @@ function requireSafeSessionId(value) {
2867
820
  return value
2868
821
  }
2869
822
 
2870
- function requireSafeWaveId(value, label) {
2871
- if (typeof value !== 'string' || !SAFE_WAVE_ID.test(value)) {
2872
- throw new RunnerUsageError(
2873
- `Invalid ${label}: expected 1-64 lowercase letters, digits, or hyphens`,
2874
- )
2875
- }
2876
- return value
2877
- }
2878
-
2879
- function requireGitOid(value, label) {
2880
- if (typeof value !== 'string' || !SAFE_GIT_OID.test(value)) {
2881
- throw new RunnerUsageError(
2882
- `Invalid ${label}: expected a 40- or 64-character lowercase hex commit id`,
2883
- )
2884
- }
2885
- return value
2886
- }
2887
-
2888
- function requireSafeRelativePath(value, label) {
2889
- if (
2890
- typeof value !== 'string' ||
2891
- value.length === 0 ||
2892
- Buffer.byteLength(value, 'utf8') > 4_096 ||
2893
- value.includes('\0') ||
2894
- value.includes('\\') ||
2895
- path.posix.isAbsolute(value) ||
2896
- path.posix.normalize(value) !== value ||
2897
- value.split('/').some((part) => part === '' || part === '.' || part === '..') ||
2898
- isManagedRolePath(value)
2899
- ) {
2900
- throw new RunnerUsageError(`Invalid ${label}`)
2901
- }
2902
- return value
2903
- }
2904
-
2905
823
  function requireNonEmpty(value, label) {
2906
824
  if (typeof value !== 'string' || value.trim() === '') {
2907
825
  throw new RunnerUsageError(`Missing or empty ${label}`)