specrails-core 4.8.2 → 4.9.1
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/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 +75 -35
- package/dist/installer/commands/update.js.map +1 -1
- package/dist/installer/phases/scaffold.js +493 -73
- 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 +2 -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 +10 -6
|
@@ -2,8 +2,9 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { rmSync } from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
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';
|
|
6
6
|
import { info, ok, warn } from '../util/logger.js';
|
|
7
|
+
import { buildManifest, writeManifestFiles } from './manifest.js';
|
|
7
8
|
/**
|
|
8
9
|
* The three baseline agents that every specrails install requires.
|
|
9
10
|
* These are the only agents guaranteed to be present — the implement
|
|
@@ -156,6 +157,21 @@ const COMMAND_AGENT_DEPENDENCIES = [
|
|
|
156
157
|
* directory alongside the per-agent memory dirs.
|
|
157
158
|
*/
|
|
158
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
|
+
};
|
|
159
175
|
/**
|
|
160
176
|
* Returns true iff any of the provider directories already contains
|
|
161
177
|
* content. The desktop-app-driven path skips the "merge existing?" prompt and
|
|
@@ -164,10 +180,11 @@ const EXPLANATION_AUTHORS = new Set(['sr-architect', 'sr-reviewer']);
|
|
|
164
180
|
*/
|
|
165
181
|
export function detectExistingSetup(input) {
|
|
166
182
|
const roots = [
|
|
167
|
-
path.join(input.
|
|
168
|
-
path.join(input.
|
|
169
|
-
path.join(input.
|
|
170
|
-
|
|
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'),
|
|
171
188
|
];
|
|
172
189
|
for (const r of roots) {
|
|
173
190
|
if (isDir(r) && listDir(r).length > 0)
|
|
@@ -187,26 +204,26 @@ export function scaffoldInstallation(input) {
|
|
|
187
204
|
createdDirs.push(abs);
|
|
188
205
|
};
|
|
189
206
|
// --- Directory skeleton ---
|
|
190
|
-
mk(path.join(input.
|
|
207
|
+
mk(path.join(input.artifactRoot, input.providerDir));
|
|
191
208
|
if (input.provider === 'codex') {
|
|
192
209
|
// Codex skills live under <providerDir>/skills/ (e.g. .codex/skills/).
|
|
193
210
|
// The pre-§18 code wrote to `.agents/skills/` which codex doesn't read;
|
|
194
211
|
// that was a placeholder name from the gated state.
|
|
195
|
-
mk(path.join(input.
|
|
196
|
-
mk(path.join(input.
|
|
197
|
-
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'));
|
|
198
215
|
}
|
|
199
216
|
else if (input.provider === 'gemini') {
|
|
200
217
|
// Gemini: TOML commands under .gemini/commands/specrails/ + native
|
|
201
218
|
// subagents under .gemini/agents/. No skills/ tree.
|
|
202
|
-
mk(path.join(input.
|
|
203
|
-
mk(path.join(input.
|
|
219
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails'));
|
|
220
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'agents'));
|
|
204
221
|
}
|
|
205
222
|
else {
|
|
206
|
-
mk(path.join(input.
|
|
207
|
-
mk(path.join(input.
|
|
223
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails'));
|
|
224
|
+
mk(path.join(input.artifactRoot, input.providerDir, 'skills'));
|
|
208
225
|
}
|
|
209
|
-
const setupTemplates = path.join(input.
|
|
226
|
+
const setupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
|
|
210
227
|
mk(path.join(setupTemplates, 'agents'));
|
|
211
228
|
mk(path.join(setupTemplates, 'commands'));
|
|
212
229
|
mk(path.join(setupTemplates, 'skills'));
|
|
@@ -215,10 +232,16 @@ export function scaffoldInstallation(input) {
|
|
|
215
232
|
mk(path.join(setupTemplates, 'claude-md'));
|
|
216
233
|
mk(path.join(setupTemplates, 'settings'));
|
|
217
234
|
// --- .gitignore hygiene ---
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
}
|
|
222
245
|
// --- Copy bundled templates into setup-templates/ ---
|
|
223
246
|
const templatesSrc = path.join(input.scriptDir, 'templates');
|
|
224
247
|
if (pathExists(templatesSrc)) {
|
|
@@ -287,13 +310,362 @@ export function scaffoldInstallation(input) {
|
|
|
287
310
|
ok(`Created ${createdDirs.length} directories, copied ${copiedFiles} files`);
|
|
288
311
|
return {
|
|
289
312
|
existingSetup: detectExistingSetup({
|
|
290
|
-
|
|
313
|
+
artifactRoot: input.artifactRoot,
|
|
314
|
+
codeRoot: input.codeRoot,
|
|
291
315
|
providerDir: input.providerDir,
|
|
292
316
|
}),
|
|
293
317
|
createdDirs,
|
|
294
318
|
copiedFiles,
|
|
295
319
|
};
|
|
296
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
|
+
}
|
|
297
669
|
function copyBundledCommands(input) {
|
|
298
670
|
const commandsSrc = path.join(input.scriptDir, 'commands');
|
|
299
671
|
if (!isDir(commandsSrc))
|
|
@@ -314,7 +686,7 @@ function copyBundledCommands(input) {
|
|
|
314
686
|
if (!input.agentTeams && /^team-/.test(name))
|
|
315
687
|
continue;
|
|
316
688
|
const skillName = name.replace(/\.md$/, '');
|
|
317
|
-
const destDir = path.join(input.
|
|
689
|
+
const destDir = path.join(input.artifactRoot, input.providerDir, 'skills', skillName);
|
|
318
690
|
// A codex-native override (written for spawn_agent semantics + the
|
|
319
691
|
// correct `.codex/skills/rails/` layout) wins over the claude port.
|
|
320
692
|
// This is the ONLY codex command-placement pass in full tier, so
|
|
@@ -339,7 +711,7 @@ function copyBundledCommands(input) {
|
|
|
339
711
|
if (input.provider === 'gemini') {
|
|
340
712
|
// Gemini: each bundled command becomes a TOML custom command under
|
|
341
713
|
// .gemini/commands/specrails/<name>.toml.
|
|
342
|
-
const destDir = path.join(input.
|
|
714
|
+
const destDir = path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails');
|
|
343
715
|
let count = 0;
|
|
344
716
|
for (const entry of listDir(commandsSrc)) {
|
|
345
717
|
const name = path.basename(entry);
|
|
@@ -359,7 +731,7 @@ function copyBundledCommands(input) {
|
|
|
359
731
|
return;
|
|
360
732
|
}
|
|
361
733
|
// Claude: all bundled commands land under <providerDir>/commands/specrails/.
|
|
362
|
-
const destDir = path.join(input.
|
|
734
|
+
const destDir = path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails');
|
|
363
735
|
let count = 0;
|
|
364
736
|
for (const entry of listDir(commandsSrc)) {
|
|
365
737
|
const name = path.basename(entry);
|
|
@@ -533,8 +905,10 @@ function writeGeminiAgentFromTemplate(args) {
|
|
|
533
905
|
...args.placeholders,
|
|
534
906
|
MEMORY_PATH: `.gemini/agent-memory/${args.agentId}/`,
|
|
535
907
|
}).replace(/\.claude\//g, '.gemini/'));
|
|
536
|
-
writeFileLf(path.join(args.
|
|
537
|
-
|
|
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
|
+
}
|
|
538
912
|
}
|
|
539
913
|
/**
|
|
540
914
|
* Place the gemini subagents under `.gemini/agents/` from the staged persona
|
|
@@ -542,15 +916,15 @@ function writeGeminiAgentFromTemplate(args) {
|
|
|
542
916
|
*/
|
|
543
917
|
function placeGeminiAgents(input) {
|
|
544
918
|
const result = { placed: 0, skipped: 0, filesCopied: 0 };
|
|
545
|
-
const agentsSrc = path.join(input.
|
|
919
|
+
const agentsSrc = path.join(input.artifactRoot, '.specrails', 'setup-templates', 'agents');
|
|
546
920
|
if (!isDir(agentsSrc))
|
|
547
921
|
return result;
|
|
548
|
-
mkdirp(path.join(input.
|
|
922
|
+
mkdirp(path.join(input.artifactRoot, '.gemini', 'agents'));
|
|
549
923
|
const selectedAgents = input.selectedAgents
|
|
550
924
|
? new Set([...input.selectedAgents, ...CORE_AGENTS])
|
|
551
925
|
: new Set([...CORE_AGENTS]);
|
|
552
926
|
const placeholders = {
|
|
553
|
-
PROJECT_NAME: path.basename(input.
|
|
927
|
+
PROJECT_NAME: path.basename(input.codeRoot),
|
|
554
928
|
SECURITY_EXEMPTIONS_PATH: '.gemini/security-exemptions.yaml',
|
|
555
929
|
PERSONA_DIR: '.gemini/agents/personas/',
|
|
556
930
|
};
|
|
@@ -560,22 +934,37 @@ function placeGeminiAgents(input) {
|
|
|
560
934
|
if (!name.endsWith('.md'))
|
|
561
935
|
continue;
|
|
562
936
|
const agentId = name.slice(0, -3);
|
|
563
|
-
|
|
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))
|
|
564
940
|
continue;
|
|
565
|
-
if (QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
941
|
+
if (!input.materializeAllAgents && QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
566
942
|
result.skipped++;
|
|
567
943
|
continue;
|
|
568
944
|
}
|
|
569
|
-
writeGeminiAgentFromTemplate({
|
|
945
|
+
writeGeminiAgentFromTemplate({
|
|
946
|
+
artifactRoot: input.artifactRoot,
|
|
947
|
+
src,
|
|
948
|
+
agentId,
|
|
949
|
+
placeholders,
|
|
950
|
+
seedProjectDirs: input.seedProjectDirs,
|
|
951
|
+
});
|
|
570
952
|
placedIds.push(agentId);
|
|
571
953
|
result.placed++;
|
|
572
954
|
result.filesCopied++;
|
|
573
955
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
+
}
|
|
579
968
|
}
|
|
580
969
|
return result;
|
|
581
970
|
}
|
|
@@ -596,7 +985,7 @@ function placeGeminiAgents(input) {
|
|
|
596
985
|
* Best-effort: any failure is swallowed by the caller (agents still work once
|
|
597
986
|
* acknowledged interactively).
|
|
598
987
|
*/
|
|
599
|
-
export function writeGeminiAgentAcknowledgments(repoRoot, agentIds) {
|
|
988
|
+
export function writeGeminiAgentAcknowledgments(repoRoot, agentIds, agentsBaseDir = repoRoot) {
|
|
600
989
|
if (agentIds.length === 0)
|
|
601
990
|
return;
|
|
602
991
|
const ackPath = path.join(os.homedir(), '.gemini', 'acknowledgments', 'agents.json');
|
|
@@ -612,14 +1001,24 @@ export function writeGeminiAgentAcknowledgments(repoRoot, agentIds) {
|
|
|
612
1001
|
// Corrupt/unreadable file — start fresh rather than crash the install.
|
|
613
1002
|
}
|
|
614
1003
|
}
|
|
615
|
-
|
|
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] ?? {}) };
|
|
616
1015
|
for (const agentId of agentIds) {
|
|
617
|
-
const agentFile = path.join(
|
|
1016
|
+
const agentFile = path.join(agentsBaseDir, '.gemini', 'agents', `${agentId}.md`);
|
|
618
1017
|
if (!pathExists(agentFile))
|
|
619
1018
|
continue;
|
|
620
1019
|
projectEntry[agentId] = createHash('sha256').update(readTextFile(agentFile)).digest('hex');
|
|
621
1020
|
}
|
|
622
|
-
store[
|
|
1021
|
+
store[ackKey] = projectEntry;
|
|
623
1022
|
mkdirp(path.dirname(ackPath));
|
|
624
1023
|
writeFileLf(ackPath, `${JSON.stringify(store, null, 2)}\n`);
|
|
625
1024
|
}
|
|
@@ -646,7 +1045,7 @@ function applyGeminiSettings(input) {
|
|
|
646
1045
|
let written = 0;
|
|
647
1046
|
const settingsSrc = path.join(input.scriptDir, 'templates', 'settings', 'gemini-settings.json');
|
|
648
1047
|
if (pathExists(settingsSrc)) {
|
|
649
|
-
const dest = path.join(input.
|
|
1048
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'settings.json');
|
|
650
1049
|
const template = JSON.parse(readTextFile(settingsSrc));
|
|
651
1050
|
if (pathExists(dest)) {
|
|
652
1051
|
try {
|
|
@@ -663,8 +1062,10 @@ function applyGeminiSettings(input) {
|
|
|
663
1062
|
written++;
|
|
664
1063
|
}
|
|
665
1064
|
}
|
|
666
|
-
const geminiMdPath = path.join(input.
|
|
667
|
-
|
|
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);
|
|
668
1069
|
if (!pathExists(geminiMdPath)) {
|
|
669
1070
|
writeFileLf(geminiMdPath, content);
|
|
670
1071
|
written++;
|
|
@@ -705,30 +1106,40 @@ function renderInitialGeminiMd(repoRoot) {
|
|
|
705
1106
|
}
|
|
706
1107
|
function pruneLegacyArtifacts(input) {
|
|
707
1108
|
const legacyPaths = [
|
|
708
|
-
path.join(input.
|
|
709
|
-
path.join(input.
|
|
710
|
-
path.join(input.
|
|
711
|
-
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'),
|
|
712
1113
|
];
|
|
713
1114
|
if (input.provider === 'codex') {
|
|
714
1115
|
// Pre-§18 layout used `.agents/skills/` — prune any leftovers from a
|
|
715
1116
|
// legacy install before settling on the canonical `.codex/skills/`.
|
|
716
|
-
legacyPaths.push(path.join(input.
|
|
717
|
-
legacyPaths.push(path.join(input.
|
|
1117
|
+
legacyPaths.push(path.join(input.artifactRoot, '.agents'));
|
|
1118
|
+
legacyPaths.push(path.join(input.artifactRoot, input.providerDir, 'skills', 'setup'));
|
|
718
1119
|
}
|
|
719
1120
|
else if (input.provider === 'gemini') {
|
|
720
1121
|
// Prune a stale WIP skills/ tree + any setup command leftovers.
|
|
721
|
-
legacyPaths.push(path.join(input.
|
|
722
|
-
legacyPaths.push(path.join(input.
|
|
723
|
-
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'));
|
|
724
1125
|
}
|
|
725
1126
|
else {
|
|
726
|
-
legacyPaths.push(path.join(input.
|
|
727
|
-
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'));
|
|
728
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);
|
|
729
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
|
+
}
|
|
730
1141
|
try {
|
|
731
|
-
rmSync(
|
|
1142
|
+
rmSync(resolved, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
732
1143
|
}
|
|
733
1144
|
catch (err) {
|
|
734
1145
|
warn(`failed to prune legacy artifact ${target}: ${err.message}`);
|
|
@@ -757,7 +1168,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
757
1168
|
// command surface (`propose-spec`, `explore-spec`, `retry`, …) as
|
|
758
1169
|
// claude.
|
|
759
1170
|
if (input.provider === 'codex') {
|
|
760
|
-
const setupTemplates = path.join(input.
|
|
1171
|
+
const setupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
|
|
761
1172
|
const commandsSrc = path.join(setupTemplates, 'commands', 'specrails');
|
|
762
1173
|
// Codex-native skill overrides live at `templates/codex-skills/<name>/`.
|
|
763
1174
|
// When one exists for a given slash-command name (e.g. `implement`), the
|
|
@@ -777,7 +1188,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
777
1188
|
if (!input.agentTeams && /^team-/.test(name))
|
|
778
1189
|
continue;
|
|
779
1190
|
const skillName = name.slice(0, -3);
|
|
780
|
-
const dest = path.join(input.
|
|
1191
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'skills', skillName, 'SKILL.md');
|
|
781
1192
|
// If a codex-native override exists, ship it verbatim and skip the
|
|
782
1193
|
// ported claude body entirely. Mirrors a directory copy in case the
|
|
783
1194
|
// override ships sibling assets.
|
|
@@ -803,7 +1214,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
803
1214
|
// <name>.toml. Hand-authored orchestrator overrides (implement,
|
|
804
1215
|
// batch-implement) under templates/gemini-commands/ win verbatim. Agents
|
|
805
1216
|
// are placed by placeSkills → placeGeminiAgents (both tiers).
|
|
806
|
-
const geminiSetupTemplates = path.join(input.
|
|
1217
|
+
const geminiSetupTemplates = path.join(input.artifactRoot, '.specrails', 'setup-templates');
|
|
807
1218
|
const commandsSrc = path.join(geminiSetupTemplates, 'commands', 'specrails');
|
|
808
1219
|
const overridesSrc = path.join(input.scriptDir, 'templates', 'gemini-commands');
|
|
809
1220
|
let commandsPlaced = 0;
|
|
@@ -817,7 +1228,7 @@ function placeQuickTierArtefacts(input) {
|
|
|
817
1228
|
if (!input.agentTeams && /^team-/.test(name))
|
|
818
1229
|
continue;
|
|
819
1230
|
const cmdName = name.slice(0, -3);
|
|
820
|
-
const dest = path.join(input.
|
|
1231
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'commands', 'specrails', `${cmdName}.toml`);
|
|
821
1232
|
const overrideToml = path.join(overridesSrc, `${cmdName}.toml`);
|
|
822
1233
|
if (pathExists(overrideToml)) {
|
|
823
1234
|
copyFile(overrideToml, dest);
|
|
@@ -830,9 +1241,10 @@ function placeQuickTierArtefacts(input) {
|
|
|
830
1241
|
}
|
|
831
1242
|
return { agents: 0, commands: commandsPlaced, rules: 0, skippedAgents: 0 };
|
|
832
1243
|
}
|
|
833
|
-
const setupTemplates = path.join(input.
|
|
834
|
-
|
|
835
|
-
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);
|
|
836
1248
|
const placeholders = {
|
|
837
1249
|
PROJECT_NAME: projectName,
|
|
838
1250
|
SECURITY_EXEMPTIONS_PATH: `${input.providerDir}/security-exemptions.yaml`,
|
|
@@ -858,9 +1270,12 @@ function placeQuickTierArtefacts(input) {
|
|
|
858
1270
|
if (!name.endsWith('.md'))
|
|
859
1271
|
continue;
|
|
860
1272
|
const agentId = name.slice(0, -3);
|
|
861
|
-
|
|
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))
|
|
862
1277
|
continue;
|
|
863
|
-
if (QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
1278
|
+
if (!input.materializeAllAgents && QUICK_EXCLUDED_AGENTS.has(agentId)) {
|
|
864
1279
|
agentsSkipped++;
|
|
865
1280
|
continue;
|
|
866
1281
|
}
|
|
@@ -872,11 +1287,16 @@ function placeQuickTierArtefacts(input) {
|
|
|
872
1287
|
writeFileLf(dest, rendered);
|
|
873
1288
|
agentsPlaced++;
|
|
874
1289
|
installedAgentNames.add(agentId);
|
|
875
|
-
// Per-agent memory directory. Created even when empty so
|
|
876
|
-
// the
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
+
}
|
|
880
1300
|
}
|
|
881
1301
|
}
|
|
882
1302
|
}
|
|
@@ -949,7 +1369,7 @@ function applyCodexSettings(input) {
|
|
|
949
1369
|
// reasoning_effort etc.
|
|
950
1370
|
const configTomlSrc = path.join(settingsSrc, 'codex-config.toml');
|
|
951
1371
|
if (pathExists(configTomlSrc)) {
|
|
952
|
-
const dest = path.join(input.
|
|
1372
|
+
const dest = path.join(input.artifactRoot, input.providerDir, 'config.toml');
|
|
953
1373
|
const rendered = readTextFile(configTomlSrc).replace(/\{\{MODEL_NAME\}\}/g, 'gpt-5.5-mini');
|
|
954
1374
|
writeFileLf(dest, rendered);
|
|
955
1375
|
written++;
|
|
@@ -957,8 +1377,8 @@ function applyCodexSettings(input) {
|
|
|
957
1377
|
// AGENTS.md — top-level instructions file the codex CLI loads on startup.
|
|
958
1378
|
// Written with a sentinel block so update + enrich passes can refresh the
|
|
959
1379
|
// managed content while preserving anything the user added outside it.
|
|
960
|
-
const agentsMdPath = path.join(input.
|
|
961
|
-
const agentsMdContent = renderInitialAgentsMd(input.
|
|
1380
|
+
const agentsMdPath = path.join(input.artifactRoot, 'AGENTS.md');
|
|
1381
|
+
const agentsMdContent = renderInitialAgentsMd(input.codeRoot);
|
|
962
1382
|
if (!pathExists(agentsMdPath)) {
|
|
963
1383
|
writeFileLf(agentsMdPath, agentsMdContent);
|
|
964
1384
|
written++;
|
|
@@ -1032,11 +1452,11 @@ function upsertAgentsMdManagedBlock(existing, managedBlock) {
|
|
|
1032
1452
|
// `subagent_type` calls have no codex equivalent). Codex DOES get the rails
|
|
1033
1453
|
// subtree below, sourced from `templates/codex-skills/rails/`.
|
|
1034
1454
|
function placeSkills(input) {
|
|
1035
|
-
const destBase = path.join(input.
|
|
1455
|
+
const destBase = path.join(input.artifactRoot, input.providerDir, 'skills');
|
|
1036
1456
|
const result = { placed: 0, skipped: 0, filesCopied: 0 };
|
|
1037
1457
|
// Top-level skills — Claude only, generated from the canonical command body.
|
|
1038
1458
|
if (input.provider === 'claude') {
|
|
1039
|
-
const commandsSrc = path.join(input.
|
|
1459
|
+
const commandsSrc = path.join(input.artifactRoot, '.specrails', 'setup-templates', 'commands', 'specrails');
|
|
1040
1460
|
const skillEntries = Object.entries(SKILL_FROM_COMMAND);
|
|
1041
1461
|
for (const [skillName, spec] of skillEntries) {
|
|
1042
1462
|
if (input.tier === 'quick' && QUICK_EXCLUDED_SKILLS.has(skillName)) {
|