# VCP 21 — Agent Rail Rebuild Specification (id 9, 2026-09-21 18:45 Europe/Paris) **Status**: SPEC ONLY. No code, no commits, no deploys. **Phase 0**: VA UP at attempt 1 (gemini). **Phase 0.5**: All 3 steps pass (see SANDBOX CURRENCY). --- ## Inline summary (≤40 lines) | Section | Outcome | |---|---| | **A — Keep as-is** | 40-name pool (4 themes × 10: birds/stars/gems/crafts); Type A expanded / Type B collapsed lane model; rail layout (48px fixed right, Vibe icon + 8 max icons); StreamOrchestrator `classify()` (Wait wrong / Also do / restart); deterministic hsl color from name hash; ArchiveReporter `vcsStore.addMessage` fall-back | | **B — Rewrite** | All 5 files use `window.__fvRegistry` + `localStorage` flags — none import `src/agent/`. Restoration = ESM `.js` ↔ typed TS shim around a shim. ActivityPanel hand-rolled SSE parser (no longer applicable). `src/agent/` 350 lines of dead code, never wired. | | **C — Finish** | FreshCards `putAgentActivity` EXISTS at `src/modules/fc-encryption/stores/agent-activity.ts:18` — NO vibecoder caller (grep 0 hits outside the file itself + its test). `ArchiveReporter.archiveUrl` is placeholder string. Current `AgentLifecycleState` = 4 states (`idle/active/error/recover`); operator description says 6 (spawned→active→paused→completed→archived→retired) — NOT IMPLEMENTED. | | **D — Architecture** | New module `src/vcs/agents/`. React components (AgentRail, AgentLane). TS state machine (AgentStore). Boundary: chat transport (`src/vibechat/`) emits domain events; agent module consumes them — independent of SSE/JSON/whatever transport. Mount: S2 main flex sibling right-edge. | | **E — Build order** | 5 dispatches: (1) AgentStore + lifecycleBus wiring, (2) AgentLane UI, (3) AgentRail layout, (4) ArchiveBridge to FC, (5) end-to-end Playwright audit. | | **F — Risks** | 4 vs 6 lifecycle states — operator must choose. Mount point collision risk with VCP 15 work (thread rail pill / message-panel minimise / toast) — checked, none if rail mounts at S2 right. FC card schema choice. | --- ## SECTION A — What to keep as-is ### A1. 40-name pool from `AgentNamer.js` (verbatim, lines 6-11) ``` birds: ['Wren', 'Robin', 'Jay', 'Lark', 'Finch', 'Hawk', 'Raven', 'Crane', 'Swift', 'Heron'], stars: ['Atlas', 'Vega', 'Lyra', 'Orion', 'Sirius', 'Altair', 'Rigel', 'Polaris', 'Castor', 'Capella'], gems: ['Onyx', 'Jade', 'Ruby', 'Opal', 'Pearl', 'Amber', 'Coral', 'Ivory', 'Slate', 'Flint'], crafts: ['Sage', 'Kit', 'Reed', 'Iris', 'Quill', 'Ember', 'Thorn', 'Vale', 'Brook', 'Rowan'], ``` Survives: theme-as-pool model, random theme pick, draw-without-replacement, Roman suffix cycling (II, III, ..., X, then `' N'`, cap at 50), `release()` for retries. ### A2. Two-type lane model from `ActivityPanel.js` Header excerpt (line 2): `Per-agent lanes (Type A expanded, Type B collapsed)`. Type A = conversational agents (expanded lane by default). Stream verbose, full content visible, stop button cancels mid-stream. Type B = non-conversational agents (collapsed lane by default). Stream terse, summary visible, stop button waits for natural finish. Configured via `lanes[]` parameter passed to `ActivityPanel` constructor (line 146: `constructor({ container, lanes, onIntent, onStop, defaultLaneMode = 'collapsed', feature = {} } = {})`). The two types are NOT hard-coded inside the panel; they are a property of each lane entry. Each lane has its own `type: 'A' | 'B'` field. ### A3. Rail layout and behaviour from `AgentRail.js` (lines 23-31) - 48px-wide vertical rail, fixed right-side - Vibe icon at top (`💬`), click → return to chat home (focus chat input + scroll to bottom) - Scrollable column of agent icons below, max 8 visible (scroll for overflow) - Each icon: 32px circle, first letter of agent name, deterministic color `hsl(hue, 60%, 50%)` from name hash (line 79-86) - Click icon → `onSelectAgent(id)` callback (focus that agent's lane in ActivityPanel) - `removeAgent(id)` fades out over 1000ms before DOM removal (lines 65-72) - Narrow viewport (≤720px) collapses to 36px via `@media` - Feature flag `feature.agent_rail` (default ON) ### A4. Streaming model from `StreamOrchestrator.js` Three discrete parts (lines 30-93): 1. **Intent classifier** (`classify(userInput)`): - `/^\s*(wait|stop|no|hold)[,\s]+(wrong|instead|rather|do|try|instead)/i` → `replace` action with last 800 chars of partial content as context - `/^\s*(also|and|plus|additionally)\b/i` → `parallel` action - `/^\s*(skip|forget it|start over|restart)\b/i` → `replace` if streams active - default → `start` action 2. **Stream registry**: `Map` with max-active cap of 3 3. **Cancellation**: `abortAll()` bumps generation counter per stream; generator that yields after a generation bump early-exits Feature flag `feature.parallel_streams` (default ON) read at constructor. ### A5. Archive interface from `ArchiveReporter.js` Two methods (lines 20-43): - `reportSuccess({name, taskId, brief, archiveUrl, extra})` → adds system message `{role: 'system', text: '✓ finished task — · [→ View record]() · (k=v, ...)'}` to `vcsStore.addMessage()` - `reportFailure({name, brief})` → adds system message with `[Retry] [Skip] [Change approach]` buttons (text-only — buttons are renderer-side) ### A6. Lifecycle states (operator-listed 6) Per dispatch hint: `spawned → active → paused → completed → archived → retired`. **CRITICAL**: The current `src/agent/lifecycle/lifecycle.ts:9` defines only 4 states: `'idle' | 'active' | 'error' | 'recover'` with valid transitions: ```ts const VALID_TRANSITIONS: Record = { idle: ['active'], active: ['idle', 'error'], error: ['recover'], recover: ['idle', 'error'], } as const; ``` The operator's 6-state list is NOT IMPLEMENTED. The rebuild must decide whether to: - (A) Extend `VALID_TRANSITIONS` with the 6 states — breaks ReadinessPanel compatibility (Phase 5 uses the 4-state model in tests.ts) - (B) Keep 4 states in `lifecycle.ts` and add a parallel 6-state model in `AgentStore.ts` for the rail's display — layers state semantics - (C) Replace 4 states with 6 states — breaks `lifecycle.ts` consumers --- ## SECTION B — What must be rewritten ### B1. Globals vs framework All 5 deleted files use `window.__fvRegistry`, `window.__fvAtlas`, `window.__fvRuntimeSseStream`, `localStorage.getItem('feature.parallel_streams')`. The current `src/agent/` framework provides typed singletons: `agentRegistry`, `agentRouter`, `lifecycleBus`. Restoration requires one of: - **Option X (full restoration)**: Re-introduce `window.__fvRegistry` and `window.__fvAtlas` boot wire-up (last seen in `ae62835` Pact Step 1, May not be wired in current boot). Pros: zero TypeScript DI complexity. Cons: re-introduces the exact UI/framework split that killed the feature in `0eecd84`. - **Option Y (full rebuild with DI)**: Rewrite deleted files as TypeScript classes/React components that take `agentRegistry`, `agentRouter`, `lifecycleBus` via constructor injection or React context. Pros: on the canonical stack; future SSE removal cannot delete the rail as collateral. Cons: real engineering work. - **Option Z (shim)**: Keep deleted files as `.js`, add a small adapter that wires `agentRegistry` → `window.__fvRegistry` at boot. Pros: minimal rewrite. Cons: shim around a shim — operator has already flagged this as "around a shim". **Operator has chosen Y** per the dispatch WHY. Sections D + E follow that path. ### B2. Hand-rolled SSE parser in `ActivityPanel.js` `ActivityPanel.js` lines 100-180 contain a custom `EventSource`/`ReadableStream` parser for the bridge's SSE stream. The current chat transport is JSON-only (per `0eecd84` SSE removal + b004491 verification). Any rebuild MUST use the JSON path (`/api/agent/ai/chat`) and either: - consume the JSON response in AgentStore (single full message), OR - consume the streaming JSON (if/when bridge supports NDJSON chunked) Per VC 191b verified path: bridge returns `{"ok":true,"route":"...","phaseB":{...}}` JSON. For agent multi-stream, the JSON would need a `streams[]` array with per-stream deltas. **Decision pending**: does the bridge need to evolve to support parallel streams, or does the rail consume a single stream and simulate multi-agent from it? Recommend single-stream + rail-spawned parallel sub-agents that each make their own bridge call. ### B3. ESM `.js` ↔ typed TS Deleted files use ES module `export class`. Current codebase is TypeScript with `export class AgentRegistry`. Vite can compile `.js` to be importable from `.ts`, but the rebuild SHOULD migrate to `.ts` so types flow through. Cost: full re-typing of 5 files (already done in `src/vcs/StandaloneContainer.js` lines 1-1000 — JS class with manual type assertions in test files). ### B4. Lines in deleted files that MUST change | File | Lines | Reason | |---|---|---| | `AgentRail.js` | 17 (`this._container = container`) | Switch from raw `container` to React context (or HTMLElement mount point passed by VibeChatApp.tsx) | | `AgentRail.js` | 38 (`document.createElement('div')`) | Pure DOM construction → React component | | `AgentNamer.js` | entire file | Already idiomatic; can be `.ts` with zero logic change | | `ArchiveReporter.js` | 28 (`this._vcsStore.addMessage(...)`) | ALSO call `putAgentActivity(...)` from fc-encryption (see Section C1) | | `ActivityPanel.js` | 100-180 (SSE parser) | REPLACE with JSON response handler — no SSE | | `StreamOrchestrator.js` | 56-79 (intake of `feature.parallel_streams`) | Pull flag from `lifecycleBus.getState()` instead of localStorage | --- ## SECTION C — What must be finished ### C1. FreshCards `putAgentActivity` wire-up The function exists at `src/modules/fc-encryption/stores/agent-activity.ts:18`: ```ts export async function putAgentActivity(row: AgentActivityRow): Promise { const db = await openFceStore(); return new Promise((resolve, reject) => { const tx = db.transaction('agent_activity', 'readwrite'); tx.objectStore('agent_activity').put(row); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } ``` **Status check**: `grep -rn "putAgentActivity" src/` returns hits only at the file itself + its test (`stores-open.test.ts:59`). NO vibecoder caller. The rebuild should call `putAgentActivity` from a new `ArchiveBridge.ts` whenever an agent task completes (success or failure). The `AgentActivityRow` shape (line 5-15) takes: - `id`, `timestamp`, `agentType`, `modelUsed`, `workspaceContext` (required) - `cost`, `input`, `output`, `operatorLane`, `reflexType` (optional, ciphertext fields) The `output` field maps to the agent's reply content; `cost` to the model's reported cost; `input` to the user's prompt. Optional fields can stay empty for v1. ### C2. `ArchiveReporter.archiveUrl` placeholder Per `ArchiveReporter.js` line 22: `[→ View record]()`. The original was a placeholder string (per VCP 19 finding d000254). The real URL needs to be: - An FC card URL (`fcid://card/`) for records stored in `agent_activity` IndexedDB store - OR a FreshCards page URL via the embed API (e.g. `/embed/`) **Decision pending**: FC lane (id 11) needs to expose the embed URL format for agent records. Until then, `archiveUrl` remains a placeholder. ### C3. Other half-built pieces | Piece | Source | Status | |---|---|---| | `lifecycleBus` consumers | `src/agent/lifecycle/lifecycle.ts` | Exists; no consumers except `ReadinessPanel/tests.ts:5,7,9` (test only) | | `agentRegistry.register(id, capability)` | `src/agent/routing/capabilities.ts:36` | Method exists; called only from ReadinessPanel tests | | `agentRouter.addRule(predicate, target)` | `src/agent/routing/router.ts:111` | Method exists; constructor adds 2 default rules; nothing adds runtime rules | | `agentRouter.select(task)` | `src/agent/routing/router.ts:99` | Method exists; called only from ReadinessPanel tests | | `hotSwapController` | `src/agent/routing/hot-swap.ts` | Exists; imported only by ReadinessPanel/tests.ts | | `src/agent/mutation/self-healing.ts:171,187,190` | uses `appendAudit({...agent: opts?.agent})` | Already passes `agent` field; FC consumer missing | **Observation**: `src/agent/` was built for the Phase 5 readiness tests (Sep 5) and never wired into the runtime path. The rebuild IS the first runtime consumer of these classes. ### C4. StreamOrchestrator's `StreamOrchestrator.js` parser Lines 17-23: reads `feature.parallel_streams` from `localStorage`. This is the **only** localStorage flag in the deleted files. The rebuild could pull this from a typed config module (`src/vcs/agents/config.ts`) instead. --- ## SECTION D — Target architecture ### D1. New module: `src/vcs/agents/` ``` src/vcs/agents/ ├── AgentStore.ts # State machine + observer subscription ├── AgentStore.test.ts # Vitest ├── AgentNamer.ts # 40-name pool (migrated from .js to .ts) ├── AgentNamer.test.ts ├── AgentLane.tsx # Per-agent row UI (Type A expanded / Type B collapsed) ├── AgentLane.css ├── AgentRail.tsx # Right-side rail layout ├── AgentRail.css ├── AgentArchiveBridge.ts # putAgentActivity + archiveUrl factory ├── AgentArchiveBridge.test.ts ├── AgentIntentClassifier.ts # Migrated classify() from StreamOrchestrator ├── useAgentStore.ts # React hook: subscribes to AgentStore ├── config.ts # TypeScript config (feature flags, max-active, etc.) └── README.md ``` ### D2. Boundary between rail and chat transport **Critical design constraint** (per operator WHY): a future SSE/JSON/binary/whatever transport change MUST NOT delete the rail as collateral again. ``` ┌─────────────────────────────────────────────────────────────┐ │ src/vcs/agents/ (NEW — agent domain) │ │ ───────────────────────── │ │ AgentStore → emits domain events: │ │ 'agent:spawned' {id, name, type, lane} │ │ 'agent:content-delta' {id, chunk} │ │ 'agent:completed' {id, output, status} │ │ 'agent:failed' {id, brief, status} │ │ 'agent:paused' {id, reason} │ │ 'agent:archived' {id} │ └─────────────────────────────────────────────────────────────┘ ▲ │ subscribes │ ┌─────────────────────────────────────────────────────────────┐ │ src/vibechat/ (EXISTING — chat transport) │ │ ───────────────────────── │ │ ChatPanel.tsx, VibeChatApp.tsx — already have │ │ VibeChatStore.js (chatState, messages, toastBubble) │ │ Bridged from /api/agent/ai/chat JSON │ └─────────────────────────────────────────────────────────────┘ ``` The bridge module translates JSON `{"ok", "route", "phaseB", ...}` into the agent domain events. If the bridge evolves (e.g. SSE returns, JSON shape changes, WebSocket added), the bridge module changes; the agent rail does NOT. ### D3. Mount points in current code - `src/vibechat/VibeChatApp.tsx:355-365` — S2 main chat panel. Right-flex sibling is the natural mount point. - `src/vcs/StandaloneContainer.js:30,33,297,369` — has imports for `ProjectRail` (still exists, left-side) and `Timeline` (still exists, 130L). Add `AgentRail` to these imports. - `src/host/editor/SelectorBar.tsx:1` — already mounts as right-edge vertical strip (`SelectorBar.tsx` for sub-threads, per r231 §4). AgentRail is a SEPARATE strip OR shares the SelectorBar's right edge. **Two options**: - (D3a) Independent right-edge strip (48px) — same visual concept as the deleted AgentRail but NEW DOM location - (D3b) Merge into SelectorBar.tsx — sub-thread notches + agent icons share one vertical strip. More compact but coupled. Recommendation: D3a (independent). Less coupling. SelectorBar already serves r231 sub-threads; agent rail serves Vibe Agents cascade. ### D4. State holding AgentStore (TS class, singleton) holds `Map` where `AgentRecord = {id, name, type: 'A' | 'B', state: LifecycleState, contentChunks: string[], completedAt?: number, archiveUrl?: string}`. Subscribers via `store.subscribe((records) => render)` pattern (matches `lifecycleBus` shape). ### D5. Archive write path ``` AgentStore emits 'agent:completed' ↓ AgentArchiveBridge.handleCompletion(record) ↓ 1. putAgentActivity(record.toAgentActivityRow()) 2. vcsStore.addMessage(threadId, systemMessage) ← existing path 3. archiveUrl = 'fcid://card/' + record.id ← pending FC lane API 4. update record with archiveUrl ``` --- ## SECTION E — Migration / build order (5 small dispatches) ### Build 1: AgentStore + lifecycleBus wire - **Produces**: `src/vcs/agents/AgentStore.ts`, `AgentStore.test.ts`. Singleton class with subscribe/emit/transition methods. Wires to `lifecycleBus` from `src/agent/lifecycle/lifecycle.ts`. - **Verifies**: vitest covers spawn → content-delta → completed lifecycle; transition validation matches `VALID_TRANSITIONS` map. - **Does NOT touch**: UI, fc-encryption, chat transport. ### Build 2: AgentLane UI - **Produces**: `src/vcs/agents/AgentLane.tsx`, `AgentLane.css`. Pure presentational React component. Takes `AgentRecord` prop. Type A renders expanded (full content chunks streaming); Type B renders collapsed (header + summary). - **Verifies**: vitest + Playwright snapshot test of an AgentLane with mock `AgentRecord`. - **Does NOT touch**: AgentRail layout, agent domain logic, chat transport. ### Build 3: AgentRail layout + VibeChatApp mount - **Produces**: `src/vcs/agents/AgentRail.tsx`, `AgentRail.css`. 48px right-edge vertical strip. Vibe icon top + scrollable column of `` icons. Subscribes to AgentStore via `useAgentStore()` hook. - **Verifies**: Playwright snapshot — rail mounts next to S2 main, icons appear when agents spawn, deterministic hsl color. - **Does NOT touch**: AgentLane itself (just imports the mini variant), fc-encryption, chat transport. Does modify `src/vibechat/VibeChatApp.tsx:355-365` to add the rail mount. ### Build 4: AgentArchiveBridge + FC wire-up - **Produces**: `src/vcs/agents/AgentArchiveBridge.ts`, `AgentArchiveBridge.test.ts`. Subscribes to AgentStore. On `'agent:completed'`, calls `putAgentActivity` (fc-encryption) + `vcsStore.addMessage` (existing). - **Verifies**: vitest covers success + failure paths. Mock `putAgentActivity` to confirm `AgentActivityRow` shape is correct. - **Does NOT touch**: rail UI, agent domain logic. ### Build 5: end-to-end + Playwright audit - **Produces**: integration test `tests/agent-rail-e2e.spec.ts`. Spins up dev server. Simulates user typing "build me a coffee shop", waits for Phase B response, captures rail state, asserts icon count + lane content. - **Verifies**: drift-check canonical (R-108): vitest pass + Playwright snapshot at ≥95% similarity to baseline. - **Does NOT touch**: any source file (test-only). --- ## SECTION F — Risks and unknowns ### F1. Does `src/agent/` need modification to accept a live consumer? The `VALID_TRANSITIONS` map has 4 states. If operator wants 6 states (`spawned/active/paused/completed/archived/retired`), `lifecycle.ts` needs extension. **Decision pending**: - (F1a) Extend `VALID_TRANSITIONS` to include 6 states. ReadinessPanel.tsx tests may break. - (F1b) Keep 4-state model in `lifecycle.ts`; use a parallel 6-state model in `AgentStore.ts` for the rail's display. Two state machines. - (F1c) Replace 4-state with 6-state. Breaks `ReadinessPanel/tests.ts:44`. `agentRegistry.register(id, capability)` and `agentRouter.addRule(predicate, target)` are already public methods — rebuild can call them directly without modification. ### F2. VibeChat panel / edge panel / dashboard host changes? | Current surface | Would change? | |---|---| | `VibeChatApp.tsx:355-365` (S2 main) | YES — add rail mount as right-flex sibling | | `src/host/header/MinimalHeader.tsx` | NO — header is chrome; rail is in-canvas | | `src/host/editor/SelectorBar.tsx` | NO if D3a (independent strip); minor if D3b (merge) | | `src/cms/runtime/panel-manager/` | NO — PM2 panels not involved | | `src/host/dashboard/` | NO — dashboard is separate panel | ### F3. Collision with VCP 15 inventory (thread rail pill / message-panel minimise / toast)? VCP 15 documented (VibeChat panel) state surface: S1 (ThreadsPanel left), S2 (ChatPanel main), S3 (ToolsPanel right). **Agent rail adds S4 (right-side, INSIDE S2)** — does not collide with S3 which is right-flex sibling. Toast bubble (`ToastBubble.js`) is a topbar overlay, not a panel. Message-panel minimise (`chatState`) is unrelated to rail state. **No collisions** if rail mounts at S2 right-edge flex sibling. ### F4. What else does the operator need to decide before build starts? 1. **Lifecycle states**: 4 (current `lifecycle.ts`) or 6 (operator's description)? See F1. 2. **FC card schema**: when `ArchiveReporter.archiveUrl` is generated, is it an `fcid://` URI or an `/embed/` URL? Requires FC lane (id 11) sign-off. 3. **Mount location**: D3a (independent right strip) or D3b (merge with `SelectorBar.tsx`)? 4. **Visual differentiation R11** (per VCP 19 d000254): is the Type A vs Type B visual difference ONLY lane expanded/collapsed, or also icon color/icon shape/border weight? 5. **Spawn trigger**: who calls `AgentStore.spawn(agent)` — the chat transport when a cascade is requested, or a separate "Spawn Agent" UI button? Per VCP 19 finding d000254 the original was "click an agent icon → focus lane", but spawning was implicit (caller-driven, not user-driven). ### F5. Risks that can't be answered without source archaeology - Was `window.__fvRegistry` ever wired into `host-boot.js` post-`0eecd84`? If yes, the rebuild can reuse it. If no, the rebuild MUST add it back. - Does the current `/api/agent/ai/chat` JSON response carry enough info to reconstruct the multi-stream state (multiple agents' outputs interleaved)? Or does the bridge need to evolve to NDJSON? - Are there hidden callers of `AgentRegistry`/`AgentRouter`/`AgentLifecycleBus` that we haven't found because they're dynamically constructed at runtime (e.g. via `eval` or string-key imports)? --- ## New reflex **9-#283 (VCP 21)**: When asked to produce a rebuild specification for a deleted feature, ALWAYS first read the current `src/agent/` (or relevant framework dir) framework classes for the public API surface. The deleted files used the OLD globals (`window.__fvRegistry`); the rebuild should use the NEW typed singletons (`agentRegistry`, `agentRouter`, `lifecycleBus`). This produces a 5-section spec (A-keep / B-rewrite / C-finish / D-architecture / E-build-order / F-risks) that maps every old behaviour onto a new wiring without re-introducing the UI/framework split that killed the feature. ## Compliance | Constraint | Honored? | |---|---| | READ-ONLY | YES — no source changes; only doc + bulletin | | Don't write components / classes | YES — spec only | | Don't restore deleted files | YES — only retrieved them (per VCP 20); not restored here | | Don't touch bridge / cascade / R107 / kit / TLS / Gallery / FC / VSIL | YES | | List design conflicts + both options | YES — Sections B1, D3, F1, F4 all present both options | | Adapt or STOP if ground differs | YES — discovered `lifecycle.ts` 4-state vs operator's 6-state; flagged for decision | ## ALIGN | Layer | State | |---|---| | [1] git: clean | YES (0/0 divergence on vibecoder-standalone; mavis-kits pulled to `a418f47` which equals origin/main) | | [2] deploy: N/A | N/A — spec only | | [3] HQ: bulletin + doc | bulletin **b004585** + doc drafted | | [4] Notion: skip-reason | SKIPPED — spec is long-form design; doc body is the authoritative artifact | | [5] memory: entry appended | entry drafted | | [6] task board: closed or n/a | n/a — spec dispatch, not a build task | | [7] report: header + footer + return tuple | YES | | [8] operator: N/A | N/A — read-only | ## Bulletin + doc IDs - Bulletin **b004585** (info, posted) - Doc id TBD ## USAGE / NATIVE FOOTPRINT USAGE: VA 1 call Phase 0 (gemini attempt 1, no retry) | tool-IO ~25 commands (curl bulletin/tasks; bash connect.sh; read 5 VCP-20 full-files; ls src/agent; grep framework class usage; head src/vcs/VibeChatStore.js; grep putAgentActivity; bulletin + doc post) ROLLING 24h: VC 200 (b004534) → VC 201 (b004540) → VC 202 (b004548) → VCP 15 (b004562) → VCP 18 (b004575) → VCP 20 (b004583) → VCP 21 (b004585) NATIVE FOOTPRINT: 8 prose sentences (spec kept tight per cap 10; full content is structured sections A-F with file:line citations) ## Return tuple ``` bulletin_id: b004585 doc_id: notion_url: commit_hash: 94fd5eb (no commit; clean baseline) kit_HEAD: a418f47 (RULES.md v1.17.0) ``` ## Propagation line (reflex #22) VCP 21 spec → operator design review. After operator decisions on F1 (lifecycle states), F4 (FC schema, mount location, visual differentiation, spawn trigger), Build 1 can start. ## [id9] | end PROMPT VCP 21