cambium ▸ docs/superpowers/plans/2026-04-11-cambium-spore-and-discord-routing.md
updated 2026-04-12

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:

  1. @cambium/spore — a tiny new package that turns deployment identity into declarative configuration. Each deployment gets a spore.yaml at its root holding the deployment name, owner, paths (substrate list, daily, personas, rhythms), and launchd hints. A loadSpore(path) function parses, validates, and resolves paths. Every organ in the brain process receives the parsed Spore object at boot instead of reading hardcoded literals.

  2. Persona router + Discord adapter wired into the Obadiah brain. A new @cambium/cortex module persona-router.ts reads personas from spore.paths.personas, registers one receptor per persona against discord.message_created, and logs routing decisions using case-insensitive word-boundary matching of each persona’s domain_triggers against the message content. The existing DiscordAdapter from @cambium/integrations is 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

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.

Create packages/spore/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts", "test"]
}

Create packages/spore/vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts'],
  },
});

Create packages/spore/src/index.ts:

// @cambium/spore — deployment identity as declarative configuration.
// Exports added as implementation lands.
export {};

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.

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

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';
  }
}

Overwrite packages/spore/src/index.ts:

// @cambium/spore — deployment identity as declarative configuration.
export type { Spore } from './types.js';
export { LoadSporeError } from './types.js';
pnpm --filter @cambium/spore typecheck

Expected: exits 0.

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

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

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

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

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

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();
  });
});
pnpm --filter @cambium/spore test

Expected: all tests fail because loader.ts doesn’t exist yet.

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);
}

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';
pnpm --filter @cambium/spore test

Expected: all 6 tests in loader.test.ts pass.

pnpm --filter @cambium/spore typecheck
pnpm --filter @cambium/spore build

Expected: both exit 0.

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

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

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.

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)

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

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

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);
  });
});
pnpm --filter @cambium/cortex test persona-router.test

Expected: the test file fails to import persona-router.js because it doesn’t exist yet.

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.

Edit packages/cortex/src/index.ts. Append:

export {
  createPersonaRouter,
  type CreatePersonaRouterOptions,
} from './persona-router.js';
pnpm --filter @cambium/cortex test persona-router.test

Expected: all 6 tests pass including the crucial sleep/sleeper word-boundary test.

pnpm --filter @cambium/cortex typecheck

Expected: exits 0.

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).

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.

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.

pnpm --filter @cambium/rhythms test

Expected: all 25 rhythms tests still green (the rhythms package itself was not touched — this is a sanity check).

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.

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

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)');

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.

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

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.

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'],
    });
  }
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.

pnpm turbo typecheck

Expected: exits 0.

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

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);
  });
});
pnpm --filter @cambium/cortex test persona-router.integration.test

Expected: both tests pass.

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.

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.

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

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.

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.

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

Diagnose root cause. Do NOT use --force, --no-verify, or skip failing tests. Fix inline. Re-run the gate until green.

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)

  1. pnpm --filter @cambium/spore test passes — Task 3
  2. pnpm --filter @cambium/cortex test passes (with new persona-router tests) — Tasks 5, 9
  3. pnpm turbo typecheck passes across the whole monorepo — Task 10
  4. pnpm turbo build passes across the whole monorepo — Task 10
  5. ✅ Loader correctness: relative paths resolve, missing required fields produce LoadSporeError citing the field, missing substrate entries warn — Task 3
  6. ✅ Persona router correctness: Arnold’s sleep trigger matches “I slept terribly” → “I cannot sleep” whole-word but NOT sleeper; multi-match emits multiple lines — Tasks 5, 9
  7. ✅ 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.
  8. ✅ No regression: all 25 rhythms tests still pass — Tasks 6 and 10
  9. ✅ No hardcoded 'obadiah' appears anywhere in packages/* code introduced by this plan — the only literal obadiah is in deployments/obadiah/spore.yaml itself

Follow-up Work (Out of Scope for This Plan)

These are tracked separately as seeds/backlog and should not be added to this plan: