# Build a Diurna plugin

You are building a **plugin for Diurna**, a personal signal store for macOS.
Diurna records a user's workday as **signals** (commits, meetings, window
focus, check-ins) into a local SQLite store and serves each day — as a flat,
time-ordered list — over MCP. The core rule: **a plugin collects; it never
interprets.** Store facts verbatim; leave meaning-making to the consumer.

A plugin is a small TypeScript package: one `definePlugin` default export, a
hand-authored `manifest.json`, bundled to a single `.cjs` file. It runs in an
isolated subprocess, sandboxed to exactly the capabilities the manifest
declares.

## The Signal contract

```ts
interface Signal<Data = Record<string, unknown>> {
  source: string      // the producing plugin's id, e.g. "acme.standup"
  kind: string        // e.g. "commit", "window", "note"
  ts_start: string    // ISO-8601 UTC, ms precision, Z suffix
  ts_end?: string     // present iff this is an EXTENT signal (a span)
  remote_id?: string  // the source's own id; dedup key within (source, kind)
  data: Data          // the verbatim payload, exactly as the source gave it
}
```

- A **point** signal is an instant (a commit, a message): no `ts_end`.
- An **extent** signal is a span (a meeting, a focus run): has `ts_end`.
- Dedup identity is `(source, kind, remote_id)` — set `remote_id` when the
  source has stable ids.
- Field names are **snake_case** (`ts_start`, not `tsStart`).

## Authoring surface

Install `@diurna/plugin-sdk` and default-export a `definePlugin`:

```ts
import { definePlugin } from '@diurna/plugin-sdk'

export default definePlugin({
  id: 'acme.standup', // <publisher>.<name> — must equal manifest.id

  settings: {
    apiHost: { default: 'api.acme.dev', kind: 'str', help: 'Standup API host' },
    enabled: { default: true, kind: 'bool', help: 'Collect standups' },
  },

  // Every kind you emit self-describes here. `point` fixes the shape;
  // `preview(data)` renders a one-line summary for the MCP consumer.
  signals: {
    standup: {
      point: true,
      preview: (data) => `standup in #${data.channel}: ${data.text}`,
    },
  },

  // PROVIDER hook (optional): "give me this day's signals."
  // Pure — never touches the DB. `day` is a local "YYYY-MM-DD".
  // Past days are cached by the engine; today is re-provided every call.
  provide: async (settings, day) => {
    const entries = await fetchStandups(settings.apiHost, day)
    return entries.map((e) => ({
      source: 'acme.standup',
      kind: 'standup',
      ts_start: e.postedAt, // ISO-8601 UTC
      remote_id: e.id,
      data: { text: e.text, channel: e.channel },
    }))
  },

  // WATCHER hook (optional): for signals that must be captured live.
  // Called on a cadence; acts ONLY through ctx. Never draws UI, never
  // writes the DB (the engine is the sole writer).
  // watch: (ctx, settings) => { ... },
})
```

Settings descriptors (`default` / `kind: 'str' | 'int' | 'float' | 'bool'` /
`help`) drive a generated settings pane — plugins never ship UI. Hook
arguments are the resolved values (`settings.apiHost: string`).

The `ctx` handle available to `watch`:

- `ctx.readSignals(kinds, { day?, since? })` — read existing signals.
- `ctx.emitPrompt({ spanRef, question, tsStart, tsEnd, kind })` — request a
  check-in prompt (core renders it; a confirmed answer is written by core).
  Re-emitting the same `spanRef` supersedes, never duplicates.
- `ctx.expirePrompts({ before?, exceptSpanRef? })` — supersede older prompts.
- `await ctx.getOAuthToken(provider)` — throws `NotAuthenticated` when the
  account isn't connected; the core maps that to idle. Never ship your own
  OAuth client.

## Entry point and bundling

```ts
// src/entry.ts — the bundle entry (manifest.entry points at its output)
import { isChildProcess, startPluginChild } from '@diurna/plugin-sdk/host'
import plugin from './plugin'

export default plugin
if (isChildProcess()) startPluginChild(plugin)
```

Bundle to **one file with dependencies baked in** — no dependency resolution
ever runs on a user's machine:

```
esbuild src/entry.ts --bundle --platform=node --format=cjs --outfile=dist/bundle.cjs
```

## manifest.json (hand-authored — the security contract)

```json
{
  "id": "acme.standup",
  "name": "Acme Standup",
  "version": "1.0.0",
  "entry": "dist/bundle.cjs",
  "author": "acme",
  "license": "MIT",
  "repo": "https://github.com/acme/diurna-standup",
  "minCoreVersion": "0.0.0",
  "capabilities": {
    "network": { "hosts": ["api.acme.dev"] }
  },
  "provides": [
    {
      "kind": "standup",
      "point": true,
      "description": "a standup note the user posted; data.text, data.channel."
    }
  ]
}
```

Capabilities are declared, never inferred from code:

- `fs: { read: [...], write: [...] }` — path allowlists (enforced sandbox walls)
- `network: { hosts: [...] }` — host allowlist (enforced)
- `oauth: [{ provider, scopes, client }]` — reviewed promise
- `exec: true` — may run commands; **first-party plugins only**

Declare only what you need: the capability sheet is what users consent to,
and an update asking for more is held for re-consent. The build **fails on
drift** between `manifest.provides` and the code's `signals` descriptors —
kinds and `point` must match exactly.

## Testing

A unit-test kit ships at `@diurna/plugin-sdk/testing`. Test `provide` as a
pure function (settings + day in, signals out) and watcher logic against a
fake `ctx`.

## Submitting to the registry

The registry is a git repo with no backend. Open a PR adding your entry:
repo URL, pinned version, bundle URL + sha256 checksum, and the manifest
embedded verbatim. CI validates statically (it never runs your code):

1. Manifest matches the schema; entry key equals `manifest.id`.
2. License is OSI-approved — open source is required for listing.
3. The bundle at your release URL matches the pinned sha256.
4. The embedded manifest byte-matches the one inside the pinned bundle.

Pinning is the security property: every update goes through re-review and a
new pinned checksum.

## Ground rules

- Store facts verbatim — collect, never interpret.
- snake_case signal fields; ISO-8601 UTC timestamps with `Z`.
- Declare every capability; undeclared access is a sandbox wall.
- No UI, no DB writes, no bundled OAuth client.
- Open source, OSI license, public repo.
