specrails-core 6.2.2 → 6.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/agent-runtime/cli.js +1 -1
- package/dist/agent-runtime/compact/environment.d.ts +19 -2
- package/dist/agent-runtime/compact/environment.js +71 -6
- package/dist/agent-runtime/config.js +68 -53
- package/dist/agent-runtime/engine/compiler.js +10 -2
- package/dist/agent-runtime/engine/contracts.d.ts +16 -0
- package/dist/agent-runtime/engine/pieces/control.js +11 -2
- package/dist/agent-runtime/engine/pieces/role-turn.js +35 -2
- package/dist/agent-runtime/engine/pieces/verify.d.ts +10 -3
- package/dist/agent-runtime/engine/pieces/verify.js +107 -30
- package/dist/agent-runtime/executor-types.d.ts +2 -0
- package/dist/agent-runtime/graph/nodes.js +9 -55
- package/dist/agent-runtime/prompts.d.ts +1 -1
- package/dist/agent-runtime/prompts.js +58 -35
- package/dist/agent-runtime/verification-repair.d.ts +26 -0
- package/dist/agent-runtime/verification-repair.js +88 -0
- package/dist/installer/commands/framework.d.ts +7 -3
- package/dist/installer/commands/framework.js +6 -8
- package/dist/installer/commands/init.d.ts +8 -15
- package/dist/installer/commands/init.js +22 -64
- package/dist/installer/phases/install-config.d.ts +8 -2
- package/dist/installer/phases/install-config.js +24 -17
- package/dist/installer/phases/scaffold.d.ts +27 -79
- package/dist/installer/phases/scaffold.js +100 -535
- package/integration-contract.json +7 -10
- package/package.json +1 -1
- package/schemas/agent-runtime.schema.json +9 -7
- package/templates/agents/sr-architect.md +0 -29
- package/templates/agents/sr-developer.md +0 -29
- package/templates/agents/sr-reviewer.md +0 -28
- package/templates/codex-skills/rails/sr-architect/SKILL.md +0 -29
- package/templates/codex-skills/rails/sr-developer/SKILL.md +0 -29
- package/templates/codex-skills/rails/sr-reviewer/SKILL.md +0 -28
|
@@ -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. `
|
|
158
|
-
*
|
|
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: ['
|
|
81
|
+
claude: ['commands', 'skills'],
|
|
167
82
|
codex: ['skills'],
|
|
168
|
-
gemini: ['
|
|
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'
|
|
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/ +
|
|
221
|
-
//
|
|
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.
|
|
281
|
-
info(`Placed ${placed.
|
|
192
|
+
copiedFiles += placed.commands;
|
|
193
|
+
info(`Placed ${placed.commands} command(s) directly into ${input.providerDir}/`);
|
|
282
194
|
}
|
|
283
|
-
// --- Skills placement
|
|
195
|
+
// --- Skills placement ---
|
|
284
196
|
// Claude: top-level `sr-*` skills are generated from their canonical
|
|
285
|
-
// command bodies (single source of truth). Codex:
|
|
286
|
-
// (
|
|
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
|
-
|
|
292
|
-
|
|
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
|
|
440
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
* files). `
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
-
// (
|
|
639
|
-
|
|
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
|
|
538
|
+
return { links };
|
|
651
539
|
}
|
|
652
540
|
/**
|
|
653
|
-
* Seed the per-workspace PROJECT layer:
|
|
654
|
-
*
|
|
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
|
|
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
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
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
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
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
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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-*`)
|
|
831
|
-
* (`
|
|
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,
|
|
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
|
|
859
|
-
//
|
|
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-') ||
|
|
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 (
|
|
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
|
|
1086
|
-
*
|
|
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
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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).
|
|
1600
|
-
//
|
|
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'))
|