specrails-core 6.2.1 → 6.3.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.
@@ -1,59 +1,10 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { constants, cpSync, mkdtempSync, renameSync, rmSync } from 'node:fs';
3
- import os from 'node:os';
4
3
  import path from 'node:path';
5
4
  import { pathToFileURL } from 'node:url';
6
5
  import { atomicSymlinkSwap, copyDir, copyFile, isDir, isSymlink, listDir, mkdirp, pathExists, readBytes, readTextFile, removePath, symlinkOrCopy, writeFileLf, } from '../util/fs.js';
7
6
  import { info, ok, warn } from '../util/logger.js';
8
7
  import { buildManifest, writeManifestFiles } from './manifest.js';
9
- /**
10
- * The three baseline agents — the COMPLETE set of agents the installer
11
- * ships. The implement pipeline depends on all three. Any additional agent
12
- * comes from a user-authored profile (`custom-*`), never the installer.
13
- *
14
- * Mirrors the required built-in roles in schemas/agent-runtime.schema.json
15
- * (`agents`, with the sr- prefix in installed files); keep both in sync.
16
- */
17
- export const CORE_AGENTS = new Set([
18
- 'sr-architect',
19
- 'sr-developer',
20
- 'sr-reviewer',
21
- ]);
22
- /**
23
- * Gemini built-in tool ids granted to every `.gemini/agents/sr-*.md` subagent.
24
- * (Validated headless in the desktop spike — read/write/shell/glob/grep.) Because
25
- * gemini TOML commands cannot carry per-command tool/model routing, the tool +
26
- * model gating migrates into the subagent frontmatter.
27
- *
28
- * `activate_skill` is mandatory: the architect/developer/reviewer personas open
29
- * with a NON-NEGOTIABLE OpenSpec skill call (`opsx:ff`/`apply`/`archive`). Gemini
30
- * exposes skills through the `activate_skill` tool — without it in the tools list
31
- * the agent halts with "the required `Skill` tool is not available" and the only
32
- * way the pipeline ever completed was the orchestrator hand-patching the agent
33
- * file mid-run. See `translateOpsxSkillCallsForGemini` for the body half.
34
- */
35
- const GEMINI_AGENT_TOOLS = ['read_file', 'write_file', 'run_shell_command', 'glob', 'search_file_content', 'activate_skill'];
36
- /**
37
- * Claude `Skill("opsx:<id>")` → Gemini `activate_skill(name="<skill>")` id map.
38
- * The agent persona templates are authored in Claude form (the shared source of
39
- * truth across providers); Gemini invokes the same OpenSpec workflow skills under
40
- * a different tool name and skill-directory names. The mapping is NOT a uniform
41
- * `-change` suffix (`sync` → `*-sync-specs`, `explore`/`onboard` have none), so it
42
- * must be explicit. Keys mirror the skill directories scaffolded under
43
- * `.gemini/skills/openspec-*`.
44
- */
45
- const OPSX_TO_GEMINI_SKILL = {
46
- ff: 'openspec-ff-change',
47
- new: 'openspec-new-change',
48
- apply: 'openspec-apply-change',
49
- continue: 'openspec-continue-change',
50
- archive: 'openspec-archive-change',
51
- 'bulk-archive': 'openspec-bulk-archive-change',
52
- sync: 'openspec-sync-specs',
53
- verify: 'openspec-verify-change',
54
- explore: 'openspec-explore',
55
- onboard: 'openspec-onboard',
56
- };
57
8
  /** OpenSpec's published Kimi skill ids. The mapping is intentionally explicit. */
58
9
  const OPSX_TO_KIMI_SKILL = {
59
10
  propose: 'openspec-propose',
@@ -68,20 +19,6 @@ const OPSX_TO_KIMI_SKILL = {
68
19
  explore: 'openspec-explore',
69
20
  onboard: 'openspec-onboard',
70
21
  };
71
- /**
72
- * Rewrite every literal `Skill("opsx:<id>"[, …])` call in a Claude-authored agent
73
- * body into the Gemini `activate_skill(name="…")` form. Positional skill input
74
- * (e.g. `"<specName>"`) is dropped because `activate_skill` takes only `name` and
75
- * the surrounding persona prose already carries the context. Unknown ids are left
76
- * untouched (better a visible stale ref than a silent `name="undefined"`). This
77
- * runs ONLY on the gemini render path; Claude/Codex keep the `Skill(...)` form.
78
- */
79
- export function translateOpsxSkillCallsForGemini(body) {
80
- return body.replace(/Skill\("opsx:([a-z-]+)"(?:\s*,[^)]*)?\)/g, (match, id) => {
81
- const skill = OPSX_TO_GEMINI_SKILL[id];
82
- return skill ? `activate_skill(name="${skill}")` : match;
83
- });
84
- }
85
22
  /**
86
23
  * Port shared Claude-authored prose to Kimi's directory-skill contract.
87
24
  *
@@ -115,29 +52,6 @@ export function translateClaudeTextForKimi(body) {
115
52
  .replace(/\bAgent tool\b/g, 'external Kimi role process');
116
53
  return translated;
117
54
  }
118
- /**
119
- * Per-role gemini model. Defaults to `gemini-3.5-flash` — the stable flagship
120
- * (June 2026): strong agentic/coding, high quota, and unlike `gemini-2.5-pro`
121
- * it is NOT removed from the free tier (the old default produced 429
122
- * "exhausted capacity" errors on free/limited keys).
123
- */
124
- const GEMINI_MODEL_BY_AGENT = {
125
- 'sr-architect': 'gemini-3.5-flash',
126
- 'sr-developer': 'gemini-3.5-flash',
127
- 'sr-reviewer': 'gemini-3.5-flash',
128
- };
129
- const GEMINI_DEFAULT_MODEL = 'gemini-3.5-flash';
130
- // Older Gemini loaders reject optional agent-limit fields. Opt in only after
131
- // the caller verified the installed loader capability; never guess from a model.
132
- export function geminiAgentLimitMetadata(env = process.env) {
133
- if (env.SPECRAILS_GEMINI_AGENT_LIMITS !== 'supported')
134
- return [];
135
- const value = Number(env.SPECRAILS_GEMINI_MAX_TURNS ?? '60');
136
- if (!Number.isInteger(value) || value < 1 || value > 200) {
137
- throw new Error('SPECRAILS_GEMINI_MAX_TURNS must be an integer from 1 to 200');
138
- }
139
- return [`max_turns: ${value}`];
140
- }
141
55
  /**
142
56
  * Claude top-level `sr-*` skills, GENERATED at install time from their
143
57
  * canonical slash-command body under `templates/commands/specrails/<command>.md`.
@@ -154,8 +68,9 @@ const SKILL_FROM_COMMAND = {
154
68
  };
155
69
  /**
156
70
  * Provider-static subtrees inside a providerDir that are SHARED via symlink from
157
- * the framework copy into each workspace. `agent-memory/` is deliberately absent
158
- * — it is mutable per-workspace state seeded as a real dir, never linked.
71
+ * the framework copy into each workspace. `agents/` is deliberately absent — Core
72
+ * ships no role files, and `<provider>/agents/custom-*.md` is a reserved
73
+ * user/Desktop region the installer never creates or links.
159
74
  *
160
75
  * The root instruction file (`CLAUDE.md`/`AGENTS.md`/`GEMINI.md`) and the codex
161
76
  * `config.toml` / gemini `settings.json` carry the project name / a deep-merge
@@ -163,9 +78,9 @@ const SKILL_FROM_COMMAND = {
163
78
  * `assembleProjectWorkspace`.
164
79
  */
165
80
  const LINKED_PROVIDER_SUBTREES = {
166
- claude: ['agents', 'commands', 'skills'],
81
+ claude: ['commands', 'skills'],
167
82
  codex: ['skills'],
168
- gemini: ['agents', 'commands'],
83
+ gemini: ['commands'],
169
84
  // Kimi skills are linked one directory at a time so direct-child OpenSpec
170
85
  // skills and user-owned custom-* roles can coexist. The self-contained
171
86
  // headless runner and its vendored parser are Core-owned and linked as a
@@ -184,7 +99,6 @@ const KIMI_RUNNER_RELATIVE_FILES = [
184
99
  */
185
100
  export function detectExistingSetup(input) {
186
101
  const roots = [
187
- path.join(input.artifactRoot, input.providerDir, 'agents'),
188
102
  path.join(input.artifactRoot, input.providerDir, 'commands'),
189
103
  path.join(input.artifactRoot, input.providerDir, 'rules'),
190
104
  // openspec stays in the repo (codeRoot), not the relocated artifact root.
@@ -214,13 +128,12 @@ export function scaffoldInstallation(input) {
214
128
  // Codex skills live under <providerDir>/skills/ (e.g. .codex/skills/).
215
129
  // The pre-§18 code wrote to `.agents/skills/` which codex doesn't read;
216
130
  // that was a placeholder name from the gated state.
217
- mk(path.join(input.artifactRoot, input.providerDir, 'skills', 'rails'));
131
+ mk(path.join(input.artifactRoot, input.providerDir, 'skills'));
218
132
  }
219
133
  else if (input.provider === 'gemini') {
220
- // Gemini: TOML commands under .gemini/commands/specrails/ + native
221
- // subagents and OpenSpec skills both live in the execution workspace.
134
+ // Gemini: TOML commands under .gemini/commands/specrails/ + OpenSpec
135
+ // skills in the execution workspace.
222
136
  mk(path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails'));
223
- mk(path.join(input.artifactRoot, input.providerDir, 'agents'));
224
137
  mk(path.join(input.artifactRoot, input.providerDir, 'skills'));
225
138
  }
226
139
  else if (input.provider === 'kimi') {
@@ -232,7 +145,6 @@ export function scaffoldInstallation(input) {
232
145
  mk(path.join(input.artifactRoot, input.providerDir, 'skills'));
233
146
  }
234
147
  const setupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
235
- mk(path.join(setupTemplates, 'agents'));
236
148
  mk(path.join(setupTemplates, 'commands'));
237
149
  mk(path.join(setupTemplates, 'skills'));
238
150
  mk(path.join(setupTemplates, 'settings'));
@@ -277,20 +189,19 @@ export function scaffoldInstallation(input) {
277
189
  // --- Direct placement (the only path) ---
278
190
  {
279
191
  const placed = placeArtefacts({ ...input });
280
- copiedFiles += placed.agents + placed.commands;
281
- info(`Placed ${placed.agents} agent(s) + ${placed.commands} command(s) directly into ${input.providerDir}/`);
192
+ copiedFiles += placed.commands;
193
+ info(`Placed ${placed.commands} command(s) directly into ${input.providerDir}/`);
282
194
  }
283
- // --- Skills placement (both tiers, both providers) ---
195
+ // --- Skills placement ---
284
196
  // Claude: top-level `sr-*` skills are generated from their canonical
285
- // command bodies (single source of truth). Codex: skips top-level skills
286
- // (it uses the command-ports) and instead receives the codex-native rails.
197
+ // command bodies (single source of truth). Codex and Gemini: no extra
198
+ // skills (they use the command ports). Kimi: workflow skills.
287
199
  // See placeSkills for the full per-provider contract.
288
200
  {
289
201
  const skills = placeSkills(input);
290
202
  copiedFiles += skills.filesCopied;
291
- const skillsLabel = input.provider === 'gemini' ? 'agent' : 'skill';
292
- const skillsSubdir = input.provider === 'gemini' ? 'agents' : 'skills';
293
- info(`Placed ${skills.placed} ${skillsLabel}(s) into ${input.providerDir}/${skillsSubdir}/`);
203
+ if (skills.placed > 0)
204
+ info(`Placed ${skills.placed} skill(s) into ${input.providerDir}/skills/`);
294
205
  }
295
206
  // --- Codex provider settings + AGENTS.md initial content ---
296
207
  if (input.provider === 'codex') {
@@ -436,8 +347,8 @@ export function frameworkMaterializationProblem(versionDir, version, provider, p
436
347
  * `<frameworkDir>/<version>/<providerDir>/` (+ `<version>/setup-templates/`).
437
348
  * Idempotent: when the providerDir already exists with a matching stamp it is a
438
349
  * no-op (the second workspace assemble re-uses the same copy). Writes NO
439
- * per-workspace state (no agent-memory, no acks, no project-named instruction
440
- * files) — those are seeded by `assembleProjectWorkspace`.
350
+ * per-workspace state (no project-named instruction files) — those are seeded
351
+ * by `assembleProjectWorkspace`.
441
352
  */
442
353
  export function installFramework(input) {
443
354
  const versionDir = path.join(input.frameworkDir, input.version);
@@ -482,26 +393,16 @@ export function installFramework(input) {
482
393
  removePath(stagedProviderDir);
483
394
  removePath(stagedStampPath);
484
395
  // Reuse scaffoldInstallation's static-placement helpers by pointing
485
- // `artifactRoot` at the version dir. `seedProjectDirs: false` keeps the copy
486
- // free of per-workspace mutable state. The `codeRoot` is irrelevant to the
396
+ // `artifactRoot` at the version dir. The `codeRoot` is irrelevant to the
487
397
  // STATIC subtree (the project-named instruction files are skipped below), so
488
398
  // we hand it the framework dir to satisfy the contract — and we DELETE any
489
399
  // project-named instruction file the settings helpers wrote.
490
- // The SHARED framework store is always the FULL SUPERSET — EVERY agent — so a
491
- // SECOND project with a DIFFERENT agent selection links its specialists from
492
- // the same materialized copy instead of inheriting the first project's
493
- // narrower set. Per-project filtering moves to the workspace LINK step
494
- // (`linkAgentFiles` via `assembleProjectWorkspace`). `selectedAgents` on the
495
- // input is intentionally IGNORED here.
496
400
  const staticInput = {
497
401
  scriptDir: input.scriptDir,
498
402
  artifactRoot: stagedVersionDir,
499
403
  codeRoot: versionDir,
500
404
  provider: input.provider,
501
405
  providerDir: input.providerDir,
502
- selectedAgents: undefined,
503
- materializeAllAgents: true,
504
- seedProjectDirs: false,
505
406
  };
506
407
  scaffoldInstallation(staticInput);
507
408
  // The settings helpers also emit a project-named root instruction file
@@ -561,30 +462,26 @@ export function ensureCurrentSymlink(frameworkDir, version) {
561
462
  }
562
463
  /**
563
464
  * Assemble a project workspace with NO network and NO re-materialization: (a)
564
- * SYMLINK the static providerDir subtrees from `<frameworkDir>/current/
565
- * <providerDir>/` into `<workspace>/<providerDir>/`, then (b) seed the PROJECT
566
- * layer as real writable files (agent-memory dirs, the manifest, project-named
567
- * instruction/settings files, gemini headless acks re-hashed against the LINKED
568
- * files). `agent-memory/` is NEVER linked.
465
+ * prune the framework-owned `sr-*` role artifacts an older Core linked or
466
+ * copied into the workspace, (b) SYMLINK the static providerDir subtrees from
467
+ * `<frameworkDir>/current/<providerDir>/` into `<workspace>/<providerDir>/`,
468
+ * then (c) seed the PROJECT layer as real writable files (the manifest,
469
+ * project-named instruction/settings files). Reserved `custom-*` files are
470
+ * never touched.
569
471
  */
570
472
  export function assembleProjectWorkspace(input) {
571
473
  const currentProviderDir = path.join(input.frameworkDir, 'current', input.providerDir);
572
474
  const workspaceProviderDir = path.join(input.workspace, input.providerDir);
573
475
  mkdirp(workspaceProviderDir);
574
- // (a) Link the static subtrees that exist in the framework copy.
476
+ // (a) Drop the role artifacts an older Core placed. Runs for EVERY provider,
477
+ // whether or not the framework still has the subtree, so an upgraded
478
+ // workspace loses its stale `sr-*` links/copies while `custom-*` stays put.
479
+ pruneStaleRoleArtifacts(workspaceProviderDir, input.provider);
480
+ // (b) Link the static subtrees that exist in the framework copy. Every linked
481
+ // subtree (`commands/`, `skills/`, `specrails/`) holds no user files and is
482
+ // linked as a whole directory (cheapest, single inode). `agents/` is never
483
+ // linked: Core ships no roles and the dir is a reserved user region.
575
484
  //
576
- // `agents/` is linked PER-FILE (a real workspace dir holding one symlink per
577
- // framework agent) so the workspace can also carry user/desktop `custom-*.md`
578
- // agents — a RESERVED region the installer must never touch. Every other
579
- // subtree (`commands/`, `skills/`, `rules/`) holds no user files and is linked
580
- // as a whole directory (cheapest, single inode).
581
- // Per-project AGENT selection: link only the selected framework agents (∪ the
582
- // CORE trio, minus the quick-excluded product agents) — the shared store holds
583
- // the full superset, so a project's narrower pick links a SUBSET. Undefined ⇒
584
- // CORE trio only. `custom-*.md` is always preserved (reserved path).
585
- const selectedAgentSet = input.selectedAgents
586
- ? new Set([...input.selectedAgents, ...CORE_AGENTS])
587
- : new Set([...CORE_AGENTS]);
588
485
  // In-repo standalone install COPIES the static subtrees as real files; the
589
486
  // relocated (desktop / --relocate) path symlinks them. Defaults to symlink.
590
487
  const preferCopy = input.copyStatics === true;
@@ -593,16 +490,7 @@ export function assembleProjectWorkspace(input) {
593
490
  const target = path.join(currentProviderDir, sub);
594
491
  if (!pathExists(target))
595
492
  continue;
596
- const dest = path.join(workspaceProviderDir, sub);
597
- if (sub === 'agents') {
598
- // `agents/` is linked PER-FILE so the workspace can carry user/desktop
599
- // `custom-*.md` agents alongside the framework agents.
600
- links[sub] = linkAgentFiles(target, dest, selectedAgentSet, preferCopy);
601
- }
602
- else {
603
- // Every other subtree holds no user files → whole-dir symlink (single inode).
604
- links[sub] = symlinkOrCopy(target, dest, preferCopy);
605
- }
493
+ links[sub] = symlinkOrCopy(target, path.join(workspaceProviderDir, sub), preferCopy);
606
494
  }
607
495
  // Core no longer ships `rules/`; drop the dangling link an older version left.
608
496
  const retiredRules = path.join(workspaceProviderDir, 'rules');
@@ -613,7 +501,7 @@ export function assembleProjectWorkspace(input) {
613
501
  const kimiSkillsDest = path.join(workspaceProviderDir, 'skills');
614
502
  migrateLegacyKimiRoleLayout(kimiSkillsDest);
615
503
  if (pathExists(kimiSkillsTarget)) {
616
- links.skills = linkKimiSkillDirectories(kimiSkillsTarget, kimiSkillsDest, selectedAgentSet, preferCopy);
504
+ links.skills = linkKimiSkillDirectories(kimiSkillsTarget, kimiSkillsDest, preferCopy);
617
505
  }
618
506
  }
619
507
  // Link only provider-invariant settings (codex config.toml / gemini
@@ -635,8 +523,8 @@ export function assembleProjectWorkspace(input) {
635
523
  if (pathExists(runtimeTarget)) {
636
524
  links.pipelineRuntime = symlinkOrCopy(runtimeTarget, path.join(input.workspace, '.specrails', 'runtime'), preferCopy);
637
525
  }
638
- // (b) Seed the PROJECT layer (real writable files / dirs).
639
- const seededMemoryAgents = seedProjectLayer(input, currentProviderDir);
526
+ // (c) Seed the PROJECT layer (real writable files).
527
+ seedProjectLayer(input);
640
528
  // Manifest: record the consumed framework version. `buildManifest` hashes the
641
529
  // package's templates/ + commands (provenance), written under the workspace.
642
530
  const manifest = buildManifest({
@@ -647,45 +535,13 @@ export function assembleProjectWorkspace(input) {
647
535
  primaryProvider: input.provider,
648
536
  });
649
537
  writeManifestFiles(input.workspace, manifest);
650
- return { links, seededMemoryAgents };
538
+ return { links };
651
539
  }
652
540
  /**
653
- * Seed the per-workspace PROJECT layer: real agent-memory dirs (+ explanations/),
654
- * the project-named instruction file, and — for gemini — the headless
655
- * acknowledgments re-hashed against the LINKED agent files. Returns the agent
656
- * ids whose memory dirs were created.
541
+ * Seed the per-workspace PROJECT layer: the project-named instruction file and,
542
+ * for Kimi, the per-project MCP registry.
657
543
  */
658
- function seedProjectLayer(input, currentProviderDir) {
659
- const selected = input.selectedAgents
660
- ? new Set([...input.selectedAgents, ...CORE_AGENTS])
661
- : new Set([...CORE_AGENTS]);
662
- // Discover which agents the framework actually placed (so memory dirs match
663
- // the linked agent set), intersected with the selection.
664
- const agentsLinkDir = path.join(currentProviderDir, 'agents');
665
- const placedAgentIds = [];
666
- if (isDir(agentsLinkDir)) {
667
- for (const entry of listDir(agentsLinkDir)) {
668
- const name = path.basename(entry);
669
- if (!name.endsWith('.md'))
670
- continue;
671
- const id = name.slice(0, -3);
672
- if (selected.has(id))
673
- placedAgentIds.push(id);
674
- }
675
- }
676
- const seededMemoryAgents = [];
677
- if (input.provider === 'claude') {
678
- for (const id of placedAgentIds) {
679
- mkdirp(path.join(input.workspace, '.claude', 'agent-memory', id));
680
- seededMemoryAgents.push(id);
681
- }
682
- }
683
- else if (input.provider === 'gemini') {
684
- for (const id of placedAgentIds) {
685
- mkdirp(path.join(input.workspace, '.gemini', 'agent-memory', id));
686
- seededMemoryAgents.push(id);
687
- }
688
- }
544
+ function seedProjectLayer(input) {
689
545
  // Project-named instruction file (codex AGENTS.md / gemini GEMINI.md). Reuse
690
546
  // the same sentinel-upsert helpers via the settings appliers, scoped so they
691
547
  // ONLY emit the instruction file (the settings file is already linked above).
@@ -694,40 +550,14 @@ function seedProjectLayer(input, currentProviderDir) {
694
550
  }
695
551
  else if (input.provider === 'gemini') {
696
552
  seedInstructionFile(path.join(input.workspace, 'GEMINI.md'), renderInitialGeminiMd(input.codeRoot));
697
- // Gemini headless acks: hash the LINKED agent files (read through the
698
- // symlink) keyed on the real repo so `gemini -p` trusts them with no prompt.
699
- try {
700
- writeGeminiAgentAcknowledgments(input.codeRoot, placedAgentIds, input.workspace);
701
- }
702
- catch (err) {
703
- warn(`gemini agent pre-acknowledgment skipped: ${err.message}`);
704
- }
705
553
  }
706
554
  else if (input.provider === 'kimi') {
707
- const skillsDir = path.join(currentProviderDir, 'skills');
708
- if (isDir(skillsDir)) {
709
- for (const roleDir of listDir(skillsDir)) {
710
- if (!isDir(roleDir))
711
- continue;
712
- const id = path.basename(roleDir);
713
- if (/^sr-[a-z0-9-]+$/.test(id) &&
714
- selected.has(id) &&
715
- pathExists(path.join(roleDir, 'SKILL.md'))) {
716
- placedAgentIds.push(id);
717
- }
718
- }
719
- }
720
- for (const id of placedAgentIds) {
721
- mkdirp(path.join(input.workspace, '.kimi-code', 'agent-memory', id));
722
- seededMemoryAgents.push(id);
723
- }
724
555
  seedInstructionFile(path.join(input.workspace, '.kimi-code', 'AGENTS.md'), renderInitialKimiAgentsMd(input.codeRoot));
725
556
  seedKimiMcpFile(path.join(input.workspace, '.kimi-code', 'mcp.json'));
726
557
  if (input.workspace === input.codeRoot) {
727
558
  ensureGitignore(input.codeRoot, ['.kimi-code/agent-memory/', '.specrails/']);
728
559
  }
729
560
  }
730
- return seededMemoryAgents;
731
561
  }
732
562
  /**
733
563
  * Ensure Kimi's per-project MCP registry is a real writable file. Older Core
@@ -751,87 +581,56 @@ function seedKimiMcpFile(mcpPath) {
751
581
  writeFileLf(mcpPath, '{\n "mcpServers": {}\n}\n');
752
582
  }
753
583
  }
584
+ /** Framework-owned role ids (`sr-<name>`); `custom-*` and anything else is user-owned. */
585
+ const FRAMEWORK_ROLE_ID = /^sr-[a-z0-9-]+$/;
754
586
  /**
755
- * Per-file link the framework `agents/` into a REAL workspace `agents/` dir.
756
- * Keeps `custom-*.md` (and any other user-authored file that the framework does
757
- * NOT provide) byte-untouched — the reserved-paths contract — while pointing
758
- * every SELECTED framework-owned agent at the shared read-only copy.
759
- *
760
- * `selectedIds` is the per-project agent allow-list (already unioned with the
761
- * CORE trio by the caller). Only framework agents whose id is in it are linked —
762
- * the shared framework store is the full superset, so this is where per-project
763
- * filtering lands. `undefined` ⇒ link every framework agent (used by the legacy
764
- * callers / parity tests).
765
- *
766
- * When `preferCopy` is true each agent is COPIED as a real file rather than
767
- * symlinked (the in-repo standalone install — so a standalone user's CLI finds
768
- * real agent files in the repo, not links into `$HOME`).
587
+ * Remove the role artifacts an older Core linked or copied into a workspace:
588
+ * - claude, gemini: `agents/sr-*.md` (symlink or copy-fallback regular file)
589
+ * - codex: `skills/rails/sr-*` and the `rails/` container once empty
590
+ * - kimi: `skills/sr-*`
769
591
  *
770
- * Returns the dominant mechanism used across the linked files (`copy` if any
771
- * file fell back to copy — the normal case on Windows without Developer Mode, or
772
- * always when `preferCopy` is set).
592
+ * Keyed strictly by NAME, never by content: `custom-*` (the reserved
593
+ * user/Desktop region) and any unknown name are left byte-untouched. A
594
+ * subtree that is itself a symlink into the shared framework store is skipped —
595
+ * the store is Core-owned and rebuilt by `installFramework`, so pruning through
596
+ * the link would only reach into another version's files.
773
597
  */
774
- function linkAgentFiles(frameworkAgentsDir, workspaceAgentsDir, selectedIds, preferCopy = false) {
775
- mkdirp(workspaceAgentsDir);
776
- // Names the framework currently PROVIDES (regardless of selection) — used to
777
- // distinguish a framework-owned file from a user `custom-*.md` during cleanup.
778
- const frameworkProvided = new Set();
779
- // Names actually LINKED this pass (the selected subset).
780
- const linkedNames = new Set();
781
- let mechanism = 'symlink';
782
- for (const src of listDir(frameworkAgentsDir)) {
783
- const name = path.basename(src);
784
- if (!name.endsWith('.md'))
785
- continue;
786
- frameworkProvided.add(name);
787
- const id = name.slice(0, -3);
788
- if (selectedIds && !selectedIds.has(id))
789
- continue;
790
- linkedNames.add(name);
791
- const m = symlinkOrCopy(src, path.join(workspaceAgentsDir, name), preferCopy);
792
- if (m === 'copy')
793
- mechanism = 'copy';
794
- else if (m === 'junction' && mechanism !== 'copy')
795
- mechanism = 'junction';
796
- }
797
- // Drop STALE framework artifacts in the workspace agents dir — both prior-
798
- // version symlinks AND copy-fallback files (Windows) that are no longer linked
799
- // this pass (a dropped agent, or one deselected). NEVER remove a user file:
800
- // `custom-*.md` and agent-memory are reserved. The discriminator is "the
801
- // framework owns this name (it's currently provided OR it was a previous
802
- // framework link/copy that the framework no longer provides)" — we approximate
803
- // it as: remove any entry NOT in `linkedNames` that is either a symlink (old
804
- // framework link) OR a NON-custom framework-shaped file the framework once
805
- // provided. `custom-*.md` is always skipped.
806
- for (const existing of listDir(workspaceAgentsDir)) {
807
- const name = path.basename(existing);
808
- if (linkedNames.has(name))
809
- continue;
810
- if (name.startsWith('custom-'))
811
- continue; // reserved user agent — never touch
812
- if (isSymlink(existing)) {
813
- // A prior framework symlink no longer selected/provided → stale, drop it.
814
- removePath(existing);
815
- continue;
816
- }
817
- // A copy-fallback framework file (Windows): a non-symlink `.md` that the
818
- // framework provides (or provided) but is not a user custom agent. Remove it
819
- // so a version swap or a deselect cleans up the copied agent. Files the
820
- // framework never provided (genuine user agents) are left untouched.
821
- if (name.endsWith('.md') && (frameworkProvided.has(name) || isFrameworkAgentName(name))) {
822
- removePath(existing);
598
+ export function pruneStaleRoleArtifacts(workspaceProviderDir, provider) {
599
+ const prune = (dir, isStale) => {
600
+ if (!isDir(dir) || isSymlink(dir))
601
+ return;
602
+ for (const entry of listDir(dir)) {
603
+ if (isStale(entry))
604
+ removePath(entry);
823
605
  }
606
+ };
607
+ if (provider === 'claude' || provider === 'gemini') {
608
+ prune(path.join(workspaceProviderDir, 'agents'), (entry) => {
609
+ const name = path.basename(entry);
610
+ return name.endsWith('.md') && FRAMEWORK_ROLE_ID.test(name.slice(0, -3)) && !isDir(entry);
611
+ });
612
+ return;
824
613
  }
825
- return mechanism;
614
+ if (provider === 'codex') {
615
+ const skills = path.join(workspaceProviderDir, 'skills');
616
+ if (isSymlink(skills))
617
+ return;
618
+ const rails = path.join(skills, 'rails');
619
+ prune(rails, (entry) => FRAMEWORK_ROLE_ID.test(path.basename(entry)));
620
+ if (isDir(rails) && !isSymlink(rails) && listDir(rails).length === 0)
621
+ removePath(rails);
622
+ return;
623
+ }
624
+ prune(path.join(workspaceProviderDir, 'skills'), (entry) => FRAMEWORK_ROLE_ID.test(path.basename(entry)));
826
625
  }
827
626
  /**
828
627
  * Assemble Kimi skills without turning the whole directory into a symlink.
829
628
  * Kimi's loader inspects only immediate children of `.kimi-code/skills`, so
830
- * workflows (`specrails-*`), OpenSpec skills (`openspec-*`), managed roles
831
- * (`sr-*`), and user roles (`custom-*`) all share this flat directory.
629
+ * workflows (`specrails-*`), OpenSpec skills (`openspec-*`) and user roles
630
+ * (`custom-*`) all share this flat directory. Every framework skill is linked;
832
631
  * OpenSpec and custom/unknown skills must survive every update.
833
632
  */
834
- function linkKimiSkillDirectories(frameworkSkillsDir, workspaceSkillsDir, selectedRoleIds, preferCopy) {
633
+ function linkKimiSkillDirectories(frameworkSkillsDir, workspaceSkillsDir, preferCopy) {
835
634
  mkdirp(workspaceSkillsDir);
836
635
  const linkedFrameworkNames = new Set();
837
636
  let mechanism = 'symlink';
@@ -845,9 +644,6 @@ function linkKimiSkillDirectories(frameworkSkillsDir, workspaceSkillsDir, select
845
644
  // it, but never expose nested roles if a caller supplies one directly.
846
645
  continue;
847
646
  }
848
- if (/^sr-[a-z0-9-]+$/.test(name) && !selectedRoleIds.has(name)) {
849
- continue;
850
- }
851
647
  linkedFrameworkNames.add(name);
852
648
  const used = symlinkOrCopy(source, path.join(workspaceSkillsDir, name), preferCopy);
853
649
  if (used === 'copy')
@@ -855,13 +651,14 @@ function linkKimiSkillDirectories(frameworkSkillsDir, workspaceSkillsDir, select
855
651
  else if (used === 'junction' && mechanism !== 'copy')
856
652
  mechanism = 'junction';
857
653
  }
858
- // `specrails-*` workflows and `sr-*` roles are framework-owned. OpenSpec,
859
- // custom-* and unknown/user skill directories remain outside this boundary.
654
+ // `specrails-*` workflows are framework-owned, and `sr-*` roles were until
655
+ // Core 6.3 — both are strays when not linked this pass. OpenSpec, custom-*
656
+ // and unknown/user skill directories remain outside this boundary.
860
657
  for (const existing of listDir(workspaceSkillsDir)) {
861
658
  const name = path.basename(existing);
862
659
  if (linkedFrameworkNames.has(name))
863
660
  continue;
864
- if (name.startsWith('specrails-') || /^sr-[a-z0-9-]+$/.test(name)) {
661
+ if (name.startsWith('specrails-') || FRAMEWORK_ROLE_ID.test(name)) {
865
662
  removePath(existing);
866
663
  }
867
664
  }
@@ -883,7 +680,7 @@ function migrateLegacyKimiRoleLayout(skillsDir) {
883
680
  if (!isDir(source))
884
681
  continue;
885
682
  const id = path.basename(source);
886
- if (/^sr-[a-z0-9-]+$/.test(id)) {
683
+ if (FRAMEWORK_ROLE_ID.test(id)) {
887
684
  removePath(source);
888
685
  continue;
889
686
  }
@@ -906,16 +703,6 @@ function migrateLegacyKimiRoleLayout(skillsDir) {
906
703
  if (listDir(legacyRolesDir).length === 0)
907
704
  removePath(legacyRolesDir);
908
705
  }
909
- /**
910
- * True when `name` (an `<id>.md`) matches a framework-owned agent id (`sr-*`).
911
- * Used to identify a stale COPY-fallback framework agent on Windows that the
912
- * current framework version no longer provides, so it can be cleaned up on a
913
- * version swap. `custom-*.md` (handled by the caller) and any non-`sr-` user
914
- * file are deliberately excluded.
915
- */
916
- function isFrameworkAgentName(name) {
917
- return /^sr-[a-z0-9-]+\.md$/.test(name);
918
- }
919
706
  /**
920
707
  * Install Core's self-contained Kimi headless skill runner and the vendored
921
708
  * js-yaml parser used by upstream Kimi 0.27. These files are provider-static
@@ -1039,21 +826,6 @@ function writeKimiWorkflowSkill(args) {
1039
826
  ].join('\n');
1040
827
  writeFileLf(args.dest, frontmatter + translateClaudeTextForKimi(renderPlaceholders(body, values)));
1041
828
  }
1042
- function writeKimiRoleSkill(args) {
1043
- if (!pathExists(args.src))
1044
- return;
1045
- const { body, description } = stripFrontmatter(readTextFile(args.src));
1046
- const values = { PROJECT_NAME: args.projectName };
1047
- const frontmatter = [
1048
- '---',
1049
- `name: ${args.roleId}`,
1050
- `description: ${JSON.stringify(translateClaudeTextForKimi(renderPlaceholders(description ?? `SpecRails ${args.roleId} role for Kimi Code.`, values)))}`,
1051
- 'type: prompt',
1052
- '---',
1053
- '',
1054
- ].join('\n');
1055
- writeFileLf(args.dest, frontmatter + translateClaudeTextForKimi(renderPlaceholders(body, values)));
1056
- }
1057
829
  /**
1058
830
  * Strip a leading `---`-delimited YAML frontmatter block and return the
1059
831
  * remaining body plus the `description:` value if present. Defensive
@@ -1082,8 +854,8 @@ function stripFrontmatter(raw) {
1082
854
  /**
1083
855
  * Convert a claude slash-command markdown file into a gemini custom command TOML
1084
856
  * (`.gemini/commands/specrails/<name>.toml`). Gemini commands carry ONLY
1085
- * `prompt` + `description` (no per-command tool/model keys — that routing lives
1086
- * in the subagent frontmatter). Keeps gemini's native `/specrails:<name>` slash
857
+ * `prompt` + `description` (no per-command tool/model keys). Keeps gemini's
858
+ * native `/specrails:<name>` slash
1087
859
  * form (unlike codex's `$name`); only `.claude/` paths are rewritten.
1088
860
  */
1089
861
  function writeGeminiCommandFromCommand(args) {
@@ -1105,137 +877,6 @@ function writeGeminiCommandFromCommand(args) {
1105
877
  }
1106
878
  writeFileLf(args.dest, `description = ${JSON.stringify(description)}\n${promptToml}`);
1107
879
  }
1108
- /**
1109
- * Emit a gemini subagent (`.gemini/agents/<id>.md`) from a claude persona
1110
- * template. Re-emits gemini YAML frontmatter (name/description/model/tools),
1111
- * dropping claude `color:`/`memory:`; the persona body is reused, `.claude/`
1112
- * paths rewritten.
1113
- */
1114
- function writeGeminiAgentFromTemplate(args) {
1115
- if (!pathExists(args.src))
1116
- return;
1117
- const { body, description } = stripFrontmatter(readTextFile(args.src));
1118
- const model = GEMINI_MODEL_BY_AGENT[args.agentId] ?? GEMINI_DEFAULT_MODEL;
1119
- const frontmatter = [
1120
- '---',
1121
- `name: ${args.agentId}`,
1122
- `description: ${JSON.stringify(description ?? args.agentId)}`,
1123
- `model: ${model}`,
1124
- `tools: [${GEMINI_AGENT_TOOLS.join(', ')}]`,
1125
- ...geminiAgentLimitMetadata(),
1126
- '---',
1127
- '',
1128
- ].join('\n');
1129
- const renderedBody = translateOpsxSkillCallsForGemini(renderPlaceholders(body, args.placeholders).replace(/\.claude\//g, '.gemini/'));
1130
- writeFileLf(path.join(args.artifactRoot, '.gemini', 'agents', `${args.agentId}.md`), frontmatter + renderedBody);
1131
- if (args.seedProjectDirs !== false) {
1132
- mkdirp(path.join(args.artifactRoot, '.gemini', 'agent-memory', args.agentId));
1133
- }
1134
- }
1135
- /**
1136
- * Place the gemini subagents under `.gemini/agents/` from the staged persona
1137
- * templates (both tiers). Honours the agent selection; defaults to CORE_AGENTS.
1138
- */
1139
- function placeGeminiAgents(input) {
1140
- const result = { placed: 0, skipped: 0, filesCopied: 0 };
1141
- const agentsSrc = path.join(input.artifactRoot, '.specrails', 'setup-templates', 'agents');
1142
- if (!isDir(agentsSrc))
1143
- return result;
1144
- mkdirp(path.join(input.artifactRoot, '.gemini', 'agents'));
1145
- const selectedAgents = input.selectedAgents
1146
- ? new Set([...input.selectedAgents, ...CORE_AGENTS])
1147
- : new Set([...CORE_AGENTS]);
1148
- const placeholders = { PROJECT_NAME: path.basename(input.codeRoot) };
1149
- const placedIds = [];
1150
- for (const src of listDir(agentsSrc)) {
1151
- const name = path.basename(src);
1152
- if (!name.endsWith('.md'))
1153
- continue;
1154
- const agentId = name.slice(0, -3);
1155
- // Superset materialization (installFramework) places EVERY agent; per-project
1156
- // filtering happens at the workspace LINK step (linkAgentFiles).
1157
- if (!input.materializeAllAgents && !selectedAgents.has(agentId))
1158
- continue;
1159
- writeGeminiAgentFromTemplate({
1160
- artifactRoot: input.artifactRoot,
1161
- src,
1162
- agentId,
1163
- placeholders,
1164
- seedProjectDirs: input.seedProjectDirs,
1165
- });
1166
- placedIds.push(agentId);
1167
- result.placed++;
1168
- result.filesCopied++;
1169
- }
1170
- // The pre-acknowledgment is a PER-WORKSPACE seed (keyed on codeRoot, hashing the
1171
- // workspace's linked agent files). It is skipped when materializing the shared
1172
- // framework — `assembleProjectWorkspace` re-writes it against the LINKED files.
1173
- if (input.seedProjectDirs !== false) {
1174
- try {
1175
- // Key the acknowledgment on the real repo (codeRoot) so gemini matches the
1176
- // project, but hash the agent files from the relocated artifactRoot.
1177
- writeGeminiAgentAcknowledgments(input.codeRoot, placedIds, input.artifactRoot);
1178
- }
1179
- catch (err) {
1180
- warn(`gemini agent pre-acknowledgment skipped: ${err.message}`);
1181
- }
1182
- }
1183
- return result;
1184
- }
1185
- /**
1186
- * Pre-acknowledge the generated gemini subagents so they load in HEADLESS
1187
- * (`gemini -p`) runs. gemini 0.46+ DISCOVERS `.gemini/agents/*.md` but only
1188
- * ENABLES a project's custom agents after the interactive "New Agents Discovered
1189
- * → Acknowledge and Enable" prompt — which never fires headless, so
1190
- * `invoke_agent sr-architect` returns "Subagent not found" and the implement
1191
- * orchestrator silently falls back to a generic agent (the specialised personas
1192
- * never run, the pipeline degrades). The acknowledgment is a user-global file
1193
- * `~/.gemini/acknowledgments/agents.json` shaped
1194
- * `{ [projectRoot]: { [agentName]: <sha256-hex of the agent .md file> } }`
1195
- * (hash algorithm verified empirically against gemini 0.47 = sha256 of the full
1196
- * file). Writing it at install time makes the freshly-generated agents trusted
1197
- * with no prompt, for both `gemini` CLI and the desktop's headless spawns. The
1198
- * file is MERGED — other projects' and other agents' entries are preserved.
1199
- * Best-effort: any failure is swallowed by the caller (agents still work once
1200
- * acknowledged interactively).
1201
- */
1202
- export function writeGeminiAgentAcknowledgments(repoRoot, agentIds, agentsBaseDir = repoRoot) {
1203
- if (agentIds.length === 0)
1204
- return;
1205
- const ackPath = path.join(os.homedir(), '.gemini', 'acknowledgments', 'agents.json');
1206
- let store = {};
1207
- if (pathExists(ackPath)) {
1208
- try {
1209
- const parsed = JSON.parse(readTextFile(ackPath));
1210
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1211
- store = parsed;
1212
- }
1213
- }
1214
- catch {
1215
- // Corrupt/unreadable file — start fresh rather than crash the install.
1216
- }
1217
- }
1218
- // The store is KEYED on `agentsBaseDir` — the directory gemini ACTUALLY runs
1219
- // in when it resolves the project's agents. Under relocation the linked agents
1220
- // live in the WORKSPACE (rails spawn with cwd=workspace), so the ack must be
1221
- // keyed on the workspace providerDir base, not the repo; otherwise headless
1222
- // `gemini -p` looks up `store[<workspace>]`, finds nothing, and the specialised
1223
- // personas never load. The agent FILES are hashed from `agentsBaseDir` too
1224
- // (read through the workspace symlinks ⇒ framework file content). When
1225
- // `agentsBaseDir` defaults to `repoRoot` (legacy in-repo layout, 2-arg call)
1226
- // the key is byte-identical to before.
1227
- const ackKey = agentsBaseDir;
1228
- const projectEntry = { ...(store[ackKey] ?? {}) };
1229
- for (const agentId of agentIds) {
1230
- const agentFile = path.join(agentsBaseDir, '.gemini', 'agents', `${agentId}.md`);
1231
- if (!pathExists(agentFile))
1232
- continue;
1233
- projectEntry[agentId] = createHash('sha256').update(readTextFile(agentFile)).digest('hex');
1234
- }
1235
- store[ackKey] = projectEntry;
1236
- mkdirp(path.dirname(ackPath));
1237
- writeFileLf(ackPath, `${JSON.stringify(store, null, 2)}\n`);
1238
- }
1239
880
  /** Recursive JSON object merge; source wins on scalars/arrays. */
1240
881
  function deepMergeJson(target, source) {
1241
882
  const out = { ...target };
@@ -1444,9 +1085,9 @@ function pruneLegacyArtifacts(input) {
1444
1085
  }
1445
1086
  /**
1446
1087
  * Direct placement of the staged templates (`.specrails/setup-templates/`)
1447
- * into the live provider directory. Claude receives agents + slash commands;
1448
- * Codex receives each command as a skill; Gemini receives TOML commands. Kimi
1449
- * roles and workflows are rendered as skills by `placeSkills`.
1088
+ * into the live provider directory. Claude receives slash commands; Codex
1089
+ * receives each command as a skill; Gemini receives TOML commands. Kimi
1090
+ * workflows are rendered as skills by `placeSkills`.
1450
1091
  */
1451
1092
  function placeArtefacts(input) {
1452
1093
  const setupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
@@ -1455,49 +1096,27 @@ function placeArtefacts(input) {
1455
1096
  const commandSources = isDir(commandsSrc) ? listDir(commandsSrc).filter((src) => src.endsWith('.md')) : [];
1456
1097
  const commandName = (src) => path.basename(src).slice(0, -3);
1457
1098
  if (input.provider === 'kimi')
1458
- return { agents: 0, commands: 0 };
1099
+ return { commands: 0 };
1459
1100
  if (input.provider === 'codex') {
1460
1101
  for (const src of commandSources) {
1461
1102
  writeCodexSkillFromCommand({ src, dest: path.join(providerDirAbs, 'skills', commandName(src), 'SKILL.md'), name: commandName(src) });
1462
1103
  }
1463
- return { agents: 0, commands: commandSources.length };
1104
+ return { commands: commandSources.length };
1464
1105
  }
1465
1106
  if (input.provider === 'gemini') {
1466
1107
  for (const src of commandSources) {
1467
1108
  writeGeminiCommandFromCommand({ src, dest: path.join(providerDirAbs, 'commands', 'specrails', `${commandName(src)}.toml`) });
1468
1109
  }
1469
- return { agents: 0, commands: commandSources.length };
1110
+ return { commands: commandSources.length };
1470
1111
  }
1471
1112
  // PROJECT_NAME is the real repo's basename, not the relocated workspace dir.
1472
1113
  const placeholders = { PROJECT_NAME: path.basename(input.codeRoot) };
1473
- const agentsSrc = path.join(setupTemplates, 'agents');
1474
- const agentsDest = path.join(providerDirAbs, 'agents');
1475
- const selectedAgents = new Set([...(input.selectedAgents ?? []), ...CORE_AGENTS]);
1476
- let agentsPlaced = 0;
1477
- if (isDir(agentsSrc)) {
1478
- mkdirp(agentsDest);
1479
- for (const src of listDir(agentsSrc)) {
1480
- const name = path.basename(src);
1481
- if (!name.endsWith('.md'))
1482
- continue;
1483
- const agentId = name.slice(0, -3);
1484
- // Superset materialization (installFramework) places EVERY agent so any
1485
- // project's selection can later link from the shared store.
1486
- if (!input.materializeAllAgents && !selectedAgents.has(agentId))
1487
- continue;
1488
- writeFileLf(path.join(agentsDest, name), renderPlaceholders(readTextFile(src), placeholders));
1489
- agentsPlaced++;
1490
- // agent-memory is per-workspace mutable state, never part of the shared framework copy.
1491
- if (input.seedProjectDirs !== false)
1492
- mkdirp(path.join(input.artifactRoot, '.claude', 'agent-memory', agentId));
1493
- }
1494
- }
1495
1114
  const commandsDest = path.join(providerDirAbs, 'commands', 'specrails');
1496
1115
  mkdirp(commandsDest);
1497
1116
  for (const src of commandSources) {
1498
1117
  writeFileLf(path.join(commandsDest, path.basename(src)), renderPlaceholders(readTextFile(src), placeholders));
1499
1118
  }
1500
- return { agents: agentsPlaced, commands: commandSources.length };
1119
+ return { commands: commandSources.length };
1501
1120
  }
1502
1121
  /**
1503
1122
  * Codex-specific provider settings. Writes `.codex/config.toml` (model +
@@ -1596,8 +1215,13 @@ function upsertAgentsMdManagedBlock(existing, managedBlock) {
1596
1215
  // that the command path ports to `.codex/skills/<name>/` (with codex-native
1597
1216
  // overrides), so the codex user invokes `$implement` etc. A claude-shaped
1598
1217
  // `$sr-implement` port would be redundant AND broken (its `Skill()` /
1599
- // `subagent_type` calls have no codex equivalent). Codex DOES get the rails
1600
- // subtree below, sourced from `templates/codex-skills/rails/`.
1218
+ // `subagent_type` calls have no codex equivalent).
1219
+ //
1220
+ // GEMINI: nothing beyond the TOML commands placed by `placeArtefacts`.
1221
+ //
1222
+ // KIMI: every workflow command is rendered as a `specrails-<command>` skill.
1223
+ //
1224
+ // No provider receives role files: roles are runtime-defined.
1601
1225
  function placeSkills(input) {
1602
1226
  const destBase = path.join(input.artifactRoot, input.providerDir, 'skills');
1603
1227
  const result = { placed: 0, skipped: 0, filesCopied: 0 };
@@ -1619,69 +1243,10 @@ function placeSkills(input) {
1619
1243
  result.filesCopied++;
1620
1244
  }
1621
1245
  }
1622
- // Rail skills are a CODEX-ONLY concern. Codex doesn't honour Claude's
1623
- // `.claude/agents/` convention, so each agent role ships as a codex-native
1624
- // SKILL.md under `templates/codex-skills/rails/<name>/` that the codex
1625
- // orchestrator invokes via spawn_agent / $-mention. On Claude the pipeline
1626
- // launches `.claude/agents/sr-*.md` directly via `subagent_type`, so no
1627
- // claude-shape rail skill is placed (the former templates/skills/rails/
1628
- // copies were vestigial — unused on Claude, always overridden on codex,
1629
- // and shipped with unsubstituted placeholders — and were removed).
1630
- //
1631
- // Only the three CORE_AGENTS ship, so only their rail skills exist to place.
1632
- const codexRailsOverridesDir = input.provider === 'codex'
1633
- ? path.join(input.scriptDir, 'templates', 'codex-skills', 'rails')
1634
- : null;
1635
- if (codexRailsOverridesDir && isDir(codexRailsOverridesDir)) {
1636
- const destRails = path.join(destBase, 'rails');
1637
- mkdirp(destRails);
1638
- // Honour the wizard's agent selection. Without a selection (fresh install
1639
- // with no install-config), default to the three core agents only.
1640
- const selectedAgents = input.selectedAgents
1641
- ? new Set([...input.selectedAgents, ...CORE_AGENTS])
1642
- : new Set([...CORE_AGENTS]);
1643
- for (const entry of listDir(codexRailsOverridesDir)) {
1644
- if (!isDir(entry))
1645
- continue;
1646
- const skillId = path.basename(entry);
1647
- if (!selectedAgents.has(skillId)) {
1648
- result.skipped++;
1649
- continue;
1650
- }
1651
- if (!pathExists(path.join(entry, 'SKILL.md')))
1652
- continue;
1653
- const dest = path.join(destRails, skillId);
1654
- copyDir(entry, dest);
1655
- result.placed++;
1656
- result.filesCopied += countFiles(dest);
1657
- }
1658
- }
1659
- // Gemini: place the `sr-*` subagents under .gemini/agents/ (both tiers).
1660
- if (input.provider === 'gemini') {
1661
- const g = placeGeminiAgents(input);
1662
- result.placed += g.placed;
1663
- result.skipped += g.skipped;
1664
- result.filesCopied += g.filesCopied;
1665
- }
1666
1246
  if (input.provider === 'kimi') {
1667
1247
  const setupRoot = path.join(input.artifactRoot, '.specrails', 'setup-templates');
1668
1248
  const commandsSrc = path.join(setupRoot, 'commands', 'specrails');
1669
- const agentsSrc = path.join(setupRoot, 'agents');
1670
- const selectedAgents = new Set([...(input.selectedAgents ?? []), ...CORE_AGENTS]);
1671
1249
  const projectName = path.basename(input.codeRoot);
1672
- for (const src of isDir(agentsSrc) ? listDir(agentsSrc) : []) {
1673
- const name = path.basename(src);
1674
- if (!name.endsWith('.md'))
1675
- continue;
1676
- const roleId = name.slice(0, -3);
1677
- if (!input.materializeAllAgents && !selectedAgents.has(roleId))
1678
- continue;
1679
- writeKimiRoleSkill({ src, dest: path.join(destBase, roleId, 'SKILL.md'), roleId, projectName });
1680
- result.placed++;
1681
- result.filesCopied++;
1682
- if (input.seedProjectDirs !== false)
1683
- mkdirp(path.join(input.artifactRoot, input.providerDir, 'agent-memory', roleId));
1684
- }
1685
1250
  for (const src of isDir(commandsSrc) ? listDir(commandsSrc) : []) {
1686
1251
  const name = path.basename(src);
1687
1252
  if (!name.endsWith('.md'))