| name | ob-guardrails-project |
| description | Project-specific rules and constraints extracted from ARCHITECTURE.md. Load this skill before implementing any change to understand boundaries, conventions, and constraints for this codebase. |
| license | MIT |
Project Guardrails
Auto-generated by /make-guardrails. Regenerate with the same command when architecture or conventions change.
Architecture Constraints
- Three-process Electron model with strict boundaries. The main process (
src/main/) owns ALL network I/O (HTTP + SSE), filesystem, and OS concerns. The preload (src/preload/) is a thin typed contextBridge. The renderer (src/renderer/) is sandboxed with NO direct Node or network access. Evidence: ARCHITECTURE.md §2, §9; contextIsolation: true, nodeIntegration: false.
- All HTTP runs in the main process. The loop-task daemon sends no CORS headers, so renderer-side
fetch would be blocked. Main-process fetch also works for daemons on remote hosts. The renderer reaches the network ONLY through the typed window.api bridge. Evidence: ARCHITECTURE.md §2, §14; src/main/index.ts handleApiRequest.
src/shared/ipc.ts is the single source of truth for the IPC contract. All cross-process types (ApiRequestArgs, ApiResponse<T>, StreamSubscribeArgs, StreamEventPayload, I18nMessage, LoopTaskBridge, ConfigBridge, etc.) are defined here and imported by all three layers. Any new IPC channel MUST be added to src/shared/ipc.ts first. Evidence: ARCHITECTURE.md §3.3; code graph shows 31 callers of I18nMessage alone.
- Renderer must not import from
src/main/ or src/preload/. The renderer may only import from src/shared/ and its own src/renderer/src/ tree. The preload may only import from src/shared/. The main process may import from src/shared/ and its own src/main/ tree. Evidence: tsconfig.web.json includes only src/renderer/src/**/* and src/shared/**/*; tsconfig.node.json includes src/main/**/*, src/preload/**/*, src/shared/**/*.
- The renderer accesses Electron only through
window.api. The preload exposes a LoopTaskBridge via contextBridge.exposeInMainWorld. The renderer must never call ipcRenderer directly or reach for Node APIs. Evidence: ARCHITECTURE.md §3.4; src/renderer/src/bridge.d.ts ambient typing.
- The app is read-only in v1. It observes loop-task state but does not mutate it (no pause/resume/trigger/edit/delete on daemons). Evidence: ARCHITECTURE.md §2, openspec/config.yaml context.
- SSH onboarding may bootstrap loop-task only with explicit consent. After SSH authentication, require Node.js 20+, detect the
loop-task command separately from daemon state, and offer install-and-start when missing. Use the existing pinned npm path, bind the daemon to 127.0.0.1, verify bounded startup readiness, and surface real remote output on failure. Local/direct instances remain user-managed and must never enter the SSH install path. Evidence: ARCHITECTURE.md §3.2 and §4; src/main/vm-wizard.ts, src/main/ssh-probe.ts, src/main/ssh-launch.ts.
- Mock adapter fallback. When
window.api is absent (browser-only dev via pnpm dev:web), the renderer falls back to src/renderer/src/mock.ts. Any new API function added to api.ts must also provide a mock counterpart. Evidence: ARCHITECTURE.md §14; api.ts checks !window.api and dispatches to mock.
File Organization
- One responsibility per file, no god-files, no dumping grounds. Never create files named
constants.ts, types.ts, config.ts, utils.ts, helpers.ts, or similar catch-alls that collect unrelated items from different domains. If a file imports from 5+ unrelated modules it is a sign it must be split. Split by domain or feature instead (e.g. loop-types.ts, env-config.ts, ssh-utils.ts, wizard-constants.ts). Evidence: ARCHITECTURE.md §1 shows clear per-concern file names (config-store.ts, connection-supervisor.ts, vm-wizard.ts, ssh-probe.ts).
- Renderer follows Feature-Sliced Design (FSD). The
src/renderer/src/ tree is organised into FSD layers: app/ → pages/ → widgets/ → features/ → entities/ → shared/. Upper layers may import from lower layers only, never the reverse. shared/ holds truly cross-cutting UI primitives (design tokens, i18n helpers, generic hooks). Domain logic lives in entities/ or features/, never in shared/. Evidence: frontend-engineer.md description; @feature-sliced-design skill installed.
- Keep components small and single-purpose. A component file that exceeds ~200 lines is a signal it is doing too much. Extract sub-components, custom hooks, or utility functions into separate files within the same FSD slice. Evidence: FSD conventions;
@react-render-optimization skill; project history shows large components being split.
- Domain types live in
src/renderer/src/types.ts or src/shared/ipc.ts. Types mirrored from the loop-task daemon (LoopMeta, RunRecord, Project, TaskDefinition, LoopStatus) go in types.ts. Types shared across process boundaries go in ipc.ts. Do not duplicate type definitions across layers, re-export from the canonical location. Evidence: types.ts re-exports from ipc.ts rather than redefining.
- Formatting helpers are pure functions in
src/renderer/src/format.ts. Time/label/status formatting (timeAgo, timeUntil, commandLine, hostLabel, STATUS_COLORS) must remain pure and side-effect-free. Evidence: ARCHITECTURE.md §3.3.
Naming Conventions
- Component files use PascalCase. e.g.
Sidebar.tsx, LogViewer.tsx, AddVmWizard.tsx. Evidence: ARCHITECTURE.md §1 file tree; openspec/config.yaml conventions.
- Non-component source files use kebab-case. e.g.
config-store.ts, connection-supervisor.ts, ssh-probe.ts, vm-wizard.ts, fleet-status.ts, fleet-mapping.ts. Evidence: src/main/ and src/renderer/src/ file listings.
- i18n message keys use dot-notation with domain prefix. Keys like
vmWizard.mainInvalidEnvUrl, vmWizard.mainHttpError, vmWizard.mainRequestTimedOut follow the pattern <domain>.<camelCaseDescription>. Evidence: src/shared/ipc.ts I18nMessage interface; src/main/i18n.ts msg() function; usage across src/main/*.ts.
- IPC channels use
namespace:action colon syntax. e.g. api:request, stream:subscribe, stream:unsubscribe, stream:event, config:getEnvironments, connection:status. Evidence: src/main/index.ts ipcMain.handle registrations; ARCHITECTURE.md §3.2.
- Versioned localStorage keys use
.vN suffix. e.g. lta.instances.v1, lta.selectedInstance.v1. Evidence: ARCHITECTURE.md §5 migration section; openspec/config.yaml conventions.
Code Style
- Strict TypeScript, no
any. Both tsconfig.web.json and tsconfig.node.json set "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "forceConsistentCasingInFileNames": true. Evidence: tsconfig files.
- ESM only. Both tsconfigs use
"module": "ESNext", "moduleResolution": "bundler". Imports in main/preload use .js extensions (e.g. import { msg } from "./i18n.js") following the ESM convention. Evidence: src/main/config-store.ts line 3; src/main/index.ts imports.
- No CSS framework, plain CSS with custom-property design tokens. Styling uses
src/renderer/src/theme.css with CSS custom properties. Do not introduce Tailwind, CSS-in-JS, or styled-components without team alignment (openspec/config.yaml notes the team is considering adopting Tailwind + shadcn). Evidence: ARCHITECTURE.md §7; DESIGN.md frontmatter components section.
- All user-facing copy must go through i18n keys, no magic strings. The main process uses
msg(key, params) to create I18nMessage objects; the renderer uses react-intl formatMessage / translateMessage(). Never hardcode user-facing text in components. Evidence: openspec/config.yaml rules; src/main/i18n.ts; src/renderer/src/i18n/index.ts.
- Function components + hooks only. No class components. React 19 function components with hooks. Evidence: ARCHITECTURE.md §3.1; openspec/config.yaml conventions.
- REST API responses unwrapped from loop-task envelope. The daemon returns
{ ok, data } / { ok, error: { message } }. The main process handleApiRequest unwraps this into ApiResponse<T> shape. Always unwrap at the main-process boundary, not in the renderer. Evidence: ARCHITECTURE.md §3.2; src/main/index.ts lines 189–200.
- Keep comment ratio under 10%. Code must be self-explanatory through names, structure, and types. Comments are for WHY, not WHAT, only when the reason cannot be inferred from context. If more than 10% of lines in a file are comments, refactor for clarity instead of commenting. Delete stale, obvious, or code-restating comments.
- Never use em-dashes (
—). Use commas, colons, or parentheses instead. This applies to all source files, comments, i18n strings, and documentation. The em-dash character (U+2014) is forbidden in this codebase.
- Never access
window.api directly from components or hooks. All IPC access goes through injected services via useInject<IXxxService>(cid.IXxxService). The container is built in src/renderer/src/services/container.ts and picks real vs mock implementations based on window.api presence. Components resolve services with useInject; hooks that can't use hooks (e.g. inside effects) use container.resolve. The bridge.d.ts ambient declaration has been deleted; mock.ts is replaced by mock service implementations in src/renderer/src/services/mock/.
- Use structured logging, not
console.*. Main-process modules use logger or createLogger() from src/main/logger.ts; renderer components and hooks use injected ILogService. Console output is allowed only inside MockLogService and the logger transport configuration.
Testing
- Test framework: Vitest 4. Tests import from
"vitest" (describe, it, expect, vi). Evidence: tests/*.test.ts; package.json devDependencies.
- Test files live in
tests/ at the project root. Test file naming: <module-name>.test.ts (e.g. connection-supervisor.test.ts, fleet-status.test.ts, fleet-mapping.test.ts). Evidence: tests/ directory.
- No
test script in package.json. Run tests via pnpm vitest or npx vitest. Tests import source files via relative paths with .js extensions (e.g. import { ... } from "../src/main/connection-supervisor.js"). Evidence: package.json scripts; test file imports.
- No coverage gates or CI.
pnpm typecheck is the only automated gate. When adding new logic, add corresponding Vitest tests in tests/. Evidence: ARCHITECTURE.md §13; openspec/config.yaml context.
- Pure logic is testable without Electron mocks.
ConnectionSupervisor, classifyError, isNetworkDownError, fleet-status functions, and fleet-mapping functions are all pure/testable. Follow this pattern: extract logic from Electron-coupled code into pure modules. Evidence: test files test pure functions, not IPC handlers.
Build & Deployment
- Package manager:
pnpm (never npm or yarn). Node >= 20 required. Lockfile: pnpm-lock.yaml. Evidence: README.md; package.json; openspec/config.yaml.
- Build commands:
pnpm dev, Electron app with HMR (main + preload + renderer)
pnpm dev:web, renderer-only in browser on port 5183 with mocked data (no Electron/daemon)
pnpm build, electron-vite build → out/main, out/preload, out/renderer
pnpm start, electron-vite preview (production build preview)
pnpm typecheck, tsc --noEmit for both node + web project references
- Build output goes to
out/. out/ is gitignored. package.json main points to ./out/main/index.js. Evidence: .gitignore; package.json.
- No CI/CD, no packaging config. No electron-builder/forge, no Docker, no deployment pipeline. This is a locally-run desktop app. Evidence: ARCHITECTURE.md §8, §15.
- Two build targets share
src/renderer: the Electron renderer (electron.vite.config.ts) and a plain-browser preview (vite.web.config.ts, root: src/renderer). Evidence: ARCHITECTURE.md §1; vite.web.config.ts.
Data & State
- No database, the app owns no durable domain data. All authoritative data lives in the loop-task daemons. Local persistence is limited to config and window bounds. Evidence: ARCHITECTURE.md §5.
- Config persistence:
electron-store in the main process. Typed JSON in Electron's userData directory. Holds environments, selectedEnvironmentId, sessionTokens. Accessed by the renderer ONLY through typed IPC channels. Evidence: ARCHITECTURE.md §5; src/main/config-store.ts.
- No
localStorage for config. Instance/environment data is stored in the main process via electron-store, eliminating the renderer-side XSS surface. localStorage is used only as a fallback in mock mode (browser-only dev with no Electron). Evidence: ARCHITECTURE.md §9, §14.
- Wizard credentials use a dedicated reference vault. Environments store only opaque
credentialRefs; credential-vault.ts stores safeStorage-encrypted session tokens and SSH key passphrases in a separate electron-store. Main-process consumers resolve references, and removing an environment deletes its owned vault entries. Never add credential values or ciphertext to Environment. Evidence: src/main/credential-vault.ts; src/main/config-store.ts credential functions.
- Window bounds persisted to
window-bounds.json in Electron's userData dir, debounced (500ms). Evidence: ARCHITECTURE.md §3.2; src/main/index.ts saveBounds.
- SSE stream parsing uses a spec-compliant parser module. The main process
sse-parser.ts uses eventsource-parser to handle multi-line data: concatenation, chunk-boundary splits, and id:/retry:/comment lines per the WHATWG SSE spec. Do not re-introduce hand-rolled \n\n splitting. Evidence: src/main/sse-parser.ts; src/main/index.ts handleStreamSubscribe.
- SSS subscriptions tracked in
Map<subId, AbortController> for clean teardown. Subscriptions are aborted on unsubscribe. Evidence: ARCHITECTURE.md §3.2; src/main/index.ts streams map.
- Log viewer caps in-memory lines at 2000 (
MAX_LINES) and starts from a 200-line tail. Evidence: ARCHITECTURE.md §11.
- Loop list polled every 5s; background health checks every 20s. Logs are pushed via SSE (not polled). Evidence: ARCHITECTURE.md §4, §11.
Security
- Renderer sandboxing:
contextIsolation: true, nodeIntegration: false. The renderer has no Node/network access and reaches the outside world only through the narrow window.api bridge. Evidence: ARCHITECTURE.md §9.
- URL validation: only
http: and https: protocols allowed. The main process isAllowedBaseUrl() rejects any other protocol before making a request. Evidence: src/main/index.ts lines 129–136.
- External links open in the system browser.
setWindowOpenHandler denies in-app navigation and routes links to the OS browser. Evidence: ARCHITECTURE.md §9.
- Trust boundary: renderer ⇄ preload ⇄ main. Only the main process performs network and filesystem I/O. The preload is a pass-through typed bridge. Evidence: ARCHITECTURE.md §9.
- No secrets stored unencrypted. Wizard credentials are encrypted via
safeStorage in the dedicated credential vault, with no plaintext fallback. Environment config contains references only. Existing OpenCode passwords use the config-store encryption wrapper. The serialization layer (sanitizeEnvironmentForSync in config-store.ts) uses explicit allowlists at every nesting level to structurally prevent secret fields (password, wasEncrypted, encryptedAccessToken, encryptedValue, accessToken) from appearing in synced/serialized output. Fields not on the allowlist are silently dropped. Evidence: ARCHITECTURE.md §5 and §9; src/main/credential-vault.ts; src/main/config-store.ts; SECRET_FIELD_NAMES constant.
- Auth tokens injected at the main-process boundary.
handleApiRequest and SSE subscriptions attach Authorization: Bearer <token> headers only in the main process, never in the renderer. On 401, session tokens are removed and the environment auth state is set to "blocked". Evidence: src/main/index.ts lines 165–179, 227–231.
Dependencies
- Package manager:
pnpm only. Never use npm or yarn. Evidence: README.md; pnpm-lock.yaml; openspec/config.yaml.
- Lockfile:
pnpm-lock.yaml. Must be committed. Evidence: .gitignore does not exclude it.
- Node >= 20 required. Evidence: README.md; ARCHITECTURE.md §7.
- Key dependencies (do not remove without team alignment): Electron 37, electron-vite 4, React 19, electron-store 11, Vite 7, TypeScript 5.8, react-intl 10, vitest 4, electron-log 5, eventsource-parser 3, inversify-props 3, reflect-metadata 0.2. Evidence: package.json.
- Key devDependencies: @playwright/test, playwright, sharp 0.35, tsx 4, zod 4. Evidence: package.json.
allowBuilds in pnpm-workspace.yaml: electron: true, esbuild: false, sharp: true. Electron and sharp native builds are now allowed. Do not change without understanding the implications. Evidence: pnpm-workspace.yaml.
- No lint/formatter config present. No ESLint, Prettier, Biome, or similar.
pnpm typecheck is the only code-quality gate. Evidence: no config files found; ARCHITECTURE.md §12.
Git Workflow
- Platform: GitHub. Use
gh CLI for all GitHub tasks (PRs, issues, checks). Evidence: .opencode/opencode-onboard.json platform.backlog: "github", platform.repo: "github".
- Prefix all shell commands with
rtk. Evidence: openspec/config.yaml context.
- Concurrent tasks must touch disjoint files (max 3 parallel). Evidence: openspec/config.yaml rules.tasks.
- Every change must map to a concrete user-facing capability or an architectural constraint from ARCHITECTURE.md. Evidence: openspec/config.yaml rules.proposal.
- Respect Electron process boundaries in proposals. Network I/O belongs in main; the renderer only calls
window.api. Call out any new IPC channel explicitly. Evidence: openspec/config.yaml rules.proposal.
- Include a verification step in every task. At minimum
pnpm typecheck must pass. Evidence: openspec/config.yaml rules.tasks.
Skills & Patterns
The following skills are installed in .agents/skills/ and must be respected when working in their domain. When in doubt, load the relevant skill before implementing.
Architecture & Structure
@feature-sliced-design, FSD layer rules for src/renderer/src/. Upper layers import from lower; shared/ holds only cross-cutting primitives. Use this skill for any renderer structural decision.
@inversify-hooks, DI container wiring with useInject hook. Use for injecting services into React components and registering singletons/transients in the container.
React & Performance
@react-2026, React 19 patterns: Server Components, Actions, use(), useOptimistic, useTransition. Load before adding new React features.
@react-render-optimization, memoization, virtualization, avoiding re-renders. Load before touching hot-path components or adding lists. The renderer uses @tanstack/react-virtual for long lists.
@vercel-react-best-practices, data fetching, Suspense, concurrent patterns. Load for any data-loading or async UI work.
@hooks-pattern (PatternsDev), custom hook composition. Prefer extracting stateful logic into hooks over putting it in components.
@provider-pattern (PatternsDev), React context/provider patterns. Prefer this over prop drilling across more than 2 levels.
@compound-pattern (PatternsDev), compound component API design for complex UI.
@virtual-lists (PatternsDev), virtualizing large lists. Required when rendering >50 rows.
Build & Bundling
@vite, Vite 7 / electron-vite 4 configuration. Load for any build config change.
@vite-bundle-optimization (PatternsDev), code splitting, tree shaking, dynamic imports. Load before changing import structure.
@js-performance-patterns (PatternsDev), JS runtime performance. Load for hot-path or CPU-sensitive work.
Styling & Design
@accelint-design-foundation, CSS custom-property design tokens, spacing scale, variant system. Load for any styling work.
DI wiring rule: inversify-hooks container must be configured in app/ layer (FSD). Features inject via useInject; they do not construct services directly.