Skip to content

feat(reflect): make reflection evolution-aware (revise/close desires)#249

Merged
oratis merged 2 commits into
mainfrom
claude/desire-reflect-evolve
Jul 15, 2026
Merged

feat(reflect): make reflection evolution-aware (revise/close desires)#249
oratis merged 2 commits into
mainfrom
claude/desire-reflect-evolve

Conversation

@oratis

@oratis oratis commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Replaces #246, which GitHub auto-closed when its base branch (PR #242's) was deleted after that merge. Same branch/content, now targeting main. Part of the desire-evolution series (#242 merged in v0.17.0).

Why

Reflection is the path that turns a conversation into a desire — but it could only desire_add, and it was never shown the desires it already had. So it appended near-duplicates blind, and the set only ever grew. This is a second reason the "wish" felt frozen.

What

  1. Reflection now sees its own desires — a compact ## your current desires block (slug + what + why + actionable) is added to the reflector prompt.
  2. Two new operations:
    • desire_revise { slug, what?, why?, actionable?, heartbeat_prompt?, pursuit? } — read-modify-write by slug; only supplied fields change; bornAt/slug are identity; undefined never wipes; throws on unknown slug.
    • desire_close { slug, outcome, reflection }soft close: actionable=false + [CLOSED:<outcome>] progress note + [DESIRE_CLOSED] journal line. File retained and git-tracked — reversible, nothing destroyed.
  3. Guidance now prefers revising/closing over piling up duplicates.
  4. One shared path: close logic lives in store.closeDesire(), used by both reflect's desire_close op and the pre-existing desire_close tool (refactored onto it).

Debate 2 in docs/PLAN_DESIRE_EVOLUTION_v1.0.md: append-only vs allow revise/close. Decision: allow, behind four guardrails (shown existing desires; soft close; no hard-delete op; git-recoverable).

Testing

src/soul/desire-evolve.test.ts — 7 tests (partial revise preserves fields + identity, undefined no-wipe, dormant→actionable, soft-close retains file + notes, both throw on unknown slug). npm test: zero regressions vs main. Typecheck clean.

🤖 Generated with Claude Code

Reflection could only desire_add, and was never shown the desires it
already had — so it appended near-duplicates and the set only ever grew,
never evolving. Two changes fix that:

1. Feed the current desires into the reflector prompt (slug + what + why +
   actionable), so it can target existing ones instead of guessing blind.
2. Add two operations: desire_revise (read-modify-write by slug; only the
   supplied fields change, bornAt/slug are identity) and desire_close
   (soft close: actionable=false + [CLOSED:<outcome>] progress note +
   journal line; the file is retained and git-tracked, so it's reversible
   and nothing is destroyed — preserving soul sovereignty).

Guidance now tells reflection to prefer revising/closing over piling up
duplicates. The close logic is factored into store.closeDesire(), shared
by both reflect's desire_close op and the existing desire_close tool
(which is refactored onto it — one path, no duplication).

PR 2 of 3 from docs/PLAN_DESIRE_EVOLUTION_v1.0.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis

oratis commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Review — merge-ready ✅

Read the read-modify-write and the tool refactor closely; the two risks I went hunting for are both absent:

  • reviseDesire doesn't clobber with undefined. It clones {...existing} then applies each field behind an explicit if (patch.X !== undefined) guard; bornAt/slug aren't in the patch surface (so identity is immutable); and an unknown slug throws (no create-via-revise). The { what:"new", why: undefined }why-preserved case is covered by a passing test.
  • The desire_close tool refactor onto store.closeDesire() is behavior-preserving — same writeDesire(actionable:false) + [CLOSED:…] progress note + [DESIRE_CLOSED] journal line, same order/return/git caller. Soft-close retains the .md file (no unlink on any desire path). 7/7 new tests pass; typecheck clean.

Merge-ready. Non-blocking follow-ups I'll fold in before merging:

  1. withSoulLock on the RMW. reviseDesire/closeDesire do listDesires()writeDesire() unlocked, unlike the other soul RMW paths. Consistent with the existing writeDesire callers so not a regression — but now that web idle-reflect (fix(web): reflect on idle so web conversations evolve Lisa's desires #242) can race a CLI reflect, wrapping both in withSoulLock closes a last-writer-wins window on the same slug.
  2. Comment accuracy. closeDesire's comment says it keeps heartbeatPrompt/pursuit "for the record," but writeDesire only serializes those when actionable:true, so closing drops them from disk (git history still has them). I'll soften the wording + the "re-open later" docstring.

Left for PR3 / later (noted, not fixing here): soft-closed desires still show up in the reflector's "current desires" block (only actionable flips; no closed marker), so they slowly accumulate in the prompt — a [closed] marker or filter would fix it.

Per review of #249: wrap reviseDesire and closeDesire's read-modify-write of the
desire file in withSoulLock, matching the other soul RMW paths — now that web
idle-reflect (#242) can race a CLI reflect on the same slug, an unlocked
list→write loses one field edit (last-writer-wins). writeDesire doesn't
self-lock so this doesn't nest; closeDesire's progress/journal appends
self-lock, so they stay outside the block (nesting would deadlock the
non-reentrant file lock).

Also fix the misleading comment: writeDesire only serializes
heartbeatPrompt/pursuit while actionable, so closing drops them from disk (git
history retains the last actionable version for re-open).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oratis oratis merged commit 6ddafc8 into main Jul 15, 2026
@oratis oratis deleted the claude/desire-reflect-evolve branch July 15, 2026 03:04
@oratis

oratis commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Review — SHIP-WITH-NITS ✅

Core mechanism works and is well-tested (ran desire-evolve.test.ts at head → 7/7 pass; undefined-never-wipes + identity-preservation hold; unknown-slug throws caught per-op; empty/missing-desire paths graceful). No blockers — findings are robustness, not crashes/corruption.

Findings (to fix as a follow-up):

  • [MED] closed desires are indistinguishable from dormant (reflect.ts:498 + store.ts:335) — closeDesire only sets actionable:false with no persisted "closed" marker, so listDesires() keeps feeding closed desires back into the reflector's "revise or close these" block; no already-closed guard means a slug can be re-closed on later passes (verified: 2× [DESIRE_CLOSED] journal lines). The reflector's list never shrinks. → persist a closed marker, filter it out of the block, guard re-close.
  • [MED] reviseDesire/closeDesire read-modify-write without withSoulLock (store.ts:307,335) — every other RMW here locks (applyEmotionDelta, appendDesireProgress, appendJournal). Idle-reflect desire_revise racing a chat-process desire_close on the same slug → lost update (closed-yet-still-actionable). atomicWrite prevents torn files, so logical lost-update only.
  • [LOW] heartbeatPrompt silently dropped on close / actionable→false revise (store.ts:348) — so "re-open later" doesn't restore auto-pursuit; contradicts the reflector guidance. Recoverable from git, hence low.
  • [LOW] slug-only desire_revise is a reported no-op (reflect.ts:339) — over-reports in applied.

Merging now (stack base); the closed-marker + lock fixes will land in a consolidated follow-up.

oratis added a commit that referenced this pull request Jul 15, 2026
…cus gate (#251)

From the #248/#249 reviews (the two MED items not already handled by #250's
review fix, which added the withSoulLock + desireActivity guard):

- Persisted CLOSED marker (#249): DesireEntry gains `closed`; writeDesire
  serializes `closed: yes` and now keeps heartbeatPrompt/pursuit even when not
  actionable, so closing preserves them for a clean re-open. closeDesire flips
  closed on (+ actionable off) and is idempotent — an already-closed desire is a
  no-op, so it can't append duplicate [DESIRE_CLOSED]/[CLOSED] notes. reviseDesire
  clears closed when it re-opens (actionable:true). Closed desires are filtered
  out of the reflector's "revise or close" block (so the list actually shrinks)
  and never surface as her current/focused desire (ping + `lisa status`).

- Restart-safe focus freshness (#248): the intra-session focus gate keyed on
  reflectClock.idleFor(), which reads "fresh" right after a launchd restart and
  would pin focus onto a stale resumed conversation. Gate on a new
  lastUserMessageAt (0 until a real POST /chat arrives, so a restart closes the
  gate until the user actually talks).

Adds closed-marker/idempotency/re-open tests. Full suite green (1013 pass).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant