specrails-core 4.8.1 → 4.9.0

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