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)
- [ ] Step 1: Add generosity_score to Agent interface
In packages/core/src/types/agents.ts, add to the Agent interface after trust_score:
generosity_score: number;
- [ ] Step 2: Add PerennialLearning type and perennial_learnings to AgentTemplate
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[];
- [ ] Step 3: Add gift signal types
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.
- [ ] Step 4: Add knowledge markers and payload to Marker type
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[];
- [ ] Step 5: Add GiftChainMetrics and lineage tracking to workflow types
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[];
- [ ] Step 6: Run typecheck
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
- [ ] Step 7: Fix all downstream type errors
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.
- [ ] Step 8: Commit
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
- [ ] Step 1: Add columns to schema definition
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'),
- [ ] Step 2: Generate and run migration
Run: pnpm --filter @cambium/db migrate
Expected: Migration applies cleanly. All new columns are nullable or have defaults, so existing data is unaffected.
- [ ] Step 3: Verify migration
Run: pnpm --filter @cambium/db test
Expected: All existing DB tests pass.
- [ ] Step 4: Commit
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
- [ ] Step 1: Write failing tests for knowledge markers
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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: pnpm --filter @cambium/stigmergy test -- environment.gift.test.ts
Expected: FAIL — transformMarker doesn’t exist, knowledge markers not recognized, payload not on Marker.
- [ ] Step 3: Add knowledge category to DEFAULT_HALF_LIVES
In packages/stigmergy/src/environment.ts, find DEFAULT_HALF_LIVES (around line 17-23). Add:
knowledge: 1800,
- [ ] Step 4: Update placeMarker to handle payload
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>.
- [ ] Step 5: Implement transformMarker method
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.
- [ ] Step 6: Run tests
Run: pnpm --filter @cambium/stigmergy test -- environment.gift.test.ts
Expected: All 5 tests PASS.
- [ ] Step 7: Run full stigmergy test suite
Run: pnpm --filter @cambium/stigmergy test
Expected: All existing + new tests PASS.
- [ ] Step 8: Commit
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
- [ ] Step 1: Write failing tests for generosity scoring
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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: pnpm --filter @cambium/immune test -- generosity.test.ts
Expected: FAIL — recordContribution, recordConsumption, recordKnowledgeShare don’t exist.
- [ ] Step 3: Add GenerosityPolicy and GenerosityAction types
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';
- [ ] Step 4: Implement generosity recording methods
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.
- [ ] Step 5: Add hoarding anomaly rule
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,
},
- [ ] Step 6: Add gift signal receptors
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.
- [ ] Step 7: Run tests
Run: pnpm --filter @cambium/immune test -- generosity.test.ts
Expected: All 6 tests PASS.
- [ ] Step 8: Run full immune test suite
Run: pnpm --filter @cambium/immune test
Expected: All existing + new tests PASS.
- [ ] Step 9: Commit
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
- [ ] Step 1: Write failing tests for GiftChainTracker
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
});
});
- [ ] Step 2: Run tests to verify they fail
Run: pnpm --filter @cambium/hypha test -- gift-chain-tracker.test.ts
Expected: FAIL — module not found.
- [ ] Step 3: Implement GiftChainTracker
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,
};
}
}
- [ ] Step 4: Run gift chain tracker tests
Run: pnpm --filter @cambium/hypha test -- gift-chain-tracker.test.ts
Expected: All 5 tests PASS.
- [ ] Step 5: Write failing tests for gratitude signal emission
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.
- [ ] Step 6: Implement gratitude emission in GraphExecutor.completeTask
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.
- [ ] Step 7: Add gift chain metrics computation on run completion
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);
- [ ] Step 8: Run gratitude tests
Run: pnpm --filter @cambium/hypha test -- gratitude.test.ts
Expected: PASS (after adapting test setup to real API).
- [ ] Step 9: Run full hypha test suite
Run: pnpm --filter @cambium/hypha test
Expected: All existing + new tests PASS.
- [ ] Step 10: Commit
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
- [ ] Step 1: Write failing tests for perennial learning promotion
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');
});
});
- [ ] Step 2: Run tests to verify they fail
Run: pnpm --filter @cambium/genesis test -- perennial-learnings.test.ts
Expected: FAIL — promotion logic doesn’t exist, inherited_learnings not in agent config.
- [ ] Step 3: Implement perennial learning promotion in CompostingProcess
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.
- [ ] Step 4: Implement threshold gifts in GenesisProcess.spawn
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,
};
}
- [ ] Step 5: Run tests
Run: pnpm --filter @cambium/genesis test -- perennial-learnings.test.ts
Expected: All 5 tests PASS.
- [ ] Step 6: Run full genesis test suite
Run: pnpm --filter @cambium/genesis test
Expected: All existing + new tests PASS.
- [ ] Step 7: Commit
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
- [ ] Step 1: Write failing test
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);
});
});
- [ ] Step 2: Run tests to verify they fail
Run: pnpm --filter @cambium/agents test -- capability-matcher.gift.test.ts
Expected: FAIL — generosity_score not factored into scoring.
- [ ] Step 3: Update DEFAULT_WEIGHTS and scoring logic
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.
- [ ] Step 4: Run tests
Run: pnpm --filter @cambium/agents test -- capability-matcher.gift.test.ts
Expected: Both tests PASS.
- [ ] Step 5: Run full agents test suite
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.
- [ ] Step 6: Commit
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
- [ ] Step 1: Write failing test
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');
});
});
- [ ] Step 2: Run test to verify it fails
Run: pnpm --filter @cambium/hypha test -- consumption-detection.test.ts
Expected: FAIL — findDeadEndAgents doesn’t exist.
- [ ] Step 3: Add findDeadEndAgents to GiftChainTracker
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;
}
- [ ] Step 4: Wire consumption detection into run completion
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);
}
},
});
- [ ] Step 5: Run tests
Run: pnpm --filter @cambium/hypha test -- consumption-detection.test.ts
Expected: PASS.
- [ ] Step 6: Run full test suites
Run: pnpm turbo test
Expected: All packages PASS.
- [ ] Step 7: Commit
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
- [ ] Step 1: Create GiftChainOverlay component
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>
);
}
- [ ] Step 2: Create GenerosityBadge component
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>
);
}
- [ ] Step 3: Create GiftEconomySummary component
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>
);
}
- [ ] Step 4: Add GiftChainOverlay to workflow run detail page
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} />
- [ ] Step 5: Add GenerosityBadge to agent detail page
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}
/>
- [ ] Step 6: Add GiftEconomySummary to workspace health page
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}
/>
- [ ] Step 7: Run Loom build
Run: pnpm --filter @cambium/loom build
Expected: Build succeeds with no type errors.
- [ ] Step 8: Commit
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
- [ ] Step 1: Write end-to-end integration test
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.
- [ ] Step 2: Run integration tests
Run: pnpm turbo test --filter=@cambium/integration-tests
Expected: All new + existing integration tests PASS.
- [ ] Step 3: Run full test suite
Run: pnpm turbo test
Expected: All packages PASS. Note any that time out (the 7 integration tests requiring live LLM are expected to timeout).
- [ ] Step 4: Run typecheck
Run: pnpm turbo typecheck
Expected: PASS across all packages.
- [ ] Step 5: Commit
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 |