diff --git a/backend/src/agent/podman.ts b/backend/src/agent/podman.ts index c945136..902d55a 100644 --- a/backend/src/agent/podman.ts +++ b/backend/src/agent/podman.ts @@ -8,6 +8,7 @@ import { recordObservation, recordCollision, recordIntervention, + recordSuppression, updateInterventionStatus, } from '../memory/store.js'; import { getGitStates, type GitState } from '../memory/db.js'; @@ -144,7 +145,22 @@ export class PodMan { if (prior?.priorOutcome?.accepted && prior?.priorOutcome?.wasRealCollision) { collision.severity = 'critical'; } - if (!shouldIntervene(collision, prior)) return; // Loop B: policy gate + if (!shouldIntervene(collision, prior)) { + // Feature A — make the negative-feedback loop VISIBLE. If we stayed quiet + // *specifically* because this signature was DISMISSED before, record a + // durable suppressed-repeat event (timestamped now, at the repeat) so the + // activity stream shows the learning instead of nothing. + if (prior?.priorOutcome && !prior.priorOutcome.accepted) { + void recordSuppression( + collision, + prior.priorOutcome.interventionId, + prior.priorOutcome.recordedAt, + ).catch((err) => + console.error(`[memory] suppression record failed: ${(err as Error).message}`), + ); + } + return; // Loop B: policy gate + } this.activeConflicts.add(key); // claim now we're alerting; re-armed in onScreenFrame on resolution await recordCollision(collision); diff --git a/backend/src/graph/live.ts b/backend/src/graph/live.ts index 6dde1be..0b9e424 100644 --- a/backend/src/graph/live.ts +++ b/backend/src/graph/live.ts @@ -457,6 +457,31 @@ export async function materializePodGraph(podId: string): Promise { b.nodes.delete(id); diff --git a/backend/src/memory/db.ts b/backend/src/memory/db.ts index 9087859..75c467f 100644 --- a/backend/src/memory/db.ts +++ b/backend/src/memory/db.ts @@ -31,12 +31,29 @@ export async function closeMemory(): Promise { await client.close(); } +/** A durable record that PodMan stayed quiet on a recurring collision because + * its signature was previously dismissed — the negative-feedback loop made + * auditable. Timestamped at the repeat; materialized as `suppressed` activity + * in graph/live.ts. The suppressed collision is never written to `collisions` + * (the agent returns before recordCollision), so this is its own record. */ +export interface SuppressionDoc { + id: string; + podId: string; + collisionId: string; + file: string; + engineers: string[]; + priorInterventionId?: string; + priorDismissedAt?: string; + suppressedAt: string; +} + export interface PodCollections { pods: Collection; observations: Collection; collisions: Collection; interventions: Collection; outcomes: Collection; + suppressions: Collection; } export async function collections(): Promise { @@ -47,6 +64,7 @@ export async function collections(): Promise { collisions: db.collection('collisions'), interventions: db.collection('interventions'), outcomes: db.collection('outcomes'), + suppressions: db.collection('suppressions'), }; } @@ -114,6 +132,7 @@ export async function initMemory(): Promise { ['collisions.file', () => c.collisions.createIndex({ podId: 1, file: 1, detectedAt: -1 })], ['interventions.collisionId', () => c.interventions.createIndex({ collisionId: 1 })], ['outcomes.interventionId', () => c.outcomes.createIndex({ interventionId: 1 })], + ['suppressions.podId', () => c.suppressions.createIndex({ podId: 1, suppressedAt: -1 })], ['hermes_jobs.id', () => db.collection('hermes_jobs').createIndex({ id: 1 }, { unique: true })], [ 'hermes_jobs.session', diff --git a/backend/src/memory/store.ts b/backend/src/memory/store.ts index 97bff45..805879f 100644 --- a/backend/src/memory/store.ts +++ b/backend/src/memory/store.ts @@ -50,6 +50,31 @@ export async function recordIntervention(intervention: Intervention): Promise { + await persist('suppression', async () => + (await collections()).suppressions.insertOne({ + id: `supp_${Date.now()}`, + podId: collision.podId, + collisionId: collision.id, + file: collision.file, + engineers: collision.engineers, + priorInterventionId, + priorDismissedAt, + suppressedAt: new Date().toISOString(), + }), + ); +} + export async function hasRecentInterventionForCollision( collision: Collision, windowMs = Number(process.env.NUDGE_COOLDOWN_MS ?? '180000'), diff --git a/docs/cont_learning.md b/docs/cont_learning.md index d6fa804..1b67e18 100644 --- a/docs/cont_learning.md +++ b/docs/cont_learning.md @@ -98,6 +98,7 @@ Additive routes in `backend/src/server.ts` (shared file — additive only). | `collisions` | **collision** nodes; `collides` (eng→col) + `touches` (file→col) | | `interventions` | **intervention** nodes; `warns` (col→intervention) | | `outcomes` | `learned_from` (intervention→owner) on accepted; flips nodes to `learned` | +| `suppressions` | `suppressed` activity beats — a dismissed signature recurred and PodMan stayed quiet (negative-feedback made visible; written at repeat time by the agent) | Metrics (learned owners / open risk paths / accept rate) are live counts. diff --git a/frontend/src/components/graph/encoding.ts b/frontend/src/components/graph/encoding.ts index f1fc087..75d2c0a 100644 --- a/frontend/src/components/graph/encoding.ts +++ b/frontend/src/components/graph/encoding.ts @@ -28,6 +28,7 @@ export const ACTIVITY_TAG: Record = { diff --git a/shared/src/graph.ts b/shared/src/graph.ts index a0cc26e..82c2b8c 100644 --- a/shared/src/graph.ts +++ b/shared/src/graph.ts @@ -71,6 +71,7 @@ export type PodGraphActivityKind = | 'intervention' | 'outcome' | 'learned' + | 'suppressed' | 'agent'; export interface PodGraphActivity {