cambium ▸ docs/superpowers/plans/2026-04-04-gift-circulation-primitives.md
updated 2026-04-04

Gift Circulation Primitives 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: Add gift-economy mechanics to Cambium’s core platform — gratitude signals, generosity scoring, knowledge markers, transformation tracking, multi-generational composting, and gift chain metrics.

Architecture: Thin-layer approach extending 7 existing packages. No new packages. Gift economics emerge from the interaction of Signals, Stigmergy, Immune, Genesis, Hypha, and Agents with targeted additions. See docs/superpowers/specs/2026-04-04-gift-circulation-primitives-design.md for the full spec.

Tech Stack: TypeScript (strict), Vitest, PostgreSQL/Drizzle ORM, Next.js 15 / React 19 / React Flow (Loom UI)


Task 1: Core Type Extensions

Files: - Modify: packages/core/src/types/agents.ts:53-107 - Modify: packages/core/src/types/signals.ts:3-10 - Modify: packages/core/src/types/stigmergy.ts:3-55 - Modify: packages/core/src/types/workflows.ts:62-102 - Test: packages/core/src/types/agents.test.ts (if exists, otherwise skip — these are pure types)

In packages/core/src/types/agents.ts, add to the Agent interface after trust_score:

generosity_score: number;

In packages/core/src/types/agents.ts (where AgentTemplate is defined), add above AgentTemplate:

export interface PerennialLearning {
  id: string;
  pattern: string;
  context: string;
  source_agent_count: number;
  success_correlation: number;
  first_observed_at: string;
  last_confirmed_at: string;
}

Add to the AgentTemplate interface:

perennial_learnings: PerennialLearning[];

In packages/core/src/types/signals.ts, find the SignalClass union type. No changes needed to SignalClass itself — the new signals (coord.gratitude, coord.marker_transformed, stress.context_hoarding, stress.overextension) use existing classes (coordination and stress).

If there’s a signal type enum or union for specific signal names, add:

// Coordination signals
'coord.gratitude'              // task successfully used another task's output
'coord.marker_transformed'     // marker built upon, not just reinforced
'coord.consumption_detected'   // agent consumed input but output went unused

// Stress signals
'stress.context_hoarding'    // agent consumes without producing
'stress.overextension'       // high generosity + declining health

If signal names aren’t enumerated (just strings), skip this — the names are used by convention.

In packages/core/src/types/stigmergy.ts, add to the MarkerType union:

| 'knowledge.pattern'
| 'knowledge.strategy'
| 'knowledge.warning'

Add to the Marker interface:

payload?: Record<string, unknown>;
transformation_depth: number;
transformed_by: string[];

In packages/core/src/types/workflows.ts, add above WorkflowRun:

export interface GiftChainMetrics {
  chain_length: number;
  chain_breadth: number;
  total_handoffs: number;
  circulation_score: number;
}

Add to WorkflowRun interface:

gift_chain_metrics?: GiftChainMetrics;

Add to TaskExecution interface:

input_source_task_ids?: string[];

Run: pnpm turbo typecheck --filter=@cambium/core

Expected: Type errors in downstream packages that create Agent, AgentTemplate, Marker, etc. objects without the new required fields. Fix by adding defaults: - generosity_score: 0.5 wherever Agent objects are created - perennial_learnings: [] wherever AgentTemplate objects are created - transformation_depth: 0 and transformed_by: [] wherever Marker objects are created

Search for all places that create Agent, AgentTemplate, and Marker objects. Add the new fields with defaults. This will touch files in packages/agents/, packages/genesis/, packages/stigmergy/, packages/immune/, packages/hypha/, and test files.

Run: pnpm turbo typecheck

Expected: PASS across all packages.

git add packages/core/src/types/
git commit -m "feat(core): add gift circulation type extensions

Add generosity_score to Agent, perennial_learnings to AgentTemplate,
payload/transformation_depth/transformed_by to Marker, gift_chain_metrics
to WorkflowRun, input_source_task_ids to TaskExecution. Add knowledge
marker category."

Task 2: Database Migration

Files: - Create: packages/db/src/migrations/XXXX_gift_circulation.ts (use next migration number) - Modify: packages/db/src/schema.ts:35-212

In packages/db/src/schema.ts:

Add to agents table (near trust_score around line 145):

generosity_score: real('generosity_score').default(0.5).notNull(),

Add to agentTemplates table (around line 168-190):

perennial_learnings: jsonb('perennial_learnings').default('[]').notNull(),

Add to markers table:

payload: jsonb('payload'),
transformation_depth: integer('transformation_depth').default(0).notNull(),
transformed_by: jsonb('transformed_by').default('[]').notNull(),

Add to taskExecutions table (around line 54-72):

input_source_task_ids: jsonb('input_source_task_ids'),

Add to workflowRuns table (around line 35-52):

gift_chain_metrics: jsonb('gift_chain_metrics'),

Run: pnpm --filter @cambium/db migrate

Expected: Migration applies cleanly. All new columns are nullable or have defaults, so existing data is unaffected.

Run: pnpm --filter @cambium/db test

Expected: All existing DB tests pass.

git add packages/db/
git commit -m "feat(db): add gift circulation columns

Add generosity_score to agents, perennial_learnings to agent_templates,
payload/transformation_depth/transformed_by to markers,
input_source_task_ids to task_executions, gift_chain_metrics to
workflow_runs. All additive, no breaking changes."

Task 3: Knowledge Markers & Transformation (Stigmergy)

Files: - Modify: packages/stigmergy/src/environment.ts:17-235 - Create: packages/stigmergy/src/environment.gift.test.ts

Create packages/stigmergy/src/environment.gift.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { StigmergicEnvironment } from './environment.js';
import { createTestSignalBus, createTestMarkerStore, createTestRegionStore } from '../test-helpers.js';

describe('Gift Circulation — Knowledge Markers', () => {
  let env: StigmergicEnvironment;
  let signalBus: ReturnType<typeof createTestSignalBus>;

  beforeEach(() => {
    signalBus = createTestSignalBus();
    env = new StigmergicEnvironment(
      createTestMarkerStore(),
      createTestRegionStore(),
      signalBus,
    );
  });

  it('places a knowledge.pattern marker with payload', async () => {
    const region = await env.createRegion({
      id: 'r1' as any,
      workspace_id: 'ws1' as any,
      name: 'test',
      context_type: 'workspace',
      context_id: 'ws1',
      parent_region_id: undefined,
      created_at: new Date().toISOString(),
    });

    const marker = await env.placeMarker({
      type: 'knowledge.pattern',
      region_id: region.id,
      placed_by: 'agent-1',
      workspace_id: 'ws1' as any,
      intensity: 0.5,
      payload: { description: 'Retry after 429 errors improves success rate by 40%', confidence: 0.8 },
    });

    expect(marker.payload).toEqual({ description: 'Retry after 429 errors improves success rate by 40%', confidence: 0.8 });
    expect(marker.transformation_depth).toBe(0);
    expect(marker.transformed_by).toEqual([]);
  });

  it('uses 1800s half-life for knowledge markers', async () => {
    const region = await env.createRegion({
      id: 'r1' as any,
      workspace_id: 'ws1' as any,
      name: 'test',
      context_type: 'workspace',
      context_id: 'ws1',
      parent_region_id: undefined,
      created_at: new Date().toISOString(),
    });

    const marker = await env.placeMarker({
      type: 'knowledge.strategy',
      region_id: region.id,
      placed_by: 'agent-1',
      workspace_id: 'ws1' as any,
      intensity: 0.5,
    });

    expect(marker.decay_rate).toBe(1800);
  });
});

describe('Gift Circulation — Marker Transformation', () => {
  let env: StigmergicEnvironment;
  let signalBus: ReturnType<typeof createTestSignalBus>;

  beforeEach(() => {
    signalBus = createTestSignalBus();
    env = new StigmergicEnvironment(
      createTestMarkerStore(),
      createTestRegionStore(),
      signalBus,
    );
  });

  it('transforms a marker — increments depth, updates payload, adds to transformed_by', async () => {
    const region = await env.createRegion({
      id: 'r1' as any,
      workspace_id: 'ws1' as any,
      name: 'test',
      context_type: 'workspace',
      context_id: 'ws1',
      parent_region_id: undefined,
      created_at: new Date().toISOString(),
    });

    const original = await env.placeMarker({
      type: 'knowledge.pattern',
      region_id: region.id,
      placed_by: 'agent-1',
      workspace_id: 'ws1' as any,
      intensity: 0.5,
      payload: { finding: 'v1' },
    });

    const transformed = await env.transformMarker(
      original.id,
      'agent-2' as any,
      { finding: 'v2 — refined with additional data' },
      region.id,
    );

    expect(transformed.transformation_depth).toBe(1);
    expect(transformed.transformed_by).toEqual(['agent-2']);
    expect(transformed.payload).toEqual({ finding: 'v2 — refined with additional data' });
    expect(transformed.intensity).toBeGreaterThan(original.intensity);
  });

  it('rejects self-transform', async () => {
    const region = await env.createRegion({
      id: 'r1' as any,
      workspace_id: 'ws1' as any,
      name: 'test',
      context_type: 'workspace',
      context_id: 'ws1',
      parent_region_id: undefined,
      created_at: new Date().toISOString(),
    });

    const marker = await env.placeMarker({
      type: 'knowledge.pattern',
      region_id: region.id,
      placed_by: 'agent-1',
      workspace_id: 'ws1' as any,
      intensity: 0.5,
      payload: { finding: 'v1' },
    });

    await expect(
      env.transformMarker(marker.id, 'agent-1' as any, { finding: 'v2' }, region.id),
    ).rejects.toThrow(/self-transform/i);
  });

  it('emits coord.marker_transformed signal', async () => {
    const region = await env.createRegion({
      id: 'r1' as any,
      workspace_id: 'ws1' as any,
      name: 'test',
      context_type: 'workspace',
      context_id: 'ws1',
      parent_region_id: undefined,
      created_at: new Date().toISOString(),
    });

    const marker = await env.placeMarker({
      type: 'knowledge.strategy',
      region_id: region.id,
      placed_by: 'agent-1',
      workspace_id: 'ws1' as any,
      intensity: 0.5,
      payload: { strategy: 'batch requests' },
    });

    await env.transformMarker(marker.id, 'agent-2' as any, { strategy: 'batch with backoff' }, region.id);

    const signals = signalBus.getEmitted();
    const transformSignal = signals.find((s: any) => s.type === 'coord.marker_transformed');
    expect(transformSignal).toBeDefined();
    expect(transformSignal!.payload.transformation_depth).toBe(1);
  });

  it('respects rate limits on transforms', async () => {
    const region = await env.createRegion({
      id: 'r1' as any,
      workspace_id: 'ws1' as any,
      name: 'test',
      context_type: 'workspace',
      context_id: 'ws1',
      parent_region_id: undefined,
      created_at: new Date().toISOString(),
    });

    // Place 10 markers and transform them all (hitting rate limit)
    const markers = [];
    for (let i = 0; i < 11; i++) {
      markers.push(await env.placeMarker({
        type: 'knowledge.pattern',
        region_id: region.id,
        placed_by: `agent-${i}`,
        workspace_id: 'ws1' as any,
        intensity: 0.3,
        payload: { n: i },
      }));
    }

    // agent-99 transforms all 11 — should fail on the 11th (rate limit 10/agent/region/min)
    for (let i = 0; i < 10; i++) {
      await env.transformMarker(markers[i]!.id, 'agent-99' as any, { n: i, transformed: true }, region.id);
    }

    await expect(
      env.transformMarker(markers[10]!.id, 'agent-99' as any, { n: 10, transformed: true }, region.id),
    ).rejects.toThrow(/rate limit/i);
  });
});

Run: pnpm --filter @cambium/stigmergy test -- environment.gift.test.ts

Expected: FAIL — transformMarker doesn’t exist, knowledge markers not recognized, payload not on Marker.

In packages/stigmergy/src/environment.ts, find DEFAULT_HALF_LIVES (around line 17-23). Add:

knowledge: 1800,

In packages/stigmergy/src/environment.ts, in the placeMarker method (around line 145-209), when creating the marker object, include:

payload: opts.payload,
transformation_depth: 0,
transformed_by: [],

Update the method signature’s options type to accept payload?: Record<string, unknown>.

In packages/stigmergy/src/environment.ts, add after reinforceMarker:

async transformMarker(
  markerId: MarkerId,
  agentId: AgentId,
  newPayload: Record<string, unknown>,
  regionId: RegionId,
): Promise<Marker> {
  const marker = await this.markerStore.findById(markerId);
  if (!marker) {
    throw new Error(`Marker ${markerId} not found`);
  }

  // No self-transforms
  if (marker.placed_by === agentId || marker.transformed_by.includes(agentId as string)) {
    throw new Error(`Self-transform not allowed: agent ${agentId} already contributed to this marker`);
  }

  // Rate limit — counts toward same limit as placeMarker
  this.checkRateLimit(agentId as string, regionId);

  const updated: Marker = {
    ...marker,
    payload: newPayload,
    transformation_depth: marker.transformation_depth + 1,
    transformed_by: [...marker.transformed_by, agentId as string],
    intensity: Math.min(1.0, marker.intensity + 0.1),
    placed_at: new Date().toISOString(), // reset decay clock
  };

  await this.markerStore.update(updated);

  await this.signalBus.createAndEmit({
    type: 'coord.marker_transformed',
    signal_class: 'coordination',
    workspace_id: marker.workspace_id,
    intensity: 0.4,
    decay_rate: 120,
    payload: {
      marker_id: markerId,
      agent_id: agentId,
      transformation_depth: updated.transformation_depth,
    },
  });

  return updated;
}

Adapt this to match the exact method signatures and patterns used by the existing reinforceMarker and placeMarker methods — use the same signal emission pattern, same store API, same rate limit check method name.

Run: pnpm --filter @cambium/stigmergy test -- environment.gift.test.ts

Expected: All 5 tests PASS.

Run: pnpm --filter @cambium/stigmergy test

Expected: All existing + new tests PASS.

git add packages/stigmergy/
git commit -m "feat(stigmergy): add knowledge markers and marker transformation

Add knowledge.* marker category with 1800s half-life. Add payload field
to all markers. Add transformMarker() with transformation_depth tracking,
self-transform rejection, and rate limiting. Emit coord.marker_transformed
signal on transform."

Task 4: Generosity Scoring & Hoarding Detection (Immune)

Files: - Modify: packages/immune/src/trust-manager.ts:7-234 - Modify: packages/immune/src/anomaly-detector.ts:95-136 - Modify: packages/immune/src/immune-receptors.ts:14-51 - Create: packages/immune/src/generosity.test.ts

Create packages/immune/src/generosity.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { TrustManager } from './trust-manager.js';
import { AnomalyDetector } from './anomaly-detector.js';
import { createTestSignalBus, createTestAgentStore, createTestAnomalyStore } from '../test-helpers.js';

describe('Generosity Scoring', () => {
  let trustManager: TrustManager;
  let signalBus: ReturnType<typeof createTestSignalBus>;
  let agentStore: ReturnType<typeof createTestAgentStore>;

  beforeEach(() => {
    signalBus = createTestSignalBus();
    agentStore = createTestAgentStore();
    trustManager = new TrustManager(signalBus);
  });

  it('increases generosity on contribution', async () => {
    agentStore.seedAgent({ id: 'a1', generosity_score: 0.5 });

    const result = await trustManager.recordContribution(agentStore, 'a1' as any, 'ws1' as any);

    const agent = await agentStore.findById('a1' as any);
    expect(agent!.generosity_score).toBeCloseTo(0.53);
    expect(result).toBe('none');
  });

  it('decreases generosity on consumption without producing', async () => {
    agentStore.seedAgent({ id: 'a1', generosity_score: 0.5 });

    const result = await trustManager.recordConsumption(agentStore, 'a1' as any, 'ws1' as any);

    const agent = await agentStore.findById('a1' as any);
    expect(agent!.generosity_score).toBeCloseTo(0.48);
    expect(result).toBe('none');
  });

  it('increases generosity on knowledge share', async () => {
    agentStore.seedAgent({ id: 'a1', generosity_score: 0.5 });

    const result = await trustManager.recordKnowledgeShare(agentStore, 'a1' as any, 'ws1' as any);

    const agent = await agentStore.findById('a1' as any);
    expect(agent!.generosity_score).toBeCloseTo(0.52);
    expect(result).toBe('none');
  });

  it('emits stress.context_hoarding when generosity drops below threshold', async () => {
    agentStore.seedAgent({ id: 'a1', generosity_score: 0.26 });

    const result = await trustManager.recordConsumption(agentStore, 'a1' as any, 'ws1' as any);

    expect(result).toBe('hoarding_detected');
    const signals = signalBus.getEmitted();
    const hoarding = signals.find((s: any) => s.type === 'stress.context_hoarding');
    expect(hoarding).toBeDefined();
  });

  it('emits stress.overextension when generous agent is stressed', async () => {
    agentStore.seedAgent({ id: 'a1', generosity_score: 0.85, health: 'stressed' });

    const result = await trustManager.recordContribution(agentStore, 'a1' as any, 'ws1' as any);

    expect(result).toBe('overextension_detected');
    const signals = signalBus.getEmitted();
    const overext = signals.find((s: any) => s.type === 'stress.overextension');
    expect(overext).toBeDefined();
  });

  it('clamps generosity score to [0, 1]', async () => {
    agentStore.seedAgent({ id: 'a1', generosity_score: 0.99 });

    await trustManager.recordContribution(agentStore, 'a1' as any, 'ws1' as any);

    const agent = await agentStore.findById('a1' as any);
    expect(agent!.generosity_score).toBeLessThanOrEqual(1.0);
  });
});

Run: pnpm --filter @cambium/immune test -- generosity.test.ts

Expected: FAIL — recordContribution, recordConsumption, recordKnowledgeShare don’t exist.

In packages/immune/src/trust-manager.ts, add after the existing TrustPolicy:

export interface GenerosityPolicy {
  contribution_bonus: number;
  consumption_penalty: number;
  knowledge_share_bonus: number;
  hoarding_threshold: number;
  overextension_check: boolean;
}

export const DEFAULT_GENEROSITY_POLICY: GenerosityPolicy = {
  contribution_bonus: 0.03,
  consumption_penalty: -0.02,
  knowledge_share_bonus: 0.02,
  hoarding_threshold: 0.25,
  overextension_check: true,
};

export type GenerosityAction = 'none' | 'hoarding_detected' | 'overextension_detected';

Add to the TrustManager class:

private generosityPolicy: GenerosityPolicy;

// In constructor, add:
// this.generosityPolicy = generosityPolicy ?? DEFAULT_GENEROSITY_POLICY;

async recordContribution(
  store: AgentStore,
  agentId: AgentId,
  workspaceId: WorkspaceId,
): Promise<GenerosityAction> {
  return this.adjustGenerosity(store, agentId, workspaceId, this.generosityPolicy.contribution_bonus);
}

async recordConsumption(
  store: AgentStore,
  agentId: AgentId,
  workspaceId: WorkspaceId,
): Promise<GenerosityAction> {
  return this.adjustGenerosity(store, agentId, workspaceId, this.generosityPolicy.consumption_penalty);
}

async recordKnowledgeShare(
  store: AgentStore,
  agentId: AgentId,
  workspaceId: WorkspaceId,
): Promise<GenerosityAction> {
  return this.adjustGenerosity(store, agentId, workspaceId, this.generosityPolicy.knowledge_share_bonus);
}

private async adjustGenerosity(
  store: AgentStore,
  agentId: AgentId,
  workspaceId: WorkspaceId,
  delta: number,
): Promise<GenerosityAction> {
  const agent = await store.findById(agentId);
  if (!agent) return 'none';

  const newScore = Math.max(0, Math.min(1, agent.generosity_score + delta));
  await store.update({ ...agent, generosity_score: newScore });

  // Hoarding detection
  if (newScore < this.generosityPolicy.hoarding_threshold) {
    const intensity = (this.generosityPolicy.hoarding_threshold - newScore) / this.generosityPolicy.hoarding_threshold;
    await this.signalBus.createAndEmit({
      type: 'stress.context_hoarding',
      signal_class: 'stress',
      workspace_id: workspaceId,
      agent_id: agentId,
      intensity,
      decay_rate: 300,
      payload: { generosity_score: newScore, threshold: this.generosityPolicy.hoarding_threshold },
    });
    return 'hoarding_detected';
  }

  // Overextension detection
  if (this.generosityPolicy.overextension_check && newScore > 0.8) {
    const health = agent.health ?? 'healthy';
    if (health === 'stressed' || health === 'critical') {
      await this.signalBus.createAndEmit({
        type: 'stress.overextension',
        signal_class: 'stress',
        workspace_id: workspaceId,
        agent_id: agentId,
        intensity: 0.7,
        decay_rate: 300,
        payload: { generosity_score: newScore, health },
      });
      return 'overextension_detected';
    }
  }

  return 'none';
}

Match the exact patterns used by the existing adjustScore / recordSuccess methods for signal emission — use the same createAndEmit signature.

In packages/immune/src/anomaly-detector.ts, add to DEFAULT_ANOMALY_RULES array (around line 95-136):

{
  id: 'context-hoarding',
  name: 'Context Hoarding',
  signalType: 'stress.context_hoarding',
  threshold: 0.5,
  severity: 0.5,
  response: 'log' as const,
},

In packages/immune/src/immune-receptors.ts, add to registerImmuneReceptors:

// Gratitude tracker — record contribution for the producing agent
receptorRegistry.register({
  id: 'gratitude-tracker' as any,
  signal_pattern: 'coord.gratitude',
  threshold: 0.0,
  cooldown_ms: 0,
  handler: async (signal) => {
    const producerAgentId = signal.payload?.from_agent_id;
    if (producerAgentId) {
      await trustManager.recordContribution(agentStore, producerAgentId, signal.workspace_id);
    }
  },
});

// Knowledge share tracker
receptorRegistry.register({
  id: 'knowledge-share-tracker' as any,
  signal_pattern: 'coord.marker_placed',
  threshold: 0.0,
  cooldown_ms: 0,
  handler: async (signal) => {
    const markerType = signal.payload?.marker_type as string | undefined;
    if (markerType?.startsWith('knowledge.')) {
      const agentId = signal.payload?.placed_by;
      if (agentId) {
        await trustManager.recordKnowledgeShare(agentStore, agentId, signal.workspace_id);
      }
    }
  },
});

// Overextension receptor — log, don't quarantine
receptorRegistry.register({
  id: 'overextension-monitor' as any,
  signal_pattern: 'stress.overextension',
  threshold: 0.5,
  cooldown_ms: 60000,
  handler: async (signal) => {
    // Log recommendation to reduce task queue for this agent
    // No quarantine — overextension is burnout, not misbehavior
    console.log(`[immune] Overextension detected for agent ${signal.agent_id} — recommend reducing task load`);
  },
});

Adapt the receptor registration to match the exact pattern used by existing receptors in this file.

Run: pnpm --filter @cambium/immune test -- generosity.test.ts

Expected: All 6 tests PASS.

Run: pnpm --filter @cambium/immune test

Expected: All existing + new tests PASS.

git add packages/immune/
git commit -m "feat(immune): add generosity scoring, hoarding and overextension detection

Extend TrustManager with generosity_score tracking. recordContribution,
recordConsumption, recordKnowledgeShare methods parallel existing trust
methods. Emit stress.context_hoarding below 0.25 threshold and
stress.overextension for generous agents with poor health. Add
context-hoarding anomaly rule. Register gratitude and knowledge-share
receptors."

Task 5: Gratitude Signals & Gift Chain Tracking (Hypha)

Files: - Modify: packages/hypha/src/graph-executor.ts:200-230 - Create: packages/hypha/src/gift-chain-tracker.ts - Create: packages/hypha/src/gift-chain-tracker.test.ts - Create: packages/hypha/src/gratitude.test.ts

Create packages/hypha/src/gift-chain-tracker.test.ts:

import { describe, it, expect } from 'vitest';
import { GiftChainTracker } from './gift-chain-tracker.js';

describe('GiftChainTracker', () => {
  it('computes metrics for a linear chain A → B → C', () => {
    const tasks = [
      { id: 't1', node_id: 'A', status: 'succeeded', input_source_task_ids: undefined },
      { id: 't2', node_id: 'B', status: 'succeeded', input_source_task_ids: ['t1'] },
      { id: 't3', node_id: 'C', status: 'succeeded', input_source_task_ids: ['t2'] },
    ] as any[];

    const tracker = new GiftChainTracker(tasks);
    const metrics = tracker.computeMetrics();

    expect(metrics.chain_length).toBe(3);
    expect(metrics.total_handoffs).toBe(2);
    expect(metrics.chain_breadth).toBe(1);
    expect(metrics.circulation_score).toBeCloseTo(1.0); // all outputs consumed
  });

  it('computes metrics for a branching graph A → B, A → C', () => {
    const tasks = [
      { id: 't1', node_id: 'A', status: 'succeeded', input_source_task_ids: undefined },
      { id: 't2', node_id: 'B', status: 'succeeded', input_source_task_ids: ['t1'] },
      { id: 't3', node_id: 'C', status: 'succeeded', input_source_task_ids: ['t1'] },
    ] as any[];

    const tracker = new GiftChainTracker(tasks);
    const metrics = tracker.computeMetrics();

    expect(metrics.chain_length).toBe(2);
    expect(metrics.total_handoffs).toBe(2);
    expect(metrics.chain_breadth).toBe(2); // A fans out to 2
    expect(metrics.circulation_score).toBeCloseTo(1.0);
  });

  it('penalizes circulation score for unconsumed outputs', () => {
    const tasks = [
      { id: 't1', node_id: 'A', status: 'succeeded', input_source_task_ids: undefined },
      { id: 't2', node_id: 'B', status: 'succeeded', input_source_task_ids: ['t1'] },
      { id: 't3', node_id: 'C', status: 'succeeded', input_source_task_ids: undefined }, // no upstream — isolated
    ] as any[];

    const tracker = new GiftChainTracker(tasks);
    const metrics = tracker.computeMetrics();

    // t1 output consumed by t2 (1 consumed). t2 and t3 outputs not consumed by anyone (2 not consumed).
    // But t2 and t3 are terminal — they're allowed to not be consumed.
    // Only non-terminal tasks with unconsumed output count against circulation.
    // In this case t1 is consumed, t2 and t3 are terminal. Circulation = 1.0.
    // But t3 didn't consume anyone's output — that's measured elsewhere (generosity).
    expect(metrics.circulation_score).toBeCloseTo(1.0);
    expect(metrics.total_handoffs).toBe(1); // only A→B
  });

  it('handles single-task workflow', () => {
    const tasks = [
      { id: 't1', node_id: 'A', status: 'succeeded', input_source_task_ids: undefined },
    ] as any[];

    const tracker = new GiftChainTracker(tasks);
    const metrics = tracker.computeMetrics();

    expect(metrics.chain_length).toBe(1);
    expect(metrics.total_handoffs).toBe(0);
    expect(metrics.chain_breadth).toBe(0);
    expect(metrics.circulation_score).toBe(0); // nothing circulated
  });

  it('only counts succeeded tasks', () => {
    const tasks = [
      { id: 't1', node_id: 'A', status: 'succeeded', input_source_task_ids: undefined },
      { id: 't2', node_id: 'B', status: 'failed', input_source_task_ids: ['t1'] },
      { id: 't3', node_id: 'C', status: 'succeeded', input_source_task_ids: ['t1'] },
    ] as any[];

    const tracker = new GiftChainTracker(tasks);
    const metrics = tracker.computeMetrics();

    expect(metrics.total_handoffs).toBe(1); // only A→C, B failed
  });
});

Run: pnpm --filter @cambium/hypha test -- gift-chain-tracker.test.ts

Expected: FAIL — module not found.

Create packages/hypha/src/gift-chain-tracker.ts:

import type { TaskExecution, GiftChainMetrics } from '@cambium/core';

interface TaskLineage {
  producer_task_id: string;
  consumer_task_id: string;
}

export class GiftChainTracker {
  private tasks: TaskExecution[];
  private succeeded: TaskExecution[];

  constructor(tasks: TaskExecution[]) {
    this.tasks = tasks;
    this.succeeded = tasks.filter((t) => t.status === 'succeeded');
  }

  computeChain(): TaskLineage[] {
    const lineage: TaskLineage[] = [];

    for (const task of this.succeeded) {
      if (!task.input_source_task_ids) continue;
      for (const sourceId of task.input_source_task_ids) {
        // Only count if the source also succeeded
        const source = this.succeeded.find((t) => t.id === sourceId);
        if (source) {
          lineage.push({ producer_task_id: sourceId, consumer_task_id: task.id as string });
        }
      }
    }

    return lineage;
  }

  computeMetrics(): GiftChainMetrics {
    const lineage = this.computeChain();

    if (this.succeeded.length <= 1) {
      return {
        chain_length: this.succeeded.length,
        chain_breadth: 0,
        total_handoffs: 0,
        circulation_score: 0,
      };
    }

    // Build adjacency for longest-path computation
    const adjacency = new Map<string, string[]>();
    const inDegree = new Map<string, number>();
    const fanOut = new Map<string, number>();

    for (const task of this.succeeded) {
      adjacency.set(task.id as string, []);
      inDegree.set(task.id as string, 0);
      fanOut.set(task.id as string, 0);
    }

    for (const edge of lineage) {
      adjacency.get(edge.producer_task_id)?.push(edge.consumer_task_id);
      inDegree.set(edge.consumer_task_id, (inDegree.get(edge.consumer_task_id) ?? 0) + 1);
      fanOut.set(edge.producer_task_id, (fanOut.get(edge.producer_task_id) ?? 0) + 1);
    }

    // Longest path via BFS from roots
    const roots = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
    let maxLength = 1;
    for (const root of roots) {
      const visited = new Map<string, number>();
      const queue: Array<[string, number]> = [[root, 1]];
      while (queue.length > 0) {
        const [node, depth] = queue.shift()!;
        if ((visited.get(node) ?? 0) >= depth) continue;
        visited.set(node, depth);
        maxLength = Math.max(maxLength, depth);
        for (const child of adjacency.get(node) ?? []) {
          queue.push([child, depth + 1]);
        }
      }
    }

    // Breadth = max fan-out
    const maxBreadth = Math.max(0, ...fanOut.values());

    // Circulation = tasks whose output was consumed / tasks that produced output
    // A task "produced output" if it succeeded. It was "consumed" if at least one
    // other task lists it in input_source_task_ids.
    const producerIds = new Set(lineage.map((e) => e.producer_task_id));
    const tasksWithOutput = this.succeeded.length;
    const circulation = tasksWithOutput > 0 ? producerIds.size / tasksWithOutput : 0;

    return {
      chain_length: maxLength,
      chain_breadth: maxBreadth,
      total_handoffs: lineage.length,
      circulation_score: Math.round(circulation * 100) / 100,
    };
  }
}

Run: pnpm --filter @cambium/hypha test -- gift-chain-tracker.test.ts

Expected: All 5 tests PASS.

Create packages/hypha/src/gratitude.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { GraphExecutor } from './graph-executor.js';
import { createTestSignalBus, createTestRunStore, createTestTaskStore } from '../test-helpers.js';

describe('Gratitude Signal Emission', () => {
  let executor: GraphExecutor;
  let signalBus: ReturnType<typeof createTestSignalBus>;

  beforeEach(() => {
    signalBus = createTestSignalBus();
    // Set up executor with test stores and signal bus
    // Adapt to match the actual GraphExecutor constructor
    executor = new GraphExecutor(
      createTestRunStore(),
      createTestTaskStore(),
      signalBus,
    );
  });

  it('emits coord.gratitude when completing a task with upstream lineage', async () => {
    // Set up a run with two tasks: t1 (completed) → t2 (about to complete)
    // The DAG has an edge from node A to node B
    // t1 is already completed, t2 is in progress
    // When t2 completes, gratitude should fire for t1

    // This test needs to be adapted to the actual GraphExecutor API.
    // The key assertion:
    const signals = signalBus.getEmitted();
    const gratitude = signals.find((s: any) => s.type === 'coord.gratitude');
    expect(gratitude).toBeDefined();
    expect(gratitude!.payload.from_agent_id).toBeDefined();
    expect(gratitude!.payload.to_agent_id).toBeDefined();
  });

  it('does NOT emit gratitude for root tasks with no upstream', async () => {
    // Complete a root task (no incoming edges)
    // No gratitude signal should fire

    const signals = signalBus.getEmitted();
    const gratitude = signals.find((s: any) => s.type === 'coord.gratitude');
    expect(gratitude).toBeUndefined();
  });
});

Note: These tests are skeletal — they need to be adapted to the actual GraphExecutor constructor and test setup. Read packages/hypha/src/graph-executor.ts and existing test files to match the real API.

In packages/hypha/src/graph-executor.ts, in the completeTask method (around line 200-230), after the existing success signal emission:

// Emit gratitude signals for upstream tasks
const incomingEdges = definition.graph.edges.filter(
  (e) => e.target === task.node_id,
);

const sourceTaskIds: string[] = [];
for (const edge of incomingEdges) {
  const sourceTask = tasks.find(
    (t) => t.node_id === edge.source && t.status === 'succeeded',
  );
  if (sourceTask) {
    sourceTaskIds.push(sourceTask.id as string);
    await this.signalBus.createAndEmit({
      type: 'coord.gratitude',
      signal_class: 'coordination',
      workspace_id: run.workspace_id,
      workflow_run_id: run.id,
      agent_id: task.agent_id,
      intensity: 0.5,
      decay_rate: 120,
      payload: {
        from_task_id: sourceTask.id,
        to_task_id: task.id,
        from_agent_id: sourceTask.agent_id,
        to_agent_id: task.agent_id,
        workflow_run_id: run.id,
      },
    });
  }
}

// Record lineage on the task
if (sourceTaskIds.length > 0) {
  task.input_source_task_ids = sourceTaskIds;
  await this.taskStore.update(task);
}

Adapt variable names (task, tasks, run, definition) to match what’s in scope in the actual completeTask method.

In GraphExecutor, find where a run transitions to completed status. After that transition, add:

import { GiftChainTracker } from './gift-chain-tracker.js';

// When run completes:
const allTasks = await this.taskStore.findByRunId(run.id);
const tracker = new GiftChainTracker(allTasks);
run.gift_chain_metrics = tracker.computeMetrics();
await this.runStore.update(run);

Run: pnpm --filter @cambium/hypha test -- gratitude.test.ts

Expected: PASS (after adapting test setup to real API).

Run: pnpm --filter @cambium/hypha test

Expected: All existing + new tests PASS.

git add packages/hypha/
git commit -m "feat(hypha): add gratitude signals and gift chain tracking

Emit coord.gratitude on task completion when upstream tasks provided
input. Track input_source_task_ids on task executions. Add
GiftChainTracker that computes chain_length, chain_breadth,
total_handoffs, and circulation_score per workflow run. Store
gift_chain_metrics on run completion."

Task 6: Multi-Generational Composting (Genesis)

Files: - Modify: packages/genesis/src/composting.ts:80-131 - Modify: packages/genesis/src/genesis-process.ts:61-78 - Create: packages/genesis/src/perennial-learnings.test.ts

Create packages/genesis/src/perennial-learnings.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { CompostingProcess } from './composting.js';
import { GenesisProcess } from './genesis-process.js';
import { createTestAgentStore, createTestTemplateStore, createTestCompostStore, createTestSignalBus } from '../test-helpers.js';

describe('Perennial Learning Promotion', () => {
  let composting: CompostingProcess;
  let templateStore: ReturnType<typeof createTestTemplateStore>;
  let compostStore: ReturnType<typeof createTestCompostStore>;

  beforeEach(() => {
    templateStore = createTestTemplateStore();
    compostStore = createTestCompostStore();
    composting = new CompostingProcess(
      createTestAgentStore(),
      templateStore,
      compostStore,
      createTestSignalBus(),
    );
  });

  it('does not promote learnings from low-success agents', async () => {
    templateStore.seedTemplate({ id: 'tpl1', perennial_learnings: [] });

    // Compost an agent with 60% success — below 0.8 threshold
    await composting.compost('agent-1' as any, 'test', {
      success_rate: 0.6,
      failure_patterns: [{ pattern: 'timeout on external APIs', frequency: 3, context: 'http_fetch' }],
    });

    const template = await templateStore.findById('tpl1' as any);
    expect(template!.perennial_learnings).toEqual([]);
  });

  it('promotes a learning after 3 independent agents confirm it', async () => {
    templateStore.seedTemplate({ id: 'tpl1', perennial_learnings: [] });

    // Three agents with >0.8 success all report the same pattern
    for (let i = 1; i <= 3; i++) {
      await composting.compost(`agent-${i}` as any, 'test', {
        success_rate: 0.9,
        template_id: 'tpl1',
        failure_patterns: [{ pattern: 'timeout on external APIs', frequency: 3, context: 'http_fetch' }],
      });
    }

    const template = await templateStore.findById('tpl1' as any);
    expect(template!.perennial_learnings.length).toBe(1);
    expect(template!.perennial_learnings[0]!.pattern).toBe('timeout on external APIs');
    expect(template!.perennial_learnings[0]!.source_agent_count).toBe(3);
  });

  it('increments source_agent_count when confirmed by additional agents', async () => {
    templateStore.seedTemplate({
      id: 'tpl1',
      perennial_learnings: [{
        id: 'pl1',
        pattern: 'timeout on external APIs',
        context: 'http_fetch',
        source_agent_count: 3,
        success_correlation: 0.85,
        first_observed_at: '2026-01-01T00:00:00Z',
        last_confirmed_at: '2026-03-01T00:00:00Z',
      }],
    });

    await composting.compost('agent-4' as any, 'test', {
      success_rate: 0.95,
      template_id: 'tpl1',
      failure_patterns: [{ pattern: 'timeout on external APIs', frequency: 2, context: 'http_fetch' }],
    });

    const template = await templateStore.findById('tpl1' as any);
    expect(template!.perennial_learnings[0]!.source_agent_count).toBe(4);
  });

  it('prunes stale perennial learnings', async () => {
    templateStore.seedTemplate({
      id: 'tpl1',
      perennial_learnings: [{
        id: 'pl1',
        pattern: 'stale pattern',
        context: 'old',
        source_agent_count: 3,
        success_correlation: 0.1, // already decayed low
        first_observed_at: '2025-01-01T00:00:00Z',
        last_confirmed_at: '2025-06-01T00:00:00Z',
      }],
    });

    // Compost 1 agent that doesn't confirm the stale pattern
    await composting.compost('agent-1' as any, 'test', {
      success_rate: 0.9,
      template_id: 'tpl1',
      failure_patterns: [{ pattern: 'different pattern', frequency: 1, context: 'new' }],
    });

    const template = await templateStore.findById('tpl1' as any);
    // success_correlation should decay. At 0.1, one decay of 0.1 brings it to 0.0 = removed.
    expect(template!.perennial_learnings.length).toBe(0);
  });
});

describe('Threshold Gifts at Spawn', () => {
  it('includes perennial_learnings in spawned agent config as inherited_learnings', async () => {
    const templateStore = createTestTemplateStore();
    const agentStore = createTestAgentStore();
    const signalBus = createTestSignalBus();

    templateStore.seedTemplate({
      id: 'tpl1',
      perennial_learnings: [{
        id: 'pl1',
        pattern: 'retry on 429 with exponential backoff',
        context: 'http_fetch',
        source_agent_count: 5,
        success_correlation: 0.9,
        first_observed_at: '2026-01-01T00:00:00Z',
        last_confirmed_at: '2026-03-30T00:00:00Z',
      }],
    });

    const genesis = new GenesisProcess(agentStore, templateStore, signalBus);
    const agent = await genesis.spawn('tpl1' as any, 'ws1' as any);

    expect(agent.config?.inherited_learnings).toBeDefined();
    expect(agent.config.inherited_learnings).toHaveLength(1);
    expect(agent.config.inherited_learnings[0].pattern).toBe('retry on 429 with exponential backoff');
  });
});

Run: pnpm --filter @cambium/genesis test -- perennial-learnings.test.ts

Expected: FAIL — promotion logic doesn’t exist, inherited_learnings not in agent config.

In packages/genesis/src/composting.ts, add a promotePerennialLearnings method and call it at the end of compost():

private async promotePerennialLearnings(
  templateId: TemplateId,
  compostRecord: CompostRecord,
): Promise<void> {
  if (compostRecord.success_rate <= 0.8) return;

  const template = await this.templateStore.findById(templateId);
  if (!template) return;

  const learnings = [...template.perennial_learnings];
  let changed = false;

  // Check each failure pattern against existing perennial learnings
  for (const pattern of compostRecord.failure_patterns) {
    const existing = learnings.find((l) => l.pattern === pattern.pattern);
    if (existing) {
      existing.source_agent_count += 1;
      existing.last_confirmed_at = new Date().toISOString();
      changed = true;
    }
    // If no match, it stays in compost record as candidate — no promotion yet
  }

  // Prune: decay unconfirmed learnings
  for (let i = learnings.length - 1; i >= 0; i--) {
    const learning = learnings[i]!;
    const wasConfirmedThisCycle = compostRecord.failure_patterns.some(
      (p) => p.pattern === learning.pattern,
    );
    if (!wasConfirmedThisCycle) {
      learning.success_correlation = Math.max(0, learning.success_correlation - 0.1);
      if (learning.success_correlation <= 0) {
        learnings.splice(i, 1);
      }
      changed = true;
    }
  }

  // Check for new promotions: patterns in 3+ compost records
  const allRecords = await this.compostStore.findByTemplateId(templateId);
  const patternCounts = new Map<string, { count: number; agents: Set<string>; contexts: string[] }>();

  for (const record of allRecords) {
    for (const fp of record.failure_patterns) {
      if (!patternCounts.has(fp.pattern)) {
        patternCounts.set(fp.pattern, { count: 0, agents: new Set(), contexts: [] });
      }
      const entry = patternCounts.get(fp.pattern)!;
      entry.agents.add(record.agent_id as string);
      entry.count = entry.agents.size;
      if (!entry.contexts.includes(fp.context)) {
        entry.contexts.push(fp.context);
      }
    }
  }

  for (const [pattern, data] of patternCounts) {
    if (data.count >= 3 && !learnings.find((l) => l.pattern === pattern)) {
      learnings.push({
        id: crypto.randomUUID(),
        pattern,
        context: data.contexts.join(', '),
        source_agent_count: data.count,
        success_correlation: 0.8,
        first_observed_at: new Date().toISOString(),
        last_confirmed_at: new Date().toISOString(),
      });
      changed = true;
    }
  }

  if (changed) {
    await this.templateStore.update({ ...template, perennial_learnings: learnings });
  }
}

Call this at the end of the compost() method, after persisting the compost record:

await this.promotePerennialLearnings(agent.template_id, compostRecord);

Adapt to match the actual constructor dependencies and store APIs.

In packages/genesis/src/genesis-process.ts, in the spawn or instantiate method, after creating the agent from the template:

// Threshold gift: include perennial learnings in agent config
const template = await this.templateStore.findById(templateId);
if (template?.perennial_learnings?.length) {
  agent.config = {
    ...agent.config,
    inherited_learnings: template.perennial_learnings,
  };
}

Run: pnpm --filter @cambium/genesis test -- perennial-learnings.test.ts

Expected: All 5 tests PASS.

Run: pnpm --filter @cambium/genesis test

Expected: All existing + new tests PASS.

git add packages/genesis/
git commit -m "feat(genesis): add multi-generational composting and threshold gifts

Promote high-signal failure patterns to perennial_learnings on templates
after 3 independent agents confirm them. Prune stale learnings that go
unconfirmed. Include perennial_learnings as inherited_learnings in agent
config at spawn time — the threshold gift from predecessors."

Task 7: CapabilityMatcher Generosity Weight (Agents)

Files: - Modify: packages/agents/src/capability-matcher.ts:22-67 - Create: packages/agents/src/capability-matcher.gift.test.ts

Create packages/agents/src/capability-matcher.gift.test.ts:

import { describe, it, expect } from 'vitest';
import { CapabilityMatcher } from './capability-matcher.js';

describe('CapabilityMatcher — Generosity Weight', () => {
  it('scores a generous agent higher than a stingy one, all else equal', () => {
    const matcher = new CapabilityMatcher();

    const generous = {
      id: 'a1',
      status: 'active_idle',
      capabilities: [{ name: 'text_generation', confidence: 0.9 }],
      trust_score: 0.7,
      generosity_score: 0.9,
      health: 'healthy',
      current_task_id: undefined,
    } as any;

    const stingy = {
      ...generous,
      id: 'a2',
      generosity_score: 0.2,
    } as any;

    const results = matcher.match([generous, stingy], ['text_generation']);

    expect(results[0]!.agent.id).toBe('a1');
    expect(results[0]!.score).toBeGreaterThan(results[1]!.score);
  });

  it('uses updated weights: 35/25/20/10/10', () => {
    const matcher = new CapabilityMatcher();

    const agent = {
      id: 'a1',
      status: 'active_idle',
      capabilities: [{ name: 'text_generation', confidence: 1.0 }],
      trust_score: 1.0,
      generosity_score: 1.0,
      health: 'healthy',
      current_task_id: undefined,
    } as any;

    const results = matcher.match([agent], ['text_generation']);

    // Perfect scores across all dimensions: 0.35 + 0.25 + 0.20 + 0.10 + 0.10 = 1.0
    expect(results[0]!.score).toBeCloseTo(1.0, 1);
  });
});

Run: pnpm --filter @cambium/agents test -- capability-matcher.gift.test.ts

Expected: FAIL — generosity_score not factored into scoring.

In packages/agents/src/capability-matcher.ts, update DEFAULT_WEIGHTS (around line 22-27):

const DEFAULT_WEIGHTS = {
  capabilityConfidence: 0.35,
  trustScore: 0.25,
  health: 0.2,
  generosity: 0.1,
  load: 0.1,
};

In the match method scoring calculation, add generosity:

const generosityScore = (agent.generosity_score ?? 0.5) * weights.generosity;

Add it to the total score sum alongside the existing terms.

Run: pnpm --filter @cambium/agents test -- capability-matcher.gift.test.ts

Expected: Both tests PASS.

Run: pnpm --filter @cambium/agents test

Expected: All tests PASS. Some existing tests may need score adjustments since weights changed — update expected scores to match new weights.

git add packages/agents/
git commit -m "feat(agents): add generosity weight to CapabilityMatcher

Update scoring weights from 40/30/20/10 to 35/25/20/10/10. Generous
agents get better task assignments. Generosity defaults to 0.5 when
not set."

Task 8: Consumption Detection at Run Completion (Hypha → Immune)

Files: - Modify: packages/hypha/src/graph-executor.ts (where run completes) - Create: packages/hypha/src/consumption-detection.test.ts

Create packages/hypha/src/consumption-detection.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
import { GiftChainTracker } from './gift-chain-tracker.js';

describe('Consumption Detection', () => {
  it('identifies agents that consumed without producing consumed output', () => {
    // Task graph: t1(agent-A) → t2(agent-B), t3(agent-C) is isolated (no upstream, no downstream)
    // agent-C consumed compute but produced nothing anyone else used AND received no input
    // agent-B consumed t1's output but produced terminal output (fine — terminal nodes are exempt)
    // The consumption detection should identify dead-end non-terminal tasks

    const tasks = [
      { id: 't1', node_id: 'A', status: 'succeeded', agent_id: 'agent-A', input_source_task_ids: undefined },
      { id: 't2', node_id: 'B', status: 'succeeded', agent_id: 'agent-B', input_source_task_ids: ['t1'] },
      { id: 't3', node_id: 'C', status: 'succeeded', agent_id: 'agent-C', input_source_task_ids: ['t1'] },
      { id: 't4', node_id: 'D', status: 'succeeded', agent_id: 'agent-B', input_source_task_ids: ['t2'] },
    ] as any[];

    const tracker = new GiftChainTracker(tasks);
    const deadEnds = tracker.findDeadEndAgents();

    // t3 consumed t1's output but nobody consumed t3's output (and t3 is not terminal because t4 exists)
    // Actually — "terminal" means no outgoing edges in the DAG. We need the graph spec to know this.
    // For simplicity, a task is a dead end if it has input_source_task_ids but nobody references it.
    expect(deadEnds).toContain('agent-C');
    expect(deadEnds).not.toContain('agent-A');
    expect(deadEnds).not.toContain('agent-B');
  });
});

Run: pnpm --filter @cambium/hypha test -- consumption-detection.test.ts

Expected: FAIL — findDeadEndAgents doesn’t exist.

In packages/hypha/src/gift-chain-tracker.ts, add:

/**
 * Find agents that consumed input (have input_source_task_ids) but whose
 * output was not consumed by any other task. These are "dead ends" in the
 * gift chain — they received but didn't give back.
 */
findDeadEndAgents(): string[] {
  const consumedTaskIds = new Set<string>();
  for (const task of this.succeeded) {
    if (task.input_source_task_ids) {
      for (const id of task.input_source_task_ids) {
        consumedTaskIds.add(id);
      }
    }
  }

  const deadEndAgents: string[] = [];
  for (const task of this.succeeded) {
    // Task received input (was a consumer)
    const receivedInput = task.input_source_task_ids && task.input_source_task_ids.length > 0;
    // Task's output was not consumed by anyone
    const outputConsumed = consumedTaskIds.has(task.id as string);

    if (receivedInput && !outputConsumed) {
      if (task.agent_id && !deadEndAgents.includes(task.agent_id as string)) {
        deadEndAgents.push(task.agent_id as string);
      }
    }
  }

  return deadEndAgents;
}

In GraphExecutor, where the run completes (after computing gift chain metrics in Task 5):

// Record consumption for dead-end agents
const deadEndAgents = tracker.findDeadEndAgents();
for (const agentId of deadEndAgents) {
  await this.trustManager.recordConsumption(this.agentStore, agentId as any, run.workspace_id);
}

This requires GraphExecutor to have access to the TrustManager — add it as a constructor dependency or pass it through the signal bus (emit a signal and let the receptor handle it). The cleanest approach is to emit a signal:

for (const agentId of deadEndAgents) {
  await this.signalBus.createAndEmit({
    type: 'coord.consumption_detected',
    signal_class: 'coordination',
    workspace_id: run.workspace_id,
    agent_id: agentId as any,
    intensity: 0.3,
    decay_rate: 120,
    payload: { workflow_run_id: run.id, agent_id: agentId },
  });
}

Then add a receptor in immune-receptors.ts:

receptorRegistry.register({
  id: 'consumption-tracker' as any,
  signal_pattern: 'coord.consumption_detected',
  threshold: 0.0,
  cooldown_ms: 0,
  handler: async (signal) => {
    const agentId = signal.agent_id;
    if (agentId) {
      await trustManager.recordConsumption(agentStore, agentId, signal.workspace_id);
    }
  },
});

Run: pnpm --filter @cambium/hypha test -- consumption-detection.test.ts

Expected: PASS.

Run: pnpm turbo test

Expected: All packages PASS.

git add packages/hypha/ packages/immune/
git commit -m "feat(hypha,immune): detect dead-end consumers at run completion

Add findDeadEndAgents() to GiftChainTracker — identifies agents that
consumed input but produced nothing others used. Emit
coord.consumption_detected signal at run completion. Immune receptor
calls recordConsumption to adjust generosity score."

Task 9: Loom Dashboard — Gift Flow Visualization

Files: - Modify: apps/loom/src/app/runs/[id]/page.tsx - Modify: apps/loom/src/app/health/page.tsx - Create: apps/loom/src/components/gift-chain-overlay.tsx - Create: apps/loom/src/components/generosity-badge.tsx - Create: apps/loom/src/components/gift-economy-summary.tsx

Create apps/loom/src/components/gift-chain-overlay.tsx:

'use client';

import type { GiftChainMetrics } from '@cambium/core';

interface GiftChainOverlayProps {
  metrics: GiftChainMetrics | undefined;
}

export function GiftChainOverlay({ metrics }: GiftChainOverlayProps) {
  if (!metrics) return null;

  return (
    <div className="rounded-lg border p-4 space-y-3">
      <h3 className="text-sm font-medium">Gift Flow</h3>
      <div className="grid grid-cols-4 gap-3">
        <MetricCard label="Circulation" value={`${Math.round(metrics.circulation_score * 100)}%`} />
        <MetricCard label="Chain Length" value={String(metrics.chain_length)} />
        <MetricCard label="Breadth" value={String(metrics.chain_breadth)} />
        <MetricCard label="Handoffs" value={String(metrics.total_handoffs)} />
      </div>
    </div>
  );
}

function MetricCard({ label, value }: { label: string; value: string }) {
  return (
    <div className="text-center">
      <div className="text-2xl font-semibold">{value}</div>
      <div className="text-xs text-muted-foreground">{label}</div>
    </div>
  );
}

Create apps/loom/src/components/generosity-badge.tsx:

'use client';

interface GenerosityBadgeProps {
  score: number;
  showAlert?: 'hoarding' | 'overextension' | null;
}

export function GenerosityBadge({ score, showAlert }: GenerosityBadgeProps) {
  const percentage = Math.round(score * 100);

  return (
    <div className="flex items-center gap-2">
      <div className="flex-1">
        <div className="flex justify-between text-xs mb-1">
          <span>Generosity</span>
          <span>{percentage}%</span>
        </div>
        <div className="h-2 rounded-full bg-muted overflow-hidden">
          <div
            className="h-full rounded-full bg-emerald-500 transition-all"
            style={{ width: `${percentage}%` }}
          />
        </div>
      </div>
      {showAlert === 'hoarding' && (
        <span className="text-xs px-2 py-0.5 rounded bg-orange-100 text-orange-700">Hoarding</span>
      )}
      {showAlert === 'overextension' && (
        <span className="text-xs px-2 py-0.5 rounded bg-yellow-100 text-yellow-700">Overextended</span>
      )}
    </div>
  );
}

Create apps/loom/src/components/gift-economy-summary.tsx:

'use client';

interface GiftEconomySummaryProps {
  avgCirculation: number;
  topContributors: Array<{ id: string; name: string; generosity_score: number }>;
  alerts: Array<{ agent_id: string; type: 'hoarding' | 'overextension' }>;
}

export function GiftEconomySummary({ avgCirculation, topContributors, alerts }: GiftEconomySummaryProps) {
  return (
    <div className="rounded-lg border p-4 space-y-4">
      <h3 className="text-sm font-medium">Gift Economy</h3>

      <div className="flex items-baseline gap-2">
        <span className="text-2xl font-semibold">{Math.round(avgCirculation * 100)}%</span>
        <span className="text-xs text-muted-foreground">avg circulation</span>
      </div>

      {topContributors.length > 0 && (
        <div>
          <h4 className="text-xs text-muted-foreground mb-1">Top Contributors</h4>
          <ul className="space-y-1">
            {topContributors.slice(0, 5).map((agent) => (
              <li key={agent.id} className="flex justify-between text-sm">
                <span>{agent.name}</span>
                <span className="text-muted-foreground">{Math.round(agent.generosity_score * 100)}%</span>
              </li>
            ))}
          </ul>
        </div>
      )}

      {alerts.length > 0 && (
        <div>
          <h4 className="text-xs text-muted-foreground mb-1">Alerts</h4>
          <ul className="space-y-1">
            {alerts.map((alert) => (
              <li key={alert.agent_id} className="text-sm">
                <span className={alert.type === 'hoarding' ? 'text-orange-600' : 'text-yellow-600'}>
                  {alert.agent_id}: {alert.type}
                </span>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

In apps/loom/src/app/runs/[id]/page.tsx, import and render GiftChainOverlay:

import { GiftChainOverlay } from '@/components/gift-chain-overlay';

// In the page component, after existing run visualization:
<GiftChainOverlay metrics={run.gift_chain_metrics} />

Find the agent detail page (likely apps/loom/src/app/agents/[id]/page.tsx or similar). Import and render alongside existing trust score display:

import { GenerosityBadge } from '@/components/generosity-badge';

// Next to trust score display:
<GenerosityBadge
  score={agent.generosity_score}
  showAlert={agent.generosity_score < 0.25 ? 'hoarding' : null}
/>

In apps/loom/src/app/health/page.tsx, import and render GiftEconomySummary:

import { GiftEconomySummary } from '@/components/gift-economy-summary';

// Compute from available data:
// avgCirculation from recent workflow runs
// topContributors from agent list sorted by generosity_score
// alerts from active stress signals
<GiftEconomySummary
  avgCirculation={avgCirculation}
  topContributors={topContributors}
  alerts={giftAlerts}
/>

Run: pnpm --filter @cambium/loom build

Expected: Build succeeds with no type errors.

git add apps/loom/
git commit -m "feat(loom): add gift economy dashboard components

Add GiftChainOverlay on run detail page showing circulation score,
chain length/breadth, and handoff count. Add GenerosityBadge on agent
detail with hoarding/overextension alerts. Add GiftEconomySummary on
workspace health page with aggregate circulation, top contributors,
and active alerts."

Task 10: Integration Test & Final Verification

Files: - Create: tests/integration/gift-circulation.test.ts

Create tests/integration/gift-circulation.test.ts:

import { describe, it, expect, beforeEach } from 'vitest';
// Import all necessary test helpers and real implementations
// Adapt imports to match the actual test infrastructure in tests/integration/

describe('Gift Circulation — End to End', () => {
  it('full cycle: workflow run → gratitude → generosity update → capability matcher preference', async () => {
    // 1. Create two agent templates and spawn agents
    // 2. Run a 3-node workflow: A → B → C
    // 3. Verify gratitude signals were emitted (2 signals: A→B, B→C)
    // 4. Verify generosity scores increased for agents A and B (they produced consumed output)
    // 5. Verify gift_chain_metrics on the run: chain_length=3, handoffs=2, circulation > 0
    // 6. Run capability matcher — agent with higher generosity should rank higher

    // This test should follow the same patterns as community-health.test.ts
    // which exercises multiple subsystems in a single integration flow.
  });

  it('knowledge marker lifecycle: place → transform → decay', async () => {
    // 1. Agent A places a knowledge.pattern marker with payload
    // 2. Agent B transforms it with updated payload
    // 3. Verify transformation_depth = 1, transformed_by = ['B']
    // 4. Verify coord.marker_transformed signal emitted
    // 5. Verify Agent B got recordKnowledgeShare credit (if B also placed a marker)
  });

  it('composting promotes perennial learnings after 3 agents', async () => {
    // 1. Create template with empty perennial_learnings
    // 2. Spawn and compost 3 agents with same failure pattern, all >0.8 success
    // 3. Verify template now has 1 perennial learning with source_agent_count = 3
    // 4. Spawn a 4th agent from the template
    // 5. Verify the new agent has inherited_learnings in its config
  });

  it('hoarding detection: agent that consumes without producing gets flagged', async () => {
    // 1. Run a workflow where agent X receives input but produces unused output
    // 2. Verify consumption_detected signal fires
    // 3. Verify agent X generosity score decreased
    // 4. If score drops below 0.25, verify stress.context_hoarding fires
  });
});

Note: These tests need to be fleshed out with the actual test infrastructure. Study tests/integration/community-health.test.ts for the patterns — it sets up workflow definitions, spawns agents, runs the executor, and checks signals.

Run: pnpm turbo test --filter=@cambium/integration-tests

Expected: All new + existing integration tests PASS.

Run: pnpm turbo test

Expected: All packages PASS. Note any that time out (the 7 integration tests requiring live LLM are expected to timeout).

Run: pnpm turbo typecheck

Expected: PASS across all packages.

git add tests/integration/
git commit -m "test: add gift circulation end-to-end integration tests

Cover full cycle: workflow run → gratitude signals → generosity scoring →
capability matcher preference. Knowledge marker lifecycle. Perennial
learning promotion. Hoarding detection."

Task Summary

Task Package(s) What
1 core Type extensions — all new fields and interfaces
2 db Migration — new columns
3 stigmergy Knowledge markers, payload, transformMarker()
4 immune Generosity scoring, hoarding, overextension
5 hypha Gratitude signals, GiftChainTracker
6 genesis Perennial learnings, threshold gifts
7 agents CapabilityMatcher generosity weight
8 hypha + immune Consumption detection at run completion
9 loom Dashboard components
10 integration End-to-end tests