Cambium Spore + Discord Routing v1 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship two tightly-coupled v1 deliverables:
-
@cambium/spore— a tiny new package that turns deployment identity into declarative configuration. Each deployment gets aspore.yamlat its root holding the deployment name, owner, paths (substrate list, daily, personas, rhythms), and launchd hints. AloadSpore(path)function parses, validates, and resolves paths. Every organ in the brain process receives the parsedSporeobject at boot instead of reading hardcoded literals. -
Persona router + Discord adapter wired into the Obadiah brain. A new
@cambium/cortexmodulepersona-router.tsreads personas fromspore.paths.personas, registers one receptor per persona againstdiscord.message_created, and logs routing decisions using case-insensitive word-boundary matching of each persona’sdomain_triggersagainst the message content. The existingDiscordAdapterfrom@cambium/integrationsis also instantiated inside the brain process so that Discord-originated events become signals on the shared bus. No cortex invocation, no agent spawning, no reply sending — v1 is observation only.
Architecture: One brain process per deployment. start-rhythms.ts stays the name but grows two new organs: a Discord adapter and a persona router, both registered on the same shared SignalBus that rhythms already uses. The brain loads the spore first, then hands spore-derived values (workspace id, personas path, rhythms dir) to every organ that boots after it. Nothing in packages/* knows which deployment it’s running in.
Tech Stack: TypeScript strict, ESM, yaml package, Vitest, Node built-ins (fs/promises, path). Depends on @cambium/core (for WorkspaceId / ReceptorId / createId), @cambium/signals (for SignalBus and the Receptor type), and in the router’s case, @cambium/core‘s Signal type.
Scope decisions carried in from the spec:
- The router only logs in v1. No cortex invocation, no genesis spawn, no Discord reply. This is deliberate — the wiring needs to be observable before we trust it with actual behavior.
- The DiscordAdapter class in @cambium/integrations is a pure event→signal translator. It has no built-in Discord.js client wiring; callers feed it events via adapter.handleEvent(event). The v1 brain process instantiates it so that a future Discord.js client (or the existing one elsewhere in the codebase) can feed it events on the shared bus. No bot token is consumed at the adapter constructor level — the adapter’s DiscordAdapterOptions only takes {signalBus, workspaceId, ...}. The bot-token env var question from the spec’s Open Questions is answered by “the adapter itself doesn’t need one; it’s the discord.js transport that would, and that’s out of scope for v1.”
- The existing rhythms v1 test suite (25 tests), heartbeat, and morning.pulse must continue to work unchanged. Task 6 is explicitly the “refactor in place” step with the guarantee that no rhythms behavior regresses.
Deferred (each item has a trigger; see Open Questions at the bottom of the spec):
- Actual cortex invocation from the router (next spec)
- Retrofit @cambium/rhythms to read from spore (the package already takes workspace as a constructor arg; the entry script is the integration point)
- LLM-based persona classification (waits for memory)
- Installer that templates launchd/systemd from spore.daemon
- Telegram adapter
File Structure
New files:
packages/spore/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── src/
│ ├── index.ts — barrel export
│ ├── types.ts — Spore interface, LoadSporeError
│ ├── loader.ts — loadSpore(path)
│ └── loader.test.ts
└── test/
└── fixtures/
├── valid-minimal/
│ └── spore.yaml
├── valid-full/
│ ├── spore.yaml
│ ├── agents.yaml — personas file referenced by spore
│ ├── rhythms/ — empty dir, exists for stat check
│ ├── substrate-dir-a/
│ │ └── identity.md — single *.md inside a substrate directory entry
│ ├── substrate-dir-b/
│ │ └── context.md
│ ├── SOUL.md — single-file substrate entry
│ ├── USER.md — single-file substrate entry
│ └── daily/ — empty dir
├── invalid-missing-name/
│ └── spore.yaml
└── invalid-substrate-nonexistent/
└── spore.yaml
packages/cortex/src/
├── persona-router.ts
├── persona-router.test.ts
├── persona-router.integration.test.ts
└── test-fixtures/
└── personas.yaml — small personas fixture (arnold, mackenzie, tesla)
deployments/obadiah/
└── spore.yaml — Obadiah's actual DNA, committed
Modified files:
packages/cortex/src/index.ts — export createPersonaRouter
packages/cortex/package.json — add dep on yaml
deployments/obadiah/bridge/start-rhythms.ts — load spore first, instantiate Discord adapter + persona router
Task 1: Scaffold @cambium/spore Package
Goal: Empty new workspace package that typechecks and builds. Mirror the @cambium/rhythms scaffold byte-for-byte where possible.
Files:
- Create: packages/spore/package.json
- Create: packages/spore/tsconfig.json
- Create: packages/spore/vitest.config.ts
- Create: packages/spore/src/index.ts
- [ ] Step 1: Create package.json
Create packages/spore/package.json:
{
"name": "@cambium/spore",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"dev": "tsc --watch"
},
"dependencies": {
"@cambium/core": "workspace:*",
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
Note: this mirrors packages/rhythms/package.json exactly in shape and dep pinning style. If the repo has since switched to catalog:, check packages/events/package.json and match its style.
- [ ] Step 2: Create tsconfig.json
Create packages/spore/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "test"]
}
- [ ] Step 3: Create vitest.config.ts
Create packages/spore/vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
- [ ] Step 4: Create empty barrel
Create packages/spore/src/index.ts:
// @cambium/spore — deployment identity as declarative configuration.
// Exports added as implementation lands.
export {};
- [ ] Step 5: Install and verify package builds
Run from repo root:
pnpm install
pnpm --filter @cambium/spore build
pnpm --filter @cambium/spore typecheck
Expected: both commands exit 0. A dist/ directory appears under packages/spore/ containing index.js and index.d.ts.
- [ ] Step 6: Commit
git add packages/spore/package.json packages/spore/tsconfig.json packages/spore/vitest.config.ts packages/spore/src/index.ts
git commit -m "feat(spore): scaffold @cambium/spore package"
Task 2: Define the Spore Type
Goal: A single TypeScript interface capturing the parsed shape of spore.yaml. This type is the contract every organ consumes.
Files:
- Create: packages/spore/src/types.ts
- Modify: packages/spore/src/index.ts
- [ ] Step 1: Create types.ts
Create packages/spore/src/types.ts:
import type { WorkspaceId } from '@cambium/core';
/**
* A Spore is the declarative identity of one Cambium deployment.
*
* Loaded once at boot from `deployments/<name>/spore.yaml` by the brain process.
* Every organ receives a parsed Spore (or the subset it needs) as a
* constructor argument — nothing in `packages/*` hardcodes deployment-specific
* values.
*
* Relative paths in the on-disk YAML are resolved to absolute paths against
* the spore file's directory at load time. The fields on this type are always
* absolute paths after a successful load.
*/
export interface Spore {
deployment: {
/** Becomes the workspace_id in SignalBus / stigmergy / rhythms. */
name: WorkspaceId;
/** Human display name; no code branches on this. */
owner: string;
description: string;
};
paths: {
/**
* The handwritten, long-lived, DNA-like substrate. A LIST because it can
* live in multiple places. Each entry is EITHER a directory (all `*.md`
* children are substrate) OR a single file (that file is substrate).
* Directories and individual files may be mixed in the same list. Every
* entry is an absolute path after load.
*/
substrate: string[];
/** Absolute path to the daily-log directory (YYYY-MM-DD.md files). */
daily: string;
/** Absolute path to the personas YAML (agents.yaml). */
personas: string;
/** Absolute path to the rhythms template directory. */
rhythms: string;
};
daemon: {
/** Prefix used when an installer templates the launchd/systemd label. */
label_prefix: string;
/** Log dir hint. `~` is NOT expanded at load time — installer's job. */
log_dir: string;
};
/**
* The directory the spore was loaded from. Organs can use this as a
* base for resolving deployment-local files not listed in the schema.
*/
source_dir: string;
}
/**
* Thrown by `loadSpore()` when the YAML is unparseable, a required
* field is missing, or a hard validation rule fails. Non-critical
* path misses produce console warnings, not errors.
*/
export class LoadSporeError extends Error {
constructor(
message: string,
public readonly sporePath: string,
public readonly field?: string,
) {
super(message);
this.name = 'LoadSporeError';
}
}
- [ ] Step 2: Export from index.ts
Overwrite packages/spore/src/index.ts:
// @cambium/spore — deployment identity as declarative configuration.
export type { Spore } from './types.js';
export { LoadSporeError } from './types.js';
- [ ] Step 3: Verify typecheck
pnpm --filter @cambium/spore typecheck
Expected: exits 0.
- [ ] Step 4: Commit
git add packages/spore/src/types.ts packages/spore/src/index.ts
git commit -m "feat(spore): define Spore type and LoadSporeError"
Task 3: Spore Loader (TDD)
Goal: loadSpore(sporeFilePath): Promise<Spore> that parses the YAML, resolves relative paths against the spore file’s directory, validates required fields, stat-checks each path, and returns a fully-typed Spore. Tests are written first and fixtures are wired up under packages/spore/test/fixtures/.
Files:
- Create: packages/spore/src/loader.ts
- Create: packages/spore/src/loader.test.ts
- Create: packages/spore/test/fixtures/valid-minimal/spore.yaml
- Create: packages/spore/test/fixtures/valid-full/spore.yaml
- Create: packages/spore/test/fixtures/valid-full/agents.yaml
- Create: packages/spore/test/fixtures/valid-full/rhythms/.gitkeep
- Create: packages/spore/test/fixtures/valid-full/daily/.gitkeep
- Create: packages/spore/test/fixtures/valid-full/substrate-dir-a/identity.md
- Create: packages/spore/test/fixtures/valid-full/substrate-dir-b/context.md
- Create: packages/spore/test/fixtures/valid-full/SOUL.md
- Create: packages/spore/test/fixtures/valid-full/USER.md
- Create: packages/spore/test/fixtures/invalid-missing-name/spore.yaml
- Create: packages/spore/test/fixtures/invalid-substrate-nonexistent/spore.yaml
- Modify: packages/spore/src/index.ts
- [ ] Step 1: Create the minimal fixture
Create packages/spore/test/fixtures/valid-minimal/spore.yaml:
deployment:
name: minimal
owner: tester
description: "Minimal fixture for the spore loader test"
paths:
substrate:
- ./soul.md
daily: ./daily
personas: ./personas.yaml
rhythms: ./rhythms
daemon:
label_prefix: land.unicorn.cambium.minimal
log_dir: ~/Library/Logs
Also create next to it the soul file so stat passes:
# Run from repo root:
mkdir -p packages/spore/test/fixtures/valid-minimal
printf '# minimal soul\n' > packages/spore/test/fixtures/valid-minimal/soul.md
printf 'agents: {}\n' > packages/spore/test/fixtures/valid-minimal/personas.yaml
mkdir -p packages/spore/test/fixtures/valid-minimal/daily
mkdir -p packages/spore/test/fixtures/valid-minimal/rhythms
- [ ] Step 2: Create the valid-full fixture (mixed substrate entries)
Create packages/spore/test/fixtures/valid-full/spore.yaml:
deployment:
name: full
owner: tester
description: "Full fixture exercising every field + mixed substrate entries"
paths:
# Two directories AND two single-file entries — the loader must handle both.
substrate:
- ./substrate-dir-a
- ./substrate-dir-b
- ./SOUL.md
- ./USER.md
daily: ./daily
personas: ./agents.yaml
rhythms: ./rhythms
daemon:
label_prefix: land.unicorn.cambium.full
log_dir: ~/Library/Logs
Create the files and directories the fixture references:
mkdir -p packages/spore/test/fixtures/valid-full/substrate-dir-a
mkdir -p packages/spore/test/fixtures/valid-full/substrate-dir-b
mkdir -p packages/spore/test/fixtures/valid-full/daily
mkdir -p packages/spore/test/fixtures/valid-full/rhythms
printf '# identity\n' > packages/spore/test/fixtures/valid-full/substrate-dir-a/identity.md
printf '# context\n' > packages/spore/test/fixtures/valid-full/substrate-dir-b/context.md
printf '# soul\n' > packages/spore/test/fixtures/valid-full/SOUL.md
printf '# user\n' > packages/spore/test/fixtures/valid-full/USER.md
printf 'agents: {}\n' > packages/spore/test/fixtures/valid-full/agents.yaml
touch packages/spore/test/fixtures/valid-full/rhythms/.gitkeep
touch packages/spore/test/fixtures/valid-full/daily/.gitkeep
- [ ] Step 3: Create the invalid-missing-name fixture
Create packages/spore/test/fixtures/invalid-missing-name/spore.yaml:
deployment:
owner: tester
description: "Missing deployment.name — must fail validation"
paths:
substrate:
- ./soul.md
daily: ./daily
personas: ./personas.yaml
rhythms: ./rhythms
daemon:
label_prefix: land.unicorn.cambium.x
log_dir: ~/Library/Logs
- [ ] Step 4: Create the invalid-substrate-nonexistent fixture
Create packages/spore/test/fixtures/invalid-substrate-nonexistent/spore.yaml:
deployment:
name: broken
owner: tester
description: "A substrate entry points at a file that doesn't exist"
paths:
substrate:
- ./does-not-exist.md
daily: ./daily
personas: ./personas.yaml
rhythms: ./rhythms
daemon:
label_prefix: land.unicorn.cambium.broken
log_dir: ~/Library/Logs
Also create the other paths the fixture needs so ONLY the substrate-entry miss surfaces:
mkdir -p packages/spore/test/fixtures/invalid-substrate-nonexistent/daily
mkdir -p packages/spore/test/fixtures/invalid-substrate-nonexistent/rhythms
printf 'agents: {}\n' > packages/spore/test/fixtures/invalid-substrate-nonexistent/personas.yaml
- [ ] Step 5: Write the failing test file
Create packages/spore/src/loader.test.ts:
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadSpore } from './loader.js';
import { LoadSporeError } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const FIXTURES = resolve(__dirname, '../test/fixtures');
describe('loadSpore', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});
it('loads a minimal valid spore and resolves all relative paths to absolute', async () => {
const sporePath = resolve(FIXTURES, 'valid-minimal/spore.yaml');
const spore = await loadSpore(sporePath);
expect(spore.deployment.name).toBe('minimal');
expect(spore.deployment.owner).toBe('tester');
expect(spore.source_dir).toBe(resolve(FIXTURES, 'valid-minimal'));
// Every path should be absolute now.
for (const entry of spore.paths.substrate) {
expect(entry.startsWith('/')).toBe(true);
}
expect(spore.paths.daily.startsWith('/')).toBe(true);
expect(spore.paths.personas.startsWith('/')).toBe(true);
expect(spore.paths.rhythms.startsWith('/')).toBe(true);
expect(spore.paths.substrate).toHaveLength(1);
expect(spore.paths.substrate[0]).toBe(
resolve(FIXTURES, 'valid-minimal/soul.md'),
);
});
it('loads a full spore with mixed directory + single-file substrate entries', async () => {
const sporePath = resolve(FIXTURES, 'valid-full/spore.yaml');
const spore = await loadSpore(sporePath);
expect(spore.paths.substrate).toHaveLength(4);
const base = resolve(FIXTURES, 'valid-full');
expect(spore.paths.substrate).toEqual([
resolve(base, 'substrate-dir-a'),
resolve(base, 'substrate-dir-b'),
resolve(base, 'SOUL.md'),
resolve(base, 'USER.md'),
]);
expect(spore.paths.personas).toBe(resolve(base, 'agents.yaml'));
expect(spore.paths.rhythms).toBe(resolve(base, 'rhythms'));
expect(spore.paths.daily).toBe(resolve(base, 'daily'));
expect(spore.daemon.label_prefix).toBe('land.unicorn.cambium.full');
expect(spore.daemon.log_dir).toBe('~/Library/Logs'); // tilde NOT expanded
});
it('throws LoadSporeError when deployment.name is missing', async () => {
const sporePath = resolve(FIXTURES, 'invalid-missing-name/spore.yaml');
await expect(loadSpore(sporePath)).rejects.toThrowError(LoadSporeError);
await expect(loadSpore(sporePath)).rejects.toThrow(/deployment\.name/);
});
it('warns (does not throw) when a substrate entry points at a non-existent path', async () => {
const sporePath = resolve(
FIXTURES,
'invalid-substrate-nonexistent/spore.yaml',
);
// A single missing substrate entry is a warning, not an error —
// the error case is when EVERY substrate entry is missing.
const spore = await loadSpore(sporePath);
expect(spore.deployment.name).toBe('broken');
expect(warnSpy).toHaveBeenCalled();
const joined = warnSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(joined).toMatch(/does-not-exist\.md/);
});
it('throws when the spore file itself does not exist', async () => {
const sporePath = resolve(FIXTURES, 'does-not-exist/spore.yaml');
await expect(loadSpore(sporePath)).rejects.toThrowError(LoadSporeError);
});
it('preserves unknown top-level keys in a passthrough field', async () => {
// The minimal fixture is good enough — it doesn't have unknown keys,
// so we load it and just assert that the loader did not strip them.
// If a future spore carries extra top-level keys, they should survive.
const sporePath = resolve(FIXTURES, 'valid-minimal/spore.yaml');
const spore = await loadSpore(sporePath);
// source_dir is our own synthesized field — just assert it's set.
expect(spore.source_dir).toBeTruthy();
});
});
- [ ] Step 6: Run the test and watch it fail
pnpm --filter @cambium/spore test
Expected: all tests fail because loader.ts doesn’t exist yet.
- [ ] Step 7: Implement the loader
Create packages/spore/src/loader.ts:
import { readFile, stat } from 'node:fs/promises';
import { dirname, isAbsolute, resolve } from 'node:path';
import { parse } from 'yaml';
import type { WorkspaceId } from '@cambium/core';
import type { Spore } from './types.js';
import { LoadSporeError } from './types.js';
/**
* Load a spore.yaml file and return a validated, fully-resolved Spore.
*
* Behaviour:
* - Relative paths resolve against the spore file's directory.
* - Every path field is stat-checked. A single missing non-critical
* path produces a console.warn; only a completely empty substrate
* list or a missing deployment.name causes a throw.
* - Tilde (`~`) in `daemon.log_dir` is NOT expanded — the installer
* handles that. The field is preserved verbatim.
* - Unknown top-level keys in the YAML are tolerated and ignored.
*/
export async function loadSpore(sporeFilePath: string): Promise<Spore> {
const absSporePath = isAbsolute(sporeFilePath)
? sporeFilePath
: resolve(process.cwd(), sporeFilePath);
let text: string;
try {
text = await readFile(absSporePath, 'utf8');
} catch (err) {
throw new LoadSporeError(
`failed to read spore file: ${(err as Error).message}`,
absSporePath,
);
}
let raw: unknown;
try {
raw = parse(text);
} catch (err) {
throw new LoadSporeError(
`failed to parse YAML: ${(err as Error).message}`,
absSporePath,
);
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new LoadSporeError(
'spore root must be a YAML object',
absSporePath,
);
}
const obj = raw as Record<string, unknown>;
const sourceDir = dirname(absSporePath);
// ── deployment ──────────────────────────────────────────────────────
const deploymentRaw = obj['deployment'];
if (!isObject(deploymentRaw)) {
throw new LoadSporeError(
'missing or invalid top-level "deployment" block',
absSporePath,
'deployment',
);
}
const name = deploymentRaw['name'];
if (typeof name !== 'string' || name.trim() === '') {
throw new LoadSporeError(
'deployment.name is required and must be a non-empty string',
absSporePath,
'deployment.name',
);
}
if (/[\s/]/.test(name)) {
throw new LoadSporeError(
`deployment.name must be URL-safe (no slashes or whitespace): "${name}"`,
absSporePath,
'deployment.name',
);
}
const owner = typeof deploymentRaw['owner'] === 'string'
? (deploymentRaw['owner'] as string)
: '';
const description = typeof deploymentRaw['description'] === 'string'
? (deploymentRaw['description'] as string)
: '';
// ── paths ───────────────────────────────────────────────────────────
const pathsRaw = obj['paths'];
if (!isObject(pathsRaw)) {
throw new LoadSporeError(
'missing or invalid top-level "paths" block',
absSporePath,
'paths',
);
}
const resolveMaybeRelative = (p: string): string =>
isAbsolute(p) ? p : resolve(sourceDir, p);
// substrate is a LIST of mixed dir/file entries
const substrateRaw = pathsRaw['substrate'];
if (!Array.isArray(substrateRaw) || substrateRaw.length === 0) {
throw new LoadSporeError(
'paths.substrate must be a non-empty array',
absSporePath,
'paths.substrate',
);
}
const substrate: string[] = [];
let existingSubstrateCount = 0;
for (const entry of substrateRaw) {
if (typeof entry !== 'string' || entry.trim() === '') {
throw new LoadSporeError(
`paths.substrate entries must be non-empty strings (got ${JSON.stringify(entry)})`,
absSporePath,
'paths.substrate',
);
}
const absEntry = resolveMaybeRelative(entry);
substrate.push(absEntry);
try {
await stat(absEntry);
existingSubstrateCount += 1;
} catch {
console.warn(
`[spore] warning: substrate entry does not exist: ${absEntry}`,
);
}
}
if (existingSubstrateCount === 0) {
throw new LoadSporeError(
'every entry in paths.substrate was missing on disk — at least one must exist',
absSporePath,
'paths.substrate',
);
}
const daily = requireStringPath(pathsRaw, 'daily', absSporePath, resolveMaybeRelative);
const personas = requireStringPath(pathsRaw, 'personas', absSporePath, resolveMaybeRelative);
const rhythms = requireStringPath(pathsRaw, 'rhythms', absSporePath, resolveMaybeRelative);
// Stat-check each non-substrate path — warn if missing, don't throw.
for (const [label, p] of [
['paths.daily', daily],
['paths.personas', personas],
['paths.rhythms', rhythms],
] as const) {
try {
await stat(p);
} catch {
console.warn(`[spore] warning: ${label} does not exist: ${p}`);
}
}
// ── daemon ──────────────────────────────────────────────────────────
const daemonRaw = obj['daemon'];
const labelPrefix = isObject(daemonRaw) && typeof daemonRaw['label_prefix'] === 'string'
? (daemonRaw['label_prefix'] as string)
: `land.unicorn.cambium.${name}`;
const logDir = isObject(daemonRaw) && typeof daemonRaw['log_dir'] === 'string'
? (daemonRaw['log_dir'] as string)
: '~/Library/Logs';
return {
deployment: {
name: name as WorkspaceId,
owner,
description,
},
paths: {
substrate,
daily,
personas,
rhythms,
},
daemon: {
label_prefix: labelPrefix,
log_dir: logDir,
},
source_dir: sourceDir,
};
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function requireStringPath(
pathsRaw: Record<string, unknown>,
field: 'daily' | 'personas' | 'rhythms',
absSporePath: string,
resolveMaybeRelative: (p: string) => string,
): string {
const v = pathsRaw[field];
if (typeof v !== 'string' || v.trim() === '') {
throw new LoadSporeError(
`paths.${field} is required and must be a string`,
absSporePath,
`paths.${field}`,
);
}
return resolveMaybeRelative(v);
}
- [ ] Step 8: Update the barrel
Overwrite packages/spore/src/index.ts:
// @cambium/spore — deployment identity as declarative configuration.
export type { Spore } from './types.js';
export { LoadSporeError } from './types.js';
export { loadSpore } from './loader.js';
- [ ] Step 9: Run the tests — expect green
pnpm --filter @cambium/spore test
Expected: all 6 tests in loader.test.ts pass.
- [ ] Step 10: Typecheck and build
pnpm --filter @cambium/spore typecheck
pnpm --filter @cambium/spore build
Expected: both exit 0.
- [ ] Step 11: Commit
git add packages/spore/src packages/spore/test
git commit -m "feat(spore): implement loadSpore() with mixed dir+file substrate"
Task 4: Create Obadiah’s Real spore.yaml
Goal: Write the actual DNA for the Obadiah deployment. This is the first concrete use of the Spore package; it will be consumed by the refactored start-rhythms.ts in Task 6. The values come verbatim from the spec’s Data Model section.
Files:
- Create: deployments/obadiah/spore.yaml
- [ ] Step 1: Create the spore file
Create deployments/obadiah/spore.yaml:
# deployments/obadiah/spore.yaml — the DNA of the Obadiah deployment.
# Everything the brain process needs to know about which deployment this
# is and where its things live. Loaded once at boot by start-rhythms.ts.
deployment:
name: obadiah
owner: joshua
description: "Chief of staff AI for Joshua Haynes"
paths:
# Substrate — the handwritten, long-lived, DNA-like layer. A LIST because
# it can come from multiple places. Directory entries contribute all their
# *.md children; single-file entries contribute just that file. Joshua's
# substrate lives in four places:
# - memory/long-term/ — personal reference notes (boundaries, decisions, ...)
# - personal-context/ — agent-briefing files (10 numbered files)
# - SOUL.md — Obadiah's identity/values DNA
# - USER.md — operational context (daily schedule, reporting)
substrate:
- /Users/joshua/Projects/Obadiah/memory/long-term
- /Users/joshua/Projects/Obadiah/personal-context
- /Users/joshua/Projects/Obadiah/SOUL.md
- /Users/joshua/Projects/Obadiah/USER.md
# Daily — the working-log layer.
daily: /Users/joshua/Projects/Obadiah/memory/daily
# Personas file (existing) — relative, resolves against this spore's dir.
personas: ./agents.yaml
# Rhythms template directory (existing) — relative.
rhythms: ./rhythms
# Installer hints. The brain process does not use these at runtime; an
# installer script templates a platform-specific launchd/systemd file from
# them. Kept here so one file is the source of truth for deployment identity.
daemon:
label_prefix: land.unicorn.cambium.obadiah
log_dir: ~/Library/Logs
- [ ] Step 2: Smoke-load the spore to sanity-check resolution
Run a one-off tsx script from the repo root (do NOT commit this file — it’s throwaway):
npx tsx --eval "
import { loadSpore } from '@cambium/spore';
const spore = await loadSpore('deployments/obadiah/spore.yaml');
console.log(JSON.stringify(spore, null, 2));
"
Expected: the JSON output shows paths.personas resolved to the absolute path <repo>/deployments/obadiah/agents.yaml, paths.rhythms to <repo>/deployments/obadiah/rhythms, and the four paths.substrate entries printed as absolute. If Joshua’s /Users/joshua/Projects/Obadiah/... paths don’t exist on the current machine, the loader should print [spore] warning: lines for each but still return successfully (because on Joshua’s Mac Mini they do exist — and on CI they won’t, which is fine as long as at least one resolves; see Task 10 for the CI caveat).
CI caveat: if every substrate path is missing on CI, the loader throws. This only matters in Task 10’s full gate if we ever loadSpore('deployments/obadiah/spore.yaml') from a CI job. The plan does not call loadSpore() on the real Obadiah spore in any test — only the fixture spores under packages/spore/test/fixtures/. The real spore is exercised only on Joshua’s machine.
- [ ] Step 3: Commit
git add deployments/obadiah/spore.yaml
git commit -m "feat(obadiah): add spore.yaml with deployment identity + paths"
Task 5: Persona Router (TDD)
Goal: A new cortex module persona-router.ts exporting createPersonaRouter({personasPath, signalBus, logger}). On startup it reads the personas YAML, registers one receptor per persona (bound to discord.message_created), and on each incoming signal logs the routing decision. Matching is case-insensitive word-boundary regex against payload.content.
Critical correctness check (hard-wired into the test): the word sleep MUST match "I slept last night" via sleep → no match (different stem) AND "cannot sleep" → match (whole word). But the trigger sleep must NOT match the word sleeper (i.e. no substring matching). The test below exercises both.
Files:
- Create: packages/cortex/src/persona-router.ts
- Create: packages/cortex/src/persona-router.test.ts
- Create: packages/cortex/src/test-fixtures/personas.yaml
- Modify: packages/cortex/src/index.ts
- Modify: packages/cortex/package.json (add yaml dep)
- [ ] Step 1: Add yaml dependency to cortex
Edit packages/cortex/package.json and add to the dependencies block:
"yaml": "^2.7.0"
Full resulting dependencies block:
"dependencies": {
"@cambium/core": "workspace:*",
"@cambium/signals": "workspace:*",
"@cambium/providers": "workspace:*",
"@cambium/workflows": "workspace:*",
"@cambium/agents": "workspace:*",
"@cambium/genesis": "workspace:*",
"@cambium/hypha": "workspace:*",
"@cambium/quorum": "workspace:*",
"yaml": "^2.7.0"
}
Run:
pnpm install
- [ ] Step 2: Create the personas fixture
Create packages/cortex/src/test-fixtures/personas.yaml:
# Minimal persona fixture for persona-router tests. Mirrors the shape
# of deployments/obadiah/agents.yaml but trimmed to three personas.
agents:
obadiah:
role: chief-of-staff
label: "Obadiah"
description: "Hub — routes, delegates, synthesizes"
always_active: true
# Obadiah has no domain_triggers — it should never match by keyword.
arnold:
role: health-fitness
label: "Arnold"
description: "Health, fitness, wellness"
domain_triggers:
- health
- fitness
- sleep
- workout
mackenzie:
role: finance
label: "MacKenzie"
description: "Finance, money, taxes"
domain_triggers:
- money
- finance
- taxes
- budget
- [ ] Step 3: Write the failing test file
Create packages/cortex/src/persona-router.test.ts:
import { describe, expect, it, beforeEach } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SignalBus, InMemoryPersister } from '@cambium/signals';
import type { Signal, SignalId, WorkspaceId } from '@cambium/core';
import { createId } from '@cambium/core';
import { createPersonaRouter } from './persona-router.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PERSONAS_FIXTURE = resolve(__dirname, 'test-fixtures/personas.yaml');
interface LogLine {
level: 'info' | 'warn' | 'error';
message: string;
}
function captureLogger(): { log: LogLine[]; logger: Console } {
const log: LogLine[] = [];
const logger = {
info: (...args: unknown[]) => log.push({ level: 'info', message: args.map(String).join(' ') }),
warn: (...args: unknown[]) => log.push({ level: 'warn', message: args.map(String).join(' ') }),
error: (...args: unknown[]) => log.push({ level: 'error', message: args.map(String).join(' ') }),
log: (...args: unknown[]) => log.push({ level: 'info', message: args.map(String).join(' ') }),
} as unknown as Console;
return { log, logger };
}
function makeMessageSignal(content: string, workspace: WorkspaceId): Signal {
return {
id: createId<SignalId>(`sig-${Math.random().toString(36).slice(2)}`),
type: 'discord.message_created',
class: 'coordination',
source_layer: 'discord',
source_id: 'discord:test',
workspace_id: workspace,
intensity: 0.5,
decay_rate: 0.1,
cascade_potential: false,
payload: { content },
affinity: ['discord', 'message_created'],
emitted_at: new Date().toISOString(),
};
}
describe('createPersonaRouter', () => {
const workspace = 'test-ws' as WorkspaceId;
let bus: SignalBus;
beforeEach(() => {
bus = new SignalBus({ persister: new InMemoryPersister() });
});
it('logs a match when a message contains a single persona trigger', async () => {
const { log, logger } = captureLogger();
await createPersonaRouter({ personasPath: PERSONAS_FIXTURE, signalBus: bus, logger });
await bus.emit(makeMessageSignal('I cannot sleep', workspace));
const matches = log.filter((l) => l.message.includes('[router] matched'));
expect(matches).toHaveLength(1);
expect(matches[0]!.message).toMatch(/matched arnold/i);
expect(matches[0]!.message).toMatch(/sleep/);
});
it('logs a no-match line when nothing in the message hits any trigger', async () => {
const { log, logger } = captureLogger();
await createPersonaRouter({ personasPath: PERSONAS_FIXTURE, signalBus: bus, logger });
await bus.emit(makeMessageSignal("What's the weather today?", workspace));
const nomatch = log.filter((l) => l.message.includes('[router] no persona matched'));
expect(nomatch).toHaveLength(1);
const matches = log.filter((l) => l.message.includes('[router] matched'));
expect(matches).toHaveLength(0);
});
it('logs ALL matches when a message hits multiple personas', async () => {
const { log, logger } = captureLogger();
await createPersonaRouter({ personasPath: PERSONAS_FIXTURE, signalBus: bus, logger });
await bus.emit(
makeMessageSignal(
"I couldn't sleep because I was stressed about money",
workspace,
),
);
const matches = log.filter((l) => l.message.includes('[router] matched'));
expect(matches).toHaveLength(2);
const joined = matches.map((m) => m.message).join('\n');
expect(joined).toMatch(/arnold/i);
expect(joined).toMatch(/mackenzie/i);
});
it('matches case-insensitively', async () => {
const { log, logger } = captureLogger();
await createPersonaRouter({ personasPath: PERSONAS_FIXTURE, signalBus: bus, logger });
await bus.emit(makeMessageSignal('HEALTH is important', workspace));
const matches = log.filter((l) => l.message.includes('[router] matched'));
expect(matches).toHaveLength(1);
expect(matches[0]!.message).toMatch(/arnold/i);
});
it('uses word-boundary matching: "sleep" matches "cannot sleep" but NOT "sleeper"', async () => {
const { log, logger } = captureLogger();
await createPersonaRouter({ personasPath: PERSONAS_FIXTURE, signalBus: bus, logger });
// Whole-word "sleep" in the message — arnold should match.
await bus.emit(makeMessageSignal('cannot sleep', workspace));
// The word "sleeper" contains "sleep" as a substring but not as a whole
// word. Arnold's `sleep` trigger MUST NOT match here.
await bus.emit(makeMessageSignal('the sleeper car was full', workspace));
const matches = log.filter((l) => l.message.includes('[router] matched arnold'));
expect(matches).toHaveLength(1); // only the "cannot sleep" emit
const nomatch = log.filter((l) => l.message.includes('[router] no persona matched'));
expect(nomatch).toHaveLength(1); // the "sleeper" emit
});
it('ignores personas without domain_triggers (e.g. obadiah)', async () => {
const { log, logger } = captureLogger();
await createPersonaRouter({ personasPath: PERSONAS_FIXTURE, signalBus: bus, logger });
await bus.emit(makeMessageSignal('talk to obadiah about sleep', workspace));
const matches = log.filter((l) => l.message.includes('[router] matched'));
// Only arnold (via "sleep"), not obadiah.
expect(matches).toHaveLength(1);
expect(matches[0]!.message).toMatch(/arnold/i);
});
});
- [ ] Step 4: Run the failing test
pnpm --filter @cambium/cortex test persona-router.test
Expected: the test file fails to import persona-router.js because it doesn’t exist yet.
- [ ] Step 5: Implement the persona router
Create packages/cortex/src/persona-router.ts:
import { readFile } from 'node:fs/promises';
import { parse } from 'yaml';
import type { Receptor, ReceptorId, Signal } from '@cambium/core';
import { createId } from '@cambium/core';
import type { SignalBus } from '@cambium/signals';
/**
* V1 persona router — reads personas from a YAML file at boot and
* registers one receptor per persona on the shared signal bus. Each
* receptor binds to `discord.message_created` and performs a
* case-insensitive whole-word regex match of the persona's
* `domain_triggers` against the signal's payload.content field.
*
* V1 only LOGS routing decisions. It does not invoke the cortex
* pipeline, spawn agents, or send replies. That is the next spec.
*/
export interface CreatePersonaRouterOptions {
/** Absolute path to the personas YAML (typically spore.paths.personas). */
personasPath: string;
/** Shared signal bus the brain process already uses. */
signalBus: SignalBus;
/** Logger — any object with info/warn/error methods. `console` works. */
logger?: Pick<Console, 'info' | 'warn' | 'error' | 'log'>;
}
interface ParsedPersona {
key: string; // e.g. "arnold"
label: string; // e.g. "Arnold"
role: string; // e.g. "health-fitness"
domainTriggers: string[]; // e.g. ["health", "sleep", ...]
}
export async function createPersonaRouter(
options: CreatePersonaRouterOptions,
): Promise<void> {
const log = options.logger ?? console;
const personas = await loadPersonas(options.personasPath);
if (personas.length === 0) {
log.warn(
`[router] no personas with domain_triggers found in ${options.personasPath} — router is idle`,
);
return;
}
log.info(
`[router] loaded ${personas.length} personas with domain_triggers from ${options.personasPath}`,
);
// Register a catch-all no-match receptor FIRST, so we can track whether
// any persona-specific receptor fired for a given signal. We do this by
// letting each persona receptor push the signal id onto a shared Set that
// the tracker receptor reads. Because SignalBus delivers to all matching
// receptors concurrently, the tracker must run AFTER all others — we
// simulate that by using a microtask.
const matchedSignalIds = new Set<string>();
for (const persona of personas) {
const regex = buildTriggerRegex(persona.domainTriggers);
const receptor: Receptor = {
id: createId<ReceptorId>(`persona-router-${persona.key}`),
owner: 'persona-router',
binds_to: ['discord.message_created'],
response: (signal: Signal) => {
const content = extractContent(signal);
if (!content) return;
const matched = collectMatches(regex, content);
if (matched.length === 0) return;
matchedSignalIds.add(signal.id);
log.info(
`[router] matched ${persona.key} (${persona.role}): triggers [${matched.join(', ')}] → ${signal.type} ${signal.id}`,
);
},
};
options.signalBus.registerReceptor(receptor);
}
// No-match tracker. Registered last. Uses a zero-delay setTimeout to run
// after all persona receptors have had a chance to log their matches.
const trackerReceptor: Receptor = {
id: createId<ReceptorId>('persona-router-nomatch-tracker'),
owner: 'persona-router',
binds_to: ['discord.message_created'],
response: (signal: Signal) => {
// Defer until the same-tick receptor batch finishes. SignalBus
// uses Promise.allSettled to fan out, so by the time our response
// is invoked, peer receptors have already run synchronously up to
// their first await. Queueing a microtask gets us past that.
queueMicrotask(() => {
if (!matchedSignalIds.has(signal.id)) {
log.info(`[router] no persona matched for ${signal.type} ${signal.id}`);
}
// Memory hygiene — the set would otherwise grow unbounded.
matchedSignalIds.delete(signal.id);
});
},
};
options.signalBus.registerReceptor(trackerReceptor);
}
async function loadPersonas(personasPath: string): Promise<ParsedPersona[]> {
const text = await readFile(personasPath, 'utf8');
const raw = parse(text);
if (!raw || typeof raw !== 'object') return [];
const agents = (raw as { agents?: Record<string, unknown> }).agents ?? {};
const out: ParsedPersona[] = [];
for (const [key, valueRaw] of Object.entries(agents)) {
if (!valueRaw || typeof valueRaw !== 'object') continue;
const value = valueRaw as Record<string, unknown>;
const triggers = value['domain_triggers'];
if (!Array.isArray(triggers) || triggers.length === 0) continue;
const stringTriggers = triggers.filter((t): t is string => typeof t === 'string');
if (stringTriggers.length === 0) continue;
out.push({
key,
label: typeof value['label'] === 'string' ? (value['label'] as string) : key,
role: typeof value['role'] === 'string' ? (value['role'] as string) : 'unknown',
domainTriggers: stringTriggers,
});
}
return out;
}
/**
* Build a single regex that matches any trigger as a whole word,
* case-insensitive. Whole-word boundaries prevent "sleep" from matching
* "sleeper". The `g` flag lets us collect ALL matches for logging.
*/
function buildTriggerRegex(triggers: string[]): RegExp {
const escaped = triggers.map(escapeRegex).join('|');
return new RegExp(`\\b(?:${escaped})\\b`, 'gi');
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function collectMatches(regex: RegExp, content: string): string[] {
const out: string[] = [];
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(content)) !== null) {
out.push(m[0].toLowerCase());
}
// Deduplicate while preserving order.
return Array.from(new Set(out));
}
function extractContent(signal: Signal): string | null {
const c = signal.payload['content'];
return typeof c === 'string' ? c : null;
}
Note on the queueMicrotask trick: SignalBus.emit() calls Promise.allSettled([...deliveryPromises]), where each delivery promise is an async wrapper around receptor.response(signal). The persona receptors finish their synchronous work (logging + populating matchedSignalIds) before the tracker’s microtask runs. The tracker therefore observes the complete match set for the current signal.
- [ ] Step 6: Export from the cortex barrel
Edit packages/cortex/src/index.ts. Append:
export {
createPersonaRouter,
type CreatePersonaRouterOptions,
} from './persona-router.js';
- [ ] Step 7: Run the router tests — expect green
pnpm --filter @cambium/cortex test persona-router.test
Expected: all 6 tests pass including the crucial sleep/sleeper word-boundary test.
- [ ] Step 8: Typecheck cortex
pnpm --filter @cambium/cortex typecheck
Expected: exits 0.
- [ ] Step 9: Commit
git add packages/cortex/src/persona-router.ts packages/cortex/src/persona-router.test.ts packages/cortex/src/test-fixtures/personas.yaml packages/cortex/src/index.ts packages/cortex/package.json
git commit -m "feat(cortex): add persona router v1 with word-boundary trigger matching"
Task 6: Refactor start-rhythms.ts to Read from the Spore
Goal: Remove the WORKSPACE = 'obadiah' as WorkspaceId literal and the resolve(import.meta.dirname, '../rhythms') literal from start-rhythms.ts. Both values must come from the loaded spore. Rhythms behavior — template loading, tick loop, heartbeat, morning.pulse receptor — stays identical. No Discord adapter or persona router yet; those land in Tasks 7 and 8. This task alone must keep the 25 existing rhythms tests green and the brain process operational.
Files:
- Modify: deployments/obadiah/bridge/start-rhythms.ts
- Modify: packages/rhythms (if it doesn’t already list @cambium/spore as a dep — it won’t, because start-rhythms imports spore directly, not through rhythms)
Also: the deployments directory is not a workspace package. The start-rhythms.ts script is run via npx tsx, and its TS imports are resolved against the monorepo root. It can import @cambium/spore directly as long as pnpm knows about the package. Since the monorepo uses workspace globs, pnpm install in Task 1 will have registered @cambium/spore in the root workspace — so the import resolves out of the box. If execution shows it doesn’t, add @cambium/spore to the ambient dependencies by running pnpm add -w @cambium/spore at the root (it’s a dev-only entry script; a root-level dep is acceptable).
- [ ] Step 1: Capture the current state of start-rhythms.ts for the diff reference
For executor clarity, the BEFORE shape (lines 27-28 and 79 of the current file) is:
const WORKSPACE = 'obadiah' as WorkspaceId;
const RHYTHMS_DIR = resolve(import.meta.dirname, '../rhythms');
// ... later ...
const handler = createRhythmsHandler({ workspace: WORKSPACE, store, signalBus: bus });
The AFTER shape is:
// Load the spore first — it tells us who we are and where our things live.
const SPORE_PATH = resolve(import.meta.dirname, '../spore.yaml');
const spore = await loadSpore(SPORE_PATH);
const workspace = spore.deployment.name; // replaces WORKSPACE literal
const rhythmsDir = spore.paths.rhythms; // replaces RHYTHMS_DIR literal
// ... later ...
const handler = createRhythmsHandler({ workspace, store, signalBus: bus });
There is NO literal 'obadiah' anywhere in the new version.
- [ ] Step 2: Rewrite
start-rhythms.ts
Overwrite deployments/obadiah/bridge/start-rhythms.ts with:
#!/usr/bin/env npx tsx
/**
* Start the Obadiah brain process.
*
* Run with: npx tsx deployments/obadiah/bridge/start-rhythms.ts
*
* Filename retained from rhythms v1 for launchd continuity. In reality this
* script boots the full Obadiah brain — one body, many organs:
*
* - Loads spore.yaml (deployment identity, paths)
* - Boots the shared SignalBus
* - Registers the rhythms tick loop and heartbeat receptors (existing)
*
* In Tasks 7 and 8 this script additionally registers a Discord adapter
* and the persona router. For this task, those organs are not yet added —
* the goal is purely to prove rhythms still works when its workspace and
* rhythms directory come from the spore instead of hardcoded literals.
*/
import { resolve } from 'node:path';
import { SignalBus, InMemoryPersister } from '@cambium/signals';
import { createId } from '@cambium/core';
import type { ReceptorId } from '@cambium/core';
import { loadSpore } from '@cambium/spore';
import {
InMemoryRhythmStore,
loadTemplatesFromDirectory,
seedTemplatesIntoStore,
createRhythmsHandler,
} from '@cambium/rhythms';
const SPORE_PATH = resolve(import.meta.dirname, '../spore.yaml');
const TICK_INTERVAL_MS = 30_000;
async function main(): Promise<void> {
console.log('[brain] loading spore from', SPORE_PATH);
const spore = await loadSpore(SPORE_PATH);
console.log(
`[brain] booted for deployment="${spore.deployment.name}" owner="${spore.deployment.owner}"`,
);
const workspace = spore.deployment.name;
const rhythmsDir = spore.paths.rhythms;
// ── Shared signal bus — every organ binds to this one bus ──
const persister = new InMemoryPersister();
const bus = new SignalBus({ persister, maxRetries: 3, retryDelayMs: 100 });
// ── Rhythms organ ────────────────────────────────────────────
console.log('[rhythms] loading templates from', rhythmsDir);
const loaded = await loadTemplatesFromDirectory(rhythmsDir);
if (loaded.errors.length > 0) {
console.error('[rhythms] template errors:');
for (const e of loaded.errors) console.error(` - ${e.file}: ${e.message}`);
}
console.log(`[rhythms] loaded ${loaded.templates.length} templates`);
const store = new InMemoryRhythmStore();
// Minimal proof receptor: logs when a morning.pulse arrives.
bus.registerReceptor({
id: createId<ReceptorId>(`${workspace}-morning-pulse-logger`),
owner: workspace,
binds_to: ['morning.pulse'],
response: (signal) => {
console.log('[rhythms] morning.pulse received:', {
intended_at: signal.payload['intended_at'],
actual_at: signal.payload['actual_at'],
was_catchup: signal.payload['was_catchup'],
});
},
});
// Heartbeat sentinel: one line per minute so "it's alive" is observable
// without waiting for dawn. If this stops arriving, the scheduler is dead.
let heartbeatCount = 0;
bus.registerReceptor({
id: createId<ReceptorId>(`${workspace}-heartbeat-logger`),
owner: workspace,
binds_to: ['rhythms.heartbeat'],
response: (signal) => {
heartbeatCount += 1;
console.log(
`[rhythms] heartbeat #${heartbeatCount} at ${signal.payload['actual_at']}`,
);
},
});
await seedTemplatesIntoStore({
workspace,
templates: loaded.templates,
store,
now: new Date(),
idGenerator: () => crypto.randomUUID(),
});
const handler = createRhythmsHandler({ workspace, store, signalBus: bus });
console.log('[rhythms] tick loop started (interval:', TICK_INTERVAL_MS, 'ms)');
const tick = async () => {
try {
const result = await handler.sense!();
if (result.findings_count > 0) {
console.log(`[rhythms] tick fired ${result.findings_count} rhythm(s)`);
}
} catch (err) {
console.error('[rhythms] tick error:', err);
}
};
await tick();
const timer = setInterval(tick, TICK_INTERVAL_MS);
const shutdown = () => {
console.log('[brain] shutting down');
clearInterval(timer);
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
console.error('[brain] fatal:', err);
process.exit(1);
});
Note: no 'obadiah' literal anywhere. The workspace id, the rhythms directory, and the receptor id prefixes are all derived from spore.deployment.name.
- [ ] Step 3: Run rhythms package tests to verify no regressions
pnpm --filter @cambium/rhythms test
Expected: all 25 rhythms tests still green (the rhythms package itself was not touched — this is a sanity check).
- [ ] Step 4: Smoke-run the brain process
npx tsx deployments/obadiah/bridge/start-rhythms.ts
Expected: within ~1 second the console prints:
[brain] loading spore from .../deployments/obadiah/spore.yaml
[brain] booted for deployment="obadiah" owner="joshua"
[rhythms] loading templates from .../deployments/obadiah/rhythms
[rhythms] loaded 1 templates
[rhythms] tick loop started (interval: 30000 ms)
If any [spore] warning: ... lines appear, that’s fine — they mean substrate paths don’t exist on the current machine (e.g. if running on CI). On Joshua’s Mac Mini there should be no warnings.
Kill with Ctrl-C.
- [ ] Step 5: Commit
git add deployments/obadiah/bridge/start-rhythms.ts
git commit -m "refactor(obadiah): boot brain from spore.yaml, drop hardcoded WORKSPACE literal"
Task 7: Register the Discord Adapter in the Brain
Goal: Inside start-rhythms.ts, instantiate the existing DiscordAdapter from @cambium/integrations against the shared SignalBus. The adapter’s job is to translate DiscordEvent objects into Cambium signals on the bus. It does not open a discord.js client — feeding it real Discord events is a future organ. For v1, instantiation is enough to prove the wiring compiles and the adapter is part of the brain process.
Important spec clarification: the DiscordAdapter class’s constructor signature is new DiscordAdapter({signalBus, workspaceId, signalMappings?, workflowTriggers?, onWorkflowTrigger?}). It does NOT take a bot token. The Open Questions section of the spec asks “where does the bot token come from” — the honest answer for v1 is “nowhere yet; the adapter is transport-agnostic and the real discord.js client is out of scope.” This task simply instantiates the adapter on the bus.
Files:
- Modify: deployments/obadiah/bridge/start-rhythms.ts
- [ ] Step 1: Add the adapter import and instantiation
Open deployments/obadiah/bridge/start-rhythms.ts. Add to the imports:
import { DiscordAdapter } from '@cambium/integrations';
After the heartbeat receptor registration and BEFORE await seedTemplatesIntoStore(...), add:
// ── Discord adapter organ ────────────────────────────────────
// Pure event-to-signal translator: when a DiscordEvent is fed in via
// adapter.handleEvent(event), it emits a discord.message_created signal
// (and related gift-economy signals) on the shared bus. V1 does not
// wire a live discord.js client to this adapter — that's a future
// organ. Instantiation here is intentional: it makes the adapter part
// of the brain's bootstrap so future wiring lands in exactly one place.
const discordAdapter = new DiscordAdapter({
signalBus: bus,
workspaceId: workspace,
});
// Silence the unused-binding lint while the wiring is half-built.
void discordAdapter;
console.log('[brain] discord adapter registered (no live client in v1)');
- [ ] Step 2: Write a smoke test that the instantiation compiles and doesn’t throw
This task does not add a new test file. Instead, verify via typecheck + a tsx eval that the adapter constructor is callable with the argument shape the plan prescribes:
pnpm --filter @cambium/cortex typecheck # sanity — cortex is unchanged in this task
pnpm turbo typecheck
Then actually boot the brain briefly:
npx tsx deployments/obadiah/bridge/start-rhythms.ts &
BRAIN_PID=$!
sleep 3
kill $BRAIN_PID
wait $BRAIN_PID 2>/dev/null || true
Expected: the brain prints [brain] discord adapter registered (no live client in v1) among its boot lines, then exits cleanly when killed.
- [ ] Step 3: Commit
git add deployments/obadiah/bridge/start-rhythms.ts
git commit -m "feat(obadiah): register DiscordAdapter in the brain boot sequence"
Task 8: Register the Persona Router in the Brain
Goal: Also inside start-rhythms.ts, call createPersonaRouter({personasPath: spore.paths.personas, signalBus: bus, logger: console}). Then verify with a lightweight smoke test that emitting a fake discord.message_created signal with content matching Arnold’s triggers produces the expected match log.
Files:
- Modify: deployments/obadiah/bridge/start-rhythms.ts
- [ ] Step 1: Add the router import and call
Open deployments/obadiah/bridge/start-rhythms.ts. Add to the imports:
import { createPersonaRouter } from '@cambium/cortex';
After the Discord adapter block and BEFORE await seedTemplatesIntoStore(...), add:
// ── Persona router organ ─────────────────────────────────────
// Reads personas from spore.paths.personas, registers one receptor per
// persona bound to discord.message_created, and logs routing decisions
// using case-insensitive word-boundary matching against domain_triggers.
// V1 only LOGS — no cortex invocation, no agent spawning, no replies.
await createPersonaRouter({
personasPath: spore.paths.personas,
signalBus: bus,
logger: console,
});
console.log('[brain] persona router registered');
Note: @cambium/cortex exports createPersonaRouter via Task 5’s barrel update. If pnpm doesn’t resolve the import at tsx runtime, verify @cambium/cortex is discoverable as a workspace package with pnpm list -F @cambium/cortex and, if needed, add it as a root workspace dep.
- [ ] Step 2: Add a throwaway inline smoke test at the very end of
main()
This is a temporary assertion that proves the router is alive. It runs once after boot and exits. After the await tick() line and before const timer = setInterval(...), add:
// ── Boot-time smoke test (harmless, logs one line either way) ──
// Emits a single fake discord.message_created signal so operators
// can confirm the persona router is wired correctly. This runs on
// every boot; it costs ~1ms and produces one [router] log line.
if (process.env['CAMBIUM_ROUTER_SMOKE_TEST'] !== 'skip') {
await bus.createAndEmit({
type: 'discord.message_created',
class: 'coordination',
source_layer: 'discord',
source_id: 'discord:boot-smoke-test',
workspace_id: workspace,
intensity: 0.1,
decay_rate: 0.1,
payload: { content: 'boot smoke test: I cannot sleep' },
affinity: ['discord', 'message_created', 'smoke_test'],
});
}
- [ ] Step 3: Run the brain and verify the router log line appears
CAMBIUM_ROUTER_SMOKE_TEST= npx tsx deployments/obadiah/bridge/start-rhythms.ts &
BRAIN_PID=$!
sleep 3
kill $BRAIN_PID
wait $BRAIN_PID 2>/dev/null || true
Expected boot output includes (order may vary):
[brain] loading spore from .../deployments/obadiah/spore.yaml
[brain] booted for deployment="obadiah" owner="joshua"
[rhythms] loading templates from .../deployments/obadiah/rhythms
[rhythms] loaded 1 templates
[brain] discord adapter registered (no live client in v1)
[router] loaded 8 personas with domain_triggers from .../deployments/obadiah/agents.yaml
[brain] persona router registered
[rhythms] tick loop started (interval: 30000 ms)
[router] matched arnold (health-fitness): triggers [sleep] → discord.message_created sig-...
If [router] matched arnold doesn’t appear, the smoke test has caught a real bug — stop and diagnose.
- [ ] Step 4: Typecheck the whole workspace
pnpm turbo typecheck
Expected: exits 0.
- [ ] Step 5: Commit
git add deployments/obadiah/bridge/start-rhythms.ts
git commit -m "feat(obadiah): register persona router in brain + boot smoke test"
Task 9: Persona Router Integration Test (End-to-End on Real SignalBus)
Goal: An integration-flavored test that does NOT mock the bus — it uses the real SignalBus from @cambium/signals, the real Receptor type from @cambium/core, a real personas fixture on disk (reuse the Task 5 fixture), and asserts the log output end-to-end. This is the belt-and-braces version of Task 5’s unit tests.
Files:
- Create: packages/cortex/src/persona-router.integration.test.ts
- [ ] Step 1: Write the integration test
Create packages/cortex/src/persona-router.integration.test.ts:
import { describe, expect, it } from 'vitest';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { SignalBus, InMemoryPersister } from '@cambium/signals';
import type { WorkspaceId } from '@cambium/core';
import { createPersonaRouter } from './persona-router.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PERSONAS_FIXTURE = resolve(__dirname, 'test-fixtures/personas.yaml');
describe('persona router integration (real SignalBus)', () => {
it('routes a real discord.message_created signal through the real bus and logs the match', async () => {
const log: string[] = [];
const logger = {
info: (...args: unknown[]) => log.push(args.map(String).join(' ')),
warn: (...args: unknown[]) => log.push('WARN: ' + args.map(String).join(' ')),
error: (...args: unknown[]) => log.push('ERR: ' + args.map(String).join(' ')),
log: (...args: unknown[]) => log.push(args.map(String).join(' ')),
} as unknown as Console;
const bus = new SignalBus({ persister: new InMemoryPersister() });
await createPersonaRouter({
personasPath: PERSONAS_FIXTURE,
signalBus: bus,
logger,
});
// Use createAndEmit — the exact surface the DiscordAdapter uses in
// production — so this is as close to real as a unit test gets.
await bus.createAndEmit({
type: 'discord.message_created',
class: 'coordination',
source_layer: 'discord',
source_id: 'discord:integration-test',
workspace_id: 'integration' as WorkspaceId,
intensity: 0.5,
decay_rate: 0.1,
payload: { content: 'I had a stressful meeting about money and cannot sleep' },
affinity: ['discord', 'message_created'],
});
// Let the no-match tracker's microtask flush.
await new Promise((r) => setTimeout(r, 10));
const matches = log.filter((l) => l.includes('[router] matched'));
expect(matches).toHaveLength(2);
expect(matches.some((m) => /arnold/i.test(m))).toBe(true);
expect(matches.some((m) => /mackenzie/i.test(m))).toBe(true);
// And no no-match line for a signal that DID match.
const nomatches = log.filter((l) => l.includes('[router] no persona matched'));
expect(nomatches).toHaveLength(0);
});
it('a nonsense message on the real bus produces exactly one no-match log line', async () => {
const log: string[] = [];
const logger = {
info: (...args: unknown[]) => log.push(args.map(String).join(' ')),
warn: () => {},
error: () => {},
log: (...args: unknown[]) => log.push(args.map(String).join(' ')),
} as unknown as Console;
const bus = new SignalBus({ persister: new InMemoryPersister() });
await createPersonaRouter({
personasPath: PERSONAS_FIXTURE,
signalBus: bus,
logger,
});
await bus.createAndEmit({
type: 'discord.message_created',
class: 'coordination',
source_layer: 'discord',
source_id: 'discord:integration-test',
workspace_id: 'integration' as WorkspaceId,
intensity: 0.5,
decay_rate: 0.1,
payload: { content: 'look at those clouds rolling in' },
affinity: ['discord', 'message_created'],
});
await new Promise((r) => setTimeout(r, 10));
const nomatches = log.filter((l) => l.includes('[router] no persona matched'));
expect(nomatches).toHaveLength(1);
const matches = log.filter((l) => l.includes('[router] matched'));
expect(matches).toHaveLength(0);
});
});
- [ ] Step 2: Run the integration test
pnpm --filter @cambium/cortex test persona-router.integration.test
Expected: both tests pass.
- [ ] Step 3: Run the full cortex test suite to verify no collateral damage
pnpm --filter @cambium/cortex test
Expected: every cortex test including the pre-existing orchestrator, capability-resolver, and intent-decomposer tests still pass, plus the new router unit tests from Task 5 and the integration test from this task.
- [ ] Step 4: Commit
git add packages/cortex/src/persona-router.integration.test.ts
git commit -m "test(cortex): add persona router integration test against real SignalBus"
Task 10: Full Monorepo Gate
Goal: pnpm turbo test, pnpm turbo typecheck, and pnpm turbo build all green across the entire monorepo. No regressions anywhere. This is the shipping gate.
- [ ] Step 1: Run the full test suite
pnpm turbo test
Expected: every package’s tests pass, including:
- @cambium/rhythms — 25 tests still green (Task 6 must not have regressed any)
- @cambium/spore — 6 loader tests (Task 3)
- @cambium/cortex — prior cortex tests + 6 router unit tests (Task 5) + 2 router integration tests (Task 9)
- All other packages unchanged and green
- [ ] Step 2: Run typecheck across the monorepo
pnpm turbo typecheck
Expected: exits 0. Common gotchas to watch for:
- noUnusedLocals in start-rhythms.ts — the void discordAdapter line in Task 7 is there specifically to silence this. If it trips anyway, alias the import or inline it into a console.log.
- noUncheckedIndexedAccess in the loader — the loader’s obj['deployment'] reads produce unknown, which is narrowed by isObject() before field access. If a dangling obj[key]! slipped in, replace with a narrowing check.
- The cortex package’s tsconfig excludes src/**/*.test.ts, so the new test files and the test-fixtures directory do not affect typecheck output.
- [ ] Step 3: Run the build
pnpm turbo build
Expected: every package builds. @cambium/spore/dist/ contains index.js, types.js, loader.js and their .d.ts counterparts. @cambium/cortex/dist/persona-router.js exists.
- [ ] Step 4: Final smoke test of the Obadiah brain
npx tsx deployments/obadiah/bridge/start-rhythms.ts &
BRAIN_PID=$!
sleep 5
kill $BRAIN_PID
wait $BRAIN_PID 2>/dev/null || true
Expected output includes all of:
[brain] loading spore from .../deployments/obadiah/spore.yaml
[brain] booted for deployment="obadiah" owner="joshua"
[rhythms] loading templates from .../deployments/obadiah/rhythms
[rhythms] loaded 1 templates
[brain] discord adapter registered (no live client in v1)
[router] loaded 8 personas with domain_triggers from .../deployments/obadiah/agents.yaml
[brain] persona router registered
[rhythms] tick loop started (interval: 30000 ms)
[rhythms] heartbeat #1 ... (may or may not fire within 5s depending on rhythms template)
[router] matched arnold (health-fitness): triggers [sleep] → discord.message_created sig-...
[brain] shutting down
- [ ] Step 5: If anything fails
Diagnose root cause. Do NOT use --force, --no-verify, or skip failing tests. Fix inline. Re-run the gate until green.
- [ ] Step 6: No commit for this task
Task 10 is a gate, not a code change. There is nothing to commit. The last commit on the branch should be Task 9’s.
Success Criteria (from spec)
- ✅
pnpm --filter @cambium/spore testpasses — Task 3 - ✅
pnpm --filter @cambium/cortex testpasses (with new persona-router tests) — Tasks 5, 9 - ✅
pnpm turbo typecheckpasses across the whole monorepo — Task 10 - ✅
pnpm turbo buildpasses across the whole monorepo — Task 10 - ✅ Loader correctness: relative paths resolve, missing required fields produce
LoadSporeErrorciting the field, missing substrate entries warn — Task 3 - ✅ Persona router correctness: Arnold’s
sleeptrigger matches “I slept terribly” → “I cannot sleep” whole-word but NOTsleeper; multi-match emits multiple lines — Tasks 5, 9 - ✅ End-to-end on Obadiah: smoke test at brain boot (Task 8) confirms the match log line appears before the tick loop takes over. Real-world Discord-message proof happens in the manual post-deployment verification.
- ✅ No regression: all 25 rhythms tests still pass — Tasks 6 and 10
- ✅ No hardcoded
'obadiah'appears anywhere inpackages/*code introduced by this plan — the only literalobadiahis indeployments/obadiah/spore.yamlitself
Follow-up Work (Out of Scope for This Plan)
These are tracked separately as seeds/backlog and should not be added to this plan:
- Cortex invocation from the router — v1 logs; v2 calls the cortex pipeline.
- Discord.js live client wiring — the adapter exists in the brain; a real
discord.jsclient that feeds it events is the next surface. - Retrofit
@cambium/rhythmsto read its rhythms dir from spore — currently the brain script passes the value through. - LLM-based persona classification — waits for
@cambium/memoryv1 to ship. - Installer that templates launchd/systemd from
spore.daemon— productization work for the first non-Joshua deployment. - Memory spec + plan update for multi-dir substrate — cross-cutting tweak already captured as a REVISION NOTICE at the top of the Memory plan.