specrails-core 4.8.1 → 4.9.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/bin/specrails-core.mjs +5 -1
- package/bin/tui-installer.mjs +87 -65
- package/dist/installer/cli.js +46 -6
- package/dist/installer/cli.js.map +1 -1
- package/dist/installer/commands/doctor.js +14 -5
- package/dist/installer/commands/doctor.js.map +1 -1
- package/dist/installer/commands/framework.js +134 -0
- package/dist/installer/commands/framework.js.map +1 -0
- package/dist/installer/commands/init.js +107 -32
- package/dist/installer/commands/init.js.map +1 -1
- package/dist/installer/commands/update.js +60 -34
- package/dist/installer/commands/update.js.map +1 -1
- package/dist/installer/phases/scaffold.js +592 -67
- package/dist/installer/phases/scaffold.js.map +1 -1
- package/dist/installer/util/fs.js +143 -1
- package/dist/installer/util/fs.js.map +1 -1
- package/dist/installer/util/registry.js +339 -0
- package/dist/installer/util/registry.js.map +1 -0
- package/package.json +1 -1
- package/pinned-versions.json +1 -1
- package/templates/agents/sr-architect.md +14 -10
- package/templates/agents/sr-backend-developer.md +4 -2
- package/templates/agents/sr-developer.md +20 -8
- package/templates/agents/sr-frontend-developer.md +4 -2
- package/templates/agents/sr-reviewer.md +10 -6
- package/templates/codex-skills/implement/SKILL.md +19 -10
- package/templates/codex-skills/rails/sr-architect/SKILL.md +17 -8
- package/templates/codex-skills/rails/sr-backend-developer/SKILL.md +4 -1
- package/templates/codex-skills/rails/sr-developer/SKILL.md +13 -4
- package/templates/codex-skills/rails/sr-doc-sync/SKILL.md +3 -2
- package/templates/codex-skills/rails/sr-frontend-developer/SKILL.md +4 -1
- package/templates/codex-skills/rails/sr-product-manager/SKILL.md +9 -7
- package/templates/codex-skills/rails/sr-reviewer/SKILL.md +13 -7
- package/templates/codex-skills/retry/SKILL.md +10 -5
- package/templates/commands/specrails/implement.md +41 -23
- package/templates/commands/specrails/retry.md +3 -1
- package/templates/gemini-commands/implement.toml +76 -21
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { rmSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
2
4
|
import path from 'node:path';
|
|
3
|
-
import { copyDir, copyFile, isDir, listDir, mkdirp, pathExists, readTextFile, writeFileLf } from '../util/fs.js';
|
|
5
|
+
import { atomicSymlinkSwap, copyDir, copyFile, isDir, isSymlink, listDir, mkdirp, pathExists, readTextFile, removePath, symlinkOrCopy, writeFileLf, } from '../util/fs.js';
|
|
4
6
|
import { info, ok, warn } from '../util/logger.js';
|
|
7
|
+
import { buildManifest, writeManifestFiles } from './manifest.js';
|
|
5
8
|
/**
|
|
6
9
|
* The three baseline agents that every specrails install requires.
|
|
7
10
|
* These are the only agents guaranteed to be present — the implement
|
|
@@ -26,8 +29,50 @@ const QUICK_EXCLUDED_AGENTS = new Set(['sr-product-manager', 'sr-product-analyst
|
|
|
26
29
|
* (Validated headless in the desktop spike — read/write/shell/glob/grep.) Because
|
|
27
30
|
* gemini TOML commands cannot carry per-command tool/model routing, the tool +
|
|
28
31
|
* model gating migrates into the subagent frontmatter.
|
|
32
|
+
*
|
|
33
|
+
* `activate_skill` is mandatory: the architect/developer/reviewer personas open
|
|
34
|
+
* with a NON-NEGOTIABLE OpenSpec skill call (`opsx:ff`/`apply`/`archive`). Gemini
|
|
35
|
+
* exposes skills through the `activate_skill` tool — without it in the tools list
|
|
36
|
+
* the agent halts with "the required `Skill` tool is not available" and the only
|
|
37
|
+
* way the pipeline ever completed was the orchestrator hand-patching the agent
|
|
38
|
+
* file mid-run. See `translateOpsxSkillCallsForGemini` for the body half.
|
|
39
|
+
*/
|
|
40
|
+
const GEMINI_AGENT_TOOLS = ['read_file', 'write_file', 'run_shell_command', 'glob', 'search_file_content', 'activate_skill'];
|
|
41
|
+
/**
|
|
42
|
+
* Claude `Skill("opsx:<id>")` → Gemini `activate_skill(name="<skill>")` id map.
|
|
43
|
+
* The agent persona templates are authored in Claude form (the shared source of
|
|
44
|
+
* truth across providers); Gemini invokes the same OpenSpec workflow skills under
|
|
45
|
+
* a different tool name and skill-directory names. The mapping is NOT a uniform
|
|
46
|
+
* `-change` suffix (`sync` → `*-sync-specs`, `explore`/`onboard` have none), so it
|
|
47
|
+
* must be explicit. Keys mirror the skill directories scaffolded under
|
|
48
|
+
* `.gemini/skills/openspec-*`.
|
|
49
|
+
*/
|
|
50
|
+
const OPSX_TO_GEMINI_SKILL = {
|
|
51
|
+
ff: 'openspec-ff-change',
|
|
52
|
+
new: 'openspec-new-change',
|
|
53
|
+
apply: 'openspec-apply-change',
|
|
54
|
+
continue: 'openspec-continue-change',
|
|
55
|
+
archive: 'openspec-archive-change',
|
|
56
|
+
'bulk-archive': 'openspec-bulk-archive-change',
|
|
57
|
+
sync: 'openspec-sync-specs',
|
|
58
|
+
verify: 'openspec-verify-change',
|
|
59
|
+
explore: 'openspec-explore',
|
|
60
|
+
onboard: 'openspec-onboard',
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Rewrite every literal `Skill("opsx:<id>"[, …])` call in a Claude-authored agent
|
|
64
|
+
* body into the Gemini `activate_skill(name="…")` form. Positional skill input
|
|
65
|
+
* (e.g. `"<specName>"`) is dropped because `activate_skill` takes only `name` and
|
|
66
|
+
* the surrounding persona prose already carries the context. Unknown ids are left
|
|
67
|
+
* untouched (better a visible stale ref than a silent `name="undefined"`). This
|
|
68
|
+
* runs ONLY on the gemini render path; Claude/Codex keep the `Skill(...)` form.
|
|
29
69
|
*/
|
|
30
|
-
|
|
70
|
+
export function translateOpsxSkillCallsForGemini(body) {
|
|
71
|
+
return body.replace(/Skill\("opsx:([a-z-]+)"(?:\s*,[^)]*)?\)/g, (match, id) => {
|
|
72
|
+
const skill = OPSX_TO_GEMINI_SKILL[id];
|
|
73
|
+
return skill ? `activate_skill(name="${skill}")` : match;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
31
76
|
/**
|
|
32
77
|
* Per-role gemini model. Defaults to `gemini-3.5-flash` — the stable flagship
|
|
33
78
|
* (June 2026): strong agentic/coding, high quota, and unlike `gemini-2.5-pro`
|
|
@@ -40,6 +85,15 @@ const GEMINI_MODEL_BY_AGENT = {
|
|
|
40
85
|
'sr-reviewer': 'gemini-3.5-flash',
|
|
41
86
|
};
|
|
42
87
|
const GEMINI_DEFAULT_MODEL = 'gemini-3.5-flash';
|
|
88
|
+
// NOTE: do NOT emit a `max_turns` (or `maxTurns`/`runConfig`) key in the gemini
|
|
89
|
+
// agent frontmatter. Although gemini's documented agent schema lists `max_turns`,
|
|
90
|
+
// the 0.46 runtime loader REJECTS a `.gemini/agents/*.md` file that carries it —
|
|
91
|
+
// the agent silently fails to register and `invoke_agent` reports "Subagent
|
|
92
|
+
// '<name>' not found", so the orchestrator falls back to a generic agent and the
|
|
93
|
+
// specialised personas never run. Verified empirically (two identical agents,
|
|
94
|
+
// one with `max_turns: 40` → not found, one without → loads). The 30-turn default
|
|
95
|
+
// cap is instead absorbed by the implement.toml MAX_TURNS → re-delegate/resume
|
|
96
|
+
// contract. Re-introduce only if a future gemini build is reconfirmed to accept it.
|
|
43
97
|
/**
|
|
44
98
|
* Skills excluded from the quick tier because they depend on
|
|
45
99
|
* VPC-only agents (sr-product-manager, sr-product-analyst).
|
|
@@ -103,6 +157,21 @@ const COMMAND_AGENT_DEPENDENCIES = [
|
|
|
103
157
|
* directory alongside the per-agent memory dirs.
|
|
104
158
|
*/
|
|
105
159
|
const EXPLANATION_AUTHORS = new Set(['sr-architect', 'sr-reviewer']);
|
|
160
|
+
/**
|
|
161
|
+
* Provider-static subtrees inside a providerDir that are SHARED via symlink from
|
|
162
|
+
* the framework copy into each workspace. `agent-memory/` is deliberately absent
|
|
163
|
+
* — it is mutable per-workspace state seeded as a real dir, never linked.
|
|
164
|
+
*
|
|
165
|
+
* The root instruction file (`CLAUDE.md`/`AGENTS.md`/`GEMINI.md`) and the codex
|
|
166
|
+
* `config.toml` / gemini `settings.json` carry the project name / a deep-merge
|
|
167
|
+
* with the user's file, so they are SEEDED per-workspace (not linked) by
|
|
168
|
+
* `assembleProjectWorkspace`.
|
|
169
|
+
*/
|
|
170
|
+
const LINKED_PROVIDER_SUBTREES = {
|
|
171
|
+
claude: ['agents', 'commands', 'skills', 'rules'],
|
|
172
|
+
codex: ['skills'],
|
|
173
|
+
gemini: ['agents', 'commands'],
|
|
174
|
+
};
|
|
106
175
|
/**
|
|
107
176
|
* Returns true iff any of the provider directories already contains
|
|
108
177
|
* content. The desktop-app-driven path skips the "merge existing?" prompt and
|
|
@@ -111,10 +180,11 @@ const EXPLANATION_AUTHORS = new Set(['sr-architect', 'sr-reviewer']);
|
|
|
111
180
|
*/
|
|
112
181
|
export function detectExistingSetup(input) {
|
|
113
182
|
const roots = [
|
|
114
|
-
path.join(input.
|
|
115
|
-
path.join(input.
|
|
116
|
-
path.join(input.
|
|
117
|
-
|
|
183
|
+
path.join(input.artifactRoot, input.providerDir, 'agents'),
|
|
184
|
+
path.join(input.artifactRoot, input.providerDir, 'commands'),
|
|
185
|
+
path.join(input.artifactRoot, input.providerDir, 'rules'),
|
|
186
|
+
// openspec stays in the repo (codeRoot), not the relocated artifact root.
|
|
187
|
+
path.join(input.codeRoot, 'openspec'),
|
|
118
188
|
];
|
|
119
189
|
for (const r of roots) {
|
|
120
190
|
if (isDir(r) && listDir(r).length > 0)
|
|
@@ -134,26 +204,26 @@ export function scaffoldInstallation(input) {
|
|
|
134
204
|
createdDirs.push(abs);
|
|
135
205
|
};
|
|
136
206
|
// --- Directory skeleton ---
|
|
137
|
-
mk(path.join(input.
|
|
207
|
+
mk(path.join(input.artifactRoot, input.providerDir));
|
|
138
208
|
if (input.provider === 'codex') {
|
|
139
209
|
// Codex skills live under <providerDir>/skills/ (e.g. .codex/skills/).
|
|
140
210
|
// The pre-§18 code wrote to `.agents/skills/` which codex doesn't read;
|
|
141
211
|
// that was a placeholder name from the gated state.
|
|
142
|
-
mk(path.join(input.
|
|
143
|
-
mk(path.join(input.
|
|
144
|
-
mk(path.join(input.
|
|
212
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'skills', 'enrich'));
|
|
213
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'skills', 'doctor'));
|
|
214
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'skills', 'rails'));
|
|
145
215
|
}
|
|
146
216
|
else if (input.provider === 'gemini') {
|
|
147
217
|
// Gemini: TOML commands under .gemini/commands/specrails/ + native
|
|
148
218
|
// subagents under .gemini/agents/. No skills/ tree.
|
|
149
|
-
mk(path.join(input.
|
|
150
|
-
mk(path.join(input.
|
|
219
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails'));
|
|
220
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'agents'));
|
|
151
221
|
}
|
|
152
222
|
else {
|
|
153
|
-
mk(path.join(input.
|
|
154
|
-
mk(path.join(input.
|
|
223
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails'));
|
|
224
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'skills'));
|
|
155
225
|
}
|
|
156
|
-
const setupTemplates = path.join(input.
|
|
226
|
+
const setupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
|
|
157
227
|
mk(path.join(setupTemplates, 'agents'));
|
|
158
228
|
mk(path.join(setupTemplates, 'commands'));
|
|
159
229
|
mk(path.join(setupTemplates, 'skills'));
|
|
@@ -162,10 +232,16 @@ export function scaffoldInstallation(input) {
|
|
|
162
232
|
mk(path.join(setupTemplates, 'claude-md'));
|
|
163
233
|
mk(path.join(setupTemplates, 'settings'));
|
|
164
234
|
// --- .gitignore hygiene ---
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
235
|
+
// Under relocate-always (artifactRoot !== codeRoot) NOTHING Specrails-owned
|
|
236
|
+
// lands in the repo, so there is nothing to ignore — the gitignore step is a
|
|
237
|
+
// guarded no-op. It only runs in the legacy in-repo layout where the two roots
|
|
238
|
+
// coincide.
|
|
239
|
+
if (input.artifactRoot === input.codeRoot) {
|
|
240
|
+
const gitignoreEntries = ['.claude/agent-memory/', '.specrails/'];
|
|
241
|
+
if (input.provider === 'gemini')
|
|
242
|
+
gitignoreEntries.push('.gemini/agent-memory/');
|
|
243
|
+
ensureGitignore(input.codeRoot, gitignoreEntries);
|
|
244
|
+
}
|
|
169
245
|
// --- Copy bundled templates into setup-templates/ ---
|
|
170
246
|
const templatesSrc = path.join(input.scriptDir, 'templates');
|
|
171
247
|
if (pathExists(templatesSrc)) {
|
|
@@ -234,13 +310,362 @@ export function scaffoldInstallation(input) {
|
|
|
234
310
|
ok(`Created ${createdDirs.length} directories, copied ${copiedFiles} files`);
|
|
235
311
|
return {
|
|
236
312
|
existingSetup: detectExistingSetup({
|
|
237
|
-
|
|
313
|
+
artifactRoot: input.artifactRoot,
|
|
314
|
+
codeRoot: input.codeRoot,
|
|
238
315
|
providerDir: input.providerDir,
|
|
239
316
|
}),
|
|
240
317
|
createdDirs,
|
|
241
318
|
copiedFiles,
|
|
242
319
|
};
|
|
243
320
|
}
|
|
321
|
+
/** Path to the per-version, per-provider materialization marker (manifest hash). */
|
|
322
|
+
function frameworkStampPath(versionDir, providerDir) {
|
|
323
|
+
// Store the stamp OUTSIDE the providerDir so it never leaks into the linked
|
|
324
|
+
// subtree. `.stamp-<providerDir>.json` is provider-keyed.
|
|
325
|
+
return path.join(versionDir, `.framework-stamp${providerDir}.json`);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Materialize the provider-INVARIANT framework subtree ONCE into
|
|
329
|
+
* `<frameworkDir>/<version>/<providerDir>/` (+ `<version>/setup-templates/`).
|
|
330
|
+
* Idempotent: when the providerDir already exists with a matching stamp it is a
|
|
331
|
+
* no-op (the second workspace assemble re-uses the same copy). Writes NO
|
|
332
|
+
* per-workspace state (no agent-memory, no acks, no project-named instruction
|
|
333
|
+
* files) — those are seeded by `assembleProjectWorkspace`.
|
|
334
|
+
*/
|
|
335
|
+
export function installFramework(input) {
|
|
336
|
+
const versionDir = path.join(input.frameworkDir, input.version);
|
|
337
|
+
const providerFrameworkDir = path.join(versionDir, input.providerDir);
|
|
338
|
+
const stampPath = frameworkStampPath(versionDir, input.providerDir);
|
|
339
|
+
// Idempotency: existing materialization with a matching stamp → skip.
|
|
340
|
+
if (isDir(providerFrameworkDir) && pathExists(stampPath)) {
|
|
341
|
+
return { providerFrameworkDir, versionDir, materialized: false };
|
|
342
|
+
}
|
|
343
|
+
// Reuse scaffoldInstallation's static-placement helpers by pointing
|
|
344
|
+
// `artifactRoot` at the version dir. `seedProjectDirs: false` keeps the copy
|
|
345
|
+
// free of per-workspace mutable state. The `codeRoot` is irrelevant to the
|
|
346
|
+
// STATIC subtree (the project-named instruction files are skipped below), so
|
|
347
|
+
// we hand it the framework dir to satisfy the contract — and we DELETE any
|
|
348
|
+
// project-named instruction file the settings helpers wrote.
|
|
349
|
+
// The SHARED framework store is always the FULL SUPERSET — EVERY agent and the
|
|
350
|
+
// team commands — so a SECOND project with a DIFFERENT agent selection links
|
|
351
|
+
// its specialists from the same materialized copy instead of inheriting the
|
|
352
|
+
// first project's narrower set. Per-project filtering moves to the workspace
|
|
353
|
+
// LINK step (`linkAgentFiles` via `assembleProjectWorkspace`). `selectedAgents`
|
|
354
|
+
// / `agentTeams` on the input are intentionally IGNORED here.
|
|
355
|
+
const staticInput = {
|
|
356
|
+
scriptDir: input.scriptDir,
|
|
357
|
+
artifactRoot: versionDir,
|
|
358
|
+
codeRoot: versionDir,
|
|
359
|
+
provider: input.provider,
|
|
360
|
+
providerDir: input.providerDir,
|
|
361
|
+
agentTeams: true,
|
|
362
|
+
tier: 'quick',
|
|
363
|
+
selectedAgents: undefined,
|
|
364
|
+
materializeAllAgents: true,
|
|
365
|
+
seedProjectDirs: false,
|
|
366
|
+
};
|
|
367
|
+
scaffoldInstallation(staticInput);
|
|
368
|
+
// The settings helpers also emit a project-named root instruction file
|
|
369
|
+
// (AGENTS.md/GEMINI.md/CLAUDE.md) + (for codex) config.toml / (gemini)
|
|
370
|
+
// settings.json. The instruction file is per-project → strip it from the
|
|
371
|
+
// shared copy; the settings file IS provider-invariant and stays as a
|
|
372
|
+
// link target inside the providerDir.
|
|
373
|
+
for (const f of ['AGENTS.md', 'GEMINI.md', 'CLAUDE.md']) {
|
|
374
|
+
rmSync(path.join(versionDir, f), { force: true });
|
|
375
|
+
}
|
|
376
|
+
writeFileLf(stampPath, `${JSON.stringify({ version: input.version, provider: input.provider, at: new Date().toISOString() }, null, 2)}\n`);
|
|
377
|
+
return { providerFrameworkDir, versionDir, materialized: true };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Atomically point `<frameworkDir>/current` at `<version>` so every workspace's
|
|
381
|
+
* provider links resolve through `current/...` and an update is a single swap.
|
|
382
|
+
*/
|
|
383
|
+
export function ensureCurrentSymlink(frameworkDir, version) {
|
|
384
|
+
const currentPath = path.join(frameworkDir, 'current');
|
|
385
|
+
const versionDir = path.join(frameworkDir, version);
|
|
386
|
+
mkdirp(frameworkDir);
|
|
387
|
+
atomicSymlinkSwap(versionDir, currentPath);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Assemble a project workspace with NO network and NO re-materialization: (a)
|
|
391
|
+
* SYMLINK the static providerDir subtrees from `<frameworkDir>/current/
|
|
392
|
+
* <providerDir>/` into `<workspace>/<providerDir>/`, then (b) seed the PROJECT
|
|
393
|
+
* layer as real writable files (agent-memory dirs, the manifest, project-named
|
|
394
|
+
* instruction/settings files, gemini headless acks re-hashed against the LINKED
|
|
395
|
+
* files). `agent-memory/` is NEVER linked.
|
|
396
|
+
*/
|
|
397
|
+
export function assembleProjectWorkspace(input) {
|
|
398
|
+
const currentProviderDir = path.join(input.frameworkDir, 'current', input.providerDir);
|
|
399
|
+
const workspaceProviderDir = path.join(input.workspace, input.providerDir);
|
|
400
|
+
mkdirp(workspaceProviderDir);
|
|
401
|
+
// (a) Link the static subtrees that exist in the framework copy.
|
|
402
|
+
//
|
|
403
|
+
// `agents/` is linked PER-FILE (a real workspace dir holding one symlink per
|
|
404
|
+
// framework agent) so the workspace can also carry user/desktop `custom-*.md`
|
|
405
|
+
// agents — a RESERVED region the installer must never touch. Every other
|
|
406
|
+
// subtree (`commands/`, `skills/`, `rules/`) holds no user files and is linked
|
|
407
|
+
// as a whole directory (cheapest, single inode).
|
|
408
|
+
// Per-project AGENT selection: link only the selected framework agents (∪ the
|
|
409
|
+
// CORE trio, minus the quick-excluded product agents) — the shared store holds
|
|
410
|
+
// the full superset, so a project's narrower pick links a SUBSET. Undefined ⇒
|
|
411
|
+
// CORE trio only. `custom-*.md` is always preserved (reserved path).
|
|
412
|
+
const selectedAgentSet = input.selectedAgents
|
|
413
|
+
? new Set([...input.selectedAgents, ...CORE_AGENTS])
|
|
414
|
+
: new Set([...CORE_AGENTS]);
|
|
415
|
+
const agentTeams = input.agentTeams ?? false;
|
|
416
|
+
const links = {};
|
|
417
|
+
for (const sub of LINKED_PROVIDER_SUBTREES[input.provider]) {
|
|
418
|
+
const target = path.join(currentProviderDir, sub);
|
|
419
|
+
if (!pathExists(target))
|
|
420
|
+
continue;
|
|
421
|
+
const dest = path.join(workspaceProviderDir, sub);
|
|
422
|
+
if (sub === 'agents') {
|
|
423
|
+
links[sub] = linkAgentFiles(target, dest, selectedAgentSet);
|
|
424
|
+
}
|
|
425
|
+
else if (!agentTeams && subtreeHasTeamEntries(target)) {
|
|
426
|
+
// Lean install AND the superset store actually carries `team-*` entries:
|
|
427
|
+
// link this subtree PER-FILE, excluding the team commands/skills. The
|
|
428
|
+
// common case (no team-* in the store) keeps the cheap whole-dir symlink
|
|
429
|
+
// below — preserving the single-inode contract.
|
|
430
|
+
links[sub] = linkSubtreeExcludingTeams(target, dest);
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
links[sub] = symlinkOrCopy(target, dest);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// Link the provider-invariant settings file (codex config.toml / gemini
|
|
437
|
+
// settings.json) when the framework has one and the user has not authored a
|
|
438
|
+
// local override in the workspace.
|
|
439
|
+
const settingsFile = input.provider === 'codex' ? 'config.toml' : input.provider === 'gemini' ? 'settings.json' : null;
|
|
440
|
+
if (settingsFile) {
|
|
441
|
+
const settingsTarget = path.join(currentProviderDir, settingsFile);
|
|
442
|
+
const settingsLink = path.join(workspaceProviderDir, settingsFile);
|
|
443
|
+
if (pathExists(settingsTarget) && !pathExists(settingsLink)) {
|
|
444
|
+
links[settingsFile] = symlinkOrCopy(settingsTarget, settingsLink);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// (b) Seed the PROJECT layer (real writable files / dirs).
|
|
448
|
+
const seededMemoryAgents = seedProjectLayer(input, currentProviderDir);
|
|
449
|
+
// Manifest: record the consumed framework version. `buildManifest` hashes the
|
|
450
|
+
// package's templates/ + commands (provenance), written under the workspace.
|
|
451
|
+
const manifest = buildManifest({
|
|
452
|
+
scriptDir: input.scriptDir,
|
|
453
|
+
repoRoot: input.workspace,
|
|
454
|
+
version: input.version,
|
|
455
|
+
});
|
|
456
|
+
writeManifestFiles(input.workspace, manifest);
|
|
457
|
+
return { links, seededMemoryAgents };
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Seed the per-workspace PROJECT layer: real agent-memory dirs (+ explanations/),
|
|
461
|
+
* the project-named instruction file, and — for gemini — the headless
|
|
462
|
+
* acknowledgments re-hashed against the LINKED agent files. Returns the agent
|
|
463
|
+
* ids whose memory dirs were created.
|
|
464
|
+
*/
|
|
465
|
+
function seedProjectLayer(input, currentProviderDir) {
|
|
466
|
+
const selected = input.selectedAgents
|
|
467
|
+
? new Set([...input.selectedAgents, ...CORE_AGENTS])
|
|
468
|
+
: new Set([...CORE_AGENTS]);
|
|
469
|
+
// Discover which agents the framework actually placed (so memory dirs match
|
|
470
|
+
// the linked agent set), intersected with the selection.
|
|
471
|
+
const agentsLinkDir = path.join(currentProviderDir, 'agents');
|
|
472
|
+
const placedAgentIds = [];
|
|
473
|
+
if (isDir(agentsLinkDir)) {
|
|
474
|
+
for (const entry of listDir(agentsLinkDir)) {
|
|
475
|
+
const name = path.basename(entry);
|
|
476
|
+
if (!name.endsWith('.md'))
|
|
477
|
+
continue;
|
|
478
|
+
const id = name.slice(0, -3);
|
|
479
|
+
if (selected.has(id) && !QUICK_EXCLUDED_AGENTS.has(id))
|
|
480
|
+
placedAgentIds.push(id);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const seededMemoryAgents = [];
|
|
484
|
+
if (input.provider === 'claude') {
|
|
485
|
+
for (const id of placedAgentIds) {
|
|
486
|
+
mkdirp(path.join(input.workspace, '.claude', 'agent-memory', id));
|
|
487
|
+
seededMemoryAgents.push(id);
|
|
488
|
+
if (EXPLANATION_AUTHORS.has(id)) {
|
|
489
|
+
mkdirp(path.join(input.workspace, '.claude', 'agent-memory', 'explanations'));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else if (input.provider === 'gemini') {
|
|
494
|
+
for (const id of placedAgentIds) {
|
|
495
|
+
mkdirp(path.join(input.workspace, '.gemini', 'agent-memory', id));
|
|
496
|
+
seededMemoryAgents.push(id);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// Project-named instruction file (codex AGENTS.md / gemini GEMINI.md). Reuse
|
|
500
|
+
// the same sentinel-upsert helpers via the settings appliers, scoped so they
|
|
501
|
+
// ONLY emit the instruction file (the settings file is already linked above).
|
|
502
|
+
if (input.provider === 'codex') {
|
|
503
|
+
seedInstructionFile(path.join(input.workspace, 'AGENTS.md'), renderInitialAgentsMd(input.codeRoot));
|
|
504
|
+
}
|
|
505
|
+
else if (input.provider === 'gemini') {
|
|
506
|
+
seedInstructionFile(path.join(input.workspace, 'GEMINI.md'), renderInitialGeminiMd(input.codeRoot));
|
|
507
|
+
// Gemini headless acks: hash the LINKED agent files (read through the
|
|
508
|
+
// symlink) keyed on the real repo so `gemini -p` trusts them with no prompt.
|
|
509
|
+
try {
|
|
510
|
+
writeGeminiAgentAcknowledgments(input.codeRoot, placedAgentIds, input.workspace);
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
warn(`gemini agent pre-acknowledgment skipped: ${err.message}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return seededMemoryAgents;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Per-file link the framework `agents/` into a REAL workspace `agents/` dir.
|
|
520
|
+
* Keeps `custom-*.md` (and any other user-authored file that the framework does
|
|
521
|
+
* NOT provide) byte-untouched — the reserved-paths contract — while pointing
|
|
522
|
+
* every SELECTED framework-owned agent at the shared read-only copy.
|
|
523
|
+
*
|
|
524
|
+
* `selectedIds` is the per-project agent allow-list (already unioned with the
|
|
525
|
+
* CORE trio by the caller). Only framework agents whose id is in it AND not in
|
|
526
|
+
* `QUICK_EXCLUDED_AGENTS` are linked — the shared framework store is the full
|
|
527
|
+
* superset, so this is where per-project filtering lands. `undefined` ⇒ link
|
|
528
|
+
* every framework agent (used by the legacy callers / parity tests).
|
|
529
|
+
*
|
|
530
|
+
* Returns the dominant mechanism used across the linked files (`copy` if any
|
|
531
|
+
* file fell back to copy — the normal case on Windows without Developer Mode).
|
|
532
|
+
*/
|
|
533
|
+
function linkAgentFiles(frameworkAgentsDir, workspaceAgentsDir, selectedIds) {
|
|
534
|
+
mkdirp(workspaceAgentsDir);
|
|
535
|
+
// Names the framework currently PROVIDES (regardless of selection) — used to
|
|
536
|
+
// distinguish a framework-owned file from a user `custom-*.md` during cleanup.
|
|
537
|
+
const frameworkProvided = new Set();
|
|
538
|
+
// Names actually LINKED this pass (the selected subset).
|
|
539
|
+
const linkedNames = new Set();
|
|
540
|
+
let mechanism = 'symlink';
|
|
541
|
+
for (const src of listDir(frameworkAgentsDir)) {
|
|
542
|
+
const name = path.basename(src);
|
|
543
|
+
if (!name.endsWith('.md'))
|
|
544
|
+
continue;
|
|
545
|
+
frameworkProvided.add(name);
|
|
546
|
+
const id = name.slice(0, -3);
|
|
547
|
+
if (selectedIds && (!selectedIds.has(id) || QUICK_EXCLUDED_AGENTS.has(id)))
|
|
548
|
+
continue;
|
|
549
|
+
linkedNames.add(name);
|
|
550
|
+
const m = symlinkOrCopy(src, path.join(workspaceAgentsDir, name));
|
|
551
|
+
if (m === 'copy')
|
|
552
|
+
mechanism = 'copy';
|
|
553
|
+
else if (m === 'junction' && mechanism !== 'copy')
|
|
554
|
+
mechanism = 'junction';
|
|
555
|
+
}
|
|
556
|
+
// Drop STALE framework artifacts in the workspace agents dir — both prior-
|
|
557
|
+
// version symlinks AND copy-fallback files (Windows) that are no longer linked
|
|
558
|
+
// this pass (a dropped agent, or one deselected). NEVER remove a user file:
|
|
559
|
+
// `custom-*.md` and agent-memory are reserved. The discriminator is "the
|
|
560
|
+
// framework owns this name (it's currently provided OR it was a previous
|
|
561
|
+
// framework link/copy that the framework no longer provides)" — we approximate
|
|
562
|
+
// it as: remove any entry NOT in `linkedNames` that is either a symlink (old
|
|
563
|
+
// framework link) OR a NON-custom framework-shaped file the framework once
|
|
564
|
+
// provided. `custom-*.md` is always skipped.
|
|
565
|
+
for (const existing of listDir(workspaceAgentsDir)) {
|
|
566
|
+
const name = path.basename(existing);
|
|
567
|
+
if (linkedNames.has(name))
|
|
568
|
+
continue;
|
|
569
|
+
if (name.startsWith('custom-'))
|
|
570
|
+
continue; // reserved user agent — never touch
|
|
571
|
+
if (isSymlink(existing)) {
|
|
572
|
+
// A prior framework symlink no longer selected/provided → stale, drop it.
|
|
573
|
+
removePath(existing);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
// A copy-fallback framework file (Windows): a non-symlink `.md` that the
|
|
577
|
+
// framework provides (or provided) but is not a user custom agent. Remove it
|
|
578
|
+
// so a version swap or a deselect cleans up the copied agent. Files the
|
|
579
|
+
// framework never provided (genuine user agents) are left untouched.
|
|
580
|
+
if (name.endsWith('.md') && (frameworkProvided.has(name) || isFrameworkAgentName(name))) {
|
|
581
|
+
removePath(existing);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return mechanism;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* True when `name` (an `<id>.md`) matches a framework-owned agent id (`sr-*`).
|
|
588
|
+
* Used to identify a stale COPY-fallback framework agent on Windows that the
|
|
589
|
+
* current framework version no longer provides, so it can be cleaned up on a
|
|
590
|
+
* version swap. `custom-*.md` (handled by the caller) and any non-`sr-` user
|
|
591
|
+
* file are deliberately excluded.
|
|
592
|
+
*/
|
|
593
|
+
function isFrameworkAgentName(name) {
|
|
594
|
+
return /^sr-[a-z0-9-]+\.md$/.test(name);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* True when a framework subtree (`commands`/`skills`) contains any `team-*`
|
|
598
|
+
* entry (a `team-*.md` file or a `team-*` skill dir), recursively. Gates the
|
|
599
|
+
* per-file team-excluding link path: when no team entries exist the workspace
|
|
600
|
+
* keeps the cheap whole-dir symlink. Recurses into real subdirs (e.g.
|
|
601
|
+
* `.claude/commands/specrails/`).
|
|
602
|
+
*/
|
|
603
|
+
function subtreeHasTeamEntries(subtreeDir) {
|
|
604
|
+
for (const entry of listDir(subtreeDir)) {
|
|
605
|
+
const name = path.basename(entry);
|
|
606
|
+
if (/^team-/.test(name) || /^team-/.test(name.replace(/\.md$/, '')))
|
|
607
|
+
return true;
|
|
608
|
+
if (isDir(entry) && !isSymlink(entry) && subtreeHasTeamEntries(entry))
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Link a whole framework subtree (`commands`/`skills`) into the workspace
|
|
615
|
+
* PER-FILE, EXCLUDING the Agent-Teams `team-*` entries. Used when `agentTeams`
|
|
616
|
+
* is off and the shared framework store (always the superset) carries the team
|
|
617
|
+
* commands the lean install must not surface. Recurses into subdirs (e.g.
|
|
618
|
+
* `.claude/commands/specrails/`). Returns the dominant mechanism.
|
|
619
|
+
*/
|
|
620
|
+
function linkSubtreeExcludingTeams(frameworkSubtreeDir, workspaceSubtreeDir) {
|
|
621
|
+
mkdirp(workspaceSubtreeDir);
|
|
622
|
+
let mechanism = 'symlink';
|
|
623
|
+
const bump = (m) => {
|
|
624
|
+
if (m === 'copy')
|
|
625
|
+
mechanism = 'copy';
|
|
626
|
+
else if (m === 'junction' && mechanism !== 'copy')
|
|
627
|
+
mechanism = 'junction';
|
|
628
|
+
};
|
|
629
|
+
const linkedNames = new Set();
|
|
630
|
+
for (const src of listDir(frameworkSubtreeDir)) {
|
|
631
|
+
const name = path.basename(src);
|
|
632
|
+
// Exclude team commands/skills whether they ship as `team-*.md` files or
|
|
633
|
+
// `team-*/` skill dirs.
|
|
634
|
+
if (/^team-/.test(name) || /^team-/.test(name.replace(/\.md$/, '')))
|
|
635
|
+
continue;
|
|
636
|
+
linkedNames.add(name);
|
|
637
|
+
const dest = path.join(workspaceSubtreeDir, name);
|
|
638
|
+
if (isDir(src) && !isSymlink(src)) {
|
|
639
|
+
// Recurse: a real framework subdir is mirrored as a real workspace subdir
|
|
640
|
+
// so a future agentTeams=false re-link can prune team-* inside it too.
|
|
641
|
+
bump(linkSubtreeExcludingTeams(src, dest));
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
bump(symlinkOrCopy(src, dest));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Drop stale framework entries (including team-* left from a prior agentTeams
|
|
648
|
+
// run) that are no longer linked. Only symlinks/copied framework files — there
|
|
649
|
+
// are no user files under commands/skills.
|
|
650
|
+
for (const existing of listDir(workspaceSubtreeDir)) {
|
|
651
|
+
const name = path.basename(existing);
|
|
652
|
+
if (linkedNames.has(name))
|
|
653
|
+
continue;
|
|
654
|
+
removePath(existing);
|
|
655
|
+
}
|
|
656
|
+
return mechanism;
|
|
657
|
+
}
|
|
658
|
+
/** Write or sentinel-upsert a project instruction file (AGENTS.md/GEMINI.md). */
|
|
659
|
+
function seedInstructionFile(filePath, content) {
|
|
660
|
+
if (!pathExists(filePath)) {
|
|
661
|
+
writeFileLf(filePath, content);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const existing = readTextFile(filePath);
|
|
665
|
+
const next = upsertAgentsMdManagedBlock(existing, extractManagedBlock(content));
|
|
666
|
+
if (next !== existing)
|
|
667
|
+
writeFileLf(filePath, next);
|
|
668
|
+
}
|
|
244
669
|
function copyBundledCommands(input) {
|
|
245
670
|
const commandsSrc = path.join(input.scriptDir, 'commands');
|
|
246
671
|
if (!isDir(commandsSrc))
|
|
@@ -261,7 +686,7 @@ function copyBundledCommands(input) {
|
|
|
261
686
|
if (!input.agentTeams && /^team-/.test(name))
|
|
262
687
|
continue;
|
|
263
688
|
const skillName = name.replace(/\.md$/, '');
|
|
264
|
-
const destDir = path.join(input.
|
|
689
|
+
const destDir = path.join(input.artifactRoot, input.providerDir, 'skills', skillName);
|
|
265
690
|
// A codex-native override (written for spawn_agent semantics + the
|
|
266
691
|
// correct `.codex/skills/rails/` layout) wins over the claude port.
|
|
267
692
|
// This is the ONLY codex command-placement pass in full tier, so
|
|
@@ -286,7 +711,7 @@ function copyBundledCommands(input) {
|
|
|
286
711
|
if (input.provider === 'gemini') {
|
|
287
712
|
// Gemini: each bundled command becomes a TOML custom command under
|
|
288
713
|
// .gemini/commands/specrails/<name>.toml.
|
|
289
|
-
const destDir = path.join(input.
|
|
714
|
+
const destDir = path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails');
|
|
290
715
|
let count = 0;
|
|
291
716
|
for (const entry of listDir(commandsSrc)) {
|
|
292
717
|
const name = path.basename(entry);
|
|
@@ -306,7 +731,7 @@ function copyBundledCommands(input) {
|
|
|
306
731
|
return;
|
|
307
732
|
}
|
|
308
733
|
// Claude: all bundled commands land under <providerDir>/commands/specrails/.
|
|
309
|
-
const destDir = path.join(input.
|
|
734
|
+
const destDir = path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails');
|
|
310
735
|
let count = 0;
|
|
311
736
|
for (const entry of listDir(commandsSrc)) {
|
|
312
737
|
const name = path.basename(entry);
|
|
@@ -476,12 +901,14 @@ function writeGeminiAgentFromTemplate(args) {
|
|
|
476
901
|
'---',
|
|
477
902
|
'',
|
|
478
903
|
].join('\n');
|
|
479
|
-
const renderedBody = renderPlaceholders(body, {
|
|
904
|
+
const renderedBody = translateOpsxSkillCallsForGemini(renderPlaceholders(body, {
|
|
480
905
|
...args.placeholders,
|
|
481
906
|
MEMORY_PATH: `.gemini/agent-memory/${args.agentId}/`,
|
|
482
|
-
}).replace(/\.claude\//g, '.gemini/');
|
|
483
|
-
writeFileLf(path.join(args.
|
|
484
|
-
|
|
907
|
+
}).replace(/\.claude\//g, '.gemini/'));
|
|
908
|
+
writeFileLf(path.join(args.artifactRoot, '.gemini', 'agents', `${args.agentId}.md`), frontmatter + renderedBody);
|
|
909
|
+
if (args.seedProjectDirs !== false) {
|
|
910
|
+
mkdirp(path.join(args.artifactRoot, '.gemini', 'agent-memory', args.agentId));
|
|
911
|
+
}
|
|
485
912
|
}
|
|
486
913
|
/**
|
|
487
914
|
* Place the gemini subagents under `.gemini/agents/` from the staged persona
|
|
@@ -489,35 +916,112 @@ function writeGeminiAgentFromTemplate(args) {
|
|
|
489
916
|
*/
|
|
490
917
|
function placeGeminiAgents(input) {
|
|
491
918
|
const result = { placed: 0, skipped: 0, filesCopied: 0 };
|
|
492
|
-
const agentsSrc = path.join(input.
|
|
919
|
+
const agentsSrc = path.join(input.artifactRoot, '.specrails', 'setup-templates', 'agents');
|
|
493
920
|
if (!isDir(agentsSrc))
|
|
494
921
|
return result;
|
|
495
|
-
mkdirp(path.join(input.
|
|
922
|
+
mkdirp(path.join(input.artifactRoot, '.gemini', 'agents'));
|
|
496
923
|
const selectedAgents = input.selectedAgents
|
|
497
924
|
? new Set([...input.selectedAgents, ...CORE_AGENTS])
|
|
498
925
|
: new Set([...CORE_AGENTS]);
|
|
499
926
|
const placeholders = {
|
|
500
|
-
PROJECT_NAME: path.basename(input.
|
|
927
|
+
PROJECT_NAME: path.basename(input.codeRoot),
|
|
501
928
|
SECURITY_EXEMPTIONS_PATH: '.gemini/security-exemptions.yaml',
|
|
502
929
|
PERSONA_DIR: '.gemini/agents/personas/',
|
|
503
930
|
};
|
|
931
|
+
const placedIds = [];
|
|
504
932
|
for (const src of listDir(agentsSrc)) {
|
|
505
933
|
const name = path.basename(src);
|
|
506
934
|
if (!name.endsWith('.md'))
|
|
507
935
|
continue;
|
|
508
936
|
const agentId = name.slice(0, -3);
|
|
509
|
-
|
|
937
|
+
// Superset materialization (installFramework) places EVERY agent; per-project
|
|
938
|
+
// filtering happens at the workspace LINK step (linkAgentFiles).
|
|
939
|
+
if (!input.materializeAllAgents && !selectedAgents.has(agentId))
|
|
510
940
|
continue;
|
|
511
|
-
if (QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
941
|
+
if (!input.materializeAllAgents && QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
512
942
|
result.skipped++;
|
|
513
943
|
continue;
|
|
514
944
|
}
|
|
515
|
-
writeGeminiAgentFromTemplate({
|
|
945
|
+
writeGeminiAgentFromTemplate({
|
|
946
|
+
artifactRoot: input.artifactRoot,
|
|
947
|
+
src,
|
|
948
|
+
agentId,
|
|
949
|
+
placeholders,
|
|
950
|
+
seedProjectDirs: input.seedProjectDirs,
|
|
951
|
+
});
|
|
952
|
+
placedIds.push(agentId);
|
|
516
953
|
result.placed++;
|
|
517
954
|
result.filesCopied++;
|
|
518
955
|
}
|
|
956
|
+
// The pre-acknowledgment is a PER-WORKSPACE seed (keyed on codeRoot, hashing the
|
|
957
|
+
// workspace's linked agent files). It is skipped when materializing the shared
|
|
958
|
+
// framework — `assembleProjectWorkspace` re-writes it against the LINKED files.
|
|
959
|
+
if (input.seedProjectDirs !== false) {
|
|
960
|
+
try {
|
|
961
|
+
// Key the acknowledgment on the real repo (codeRoot) so gemini matches the
|
|
962
|
+
// project, but hash the agent files from the relocated artifactRoot.
|
|
963
|
+
writeGeminiAgentAcknowledgments(input.codeRoot, placedIds, input.artifactRoot);
|
|
964
|
+
}
|
|
965
|
+
catch (err) {
|
|
966
|
+
warn(`gemini agent pre-acknowledgment skipped: ${err.message}`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
519
969
|
return result;
|
|
520
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* Pre-acknowledge the generated gemini subagents so they load in HEADLESS
|
|
973
|
+
* (`gemini -p`) runs. gemini 0.46+ DISCOVERS `.gemini/agents/*.md` but only
|
|
974
|
+
* ENABLES a project's custom agents after the interactive "New Agents Discovered
|
|
975
|
+
* → Acknowledge and Enable" prompt — which never fires headless, so
|
|
976
|
+
* `invoke_agent sr-architect` returns "Subagent not found" and the implement
|
|
977
|
+
* orchestrator silently falls back to a generic agent (the specialised personas
|
|
978
|
+
* never run, the pipeline degrades). The acknowledgment is a user-global file
|
|
979
|
+
* `~/.gemini/acknowledgments/agents.json` shaped
|
|
980
|
+
* `{ [projectRoot]: { [agentName]: <sha256-hex of the agent .md file> } }`
|
|
981
|
+
* (hash algorithm verified empirically against gemini 0.47 = sha256 of the full
|
|
982
|
+
* file). Writing it at install time makes the freshly-generated agents trusted
|
|
983
|
+
* with no prompt, for both `gemini` CLI and the desktop's headless spawns. The
|
|
984
|
+
* file is MERGED — other projects' and other agents' entries are preserved.
|
|
985
|
+
* Best-effort: any failure is swallowed by the caller (agents still work once
|
|
986
|
+
* acknowledged interactively).
|
|
987
|
+
*/
|
|
988
|
+
export function writeGeminiAgentAcknowledgments(repoRoot, agentIds, agentsBaseDir = repoRoot) {
|
|
989
|
+
if (agentIds.length === 0)
|
|
990
|
+
return;
|
|
991
|
+
const ackPath = path.join(os.homedir(), '.gemini', 'acknowledgments', 'agents.json');
|
|
992
|
+
let store = {};
|
|
993
|
+
if (pathExists(ackPath)) {
|
|
994
|
+
try {
|
|
995
|
+
const parsed = JSON.parse(readTextFile(ackPath));
|
|
996
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
997
|
+
store = parsed;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
// Corrupt/unreadable file — start fresh rather than crash the install.
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
// The store is KEYED on `agentsBaseDir` — the directory gemini ACTUALLY runs
|
|
1005
|
+
// in when it resolves the project's agents. Under relocation the linked agents
|
|
1006
|
+
// live in the WORKSPACE (rails spawn with cwd=workspace), so the ack must be
|
|
1007
|
+
// keyed on the workspace providerDir base, not the repo; otherwise headless
|
|
1008
|
+
// `gemini -p` looks up `store[<workspace>]`, finds nothing, and the specialised
|
|
1009
|
+
// personas never load. The agent FILES are hashed from `agentsBaseDir` too
|
|
1010
|
+
// (read through the workspace symlinks ⇒ framework file content). When
|
|
1011
|
+
// `agentsBaseDir` defaults to `repoRoot` (legacy in-repo layout, 2-arg call)
|
|
1012
|
+
// the key is byte-identical to before.
|
|
1013
|
+
const ackKey = agentsBaseDir;
|
|
1014
|
+
const projectEntry = { ...(store[ackKey] ?? {}) };
|
|
1015
|
+
for (const agentId of agentIds) {
|
|
1016
|
+
const agentFile = path.join(agentsBaseDir, '.gemini', 'agents', `${agentId}.md`);
|
|
1017
|
+
if (!pathExists(agentFile))
|
|
1018
|
+
continue;
|
|
1019
|
+
projectEntry[agentId] = createHash('sha256').update(readTextFile(agentFile)).digest('hex');
|
|
1020
|
+
}
|
|
1021
|
+
store[ackKey] = projectEntry;
|
|
1022
|
+
mkdirp(path.dirname(ackPath));
|
|
1023
|
+
writeFileLf(ackPath, `${JSON.stringify(store, null, 2)}\n`);
|
|
1024
|
+
}
|
|
521
1025
|
/** Recursive JSON object merge; source wins on scalars/arrays. */
|
|
522
1026
|
function deepMergeJson(target, source) {
|
|
523
1027
|
const out = { ...target };
|
|
@@ -541,7 +1045,7 @@ function applyGeminiSettings(input) {
|
|
|
541
1045
|
let written = 0;
|
|
542
1046
|
const settingsSrc = path.join(input.scriptDir, 'templates', 'settings', 'gemini-settings.json');
|
|
543
1047
|
if (pathExists(settingsSrc)) {
|
|
544
|
-
const dest = path.join(input.
|
|
1048
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'settings.json');
|
|
545
1049
|
const template = JSON.parse(readTextFile(settingsSrc));
|
|
546
1050
|
if (pathExists(dest)) {
|
|
547
1051
|
try {
|
|
@@ -558,8 +1062,10 @@ function applyGeminiSettings(input) {
|
|
|
558
1062
|
written++;
|
|
559
1063
|
}
|
|
560
1064
|
}
|
|
561
|
-
const geminiMdPath = path.join(input.
|
|
562
|
-
|
|
1065
|
+
const geminiMdPath = path.join(input.artifactRoot, 'GEMINI.md');
|
|
1066
|
+
// Project name in the rendered body derives from the real repo (codeRoot),
|
|
1067
|
+
// while the file itself lands under the relocated artifactRoot.
|
|
1068
|
+
const content = renderInitialGeminiMd(input.codeRoot);
|
|
563
1069
|
if (!pathExists(geminiMdPath)) {
|
|
564
1070
|
writeFileLf(geminiMdPath, content);
|
|
565
1071
|
written++;
|
|
@@ -600,30 +1106,40 @@ function renderInitialGeminiMd(repoRoot) {
|
|
|
600
1106
|
}
|
|
601
1107
|
function pruneLegacyArtifacts(input) {
|
|
602
1108
|
const legacyPaths = [
|
|
603
|
-
path.join(input.
|
|
604
|
-
path.join(input.
|
|
605
|
-
path.join(input.
|
|
606
|
-
path.join(input.
|
|
1109
|
+
path.join(input.artifactRoot, '.specrails', 'bin', 'doctor.sh'),
|
|
1110
|
+
path.join(input.artifactRoot, '.specrails', 'setup-templates', '.provider-detection.json'),
|
|
1111
|
+
path.join(input.artifactRoot, '.specrails', 'setup-templates', 'settings', 'integration-contract.json'),
|
|
1112
|
+
path.join(input.artifactRoot, '.specrails-version'),
|
|
607
1113
|
];
|
|
608
1114
|
if (input.provider === 'codex') {
|
|
609
1115
|
// Pre-§18 layout used `.agents/skills/` — prune any leftovers from a
|
|
610
1116
|
// legacy install before settling on the canonical `.codex/skills/`.
|
|
611
|
-
legacyPaths.push(path.join(input.
|
|
612
|
-
legacyPaths.push(path.join(input.
|
|
1117
|
+
legacyPaths.push(path.join(input.artifactRoot, '.agents'));
|
|
1118
|
+
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'skills', 'setup'));
|
|
613
1119
|
}
|
|
614
1120
|
else if (input.provider === 'gemini') {
|
|
615
1121
|
// Prune a stale WIP skills/ tree + any setup command leftovers.
|
|
616
|
-
legacyPaths.push(path.join(input.
|
|
617
|
-
legacyPaths.push(path.join(input.
|
|
618
|
-
legacyPaths.push(path.join(input.
|
|
1122
|
+
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'skills'));
|
|
1123
|
+
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'commands', 'setup.toml'));
|
|
1124
|
+
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails', 'setup.toml'));
|
|
619
1125
|
}
|
|
620
1126
|
else {
|
|
621
|
-
legacyPaths.push(path.join(input.
|
|
622
|
-
legacyPaths.push(path.join(input.
|
|
1127
|
+
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'commands', 'setup.md'));
|
|
1128
|
+
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails', 'setup.md'));
|
|
623
1129
|
}
|
|
1130
|
+
// Safety invariant: every prune target MUST live inside artifactRoot. Under
|
|
1131
|
+
// relocate-always artifactRoot is the $HOME workspace, so this guarantees the
|
|
1132
|
+
// installer never rmSync's anything inside the user's repo (codeRoot).
|
|
1133
|
+
const artifactRootResolved = path.resolve(input.artifactRoot);
|
|
624
1134
|
for (const target of legacyPaths) {
|
|
1135
|
+
const resolved = path.resolve(target);
|
|
1136
|
+
const rel = path.relative(artifactRootResolved, resolved);
|
|
1137
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
1138
|
+
warn(`refusing to prune ${target} — outside artifactRoot ${input.artifactRoot}`);
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
625
1141
|
try {
|
|
626
|
-
rmSync(
|
|
1142
|
+
rmSync(resolved, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
627
1143
|
}
|
|
628
1144
|
catch (err) {
|
|
629
1145
|
warn(`failed to prune legacy artifact ${target}: ${err.message}`);
|
|
@@ -652,7 +1168,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
652
1168
|
// command surface (`propose-spec`, `explore-spec`, `retry`, …) as
|
|
653
1169
|
// claude.
|
|
654
1170
|
if (input.provider === 'codex') {
|
|
655
|
-
const setupTemplates = path.join(input.
|
|
1171
|
+
const setupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
|
|
656
1172
|
const commandsSrc = path.join(setupTemplates, 'commands', 'specrails');
|
|
657
1173
|
// Codex-native skill overrides live at `templates/codex-skills/<name>/`.
|
|
658
1174
|
// When one exists for a given slash-command name (e.g. `implement`), the
|
|
@@ -672,7 +1188,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
672
1188
|
if (!input.agentTeams && /^team-/.test(name))
|
|
673
1189
|
continue;
|
|
674
1190
|
const skillName = name.slice(0, -3);
|
|
675
|
-
const dest = path.join(input.
|
|
1191
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'skills', skillName, 'SKILL.md');
|
|
676
1192
|
// If a codex-native override exists, ship it verbatim and skip the
|
|
677
1193
|
// ported claude body entirely. Mirrors a directory copy in case the
|
|
678
1194
|
// override ships sibling assets.
|
|
@@ -698,7 +1214,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
698
1214
|
// <name>.toml. Hand-authored orchestrator overrides (implement,
|
|
699
1215
|
// batch-implement) under templates/gemini-commands/ win verbatim. Agents
|
|
700
1216
|
// are placed by placeSkills → placeGeminiAgents (both tiers).
|
|
701
|
-
const geminiSetupTemplates = path.join(input.
|
|
1217
|
+
const geminiSetupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
|
|
702
1218
|
const commandsSrc = path.join(geminiSetupTemplates, 'commands', 'specrails');
|
|
703
1219
|
const overridesSrc = path.join(input.scriptDir, 'templates', 'gemini-commands');
|
|
704
1220
|
let commandsPlaced = 0;
|
|
@@ -712,7 +1228,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
712
1228
|
if (!input.agentTeams && /^team-/.test(name))
|
|
713
1229
|
continue;
|
|
714
1230
|
const cmdName = name.slice(0, -3);
|
|
715
|
-
const dest = path.join(input.
|
|
1231
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails', `${cmdName}.toml`);
|
|
716
1232
|
const overrideToml = path.join(overridesSrc, `${cmdName}.toml`);
|
|
717
1233
|
if (pathExists(overrideToml)) {
|
|
718
1234
|
copyFile(overrideToml, dest);
|
|
@@ -725,9 +1241,10 @@ function placeQuickTierArtefacts(input) {
|
|
|
725
1241
|
}
|
|
726
1242
|
return { agents: 0, commands: commandsPlaced, rules: 0, skippedAgents: 0 };
|
|
727
1243
|
}
|
|
728
|
-
const setupTemplates = path.join(input.
|
|
729
|
-
|
|
730
|
-
const
|
|
1244
|
+
const setupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
|
|
1245
|
+
// PROJECT_NAME is the real repo's basename, not the relocated workspace dir.
|
|
1246
|
+
const projectName = path.basename(input.codeRoot);
|
|
1247
|
+
const providerDirAbs = path.join(input.artifactRoot, input.providerDir);
|
|
731
1248
|
const placeholders = {
|
|
732
1249
|
PROJECT_NAME: projectName,
|
|
733
1250
|
SECURITY_EXEMPTIONS_PATH: `${input.providerDir}/security-exemptions.yaml`,
|
|
@@ -753,9 +1270,12 @@ function placeQuickTierArtefacts(input) {
|
|
|
753
1270
|
if (!name.endsWith('.md'))
|
|
754
1271
|
continue;
|
|
755
1272
|
const agentId = name.slice(0, -3);
|
|
756
|
-
|
|
1273
|
+
// Superset materialization (installFramework) places EVERY agent so any
|
|
1274
|
+
// project's selection can later link from the shared store; per-project
|
|
1275
|
+
// filtering happens at the workspace LINK step, not here.
|
|
1276
|
+
if (!input.materializeAllAgents && selectedAgents && !selectedAgents.has(agentId))
|
|
757
1277
|
continue;
|
|
758
|
-
if (QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
1278
|
+
if (!input.materializeAllAgents && QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
759
1279
|
agentsSkipped++;
|
|
760
1280
|
continue;
|
|
761
1281
|
}
|
|
@@ -767,11 +1287,16 @@ function placeQuickTierArtefacts(input) {
|
|
|
767
1287
|
writeFileLf(dest, rendered);
|
|
768
1288
|
agentsPlaced++;
|
|
769
1289
|
installedAgentNames.add(agentId);
|
|
770
|
-
// Per-agent memory directory. Created even when empty so
|
|
771
|
-
// the
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1290
|
+
// Per-agent memory directory. Created even when empty so the first run of
|
|
1291
|
+
// the agent doesn't error on ENOENT. Skipped when materializing the SHARED
|
|
1292
|
+
// framework (`seedProjectDirs === false`): agent-memory is per-workspace
|
|
1293
|
+
// mutable state seeded later by `seedProjectLayer`, NEVER part of the
|
|
1294
|
+
// read-only framework copy that workspaces symlink.
|
|
1295
|
+
if (input.seedProjectDirs !== false) {
|
|
1296
|
+
mkdirp(path.join(input.artifactRoot, '.claude', 'agent-memory', agentId));
|
|
1297
|
+
if (EXPLANATION_AUTHORS.has(agentId)) {
|
|
1298
|
+
mkdirp(path.join(input.artifactRoot, '.claude', 'agent-memory', 'explanations'));
|
|
1299
|
+
}
|
|
775
1300
|
}
|
|
776
1301
|
}
|
|
777
1302
|
}
|
|
@@ -844,7 +1369,7 @@ function applyCodexSettings(input) {
|
|
|
844
1369
|
// reasoning_effort etc.
|
|
845
1370
|
const configTomlSrc = path.join(settingsSrc, 'codex-config.toml');
|
|
846
1371
|
if (pathExists(configTomlSrc)) {
|
|
847
|
-
const dest = path.join(input.
|
|
1372
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'config.toml');
|
|
848
1373
|
const rendered = readTextFile(configTomlSrc).replace(/\{\{MODEL_NAME\}\}/g, 'gpt-5.5-mini');
|
|
849
1374
|
writeFileLf(dest, rendered);
|
|
850
1375
|
written++;
|
|
@@ -852,8 +1377,8 @@ function applyCodexSettings(input) {
|
|
|
852
1377
|
// AGENTS.md — top-level instructions file the codex CLI loads on startup.
|
|
853
1378
|
// Written with a sentinel block so update + enrich passes can refresh the
|
|
854
1379
|
// managed content while preserving anything the user added outside it.
|
|
855
|
-
const agentsMdPath = path.join(input.
|
|
856
|
-
const agentsMdContent = renderInitialAgentsMd(input.
|
|
1380
|
+
const agentsMdPath = path.join(input.artifactRoot, 'AGENTS.md');
|
|
1381
|
+
const agentsMdContent = renderInitialAgentsMd(input.codeRoot);
|
|
857
1382
|
if (!pathExists(agentsMdPath)) {
|
|
858
1383
|
writeFileLf(agentsMdPath, agentsMdContent);
|
|
859
1384
|
written++;
|
|
@@ -927,11 +1452,11 @@ function upsertAgentsMdManagedBlock(existing, managedBlock) {
|
|
|
927
1452
|
// `subagent_type` calls have no codex equivalent). Codex DOES get the rails
|
|
928
1453
|
// subtree below, sourced from `templates/codex-skills/rails/`.
|
|
929
1454
|
function placeSkills(input) {
|
|
930
|
-
const destBase = path.join(input.
|
|
1455
|
+
const destBase = path.join(input.artifactRoot, input.providerDir, 'skills');
|
|
931
1456
|
const result = { placed: 0, skipped: 0, filesCopied: 0 };
|
|
932
1457
|
// Top-level skills — Claude only, generated from the canonical command body.
|
|
933
1458
|
if (input.provider === 'claude') {
|
|
934
|
-
const commandsSrc = path.join(input.
|
|
1459
|
+
const commandsSrc = path.join(input.artifactRoot, '.specrails', 'setup-templates', 'commands', 'specrails');
|
|
935
1460
|
const skillEntries = Object.entries(SKILL_FROM_COMMAND);
|
|
936
1461
|
for (const [skillName, spec] of skillEntries) {
|
|
937
1462
|
if (input.tier === 'quick' && QUICK_EXCLUDED_SKILLS.has(skillName)) {
|