Skip to content

Stage 3: differentiators — live external data, evolving profile, spatial page portals - #3

Merged
janustsen merged 20 commits into
mainfrom
stage3/differentiators
Jul 5, 2026
Merged

Stage 3: differentiators — live external data, evolving profile, spatial page portals#3
janustsen merged 20 commits into
mainfrom
stage3/differentiators

Conversation

@janustsen

Copy link
Copy Markdown
Owner

Brings the three brief differentiators onto main — 20 commits, 10 quality-gated tasks each adversarially reviewed with a final whole-branch review + fix wave.

Live external data (R-701/702/703/704/705)

data_source on Metric/Kpi/Ring/Gauge/ProgressBar; keyless weather (Open-Meteo) + nutrition (Open Food Facts) with per-provider TTL cache and honest error->stale/last-cached; GET /api/live/{provider} owner-gated + rate-limited; orchestrator emits bindings for the two launch domains only and strips out-of-domain on every parse path; frontend renders live values with freshness + provenance, degrading to a stale badge while staying manually editable. Verified against the real APIs (London 27.8C, banana 88.1 kcal).

Evolving user profile (R-801/802/803/804)

Owner-scoped user_profile store (cap50, dedup); accretion of verbatim interview answers on confirmed insert; profile feeds generation (bounded block, cache-key untouched, cross-owner isolated); inspectable/editable ProfilePanel; real erasure.

Spatial nesting (R-502/503/504)

World-coord page-portal tiles on the parent canvas (drag-persist, click-to-enter, breadcrumb return); reparent-not-orphan on parent delete.

Quality

  • 569 backend tests, 94.81% branch coverage (80% gate), mypy + ruff clean; frontend vitest 85, tsc + build clean.
  • Review-caught defects fixed before merge: a refine path that dropped a whole module on a corrupted binding, accretion firing on discarded drafts, a portal rendering invisibly under a module, a flaky migration test (root-caused to a WAL-switch race), and a live badge wrongly showing on a manual value when the provider is down.
  • Final review traced the honesty seam end to end: no path fabricates a live value or caches degraded data.

Requirements: docs/MVP-SPEC.md. Plan: docs/superpowers/plans/2026-07-03-stage3-differentiators.md.

Generated with Claude Code

janustsen and others added 20 commits July 3, 2026 11:42
…ting)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…server-side suggestion filter

Carried Stage-2b items (Stage-3 Task 1):
- orchestrator.py: _MAX_PROMPT_CHARS (~12000) caps the composed system
  message after seed-JSON + module-context + exchange fold + conversation
  block are stacked. Over budget, the conversation block is truncated first,
  then module-context detail — the raw user prompt and exchange answers are
  never touched.
- routes/transcribe.py: a reusable in-memory _RateLimiter (sliding window)
  caps transcription at 20/5min per owner → 429; the generate/preview routes
  are the next customer for the same helper.
- db.py: the 📎-prefix / refine-join / refine-imperative / <3-word filter
  moves from frontend/src/lib/suggestions.ts into db.suggestion_prompts, so
  GET /api/suggestions never returns noise regardless of consumer (frontend
  filter stays as belt-and-braces).
- routes/modules.py: POST /api/modules/generate_from_file gains a `preview`
  form field (default false) mirroring /modules/preview — the file/sketch
  caller can get `previews` back instead of a direct insert (R-223).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Carried Stage-2b items (Stage-3 Task 1):
- lib/sketchExport.ts: rasterScale() clamps the offscreen snap raster to
  ~2048px per side, returning a downscale factor (<=1) applied to both the
  canvas dimensions and stroke coordinates; Canvas.tsx's rasterizeSketch now
  applies it (R-221).
- EntryScreen.tsx: a Tab/Shift+Tab focus trap + opener-restore, mirroring
  ConfirmDialog's pattern — EntryScreen only mounts while open, so a plain
  mount/unmount effect matches ConfirmDialog's open-gated lifecycle.
- lib/api.ts + PromptBar.tsx: generateModuleFromFile gains an optional
  `preview` flag; the file-attach path now requests a preview and routes the
  result through the existing preview-confirm stack instead of inserting
  straight onto the canvas (R-223). The sketch-snap caller (Canvas.tsx) is
  deliberately left on direct-insert — see the Stage-3 Task 1 report for the
  scope decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t edge test; rate-limiter eviction

Fix Round 1 (Stage-3 Task 1 review):
- Swept 26 untracked iCloud "* 2.*" duplicate files (test/source/pyc/fixture
  copies) that pytest was auto-discovering, inflating the headline count. None
  were git-tracked. Real clean-tree gate: 463 passed / 94.39% (461 baseline +
  this round's 2 new tests).
- orchestrator token cap: added the untested safety-critical branch —
  test_cap_composed_prompt_keeps_all_protected_content_when_it_alone_exceeds_cap
  pins that a ~15000-char head + non-empty exchange (protected content alone >
  _MAX_PROMPT_CHARS) is NEVER truncated; conversation + module-context drop to
  zero, result overshoots the cap by design.
- _RateLimiter.allow now evicts a key whose hits trim to empty before re-adding
  so idle per-owner entries never accumulate; new test_rate_limiter_evicts_idle_keys
  pins "the map never holds an empty list", existing limiter tests stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…R-704)

Adds the last-mile actionability differentiator's first domain: Metric/Kpi/
Ring/Gauge/ProgressBar may now carry an optional `data_source` binding
(schema.py's new DataSource model, mirrored in frontend/src/lib/types.ts).
A new src/services/live_data.py fetches real values keyless via Open-Meteo
(lat/lon or place→geocode), zero-dep urllib mirroring llm.py's style — a
network/parse failure never raises, it degrades to a stale cached payload or
an honest null-value error. Fetches are cached server-side per provider+query
(new `live_cache` table, NOT per-owner — public data) with the caller's
refresh_secs as TTL.

New GET /api/live/{provider} (routes/live.py) is owner-gated and rate-limited
by reusing Task 1's `_RateLimiter`; TRUS_LIVE_DATA=off returns a disabled
marker so components fall back to manual entry (added to .env.example +
conftest isolation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an Open Food Facts fetcher to the live-data framework (Task 2),
dispatched via the existing provider map so ALLOWED_PROVIDERS picks it
up automatically. query = {food: "..."}, returns kcal/100g with the
same cache/TTL and honest-error contract as weather. GET
/api/live/{provider} now validates a `food` param for the nutrition
provider (422 if missing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s out-of-domain (R-702/R-705)

DECOMPOSE_SYSTEM_PROMPT now teaches the two launched live-data domains
(nutrition for calorie/food intent, weather for weather/trip/hike intent)
with a worked example each, and explicitly forbids emitting a data_source
for any other domain (stocks, flights, etc.) — R-705 honesty seam.

_parse_modules gains defense-in-depth: a bad or out-of-domain data_source
(strict provider Literal, bounded refresh_secs/query) is stripped from the
raw dict BEFORE ModuleConfig.model_validate, so the model's mistake costs
only that binding — the component survives as manual entry rather than
the whole module being rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_parse_module_config (refine/single-module parse) now applies the same
_sanitize_module_data_sources strip-defense as _parse_modules before
ModuleConfig.model_validate. REFINE_SYSTEM_PROMPT hands the model a config
that may already carry a valid data_source; a model corrupting that nested
field would otherwise fail Pydantic for the whole module. Now the bad
binding is stripped and the module survives as manual entry.

Also pins the sanitizer against garbage input (non-dict data_source,
missing provider) → stripped to None, no crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + graceful failure (R-701/R-703)

New useLiveValue hook polls GET /api/live/{provider} (mount + refresh_secs,
cleaned up on unmount) and feeds MetricField/KpiField/RingField/GaugeField/
ProgressBarField a live-value path: freshness ("as of N ago") + provenance
("via Open-Meteo"/"via Open Food Facts") when fresh; last value + a muted
stale/error badge on degrade, with every manual control staying editable
(R-703 — a dead provider never breaks the module); TRUS_LIVE_DATA=off falls
back to the plain manual field with no live chrome at all. Pure logic
(relative-time formatting, the query→URL-params builder, the provider→
display-name map, shared number formatting) extracted to lib/liveFormat.ts
with vitest coverage. Folds in the 3-2 deferred Minor: the friendly source
name is computed from the known provider rather than trusting the backend's
raw-path echo on the disabled marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…01/R-802)

- New user_profile table (id, owner, kind, text, source, created_at,
  updated_at); db.profile_list/add/update/delete/clear, all owner-scoped
  (R-903). profile_add dedups (case-insensitive, same owner+kind) and
  self-curates at a 50-fact/owner cap by pruning the oldest row.
- Routes: GET/POST/PATCH/DELETE /api/profile(+/{id}), all gated via
  _owner_id; bad kind / over-long text -> 422.
- Accretion (R-802, Option A - verbatim, no extra model call): on
  /modules/generate and /modules/preview, once the orchestrator call
  resolves an exchange into modules (no ClarifyingQuestion/refusal/error),
  up to 3 answered turns become visible interview-sourced profile facts,
  tagged goal/fact by a light keyword heuristic. Never fires without an
  exchange; best-effort so a profile write can't break a generation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…803/R-804)

generate_modules composes a bounded "What I know about you:" block from
db.profile_list(owner) into the seeded system message, mirroring the
Stage-2b conversation block's fetch point, budgeting, and cache-key
exclusion exactly (profile lives in the composed message, never the
semantic-cache key, which stays the raw prompt). _cap_composed_prompt
gains a profile_block param in the same lowest-priority tier as
conversation, never at the expense of the user prompt or exchange
answers. Never reached by the grounded-file path (no owner threaded
through there — doc content already dominates).

Erasure (R-804/R-1003): confirmed db.profile_clear is a hard SQL DELETE,
not a soft flag — a distinctive fact is gone from profile_list AND every
future generation context immediately. No full-account erasure path
exists yet (grepped routes/db); DELETE /api/profile is documented as the
erasure surface for now, with a note that full-account erasure is a
Stage-4 item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move the interview→profile accretion seam off /generate and /preview onto
the CONFIRMED insert (POST /api/modules). InsertModulesRequest carries an
optional `exchange`; when present, insert_modules accretes the interview
facts (source="interview") via the existing _accrete_profile_facts helper.
So accretion fires only on a proposal the user actually accepted — a
discarded preview draft never enters the profile. Tests moved to the insert
path; preview/generate now assert non-accretion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ProfilePanel — a slide-in dialog (mirrors ArchivedPanel/SnapshotsPanel
anatomy) listing the owner's profile facts grouped by kind, each inline-
editable (PATCH) and deletable (optimistic DELETE), with a manual add (kind
selector + text → POST), an empty state, and a "clear everything" behind
ConfirmDialog rendered as a SIBLING of the aside (2a-3 containing-block
lesson). R-1306 floor: role=dialog/aria-modal, Escape closes, focus enters
the panel and is trapped inside. R-1305: matte, accent rationed to Add only.
Opened from a new Sidebar "Profile" button; new `user` icon.

api.ts: profileList/Add/Update/Delete/Clear. Part B seam: insertModules now
carries the accepted interview `exchange`, and PromptBar passes the exchange
that produced the current preview stack on accept — so profile accretion
fires only on a confirmed insert, never a fresh build or a discarded draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(R-503/R-504)

R-504: pages gain nullable portal_x/portal_y (world coords) storing a CHILD
page's placement on its PARENT's canvas as an enterable portal tile. Added via
the existing additive-column migration pattern (idempotent; also in the fresh
CREATE TABLE), threaded through _PAGE_COLS, _page_from_row, the Page schema, and
update_page (owner-scoped by the WHERE session_id — a partial update like the
existing name/icon/parent_id fields). RenamePageRequest + the PATCH route pass
them through.

R-503 (orphan fix): parent_id is a bare column with NO FK cascade, so deleting a
parent orphaned its children (parent_id → a deleted row → they vanished from the
sidebar tree, which renders from root). delete_page now REPARENTS the deleted
page's direct children to its OWN parent (grandparent, or NULL/root when
top-level) before deleting — children move up one level, never disappear; their
modules stay intact (only the deleted page's own modules cascade). Owner-scoped.

Added page_module_counts(session_id) — one grouped COUNT for the portal tiles'
cheap "N tools" preview without loading child module configs — + GET
/api/pages/counts (owner-gated).

Tests: portal persist + read-back, owner-scoped isolation, reparent-to-
grandparent, reparent-to-root, child modules survive, counts owner-scoped,
migration idempotent on a legacy table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Child pages (parent_id === activePageId) now render as enterable WORLD-COORD
portal tiles on the parent's canvas — transformed by the SAME pan/zoom as
modules and the sketch overlay — so nesting is a spatial object, not just a
sidebar row (the "digital clay" feel).

- New pure lib lib/portalLayout.ts: PORTAL_W/H, autoPlacePortal (grid stack near
  origin for un-placed tiles), portalPosition (stored portal_x/portal_y overrides
  auto; a half-set pair or 0,0 handled). vitest covers grid wrap + stored-vs-auto.
- Canvas: a portal layer rendered BELOW the module layer (modules paint on top; a
  module drag wins any overlap). Pointer discrimination mirrors module drag —
  WINDOW listeners for the gesture, stopPropagation so a portal pointerdown never
  pans the canvas or reaches a module. A pointerdown that moves past a 3px
  threshold is a DRAG (persists placement on drop, R-504); one that doesn't is a
  CLICK (enter the page). R-1305: a matte, dashed "place you can enter", distinct
  from a solid module card, accent rationed. R-1306: each tile is focusable +
  Enter/Space enters.
- page.tsx: childPages memo + childCounts (GET /api/pages/counts, cheap "N tools"
  preview, refreshed on navigation), onEnterPortal reuses the page switch (the
  breadcrumb is the spatially-obvious return path), onPortalMove persists
  optimistically. Page-delete now reparents children locally to the grandparent,
  mirroring the server's R-503 reparent-not-orphan so the tree stays correct.
- types.ts Page mirror + api.ts gain portal_x/portal_y + pageModuleCounts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e test

Fix 1 (portal occlusion, R-502): auto-placed portals shared the module grid's
origin (both at world (32,96)), so portal[0] landed at the exact point of
module[0] — and since modules paint on top and capture the pointer, that first
tile was invisible/un-clickable until the user panned. autoPlacePortal now
places tiles in a negative-Y SHELF above the module grid (rows stack upward);
modules only ever occupy y >= 96, so no collision regardless of module count.
Canvas.contentBounds now includes the portal shelf so fit-to-content frames it,
and page.tsx defers a fit on the FIRST visit of a page that has child portals
(no saved view yet) so the shelf is visible without a manual fit — a page the
user has already arranged keeps its saved view (no regression). vitest updated
+ a guard that no auto-placed portal ever lands in the module band.

Fix 2 (de-flake): test_concurrent_migration_on_stale_db_does_not_double_alter
flaked red under CI load. Root cause was the workers racing the one-time
`PRAGMA journal_mode = WAL` switch (not the migration lock under test), raising
spurious 'database is locked'. Pre-enable WAL on the stale file so the assertion
depends only on the OUTCOME (no double-ALTER / no error), which the
double-checked _schema_lock guarantees regardless of interleaving; widened the
race window to 0.1s for margin. Also moved test_migration_adds_portal_columns_
idempotently onto its own tmp_path DB (never the shared _db_path()) so it can't
perturb the race test's isolation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
STATUS.md gains a Stage 3 section (live external data, evolving user
profile, visible spatial nesting, TRUS_LIVE_DATA env) plus fresh gate
numbers from this run: pytest 567 passed/2 skipped/94.80% coverage,
identical across 3 consecutive runs (confirms the de-flaked migration
race test is stable); mypy/ruff clean; frontend 85 passed, tsc clean,
build clean. API-level smoke against an isolated spare-port backend
(claim flow, real Open-Meteo + Open Food Facts fetches, weather 422 on
missing params, profile CRUD + cross-owner isolation, a 3-level page
tree with portal-position persistence and reparent-not-orphan on
parent delete) all passed — full transcript in
.superpowers/sdd/stage3-task-10-report.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-free invariant

_RateLimiter.allow ran a check-then-`del self._hits[key]` eviction plus a
non-atomic setdefault→trim→append on a shared dict. /transcribe and /live are
sync (threadpool) routes and /live fires parallel same-owner calls on page
load, so the check-then-del could race two callers into a KeyError → 500.
Replace `del` with idempotent `pop(key, None)` and wrap the read-modify-write
of allow() in a per-instance threading.Lock. New threaded test reproduces the
old race and passes under the fix.

Also pin R-903's live-cache-is-shared half (the Stage-Exit checklist claimed
it was test-pinned but nothing asserted it): two different owners requesting
the same provider+query share one cache row (one upstream fetch serves both)
and the live_cache table carries no owner/session column — owner-free by
construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… portal move (R-701 honesty)

MetricField rendered the "live" corner tag even when the provider was down with
nothing cached (showLive true, liveActive false) — badging a manual/computed
value "live" is dishonest. Show "live" only when an actual live value is present
or loading, else fall back to the formula label; LiveMeta's honest "via X —
unavailable" line stays. Verified the other four tag-carrying primitives
(KpiField already guards on live.value !== null; Gauge/Ring/ProgressBar carry no
"live" text tag) are already honest.

handlePortalMove logged a failed portal-position PATCH but never rolled back the
optimistic move, so a drag silently reverted on next load. Capture the prior
position and, on failure, restore it plus flash a low-drama notice (mirrors the
module saver's conflict surface).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…age 4

R-504: cross-device spatial persistence covers portal/page positions only; the
per-page viewport (pan/zoom) is still client-only localStorage — a deliberate
Stage-4 item. R-802: profile accretion currently covers interview answers only;
prompt + workspace-activity accretion (the reserved source enum values) is a
Stage-4 item. Both added to the Stage-4/Next backlog line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@janustsen
janustsen merged commit 5ece326 into main Jul 5, 2026
3 of 6 checks passed
@janustsen
janustsen deleted the stage3/differentiators branch July 5, 2026 20:00
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