> ## Documentation Index
> Fetch the complete documentation index at: https://prism.ntecdev.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SPEC-029 v0.2: Agent nudges — persistent cross-session reminders for implicit obligations

> Agent nudges — persistent cross-session reminders for implicit obligations

<Info>**Status:** `draft` · **Version** `0.2` · Filed 2026-04-22</Info>

***

spec\_id: SPEC-029
version: "0.2"
title: Agent nudges — persistent cross-session reminders for implicit obligations
status: draft
supersedes: (SPEC-029 v0.1 conversational draft, never filed)
-------------------------------------------------------------

# SPEC-029 v0.2 — Agent nudges

## 1. Summary

Introduces a `nudges` table: persistent, tool-written prompts that surface
across sessions when an agent action creates an **implicit obligation** a
future surface should check on. Ships with five write points and three
resolution paths. First-class primitive alongside TODOs / notes / WIP —
fills the gap between ephemeral `rules_reminders` (computed fresh each call)
and TODOs (explicit user work).

Closes the 2026-04-22 "3 unwrapped deltas went silently stale" failure mode
from SPEC-023 §5.4's heuristic-based detection, and generalizes the fix so
the same mechanism handles draft SPECs, proposed ADRs, unsealed WIPs, open
invites, and issued handoffs without per-case bespoke logic.

## 2. Origin

2026-04-22 PM discussion (Frank + Donna). Starting from a failed SPEC-029
v0.1 draft that proposed silent auto-wrap on `prism_start`, two objections
collapsed the design into a better primitive:

1. **SPEC-023 already rejected auto-wrap on start** — "produces artifact
   that looks like wrap but isn't." v0.1's `delta_kind='auto_wrap'` enum
   extension was a workaround, not a fix.
2. **Concurrent sessions (Lola on Claude Desktop + Donna on Claude Code)**
   — a time-threshold-based auto-wrap would silently close a live-but-quiet
   session. No liveness signal exists in the current model.

Frank: "what about a Reminders or Nudge list / table? A table for when an
item isn't quite a Todo but something to remind the tools to ask the user
and or make a recommendation." The pattern resolves both objections
structurally: nudges are session\_id-scoped (so a wrap only clears its own),
and surfacing is user-driven (so no silent side effects).

## 3. Problem

Three overlapping failure modes existed:

1. **Abandoned sessions stay invisible.** SPEC-023 §5.4 detects unwrapped
   prior sessions by delta-sequence heuristic. The signal is lossy — it
   rebuilds state from delta history every call, with no persistent marker
   of "this session owes a wrap."
2. **Draft-arc abandonment.** A SPEC filed as `status=draft` or an ADR as
   `status=proposed` has no future-surface reminder. It lives in
   `prism_*(action=list)` output, which agents rarely scan proactively.
3. **Cross-session obligations have no primitive.** WIP states, open
   invites, outstanding handoffs — each is tracked in its own table with
   its own surfacing convention (if any). No uniform "open things that
   need attention" query exists.

## 4. Schema

New table `nudges`:

| Column             | Type        | Notes                                                                                |
| ------------------ | ----------- | ------------------------------------------------------------------------------------ |
| `id`               | uuid        | PK                                                                                   |
| `tenant_id`        | uuid        | FK, org scope                                                                        |
| `project_id`       | uuid        | FK, null allowed for org-level nudges in a future spec — v1 requires non-null        |
| `kind`             | varchar(32) | see §4.1                                                                             |
| `source_type`      | varchar(32) | what the nudge refers to: `session`, `spec`, `adr`, `wip_state`, `invite`, `handoff` |
| `source_id`        | varchar(64) | polymorphic reference; e.g. session\_id, spec\_id, adr decision\_id                  |
| `message`          | text        | human-readable prompt ("Wrap this session before leaving")                           |
| `actions`          | jsonb       | array of action strings, e.g. `["wrap","continue","ignore","snooze"]`                |
| `severity`         | varchar(16) | `info` \| `nudge`, default `nudge`. No `blocking` — nudges never hard-gate           |
| `created_at`       | timestamptz | default now()                                                                        |
| `resolved_at`      | timestamptz | null while open                                                                      |
| `resolved_by_verb` | varchar(64) | e.g. `prism_wrap`, `prism_spec.update`, `prism_nudge.resolve`                        |
| `resolution`       | varchar(32) | null while open; `completed`, `ignored`, `snoozed`, `auto_cleared`                   |
| `resolution_note`  | text        | optional user note on manual resolution                                              |
| `snooze_until`     | timestamptz | if resolution=`snoozed`; nudge re-opens at this time                                 |
| `metadata`         | jsonb       | kind-specific extras (e.g. `{"persona":"Lola","delta_count":3}`)                     |

Indexes:

* `(tenant_id, project_id, resolved_at)` partial `WHERE resolved_at IS NULL` — fast open-nudge listing
* `(kind, source_type, source_id)` — fast auto-resolution lookup
* `(snooze_until)` partial `WHERE resolution='snoozed'` — reopener sweep

Invariant: **append-never-delete.** Resolution sets `resolved_at` and
`resolution`; the row stays on disk. Re-opening a snoozed nudge creates a
new row with a reference to the prior one in metadata rather than mutating
the resolved row. (Mirrors SPEC-024 projection-retirement ethos.)

### 4.1 Nudge kinds (v1)

| `kind`            | Written by                         | `source_type` | `source_id`                      | Resolves on                                                   |
| ----------------- | ---------------------------------- | ------------- | -------------------------------- | ------------------------------------------------------------- |
| `wrap_session`    | `prism_start`                      | `session`     | session\_id minted by that start | `prism_wrap` with matching session\_id                        |
| `spec_draft`      | `prism_spec(create, status=draft)` | `spec`        | spec\_id                         | `prism_spec(update, status=accepted\|superseded\|rejected)`   |
| `adr_proposed`    | `prism_decide(status=proposed)`    | `adr`         | adr decision\_id                 | `prism_decide(update, status=accepted\|superseded\|rejected)` |
| `wip_unsealed`    | `prism_wip` create                 | `wip_state`   | wip\_state\_uuid                 | `prism_seal` with matching uuid                               |
| `invite_pending`  | `prism_invite` create              | `invite`      | invite\_id                       | `prism_accept_invite` or invite decline/expiry                |
| `handoff_pending` | `prism_handoff`                    | `handoff`     | handoff doc id                   | manual resolve via `prism_nudge` (no auto-ack verb yet)       |

Scope lock for v1: these six. Agent writers do not invent new `kind`
values; new kinds require a spec amendment. This prevents "write a nudge
for anything" pollution.

## 5. Write semantics

Writers MUST:

1. Use the kind's canonical `source_type` + `source_id` per §4.1.
2. Idempotence: if an open nudge already exists with the same
   `(kind, source_type, source_id)`, do not write a duplicate. The write
   path is an UPSERT on `resolved_at IS NULL` — existing open row wins,
   new write is a no-op. Audit counter in metadata optional.
3. Failure-soft: nudge write errors must NEVER fail the parent verb.
   Log-and-proceed. (Matches `prism_wrap` patch\_project pattern,
   `mcp/server.py:1618–1634`.)

Writers MUST NOT:

* Write a nudge with a `kind` not in §4.1.
* Write a nudge whose `source_id` refers to an entity that doesn't exist.
* Write a nudge with `severity=blocking` (reserved for future spec; v1
  enum is `info | nudge` only).

## 6. Resolution semantics

Three resolution paths:

### 6.1 Auto-resolution on state change (preferred)

When the resolving verb fires with matching `(kind, source_type,
source_id)`, the server UPDATES open nudge rows in the same transaction:

```sql theme={null}
UPDATE nudges
   SET resolved_at = now(),
       resolved_by_verb = '<verb>',
       resolution = 'auto_cleared'
 WHERE kind = '<kind>'
   AND source_type = '<source_type>'
   AND source_id = '<source_id>'
   AND resolved_at IS NULL;
```

For `prism_wrap`: `(kind='wrap_session', source_type='session',
source_id=<current session_id>)`. Critical: matches **current session's**
session\_id only — structurally solves the concurrent-Lola-and-Donna case
because Lola's open `wrap_session` nudge has a different `source_id`.

### 6.2 Manual resolution (user-initiated)

New verb `prism_nudge`:

```
prism_nudge(pid, action, nudge_id?, resolution?, resolution_note?, snooze_hours?)
```

Actions:

* `list` — returns open nudges for the project (optional filter by kind).
* `resolve` — sets `resolved_at`, `resolution=ignored|completed|snoozed`,
  `resolved_by_verb='prism_nudge.resolve'`, plus optional note.
* `snooze` — shortcut for resolve with `resolution='snoozed'` plus
  `snooze_until = now() + snooze_hours`.

### 6.3 Scheduled re-opening (snooze sweep)

A background sweep (or on-read check) finds nudges with
`resolution='snoozed' AND snooze_until <= now()` and writes a fresh open
row with the same `(kind, source_type, source_id)` plus
`metadata.reopened_from = <prior row id>`. The prior resolved row stays on
disk per the append-never-delete invariant.

v1 implementation: on-read check inside `prism_start`'s open-nudge query
(`WHERE resolved_at IS NULL OR (resolution='snoozed' AND snooze_until <=
now())` — no separate background worker needed). The fresh-row write
happens on the next `prism_start` that observes the expiry. Simple and
avoids cron infrastructure.

## 7. Surfacing contract

`prism_start` response adds a top-level `pending_nudges` array:

```json theme={null}
"pending_nudges": [
  {
    "id": "...",
    "kind": "wrap_session",
    "source_id": "<prior session_id>",
    "message": "Prior session has 3 unwrapped deltas, last activity 2026-04-22T11:41Z",
    "actions": ["wrap", "continue", "ignore", "snooze"],
    "metadata": { "persona": "Lola", "delta_count": 3,
                  "orphan_summaries": ["docs-hero-diagram-rewrite ...",
                                        "docs-overview-rewrite-and-vision-plp ...",
                                        "docs-history-shipped ..."] }
  }
]
```

Agent contract (propagated via `prism_sync_bios` into CLAUDE.md /
AGENTS.md under the Session Status Card section):

> If `pending_nudges` is non-empty in the `prism_start` return,
> the agent MUST present the open nudges at the top of the first
> substantive response, with their available actions clearly offered to
> the user. The agent proceeds with the user's current request only after
> either (a) the user has chosen an action for each nudge, or
> (b) the user has explicitly said to defer ("later", "ignore for now"),
> in which case the agent writes a `snooze` with a sensible default
> (1 hour) and proceeds.

`rules_reminders` keeps its computed entries (wrap\_rate, BIOS drift) and
gains a `nudges` sibling:

```json theme={null}
"rules_reminders": [ /* computed, ephemeral */ ],
"pending_nudges": [ /* persistent, actionable */ ]
```

SPEC-023's `_check_recent_unwrapped_sessions` is **removed** — its job is
now done by the `wrap_session` nudge lifecycle. Migration note: on first
deploy, run a one-shot backfill that writes `wrap_session` nudges for any
historical unwrapped sessions in the last 30 days (see §10.2).

## 8. Agent contract: when to write a nudge

Writer tools follow this decision tree:

1. Does the action create an **implicit obligation** a future surface
   should check on? If no → no nudge.
2. Is the obligation **cheaply recomputable at read time**? If yes →
   keep in `rules_reminders` (ephemeral, no storage).
3. Is the obligation an **explicit user work item**? If yes → use
   `prism_todo`, not a nudge.
4. Is the obligation **scoped to a specific entity's lifecycle** that
   already has a resolution verb? If yes → nudge with the matching kind
   per §4.1.

This is the rubric — not a test. Agent judgment applies. But the v1
writers are locked to the six kinds in §4.1.

## 9. Rejected alternatives

* **SPEC-029 v0.1 silent auto-wrap** — superseded by this spec. Objection:
  SPEC-023's "artifact that looks like wrap but isn't"; concurrency race
  with live-but-quiet sessions.
* **Staleness threshold on the writer side** — pushes "is this abandoned?"
  judgment to wall-clock math. Unreliable for live-but-idle sessions.
  Nudges defer that judgment to the user at read time instead.
* **Reuse session\_deltas as nudge storage** — would overload
  `delta_kind` and mix ephemeral nudges with append-only history.
  Separate table keeps semantics clean.
* **Blocking on start** (hard-gate until user resolves) — violates
  SPEC-021's soft-gate ethos; breaks scripted use. Kept as soft surfacing
  contract only.
* **Scope-small v1 (wrap nudge only, defer others)** — Frank: "I don't
  want to create something else I need to come back later to complete.
  Only defer when I have to." The five other kinds are trivial write hooks
  once the table exists; no reason to defer.

## 10. Implementation

### 10.1 Sequencing — single PR (est. 1 day)

| Step | Scope                                                                                                                                                                                    |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1    | Migration: `nudges` table + indexes.                                                                                                                                                     |
| 2    | Backend model + schemas: `Nudge`, `NudgeCreate`, `NudgeResolve`.                                                                                                                         |
| 3    | Service layer: `create_nudge` (idempotent UPSERT), `resolve_nudges_for`, `list_open_nudges`, `sweep_expired_snoozes` (on-read variant).                                                  |
| 4    | Router: `POST /nudges`, `POST /nudges/{id}/resolve`, `GET /projects/{pid}/nudges`.                                                                                                       |
| 5    | MCP verb `prism_nudge` (actions: `list`, `resolve`, `snooze`).                                                                                                                           |
| 6    | MCP integration: hook into `prism_start`, `prism_wrap`, `prism_spec`, `prism_decide`, `prism_wip`, `prism_seal`, `prism_invite`, `prism_accept_invite`, `prism_handoff` per §4.1 + §6.1. |
| 7    | Remove SPEC-023 `_check_recent_unwrapped_sessions` detector; `pending_nudges` replaces it. Keep `wrap_rate` computed reminder (that's still programmatic, not nudge-shaped).             |
| 8    | BIOS template update (CLAUDE.md / AGENTS.md) with §7 agent contract. Propagate via `prism_sync_bios`.                                                                                    |
| 9    | Smoke tests: `mcp/smoke_spec029.py` covering all six kinds' write + resolve + snooze cycles.                                                                                             |

### 10.2 Historical backfill (one-shot, same migration)

On first deploy, write `wrap_session` nudges for any session\_ids in
`session_deltas` from the last 30 days where:

* At least one non-wrap delta exists
* No wrap delta exists for that session\_id

Backfill runs idempotent (UPSERT on open). Bounded window prevents
unbounded history sweep on very old projects.

### 10.3 Observability

* Counter: `nudges_opened_total{kind}` (30d rolling)
* Counter: `nudges_resolved_total{kind, resolution}` (30d rolling)
* Gauge: `nudges_open{kind}` (current open count per kind)
* Derived: per-kind median age, resolution rate, snooze rate.

Added to the existing observability surface (wherever SPEC-023 `wrap_rate`
surfaces).

### 10.4 Install / deploy impact

Zero install script changes. `backend/docker-entrypoint.sh` runs
`alembic upgrade head` on boot; the migration applies on next backend
restart. No env var additions. No compose file changes.

## 11. Relationships

* **Extends** SPEC-023 — replaces its §5.4 detection heuristic with
  persistent nudge rows. SPEC-023's `delta_kind` column, `session_id`
  minting, and `wrap_rate` observability all stand.
* **Uses** SPEC-022's `last_wrapped_*` columns unchanged. Auto-resolution
  of `wrap_session` on `prism_wrap` still patches those columns per
  SPEC-022 before resolving the nudge.
* **Supersedes** SPEC-029 v0.1 (conversational draft; never filed;
  preserved in session deltas).
* **Related** SPEC-021 Ring 0 — nudge surfacing uses the status-card /
  bootstrap contract as its delivery channel but does not add new gates.
* **Informs** future spec on org-level nudges (project\_id=null) —
  deliberately scoped out of v1.

## 12. Open questions

* **Q1**: Default snooze duration — 1h in the agent contract (§7). Tune
  with usage; revisit after 30d of data.
* **Q2**: Should `handoff_pending` auto-resolve on the next persona's
  `prism_start` (as an implicit ack)? Lean **no**: handoff
  acknowledgment should be explicit. Defer to follow-up spec.
* **Q3**: Should `spec_draft` and `adr_proposed` nudges self-snooze if the
  draft is being actively edited (delta activity in the last hour
  referencing the spec\_id)? Lean **no** in v1; nudges are about
  cross-session memory, not intra-session noise. Revisit if users
  complain.
* **Q4**: Do we need an `org_nudges` table or can org-scoped nudges reuse
  this table with `project_id=null`? Lean reuse with null. Defer until
  first org-level use case.
* **Q5**: Per-project agent-contract overrides (e.g. "this project: block
  on `wrap_session`, don't for other kinds") — deferred to follow-up
  SPEC. v1 is uniform across projects.

## 13. Acceptance criteria

1. ✅ `nudges` table exists with all columns + indexes per §4.
2. ✅ `prism_start` returns `pending_nudges` array; empty when no open
   nudges exist.
3. ✅ `prism_wrap` on session\_id X auto-resolves any open `wrap_session`
   nudges with `source_id=X` and does **not** resolve nudges with
   different source\_ids (concurrency test).
4. ✅ Writing two `wrap_session` nudges for the same session\_id in a row
   results in exactly one open row (idempotence).
5. ✅ All six kinds in §4.1 have integration tests: write, auto-resolve
   on matching verb, manual resolve via `prism_nudge`.
6. ✅ Historical backfill (§10.2) populates expected wrap\_session rows
   for known unwrapped session\_ids on the Prism project.
7. ✅ Smoke test `mcp/smoke_spec029.py` passes locally + against server1.
8. ✅ BIOS templates updated with agent contract; propagated via
   `prism_sync_bios`.
9. ✅ `rules_reminders` no longer emits `unwrapped_session` entries;
   those are replaced by `pending_nudges` entries of `kind=wrap_session`.
10. ✅ Wrap-rate metric (SPEC-023 §7) unchanged behaviorally.

Status: draft
