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.
Files changed (36) hide show
  1. package/bin/specrails-core.mjs +5 -1
  2. package/dist/installer/cli.js +46 -6
  3. package/dist/installer/cli.js.map +1 -1
  4. package/dist/installer/commands/doctor.js +14 -5
  5. package/dist/installer/commands/doctor.js.map +1 -1
  6. package/dist/installer/commands/framework.js +134 -0
  7. package/dist/installer/commands/framework.js.map +1 -0
  8. package/dist/installer/commands/init.js +107 -32
  9. package/dist/installer/commands/init.js.map +1 -1
  10. package/dist/installer/commands/update.js +75 -35
  11. package/dist/installer/commands/update.js.map +1 -1
  12. package/dist/installer/phases/scaffold.js +493 -73
  13. package/dist/installer/phases/scaffold.js.map +1 -1
  14. package/dist/installer/util/fs.js +143 -1
  15. package/dist/installer/util/fs.js.map +1 -1
  16. package/dist/installer/util/registry.js +339 -0
  17. package/dist/installer/util/registry.js.map +1 -0
  18. package/package.json +2 -1
  19. package/pinned-versions.json +1 -1
  20. package/templates/agents/sr-architect.md +14 -10
  21. package/templates/agents/sr-backend-developer.md +4 -2
  22. package/templates/agents/sr-developer.md +20 -8
  23. package/templates/agents/sr-frontend-developer.md +4 -2
  24. package/templates/agents/sr-reviewer.md +10 -6
  25. package/templates/codex-skills/implement/SKILL.md +19 -10
  26. package/templates/codex-skills/rails/sr-architect/SKILL.md +17 -8
  27. package/templates/codex-skills/rails/sr-backend-developer/SKILL.md +4 -1
  28. package/templates/codex-skills/rails/sr-developer/SKILL.md +13 -4
  29. package/templates/codex-skills/rails/sr-doc-sync/SKILL.md +3 -2
  30. package/templates/codex-skills/rails/sr-frontend-developer/SKILL.md +4 -1
  31. package/templates/codex-skills/rails/sr-product-manager/SKILL.md +9 -7
  32. package/templates/codex-skills/rails/sr-reviewer/SKILL.md +13 -7
  33. package/templates/codex-skills/retry/SKILL.md +10 -5
  34. package/templates/commands/specrails/implement.md +41 -23
  35. package/templates/commands/specrails/retry.md +3 -1
  36. 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.repoRoot, input.providerDir, 'agents'),
168
- path.join(input.repoRoot, input.providerDir, 'commands'),
169
- path.join(input.repoRoot, input.providerDir, 'rules'),
170
- path.join(input.repoRoot, 'openspec'),
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.repoRoot, input.providerDir));
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.repoRoot, input.providerDir, 'skills', 'enrich'));
196
- mk(path.join(input.repoRoot, input.providerDir, 'skills', 'doctor'));
197
- mk(path.join(input.repoRoot, input.providerDir, 'skills', 'rails'));
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.repoRoot, input.providerDir, 'commands', 'specrails'));
203
- mk(path.join(input.repoRoot, input.providerDir, 'agents'));
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.repoRoot, input.providerDir, 'commands', 'specrails'));
207
- mk(path.join(input.repoRoot, input.providerDir, 'skills'));
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.repoRoot, '.specrails', 'setup-templates');
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
- const gitignoreEntries = ['.claude/agent-memory/', '.specrails/'];
219
- if (input.provider === 'gemini')
220
- gitignoreEntries.push('.gemini/agent-memory/');
221
- ensureGitignore(input.repoRoot, gitignoreEntries);
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
- repoRoot: input.repoRoot,
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.repoRoot, input.providerDir, 'skills', skillName);
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.repoRoot, input.providerDir, 'commands', 'specrails');
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.repoRoot, input.providerDir, 'commands', 'specrails');
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.repoRoot, '.gemini', 'agents', `${args.agentId}.md`), frontmatter + renderedBody);
537
- mkdirp(path.join(args.repoRoot, '.gemini', 'agent-memory', args.agentId));
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.repoRoot, '.specrails', 'setup-templates', 'agents');
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.repoRoot, '.gemini', 'agents'));
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.repoRoot),
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
- if (!selectedAgents.has(agentId))
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({ repoRoot: input.repoRoot, src, agentId, placeholders });
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
- try {
575
- writeGeminiAgentAcknowledgments(input.repoRoot, placedIds);
576
- }
577
- catch (err) {
578
- warn(`gemini agent pre-acknowledgment skipped: ${err.message}`);
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
- const projectEntry = { ...(store[repoRoot] ?? {}) };
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(repoRoot, '.gemini', 'agents', `${agentId}.md`);
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[repoRoot] = projectEntry;
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.repoRoot, input.providerDir, 'settings.json');
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.repoRoot, 'GEMINI.md');
667
- const content = renderInitialGeminiMd(input.repoRoot);
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.repoRoot, '.specrails', 'bin', 'doctor.sh'),
709
- path.join(input.repoRoot, '.specrails', 'setup-templates', '.provider-detection.json'),
710
- path.join(input.repoRoot, '.specrails', 'setup-templates', 'settings', 'integration-contract.json'),
711
- path.join(input.repoRoot, '.specrails-version'),
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.repoRoot, '.agents'));
717
- legacyPaths.push(path.join(input.repoRoot, input.providerDir, 'skills', 'setup'));
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.repoRoot, input.providerDir, 'skills'));
722
- legacyPaths.push(path.join(input.repoRoot, input.providerDir, 'commands', 'setup.toml'));
723
- legacyPaths.push(path.join(input.repoRoot, input.providerDir, 'commands', 'specrails', 'setup.toml'));
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.repoRoot, input.providerDir, 'commands', 'setup.md'));
727
- legacyPaths.push(path.join(input.repoRoot, input.providerDir, 'commands', 'specrails', 'setup.md'));
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(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
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.repoRoot, '.specrails', 'setup-templates');
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.repoRoot, input.providerDir, 'skills', skillName, 'SKILL.md');
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.repoRoot, '.specrails', 'setup-templates');
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.repoRoot, input.providerDir, 'commands', 'specrails', `${cmdName}.toml`);
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.repoRoot, '.specrails', 'setup-templates');
834
- const projectName = path.basename(input.repoRoot);
835
- const providerDirAbs = path.join(input.repoRoot, input.providerDir);
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
- if (selectedAgents && !selectedAgents.has(agentId))
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 first run of the agent doesn't error on ENOENT.
877
- mkdirp(path.join(input.repoRoot, '.claude', 'agent-memory', agentId));
878
- if (EXPLANATION_AUTHORS.has(agentId)) {
879
- mkdirp(path.join(input.repoRoot, '.claude', 'agent-memory', 'explanations'));
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.repoRoot, input.providerDir, 'config.toml');
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.repoRoot, 'AGENTS.md');
961
- const agentsMdContent = renderInitialAgentsMd(input.repoRoot);
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.repoRoot, input.providerDir, 'skills');
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.repoRoot, '.specrails', 'setup-templates', 'commands', 'specrails');
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)) {