@dfosco/storyboard 1.0.0-beta.0 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -2,13 +2,13 @@
2
2
 
3
3
  ## Selected Widgets Context
4
4
 
5
- **Before processing ANY user prompt**, read `.storyboard/.selectedwidgets.json` at the repository root. This file is updated in real-time by the Storyboard dev server and reflects which canvas the user is currently viewing and which widgets they have selected in the browser.
5
+ **Before processing ANY user prompt**, read `.storyboard/.selectedwidgets.json` at the repository root (the content root — where you run `storyboard` commands; in the `.backstage` layout this is the repo root you see, **not** the hidden `.backstage/` directory). This file is updated in real-time by the Storyboard dev server and reflects which canvas the user is currently viewing and which widgets they have selected in the browser.
6
6
 
7
7
  **File format:**
8
8
  ```json
9
9
  {
10
10
  "canvasId": "design-system",
11
- "canvasFile": "src/canvas/design-system.canvas.jsonl",
11
+ "canvasFile": "canvas/design-system.canvas.jsonl",
12
12
  "selectedWidgetIds": ["img-abc123", "sticky-def456"],
13
13
  "widgets": [
14
14
  { "id": "img-abc123", "type": "image", "props": { "src": "screenshot.png" } },
@@ -196,6 +196,17 @@ This is a **Storyboard prototyping app** using Vite and file-based routing via `
196
196
  > content above the Vite root resolves its dependencies — `storyboard dev`
197
197
  > recreates it automatically. Either way, you only edit content +
198
198
  > `storyboard.config.json`.
199
+ >
200
+ > **Path convention — read this before following any `src/...` path below.**
201
+ > Every content path in this document is written in the **legacy** form with a
202
+ > `src/` prefix (`src/prototypes/`, `src/canvas/`, `src/components/`). In the
203
+ > **`.backstage`** layout the content folders live at the repo root with **no
204
+ > `src/` prefix** — `src/prototypes/X` becomes `prototypes/X`, `src/components/Y`
205
+ > becomes `components/Y`, and so on. Detect your layout once: if the repo root
206
+ > has a `.backstage/` directory, you are in the backstage layout — drop the
207
+ > `src/` prefix from every path below. Agent files (`.agents/`, `AGENTS.md`) and
208
+ > runtime state (`.storyboard/`) are bridged to the repo root in both layouts,
209
+ > so reference them by their plain names regardless of layout.
199
210
 
200
211
  Detailed architectural documentation lives in `.agents/architecture/`. Consult the relevant architecture docs when:
201
212
 
@@ -231,7 +242,7 @@ The fix was a one-line compaction (4MB → 9KB). Everything the user reported
231
242
  - Use **semantic HTML tags** whenever they are appropriate in between Primer React components
232
243
  - Use the **canvas widget type registry** at `.agents/data/widget-types.json` as the authoritative list of valid canvas widget `type` strings, their props, default sizes, and when-to-use guidance. Never invent widget types. In particular: when showing multiple variants of a single story file, use ONE `component-set` widget — not N `story` widgets. Regenerate after changing the widget registry: `node scripts/gen-widget-types.mjs`.
233
244
  - Use **CSS Modules** (`*.module.css`) for component-specific styles
234
- - **Components must live in their own directory:** `src/components/Name/Name.jsx`, `Name.module.css`, `name.story.jsx`. Never place component files flat in `src/components/`.
245
+ - **Components must live in their own directory:** `src/components/Name/Name.jsx`, `Name.module.css`, `name.story.jsx` (in the `.backstage` layout: `components/Name/Name.jsx` — drop the `src/` prefix). Never place component files flat in the components folder.
235
246
  - **Always use the `create` skill** when creating new components or stories — don't manually create files.
236
247
  - **Every piece of data consumed in a page must gracefully handle `null` or `undefined` without crashing.** Since flow data, records, and overrides can all be partial, incomplete, or missing, components must never assume a field exists. Use optional chaining, fallback values, or conditional rendering for every data access.
237
248
  - **Branch URL support is required for any feature involving URL fragments or URL matching.** Branch deploys use `VITE_BASE_PATH=/branch--{branch-name}/` which changes `import.meta.env.BASE_URL`. Any URL matching, same-origin detection, or src resolution must account for branch-prefixed paths (e.g. `/branch--my-feature/MyPrototype`). When implementing URL-related features, ask the user about branch URL support — if they're unavailable, build for it by default.
@@ -23,6 +23,17 @@ tools:
23
23
  > - ✅ `POST /_storyboard/canvas/batch` with body `{"name":"my-canvas", ...}`
24
24
  > - ❌ `POST /_storyboard/canvas/my-canvas/widgets` — **DOES NOT EXIST**
25
25
 
26
+ > **📂 Your working directory.** You run from the **content root** — the
27
+ > directory holding the user's content folders (`prototypes/`, `canvas/`,
28
+ > `components/`, `assets/`). Edit content there by its plain path
29
+ > (`components/Foo/Foo.jsx`, `prototypes/Bar/index.jsx`) — **no `src/` prefix**.
30
+ > Runtime + agent files are bridged into this same root, so the relative paths
31
+ > in this doc just work: `.storyboard/terminals/…` (your config + env),
32
+ > `.agents/skills/…` (skills), and `AGENTS.md` (repo instructions). In the
33
+ > `.backstage` layout the build machinery (`node_modules`, `vite.config.js`,
34
+ > `package.json`) lives in a hidden `.backstage/` directory you don't touch —
35
+ > stay in the content root and never `cd` into `.backstage/`.
36
+
26
37
  ## ⚠️ Prime Directive: Results MUST be visible on the canvas
27
38
 
28
39
  **You CANNOT signal completion unless the user can see your result on the canvas.** This is non-negotiable. If you did work but the canvas looks the same as before, you failed.
@@ -48,6 +48,7 @@ import { writeFileSync, existsSync, unlinkSync, mkdirSync } from 'node:fs'
48
48
  import { join } from 'node:path'
49
49
  import { devLog } from '../logger/devLogger.js'
50
50
  import { readCapturedSessionId, captureFilePath, clearCaptureFile } from './agent-session.js'
51
+ import { findSystemRoot } from '../paths/resolvePaths.js'
51
52
 
52
53
  /**
53
54
  * @typedef {Object} WarmSession
@@ -99,7 +100,13 @@ export class HotPool {
99
100
  * @param {Function} [opts.wsSend] — Vite server.ws.send for browser devlog events
100
101
  */
101
102
  constructor({ root, poolId = 'terminal', config = {}, agentConfig = null, wsSend = null }) {
102
- this.#root = root
103
+ // Pre-warmed agents must spawn in the CONTENT root (where the user's
104
+ // prototypes/canvas/components live), not the Vite/system root. In the
105
+ // backstage layout the server passes the system root (`.backstage`); resolve
106
+ // it to the content root so the warm tmux session's fixed cwd, capture
107
+ // files (`.storyboard/…`, bridged), and STORYBOARD_PROJECT_ROOT all match
108
+ // the post-acquisition terminal-server spawn. Legacy layout is unchanged.
109
+ this.#root = findSystemRoot(root).contentRoot
103
110
  this.#poolId = poolId
104
111
  this.#poolSize = Math.max(0, config.pool_size ?? DEFAULT_POOL_SIZE)
105
112
  this.#maxPoolSize = Math.max(this.#poolSize, config.max_pool_size ?? DEFAULT_MAX_POOL_SIZE)
@@ -3522,9 +3522,11 @@ export function Default() {
3522
3522
  // Build server URL for agent env vars
3523
3523
  const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
3524
3524
 
3525
- // Create headless tmux session
3525
+ // Create headless tmux session. Spawn in the CONTENT root (where the
3526
+ // user's prototypes/canvas/components live), not the Vite/system root —
3527
+ // matches the warm hot-pool + interactive terminal-server spawn cwd.
3526
3528
  try {
3527
- execSync(`tmux new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
3529
+ execSync(`tmux new-session -d -s "${tmuxName}" -c "${findSystemRoot(root).contentRoot}"`, { stdio: 'ignore' })
3528
3530
  execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
3529
3531
  execSync(`tmux set-option -t "${tmuxName}" mouse on`, { stdio: 'ignore' })
3530
3532
  execSync(`tmux set-option -t "${tmuxName}" set-clipboard off`, { stdio: 'ignore' })
@@ -3874,9 +3876,9 @@ export function Default() {
3874
3876
  }
3875
3877
 
3876
3878
  if (!usedWarm) {
3877
- // Fresh tmux session (cold path)
3879
+ // Fresh tmux session (cold path) — spawn in the content root.
3878
3880
  try {
3879
- execSync(`tmux -f /dev/null new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
3881
+ execSync(`tmux -f /dev/null new-session -d -s "${tmuxName}" -c "${findSystemRoot(root).contentRoot}"`, { stdio: 'ignore' })
3880
3882
  execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
3881
3883
  execSync(`tmux set-option -t "${tmuxName}" set-clipboard off 2>/dev/null`, { stdio: 'ignore' })
3882
3884
  } catch { /* session may already exist */ }
@@ -55,6 +55,7 @@ import {
55
55
  } from './terminal-config.js'
56
56
  import { findByWorktree } from '../worktree/serverRegistry.js'
57
57
  import { detectWorktreeName } from '../worktree/port.js'
58
+ import { findSystemRoot } from '../paths/resolvePaths.js'
58
59
  import { bindWidget, unbindWidget } from '../messaging/delivery.js'
59
60
  import { joinPresence, leavePresence } from '../messaging/presence.js'
60
61
  import {
@@ -982,7 +983,16 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
982
983
  ptyProcesses.delete(tmuxName)
983
984
  }
984
985
 
985
- const cwd = process.cwd()
986
+ // Spawn the agent in the CONTENT root. In the backstage layout the dev
987
+ // server's process.cwd() is `.backstage/` (the Vite + npm root), but an
988
+ // agent must run where the user's content lives (prototypes/canvas/components
989
+ // at the content root). `.storyboard`, `node_modules`, `.agents` and
990
+ // `AGENTS.md` are bridged into the content root (see ensureContentSystemLinks
991
+ // / ensureContentNodeModulesLink), so every `.storyboard/...` path below
992
+ // resolves to the same physical file from either root. Legacy layout is
993
+ // unchanged (content root === process.cwd()).
994
+ const { mode: layoutMode, contentRoot } = findSystemRoot()
995
+ const cwd = layoutMode === 'backstage' ? contentRoot : process.cwd()
986
996
  const shell = process.env.SHELL || '/bin/zsh'
987
997
  const termCfg = readTerminalConfig()
988
998
  const prompt = termCfg.prompt || '$ '
@@ -20,7 +20,7 @@ import { readFileSync, existsSync } from 'node:fs'
20
20
  import { detectWorktreeName, getPort, releasePort } from '../worktree/port.js'
21
21
  import { startFileWatcher } from '../file-watcher/watcher.js'
22
22
  import { compactAll } from '../canvas/compact.js'
23
- import { findSystemRoot, mirrorSystemConfig, ensureContentNodeModulesLink, ensureLinkedDepsOverlay } from '../paths/resolvePaths.js'
23
+ import { findSystemRoot, mirrorSystemConfig, ensureContentNodeModulesLink, ensureLinkedDepsOverlay, ensureContentSystemLinks } from '../paths/resolvePaths.js'
24
24
  import { parseFlags } from './flags.js'
25
25
  import { setupNeeded, writeUserState, getInstalledStoryboardVersion } from './userState.js'
26
26
  import { dim, magenta, bold } from './intro.js'
@@ -188,6 +188,11 @@ async function main() {
188
188
  // its hoisted deps aren't reachable from the Vite root. Overlay them so
189
189
  // Vite's optimizeDeps.include entries resolve. No-op for published installs.
190
190
  ensureLinkedDepsOverlay(layout)
191
+ // Bridge agent-facing system files (.agents, AGENTS.md, .storyboard) into
192
+ // the content root so a canvas/terminal agent — which runs FROM the content
193
+ // root, where prototypes/canvas/components live — can resolve its skills,
194
+ // instructions, and runtime bridge state by their conventional names.
195
+ ensureContentSystemLinks(layout)
191
196
  }
192
197
 
193
198
  // Port resolution priority (Vite always rolls forward to the next free
@@ -12,7 +12,17 @@
12
12
  import * as p from '@clack/prompts'
13
13
  import { execSync } from 'child_process'
14
14
  import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'fs'
15
- import { dirname, resolve } from 'path'
15
+ import { dirname, relative, resolve } from 'path'
16
+ import { findSystemRoot } from '../paths/resolvePaths.js'
17
+
18
+ // In the backstage layout, package.json / node_modules / scripts / vite.config
19
+ // live under `<contentRoot>/.backstage` (the systemRoot) while `.git` and
20
+ // `.github` live at the contentRoot. Resolve both so `storyboard update` works
21
+ // when run from the content root. In the legacy layout both are the same dir.
22
+ const { systemRoot, contentRoot } = findSystemRoot()
23
+ // Path prefix from the git repo root (contentRoot) to systemRoot — '' in legacy,
24
+ // '.backstage' in backstage. Used to stage systemRoot files from contentRoot.
25
+ const sysPrefix = relative(contentRoot, systemRoot)
16
26
 
17
27
  const command = process.argv[2]
18
28
  const channels = { 'update:beta': 'beta', 'update:alpha': 'alpha' }
@@ -27,7 +37,7 @@ if (channels[command]) {
27
37
  targetVersion = command.slice('update:'.length)
28
38
  }
29
39
 
30
- const pkgPath = resolve(process.cwd(), 'package.json')
40
+ const pkgPath = resolve(systemRoot, 'package.json')
31
41
  let pkg
32
42
  try {
33
43
  pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
@@ -74,7 +84,7 @@ for (const name of storyboardPkgs) {
74
84
  }
75
85
 
76
86
  try {
77
- execSync(`npm install ${packages}`, { stdio: 'inherit', cwd: process.cwd() })
87
+ execSync(`npm install ${packages}`, { stdio: 'inherit', cwd: systemRoot })
78
88
  p.log.success('All storyboard packages updated')
79
89
  } catch {
80
90
  p.log.error('Failed to update packages — see npm output above')
@@ -84,22 +94,23 @@ try {
84
94
  // Sync scaffold files (skills, scripts) from the updated package
85
95
  p.log.info('Syncing scaffold files…')
86
96
  try {
87
- execSync('npx storyboard-scaffold', { stdio: 'inherit', cwd: process.cwd() })
97
+ execSync('npx storyboard-scaffold', { stdio: 'inherit', cwd: systemRoot })
88
98
  } catch {
89
99
  p.log.warn('Scaffold sync failed — run `npx storyboard-scaffold` manually')
90
100
  }
91
101
 
92
102
  function checkArtifactDiscoveryWorkflow() {
93
- const cwd = process.cwd()
103
+ // Workflows + their scripts live at the git repo root (contentRoot); the
104
+ // scaffold source ships inside the installed package under systemRoot.
94
105
  const workflows = [
95
- { name: 'deploy.yml', path: resolve(cwd, '.github', 'workflows', 'deploy.yml') },
96
- { name: 'preview.yml', path: resolve(cwd, '.github', 'workflows', 'preview.yml') },
106
+ { name: 'deploy.yml', path: resolve(contentRoot, '.github', 'workflows', 'deploy.yml') },
107
+ { name: 'preview.yml', path: resolve(contentRoot, '.github', 'workflows', 'preview.yml') },
97
108
  ].filter(workflow => existsSync(workflow.path))
98
109
 
99
110
  if (workflows.length === 0) return
100
111
 
101
- const scriptTarget = resolve(cwd, '.github', 'workflows', 'scripts', 'aggregate-artifacts.mjs')
102
- const scriptSource = resolve(cwd, 'node_modules', '@dfosco', 'storyboard', 'scaffold', 'scripts', 'aggregate-artifacts.mjs')
112
+ const scriptTarget = resolve(contentRoot, '.github', 'workflows', 'scripts', 'aggregate-artifacts.mjs')
113
+ const scriptSource = resolve(systemRoot, 'node_modules', '@dfosco', 'storyboard', 'scaffold', 'scripts', 'aggregate-artifacts.mjs')
103
114
 
104
115
  if (!existsSync(scriptTarget)) {
105
116
  if (existsSync(scriptSource)) {
@@ -133,11 +144,11 @@ try {
133
144
  // Read the installed version from the storyboard package (try unified first, then legacy core)
134
145
  let installedVersion
135
146
  try {
136
- const unified = JSON.parse(readFileSync(resolve(process.cwd(), 'node_modules', '@dfosco', 'storyboard', 'package.json'), 'utf8'))
147
+ const unified = JSON.parse(readFileSync(resolve(systemRoot, 'node_modules', '@dfosco', 'storyboard', 'package.json'), 'utf8'))
137
148
  installedVersion = unified.version
138
149
  } catch {
139
150
  try {
140
- const core = JSON.parse(readFileSync(resolve(process.cwd(), 'node_modules', '@dfosco', 'storyboard-core', 'package.json'), 'utf8'))
151
+ const core = JSON.parse(readFileSync(resolve(systemRoot, 'node_modules', '@dfosco', 'storyboard-core', 'package.json'), 'utf8'))
141
152
  installedVersion = core.version
142
153
  } catch { /* empty */ }
143
154
  }
@@ -150,13 +161,15 @@ try {
150
161
  // hands-off — customers never hand-patch these. User-facing *.config.json
151
162
  // (storyboard.config.json, terminal.config.json, …) are intentionally NOT
152
163
  // staged — those are customer-owned.
153
- const filesToStage = [
164
+ //
165
+ // In the backstage layout these live under systemRoot (`.backstage`) while
166
+ // `.git`/`.github` live at contentRoot. Git runs from contentRoot, so prefix
167
+ // systemRoot-owned paths with sysPrefix; `.github/*` paths stay at the root.
168
+ const systemFiles = [
154
169
  'package.json',
155
170
  'package-lock.json',
156
171
  'yarn.lock',
157
172
  'pnpm-lock.yaml',
158
- '.github/skills',
159
- '.github/workflows/scripts/aggregate-artifacts.mjs',
160
173
  'scripts',
161
174
  // Tier-1 package-owned infrastructure (force-synced):
162
175
  'vite.config.js',
@@ -165,22 +178,31 @@ try {
165
178
  'tsconfig.json',
166
179
  'src/library',
167
180
  ]
181
+ const contentFiles = [
182
+ '.github/skills',
183
+ '.github/workflows/scripts/aggregate-artifacts.mjs',
184
+ ]
185
+ const filesToStage = [
186
+ ...systemFiles.map(f => (sysPrefix ? `${sysPrefix}/${f}` : f)),
187
+ ...contentFiles,
188
+ ]
168
189
  for (const f of filesToStage) {
169
- try { execSync(`git add ${f}`, { cwd: process.cwd(), stdio: 'pipe' }) } catch { /* empty */ }
190
+ try { execSync(`git add ${f}`, { cwd: contentRoot, stdio: 'pipe' }) } catch { /* empty */ }
170
191
  }
171
192
 
172
193
  // Svelte was removed from the storyboard base — drop the stale empty stub
173
194
  // from existing customer repos so the update sheds it automatically.
174
195
  try {
175
- execSync('git rm --quiet --ignore-unmatch svelte.config.js', { cwd: process.cwd(), stdio: 'pipe' })
196
+ const svelteStub = sysPrefix ? `${sysPrefix}/svelte.config.js` : 'svelte.config.js'
197
+ execSync(`git rm --quiet --ignore-unmatch ${svelteStub}`, { cwd: contentRoot, stdio: 'pipe' })
176
198
  } catch { /* empty */ }
177
199
 
178
200
  // Only commit if there are staged changes
179
201
  try {
180
- execSync('git diff --cached --quiet', { cwd: process.cwd(), stdio: 'pipe' })
202
+ execSync('git diff --cached --quiet', { cwd: contentRoot, stdio: 'pipe' })
181
203
  p.log.message('No changes to commit — already up to date')
182
204
  } catch {
183
- execSync(`git commit -m "${commitMsg}"`, { cwd: process.cwd(), stdio: 'pipe' })
205
+ execSync(`git commit -m "${commitMsg}"`, { cwd: contentRoot, stdio: 'pipe' })
184
206
  p.log.success(`Committed: ${commitMsg}`)
185
207
  }
186
208
  } catch (err) {
@@ -38,7 +38,7 @@
38
38
  * @module core/paths/resolvePaths
39
39
  */
40
40
 
41
- import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync, mkdirSync } from 'node:fs'
41
+ import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs'
42
42
  import { basename, dirname, join, relative, resolve } from 'node:path'
43
43
  import { copyFileSync } from 'node:fs'
44
44
  import process from 'node:process'
@@ -426,6 +426,96 @@ export function ensureLinkedDepsOverlay(loc, pkgName = '@dfosco/storyboard') {
426
426
  }
427
427
 
428
428
 
429
+ /**
430
+ * Append a path to `<root>/.git/info/exclude` exactly once (best-effort).
431
+ *
432
+ * Used to keep generated bridge symlinks out of `git status` without touching
433
+ * the committed `.gitignore` — which must stay layout-agnostic (ignoring
434
+ * `/.agents` there would wrongly hide a legacy repo's committed files). The
435
+ * local `info/exclude` is per-clone and never committed, so it is the right
436
+ * place for backstage-only generated artifacts. Skips git worktrees (where
437
+ * `.git` is a file, not a directory) — harmless if those show the symlink.
438
+ *
439
+ * @param {string} root content root containing `.git`
440
+ * @param {string} name entry to exclude (added as `/name`)
441
+ */
442
+ function registerGitExclude(root, name) {
443
+ try {
444
+ const gitDir = join(root, '.git')
445
+ if (!existsSync(gitDir) || !lstatSync(gitDir).isDirectory()) return
446
+ const infoDir = join(gitDir, 'info')
447
+ mkdirSync(infoDir, { recursive: true })
448
+ const excludeFile = join(infoDir, 'exclude')
449
+ let body = ''
450
+ try { body = readFileSync(excludeFile, 'utf8') } catch { /* new file */ }
451
+ const entry = `/${name}`
452
+ if (body.split(/\r?\n/).includes(entry)) return
453
+ const prefix = body && !body.endsWith('\n') ? '\n' : ''
454
+ appendFileSync(excludeFile, `${prefix}${entry}\n`)
455
+ } catch {
456
+ /* best-effort — never block dev start on git bookkeeping */
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Bridge hidden system files into the content root so an agent (or any tool)
462
+ * running from the content root can resolve them by their conventional name.
463
+ *
464
+ * In the backstage layout a canvas/terminal agent's working directory is the
465
+ * content root (where `prototypes/`, `canvas/`, `components/` live), but the
466
+ * agent instruction + runtime files live under `.backstage`:
467
+ *
468
+ * - `.agents/` — skills, plans and data the agent reads by relative path
469
+ * - `AGENTS.md` — top-level agent instructions read on every prompt
470
+ * - `.storyboard/` — runtime bridge state (`.selectedwidgets.json`,
471
+ * `terminals/*.json`, buffers) the agent and server share
472
+ *
473
+ * Each is symlinked from systemRoot into contentRoot with a relative target
474
+ * (created only when missing, never clobbering a real file the user placed
475
+ * there) and registered in `.git/info/exclude` so the bridged copy never shows
476
+ * up as an untracked file. The canonical copies stay committed under
477
+ * `.backstage`. No-op in the legacy layout (system === content root).
478
+ *
479
+ * @param {{ mode: 'backstage'|'legacy', systemRoot: string, contentRoot: string }} loc
480
+ * @returns {string[]} names that are now bridged into the content root
481
+ */
482
+ export function ensureContentSystemLinks(loc) {
483
+ const { mode, systemRoot, contentRoot } = loc
484
+ if (mode !== 'backstage') return []
485
+ const names = ['.agents', 'AGENTS.md', '.storyboard']
486
+ const bridged = []
487
+ for (const name of names) {
488
+ const target = join(systemRoot, name)
489
+ // `.storyboard` is created lazily by the dev server at startup; ensure it
490
+ // exists first so the symlink resolves immediately rather than dangling.
491
+ if (name === '.storyboard' && !existsSync(target)) {
492
+ try { mkdirSync(target, { recursive: true }) } catch { /* empty */ }
493
+ }
494
+ if (!existsSync(target)) continue
495
+ const link = join(contentRoot, name)
496
+ try {
497
+ if (isSymlink(link)) {
498
+ if (resolve(dirname(link), readlinkSync(link)) === resolve(target)) {
499
+ bridged.push(name)
500
+ registerGitExclude(contentRoot, name)
501
+ continue
502
+ }
503
+ unlinkSync(link)
504
+ } else if (existsSync(link)) {
505
+ // A real file/dir already lives at the content root — never clobber it.
506
+ continue
507
+ }
508
+ symlinkSync(relative(contentRoot, target), link)
509
+ bridged.push(name)
510
+ registerGitExclude(contentRoot, name)
511
+ } catch {
512
+ /* best-effort bridge — a missing link only degrades agent ergonomics */
513
+ }
514
+ }
515
+ return bridged
516
+ }
517
+
518
+
429
519
  function isSymlink(p) {
430
520
  try {
431
521
  return lstatSync(p).isSymbolicLink()
@@ -12,6 +12,7 @@ import {
12
12
  mirrorSystemConfig,
13
13
  ensureContentNodeModulesLink,
14
14
  ensureLinkedDepsOverlay,
15
+ ensureContentSystemLinks,
15
16
  ROLES,
16
17
  SYSTEM_DIR,
17
18
  } from './resolvePaths.js'
@@ -309,4 +310,67 @@ describe('core/paths/resolvePaths', () => {
309
310
  expect(ensureLinkedDepsOverlay(findSystemRoot(tmp))).toBe(0)
310
311
  })
311
312
  })
313
+
314
+ describe('ensureContentSystemLinks', () => {
315
+ // Build a backstage layout with committed system files under .backstage.
316
+ function setupBackstage({ git = false } = {}) {
317
+ const sys = join(tmp, SYSTEM_DIR)
318
+ mkdirSync(join(sys, '.agents', 'skills'), { recursive: true })
319
+ writeFileSync(join(sys, '.agents', 'skills', 'x.md'), '# skill\n')
320
+ writeFileSync(join(sys, 'AGENTS.md'), '# agents\n')
321
+ if (git) mkdirSync(join(tmp, '.git'), { recursive: true })
322
+ return { sys }
323
+ }
324
+
325
+ it('is a no-op in the legacy layout', () => {
326
+ expect(ensureContentSystemLinks(findSystemRoot(tmp))).toEqual([])
327
+ })
328
+
329
+ it('bridges .agents, AGENTS.md and .storyboard into the content root', () => {
330
+ setupBackstage()
331
+ const bridged = ensureContentSystemLinks(findSystemRoot(tmp))
332
+ expect(bridged).toEqual(expect.arrayContaining(['.agents', 'AGENTS.md', '.storyboard']))
333
+ for (const name of ['.agents', 'AGENTS.md', '.storyboard']) {
334
+ const link = join(tmp, name)
335
+ expect(lstatSync(link).isSymbolicLink()).toBe(true)
336
+ expect(realpathSync(link)).toBe(realpathSync(join(tmp, SYSTEM_DIR, name)))
337
+ }
338
+ // The bridge resolves real system content through the symlink.
339
+ expect(readFileSync(join(tmp, '.agents', 'skills', 'x.md'), 'utf8')).toContain('# skill')
340
+ })
341
+
342
+ it('creates a relative symlink target so the repo stays portable', () => {
343
+ setupBackstage()
344
+ ensureContentSystemLinks(findSystemRoot(tmp))
345
+ expect(readlinkSync(join(tmp, 'AGENTS.md'))).toBe(join(SYSTEM_DIR, 'AGENTS.md'))
346
+ })
347
+
348
+ it('lazily creates the .storyboard directory when missing', () => {
349
+ setupBackstage()
350
+ expect(existsSync(join(tmp, SYSTEM_DIR, '.storyboard'))).toBe(false)
351
+ ensureContentSystemLinks(findSystemRoot(tmp))
352
+ expect(existsSync(join(tmp, SYSTEM_DIR, '.storyboard'))).toBe(true)
353
+ expect(lstatSync(join(tmp, '.storyboard')).isSymbolicLink()).toBe(true)
354
+ })
355
+
356
+ it('never clobbers a real file already at the content root', () => {
357
+ setupBackstage()
358
+ writeFileSync(join(tmp, 'AGENTS.md'), '# user-owned\n')
359
+ ensureContentSystemLinks(findSystemRoot(tmp))
360
+ expect(lstatSync(join(tmp, 'AGENTS.md')).isSymbolicLink()).toBe(false)
361
+ expect(readFileSync(join(tmp, 'AGENTS.md'), 'utf8')).toBe('# user-owned\n')
362
+ })
363
+
364
+ it('is idempotent and registers bridges in .git/info/exclude', () => {
365
+ setupBackstage({ git: true })
366
+ ensureContentSystemLinks(findSystemRoot(tmp))
367
+ ensureContentSystemLinks(findSystemRoot(tmp))
368
+ const exclude = readFileSync(join(tmp, '.git', 'info', 'exclude'), 'utf8')
369
+ const lines = exclude.split(/\r?\n/)
370
+ // Each entry appears exactly once despite two runs.
371
+ expect(lines.filter((l) => l === '/.agents')).toHaveLength(1)
372
+ expect(lines.filter((l) => l === '/AGENTS.md')).toHaveLength(1)
373
+ expect(lines.filter((l) => l === '/.storyboard')).toHaveLength(1)
374
+ })
375
+ })
312
376
  })