# priority-forge-hub

Vendor-neutral cross-team task-state sync substrate for Priority Forge.
**Host-agnostic** (no upward imports — hosts inject providers at construction),
**fully revertible** (revert the integration commit and delete the workspace
directory), **lock-in-bounded** (one `MemoryHub` interface; vendor adapters
plug in behind it).

> **Want to plug in a different vendor (Mem0, Pinecone, your own server) or
> add a non-memory consumer (Slack, analytics, webhook)?** See
> [`ADAPTERS.md`](./ADAPTERS.md) — full recipe with a copy-pasteable
> template at [`src/adapters/_template.ts`](./src/adapters/_template.ts).

## Layout

```
packages/priority-forge-hub/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── README.md                   # this file
├── ADAPTERS.md                 # plug-in recipe for new memory backends and
│                               # non-memory consumers (Slack, analytics, etc.)
└── src/
    ├── types.ts                # MemoryHub interface + TeamMemoryEvent +
    │                           # BlockerEdge + MinimalTask + TaskProvider
    ├── team-hub-config.ts      # canonical ~/.priority-forge/team-hub.json
    │                           # persistence; one-time legacy migration
    │                           # from xmem.json + team-pulse.json
    ├── config.ts               # thin compatibility shims over team-hub-config
    ├── salience.ts             # τ=14d ranking + decay-candidate filtering
    ├── factory.ts              # backend selector + singleton cache
    ├── integration.ts          # createMemoryHubIntegration({...deps}) factory
    │                           # — the host-facing constructor
    ├── subscriber.ts           # attaches to host EventEmitter, writes events
    ├── sync-reader.ts          # TeamMemorySyncReader class (polling loop)
    ├── blocker-derivation.ts   # pure: local task state → BlockerEdge[]
    ├── blocker-emitter.ts      # createBlockerEmitter({...}) factory; diff
    │                           # edges → emit derived_blocker events
    ├── mcp-tools.ts            # hub_configure, hub_status, xmem_* (incl.
    │                           # touch_memory, list_decay_candidates,
    │                           # set_memory_flags, archive_memory)
    ├── index.ts                # public package surface
    ├── adapters/
    │   ├── noop-adapter.ts     # default; zero side effects
    │   ├── dryrun-adapter.ts   # appends to ~/.priority-forge/xmem-dryrun.jsonl
    │   ├── xmem-adapter.ts     # XMem client (api.xtrace.ai/v1)
    │   ├── pgvector-adapter.ts # Postgres + pgvector, vendor-free reference
    │   ├── setup-pgvector.sql  # schema bootstrap for the pgvector adapter
    │   └── _template.ts        # copy-paste skeleton for a new vendor adapter
    └── __tests__/
        └── contract.test.ts    # parameterized 5-method contract assertions
```

## Two planes: MemoryHub + OrchestrationHub

The same backend instance plays two roles: a **memory plane** (passive
observation log; "what happened") and an **orchestration plane** (active
single-holder lease arbiter; "who owns this right now"). Adapter classes
implement both interfaces; a cached singleton serves both via
`getMemoryHub()` and `getOrchestrationHub()`.

See [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the full role/responsibility
breakdown, system diagram, SharedTask lifecycle sequence, hardening
setup, and the backend support matrix.

## Host integration

The host (Priority Forge) constructs the integration with three injected providers:

```ts
import { createMemoryHubIntegration } from 'priority-forge-hub';

const integration = createMemoryHubIntegration({
  emitter,       // host's EventEmitter for 'sync_event' (PF's syncEmitter)
  taskProvider,  // { listTasks(): Promise<MinimalTask[]>;
                 //   getCurrentWorkspaceId(): Promise<string>; }
  // pulse.emit + memory.enabled configured in ~/.priority-forge/team-hub.json
});

// Returns: { reader, rederive(), detach() }
// - `reader` is the TeamMemorySyncReader instance for inspection/control
// - `rederive()` forces an immediate cross-agent blocker derivation
// - `detach()` unsubscribes from the emitter and stops the reader
```

This is the **entire blast radius** the host pays. The hub never imports
from the host — it asks its providers. The host implements `TaskProvider`
on top of its own storage. Pulse-emit, memory-enable, and orchestration-enable
are all persisted in `~/.priority-forge/team-hub.json`; the subscriber consults
that file at write time and early-returns when memory is off, so no in-memory
gate controller is needed.

## Guardrails (codified at the schema level)

- `MinimalTask` is the only task shape the hub depends on. It carries
  identity + dependency edges only. Adding task-content (priority, effort,
  notes, …) or learning-signal (selections, durations, weights, …) fields
  would violate the hub-boundary rule — task content and learning signals
  stay in the host. See root `CLAUDE.md` for the full rule.

- `TeamMemoryEventType` enumerates the events the hub will accept.
  Adding event types tied to task-content or learning signals (e.g.
  `priority_change`, `weights_updated`) requires reviewing the same rule.

## Revert procedure

```bash
# Option A: git revert the extraction commit
git revert <extraction-commit-sha>

# Option B: manual rollback
rm -rf packages/priority-forge-hub
# Restore the four memory-hub-related additions in src/mcp/handler.ts
# (or git checkout the file from before the extraction).
rm -f ~/.priority-forge/team-hub.json ~/.priority-forge/xmem-dryrun.jsonl
# (also remove legacy ~/.priority-forge/xmem.json + team-pulse.json if present)
# Update root package.json: remove "workspaces" and "priority-forge-hub" dep,
# re-add `pg`, `@types/pg` to dependencies.
npm install
```

After either option, PF behaves identically to today.

## Runtime kill switch

If the integration is on disk but undesired:

- `hub_configure({ memory: { enabled: false }, orchestration: { enabled: false } })` — single canonical disable path; persists to `team-hub.json`.
- `xmem_enable_sync(backend: "noop")` — legacy alias, same effect.
- `XMEM_FORCE_NOOP=1` env var — global override read at adapter-factory time.
- Or leave `memory.enabled: false` in `~/.priority-forge/team-hub.json` (default) — the subscriber early-returns and the noop adapter returns `{ accepted: false }` if anything slips through.

## Tenancy model

- **`user_id`** in XMem = team scope (e.g. `team_acme`)
- **`agent_id`** in XMem = forge owner (e.g. `alice@acme.com`)

This inversion lets one XMem query return the whole team's events with
per-person attribution preserved.

## Two-channel writes (xmem backend only)

Each PF event becomes one XMem POST `/memories` carrying:

- **Structured channel** — `memory: <PF event JSON>`, `infer: false`. Faithful
  round-trip; reconstructable source of truth.
- **Natural-language channel** — `messages: [{role: "system", content:
  <event.summary>}]`. Powers semantic search.

## Smoke test (no live API needed)

Fastest path — sandboxed dryrun, no PF backend required:

```bash
# From the repo root. Uses a tmp PF_HUB_CONFIG_DIR so it never touches
# your real ~/.priority-forge/team-hub.json.
npm run test:hub-smoke
```

Or end-to-end through a running PF backend:

```bash
# Enable in dryrun mode via hub_configure (canonical)
curl -s http://localhost:3456/mcp -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hub_configure","arguments":{"memory":{"enabled":true,"backend":"dryrun","teamId":"team_test","agentId":"alice@test"},"orchestration":{"enabled":true}}}}'

# Trigger a PF event
curl -s -X POST http://localhost:3456/tasks \
  -H "Content-Type: application/json" \
  -d '{"task":"probe","priority":"P3","project":"builds","effort":"low"}'

# Verify the dryrun log captured a wire-format-faithful payload
tail -1 ~/.priority-forge/xmem-dryrun.jsonl | python3 -m json.tool
```

Legacy `xmem_enable_sync` still works as a thin alias that maps onto `hub_configure`.

## Tests

```bash
cd packages/priority-forge-hub
npm test                                          # contract suite against dryrun
MEMHUB_PGVECTOR_URL=postgres://... npm test       # also against pgvector
XTRACE_TEST_API_KEY=... XTRACE_TEST_ORG_ID=... \
  npm test                                        # also against xmem (live)
```

The contract test asserts the five-method contract (`write` / `read` /
`search` / `blockersFor` / `health`) against every adapter the current
environment supports. `noop` is asserted separately as a sentinel.
