30 Commits

Author SHA1 Message Date
Keysat 7f9a15ebf3 Adopt the Pipeline: grid-driven opportunities link (v0.1.0:87)
The fundraising grid (canonical) now drives the classic opportunities
Pipeline board, instead of the board being a disconnected second data-entry
surface. An "Add to Pipeline" row action creates a durably-linked opportunity
via the new opportunities.fundraising_investor_id (migration 0005, additive +
reversible), reusing the grid's already-synced contact — retiring the
POST /api/contacts side-door — and mapping the grid lead to the opp owner.

Ownership is split so the two stay reconciled: the grid owns whether the link
exists and the seed; the board owns stage/probability/owner. The link endpoint
is idempotent (one live opp per investor; a re-link never reseeds funnel
fields). "Is in pipeline?"/"what stage?" are derived from a live opp join and
injected as read-only grid columns on read, stripped on write, so they never
persist or dirty the autosave. Remove-from-pipeline soft-deletes the opp and
leaves the grid row fully intact; deleting an investor from the grid archives
its orphaned opp.

Also fixes the standing soft-delete leak in handle_pipeline_report and the
dashboard pipeline aggregates, which counted tombstoned opportunities.

Tests: backend/test_grid_pipeline_link.py (link/idempotent/round-trip/guards/
unlink-intact/re-link/orphan/aggregates); 28/28 suite green, render-smoke green.
2026-06-17 23:08:36 -05:00
Keysat c7b74a2704 Email search/query + windowed digest preview (v0.1.0:83)
Communications tab (search/query roadmap items 1 & 2):
- Fix the investor dropdown: the facet only listed grid investors, so it
  came back empty whenever email matched a classic contact or org domain
  (no grid id — the common case). It now mirrors the email list, resolving
  each link to a typed identity (fund:/org:/contact:/addr:) with precedence
  grid -> org -> contact -> address; investor_id accepts the typed key
  (bare id = fund: for back-compat) and an unknown prefix matches nothing.
- Add a date-range filter and a click-to-expand full-body view
  (GET /api/email/detail, admin, soft-delete-gated; body_text only, never
  raw remote HTML).
- Add a "Search content" mode: GET /api/email/search wraps the ingest
  hybrid_search over the Qdrant email index (doc_type=email), hydrated and
  soft-delete-filtered against SQLite (canonical), 503 if Spark/Qdrant down.

Daily digest:
- Settings -> Admin builds a digest over a chosen window (last 24h or since
  a date) as an in-app preview before sending (POST /api/admin/digest/preview),
  so the local-Spark summarizer can be verified on demand even on a quiet day.
  Manual send uses the same window; neither advances the daily cursor, so a
  preview never suppresses the scheduled digest.

Code-only, migrations no-op. 22/22 backend tests, render-smoke pass.
2026-06-16 20:46:15 -05:00
Keysat 40a0270a99 Vendor + SRI-pin front-end libs; add render smoke gate (v0.1.0:82)
React/ReactDOM/Babel were loaded from the unpkg CDN at runtime — react@18
and react-dom@18 weren't even exact-pinned, and none had SRI. A CDN swap (or
react auto-resolving a new 18.x) could blank the whole app with no change on
our side: exactly the v78/v79 blank-screen class. It also made the self-hosted
box depend on outbound internet to render.

Vendor the three libs into frontend/assets/vendor/ (React 18.3.1, ReactDOM
18.3.1, @babel/standalone 7.29.7) and load them same-origin with sha384
integrity attributes. They now ship inside the s9pk (Dockerfile already COPYs
frontend/; server.py serves /assets/* with the path-containment check), so a
CDN can never swap prod deps again and no outbound fetch is needed at runtime.

Add start9/0.4/render-smoke.mjs: a jsdom render smoke check that (1) runs the
shipped Babel over the app's inline JSX and asserts a classic, non-module,
parseable script (the v79 ESM-import regression), and (2) mounts the app in
jsdom and asserts the login UI renders (the v78 blank-screen class). Wired into
the default `make` goal so every package build is gated on the frontend
actually rendering — closing the "verified live via curl only" gap. jsdom is a
build-time devDependency, not shipped in the image.
2026-06-16 16:10:26 -05:00
Keysat 42d2b4b245 Repurpose Communications tab as admin-only email-activity panel (v0.1.0:80)
The Communications tab is now an admin-only search over captured Gmail
(email_* tables), part of consolidating on the fundraising grid + email
capture as the canonical system of record.

- New GET /api/email/activity (admin-enforced server-side): filter by
  investor / mailbox / direction with free-text search over subject,
  snippet, and sender. Query logic in db.query_email_activity.
  - Soft-delete honored on the per-mailbox sighting (emails carry no
    deleted_at; deletion lives on email_account_messages).
  - Direction decided at the email level (outbound if the sender is one of
    our mailboxes), mirroring digest_builder.
  - Graveyard investors are hidden from the filter dropdown (CRM-wide
    graveyard=0 convention) but their email stays visible in the list and
    findable by free-text search — this is an audit surface.
- Communications page rewritten to render the panel; the classic manual
  "Log Communication" form is retired (the grid context menu remains the
  manual-log path). Nav item + page are admin-only.
- Tests: email_integration/test_email_activity_panel.py (filters,
  per-sighting soft-delete, roll-ups, graveyard handling, route 401/403);
  full suite 22/22. Frontend render verified via a jsdom mount smoke test
  plus the pinned classic-runtime Babel transform.

Code-only, no schema migration (version migrations are no-ops).
2026-06-16 14:49:59 -05:00
Keysat cc25be4e14 Fix blank-screen on load + close 3 admin gaps (v0.1.0:79)
The web UI rendered a blank screen for every user. Root cause: the page
loaded @babel/standalone from unpkg with no version pin, so the CDN silently
served Babel 8.0.0. Babel 8 defaults @babel/preset-react to the automatic JSX
runtime, which prepends `import {jsx} from "react/jsx-runtime"` to the compiled
output. An ESM import is illegal in this classic (non-module) inline <script>,
so the browser rejected the whole bundle and React never mounted — hence the
blank screen. The prior "verified live" checks were server-up/curl, which can't
catch a browser-render failure.

- Pin @babel/standalone@7.29.7 (its preset-react defaults to the classic
  React.createElement runtime). Verified via headless render: app mounts, login
  screen renders, no console error. Follow-up: vendor + SRI-pin the CDN libs so
  a third party can't swap our front-end deps in production again.
- Close three server-side admin gaps surfaced by a permissions audit — endpoints
  that were UI-hidden from members but not API-enforced: GET /api/users,
  /api/email/status, /api/email/accounts now require_admin. Removed the now-dead
  non-admin mailbox-row filter. 21/21 backend tests green; py_compile clean.
2026-06-16 12:59:55 -05:00
Keysat 108210d8e1 Retire lp_profiles + LP Tracker; repoint Dashboard committed to the grid (v0.1.0:78)
The fundraising grid + email capture is the canonical system of record. lp_profiles
was a superseded single-fund model with no reachable create/edit path, and the LP
Tracker page was already orphaned (no nav entry + a redirect bouncing it to the grid).

- Remove /api/lp-profiles* endpoints + handlers, the unused lp-breakdown report,
  the contact-dossier LP section, the demo-seed LP block, and (frontend) the
  LPTrackerPage component + its lp-tracker->fundraising-grid redirect.
- Dashboard "Total Committed" now sums fundraising_investors.total_invested
  (graveyarded investors excluded) instead of the orphaned lp_profiles table, which
  read ~$0. "Total Funded" dropped: the grid tracks commitments, not a funded amount,
  and the frontend never rendered it.
- Leave the empty lp_profiles table/index, the contact-delete soft-delete cascade,
  and the --reset-all-data clear in place (never-hard-delete).
- Tests: add test_dashboard_report.py; update test_soft_delete_reads.py. 21/21 green.
2026-06-16 10:48:53 -05:00
Keysat 323f016f64 Add daily activity digest — Phase B (v0.1.0:77)
Sends a once-a-day internal email to all active admins summarizing each team
member's email activity per investor, plus a team-wide by-investor view
(inbound + outbound, deduped). Narratives are generated on the LOCAL Spark
model, never Claude — the digest is intentionally un-anonymized, so substance
stays on Ten31 infra. This is an internal ops email, exempt from the
'agents draft, humans send' rule (which governs outward LP contact).

- backend/digest_builder.py: per-user + per-investor activity queries
  (soft-delete filtered), per-user Spark narrative with a deterministic
  fallback, two-section plain-text body, and the DB-backed policy resolver.
- backend/email_integration/digest_scheduler.py: always-on daily thread that
  re-reads the policy each cycle and sends once/day; window cursor in
  app_settings so a missed day rolls forward.
- server.py: POST /api/admin/digest/send-now and GET/PATCH
  /api/admin/digest/policy; scheduler wired into main().
- Control lives in Settings -> Admin (enable toggle + send-time dropdown),
  not StartOS actions; env vars only seed the first-boot default.
- Tests: backend/test_digest_builder.py.
2026-06-15 22:32:27 -05:00
Keysat 114a94c894 Add Settings 'Send Test Digest Email' button (admin) (v0.1.0:75)
Surface the digest test-send endpoint as a clickable admin control so it can be
exercised on the box without curl. Calls POST /api/admin/digest/test-email and
toasts the result (or a 'configure SMTP first' hint). JSX parse-checked.
2026-06-15 18:55:32 -05:00
Keysat 606b336a00 outreach: voice by-purpose (larger sample) + Tier-B Gmail draft creation (v0.1.0:71)
(1) Voice: _voice_examples now picks the sender's prior sent emails OF THE SAME PURPOSE
(PURPOSE_PATTERNS keyword cues per outreach type), larger sample (8) weighted by purpose
then recency — not just recent. meta carries on_topic for transparency.

(2) Tier-B sending (gmail.compose now authorized in Workspace DWD). New
email_integration/compose.py create_outreach_draft: mints a compose-scoped DWD token for
the sender (credentials._mint/access_token_for parameterized by scope; GMAIL_COMPOSE_SCOPE),
builds an RFC822 message, and POSTs gmail.drafts.create into the SENDER's mailbox — as an
in-thread reply (threadId + In-Reply-To/References, recipient = matched LP address) when
there's an active thread, else a fresh email. NEVER sends — the human sends from Gmail
(guardrails #4, #6). Route POST /api/outreach/gmail-draft; UI "Create Gmail draft" button +
"Open Gmail Drafts" link. Tests: test_compose.py (parse/reply-target/RFC822+threading).
Message construction unit-verified; the live drafts.create runs on the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:30:05 -05:00
Keysat 49f84ca9a4 outreach: per-user voice from own emails + transparency; active-thread context (v0.1.0:70)
Voice upgrade. draft_outreach now learns the SENDER's voice: the codified rules PLUS a
few-shot of that user's own recent sent emails (_voice_examples; from_email = the
sender, de-identified in the same scrub batch as the recipient context, reference-only).
The response returns which of the sender's emails were used (subject + date + recipient),
shown in the UI as "Voice based on: …" — transparency to avoid the black-box problem.
Falls back to rules-only with a clear note when the user has no captured sent email.

Context restructured: _context groups the investor's email by thread and labels the most
recent thread as the "Active conversation (what you are replying to)" with earlier emails
as background, so replies stay on-topic instead of dredging old threads.

Sender email resolved in handle_outreach_draft (users table by user_id). Test extended
(active/background split, voice examples + meta, no-sender fallback). Fixed a UI bug the
preview caught: the manual Draft button was onClick={draft}, which passed the click event
as the investor arg after draft() gained params -> circular-JSON error; now onClick={()=>draft()}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:06:38 -05:00
Keysat 787d580550 outreach: follow-up radar — deterministic "needs attention" + one-click draft (v0.1.0:69)
The Outreach page now opens with a "Needs attention" list. A deterministic scan
(outreach_agent.follow_up_radar) surfaces investors per the email history: tier 0 "you
owe a reply" (their email is the most recent, unanswered, >=3d), tier 1 flagged + quiet,
tier 2 warm lead gone quiet (no contact in >=45d). Most urgent first; every reason is
verifiable from the data (no LLM in the surfacing — the deliberate fix for the trust
problem that sank objection-grounding). Excludes graveyard; needs email history. One
click sets the investor + suggested type (follow-up/nurture) and runs the existing
outreach drafter. Route GET /api/outreach/radar. Test mcp/test_outreach.py extended
(owe-reply/warm-quiet/recent/graveyard/order). Verified live in preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:31:52 -05:00
Keysat b5619d61e1 outreach: Outreach Draft Assistant — tailored LP drafts (v0.1.0:68)
First proactive-messaging build. New "Outreach" page (all authenticated users): pick an
investor + type (intro / follow-up / fund update / meeting follow-up / nurture) + optional
guidance; the agent drafts a tailored LP email in Ten31's voice, grounded in the thesis +
that investor's CRM notes and matched email history. The draft is editable + copyable;
nothing is sent (draft-only — guardrails #4, #6).

Sovereignty: the thesis is Ten31's own non-sensitive messaging (to Claude as-is); the LP
context is scrubbed through the redaction boundary before Claude, drafted with placeholders,
and re-hydrated locally — the LP list never reaches the API. Fails closed (scrub_unavailable /
claude_not_configured / rehydrate_failed quarantines a hallucinated-token draft).

Backend: mcp/outreach_agent.py (context assembly + scrub + Claude + rehydrate, reusing
architect_agent's client/thesis/voice + the Boundary); routes GET /api/outreach/investors,
POST /api/outreach/draft; logged. Test mcp/test_outreach.py (context assembly). Verified in
preview: page/selector/types/guidance render, fail-closed at the key-less Claude step (scrub
ran locally first), success rendering verified with a mocked ok draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:06:46 -05:00
Keysat 0943aeb2df architect: remove LP Objections page — generic/unverifiable output (v0.1.0:67)
The summarize-historical-email grounding produced generic, boilerplate objections
with no quotes and no source traceability (the minimize step abstracts away the
actual email text; the newest-N corpus carries little real objection signal, so the
model pattern-completes). Pulled the page (ObjectionsPage component + nav + dispatch).
The redaction boundary is kept (reusable for proactive outreach); the dormant
/api/architect/ground route is left in place but has no UI trigger. Pivoting to
proactive outreach / messaging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:09:58 -05:00
Keysat c2b84a1f26 architect: LP Objections page — UI trigger for the grounding pass (v0.1.0:66)
New admin "LP Objections" page (frontend ObjectionsPage + nav). Pick a segment (or
All LPs) and Run grounding: the Architect mines matched LP emails + notes on the local
model, scrubs every identifier through the redaction boundary, and asks Claude for the
recurring objections + honest rebuttals (substantiated/hand-wavy flagged). Renders the
de-identified draft + an "N identifiers protected" badge; fail-closed statuses
(local_model_unavailable / scrub_unavailable / claude_not_configured / rehydrate_failed)
show a clear message. Uses the existing /api/architect/ground route. Verified in preview:
page + segment selector + Run; the local minimize/scrub legs actually ran against real
Spark on synthetic input and fail-closed correctly at the (key-less) Claude step;
success rendering verified with a mocked ok response.

NOT yet deployed — start-cli RPC to the box hit a transient transport error post a
StartOS hiccup (curl works, start-cli doesn't); CRM healthy at v0.1.0:65 meanwhile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:48:24 -05:00
Keysat 701e37b579 email: per-mailbox captured/matched counts on Email Capture (v0.1.0:65)
/api/email/accounts now returns captured + matched per account (from the per-mailbox
sighting table email_account_messages joined to emails; emails dedupe globally so an
email seen by two mailboxes counts for each). Each mailbox card on the Email Capture
page shows "<N> captured · <M> matched" so per-user coverage is visible, not just the
aggregate. Verified in preview with two seeded mailboxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:10:51 -05:00
Keysat 069e60053b email-activity agent: propose -> review -> approve grid notes (v0.1.0:64)
When a sent/received email is matched to an investor, a local-model agent drafts a
one-line dated note and queues it as a PENDING proposal (it never writes the grid
itself). On the Email Capture page a partner sees "Proposed grid notes", can edit the
text, and Approve (appends to that investor's grid notes cell, newest at bottom,
stamped with the approver) or Dismiss. Going-forward only: a cutoff (app_settings
email_activity_since, set on first run) means email dated before the feature was
enabled is never summarized, so the historical backfill makes no noise. Sovereign:
summaries run entirely on the local model (no redaction needed). Gmail sync interval
tightened 180 -> 15 min so outgoing email surfaces quickly.

Backend: migration 0002 (email_activity_proposals); propose_email_activity_notes()
runs via a new scheduler post_sync hook; list/decide functions + routes
GET /api/activity/proposals, POST .../{id}/approve|dismiss. Grid append stamps the
approving user (fundraising_state.updated_by has a FK to users). Test
test_email_activity.py (propose cutoff/idempotency, approve appends + edited note,
dismiss, already-decided guard) under FK enforcement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:55:26 -05:00
Keysat 3893a4fb9f system-status: show storage usage (DB, attachments, backups, disk free) — v0.1.0:63
/api/system/status now returns a best-effort storage block: database file size
(crm.db + WAL + SHM), the email_attachments dir, the backups dir, and disk
total/used/free via shutil.disk_usage(DATA_DIR). System Status renders a Storage
section with human-readable sizes so growth can be watched over time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 13:34:18 -05:00
Keysat 2cb476e36b email: live backfill progress on Email Capture panel — v0.1.0:61
The first Gmail backfill leaves the account at "pending · never synced" until it
fully completes (the sync_runs row only finalizes at the end), so there was no
feedback. /api/email/status now also returns captured_emails (total, which climbs
page-by-page during backfill), the latest sync run, and a backfilling flag. The
panel shows a "Backfilling… N captured so far" banner + an Emails Captured count
and auto-refreshes every 5s while a backfill is in progress. Verified live in
preview with seeded data (count auto-climbed 37 -> 50 without manual refresh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:29:01 -05:00
Keysat 1850bc4431 email: single-mailbox enroll field on Email Capture panel — v0.1.0:60
Adds a "Test with a single mailbox first" input (pre-filled with the admin's own
address) + Enroll this mailbox button calling the enroll-one endpoint, so capture
can be tried on one mailbox before enrolling the whole domain. runAction now sends
an optional JSON body. Enroll-all stays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:10:09 -05:00
Keysat ee02ccfd64 email: Email Capture admin panel (status / enroll / sync / re-match) — v0.1.0:59
Adds an admin-only "Email Capture" page so Gmail capture can be turned on and
monitored from the UI instead of an API call: shows whether the integration is
enabled, how many mailboxes are enrolled, how many emails are matched to investors,
and last sync; with Enroll Ten31 mailboxes / Sync now / Re-match buttons and a hint
that domain-wide delegation must be authorized in Google Workspace first. Disabled
state renders cleanly (no scary error) when the integration is off. Bundles the
email-into-grounding corpus wiring (bf829b7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:00:14 -05:00
Keysat 6d6f4bcc7e Thesis Workshop redesign: edit/choose/delete + approve-as-current (v0.1.0:56)
Addresses Grant's feedback that the Workshop was confusing and underbuilt (no delete,
no approve, redundant generate-vs-feedback panels, and a stray "0" on segment lines).

Backend (architect_tools.py + server.py routes/handlers):
- retire_node: soft-delete a node + its subtree (reversible). DELETE /api/thesis/nodes/{id}.
- choose_variant: 'Use this' — keep this option, soft-delete the others in its group,
  mark it approved. POST /api/thesis/nodes/{id}/choose.
- upsert_thesis_node gains actor_type so a manual human edit is recorded as 'human'.
  PUT /api/thesis/nodes/{id} edits a part's text directly.
- handle_approve_line: one-click 'approve as current' — records this admin's approval on
  the line's in-review version (creating + submitting one from the live tree if none),
  promoting to canonical at the required distinct-approval count. POST /api/thesis/lines/{key}/approve.

Frontend (ThesisWorkshop redesign):
- Merged the redundant "Generate options" + "Give feedback" panels into one "Ask the
  Architect for options" box (revise was just generate-with-guidance).
- Per option: Use this / Edit (inline) / Delete. Per part: edit + delete via the same.
- "Approve as current" bar with dual-sign-off state + a "Current ✓" badge, and a one-line
  "how it works" hint. Refreshes the tree after every action.
- Fixed the stray "0": `{line.is_core && <badge>}` rendered 0 for non-core lines (SQLite
  integer 0); now `{!!line.is_core && ...}`.

Verified: backend test_thesis_actions.py (choose/edit/retire-subtree/dual-approval->canonical),
and a live in-browser smoke test (JSX compiles, Workshop renders, options show Use/Edit/Delete,
approve returns 1-of-2, no runtime errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:29:47 -05:00
Keysat 8338c34ac0 Remove dead Add-Contact modal from ContactsPage
After the grid/contacts unification (v0.1.0:52) the Contacts page's "Add New Contact"
modal was made unreachable (new people are added from the grid). This removes the now-dead
showForm/formData/formError state, handleAddContact, and the modal JSX. Other components'
forms are untouched; html parses clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:06:29 -05:00
Keysat 300041a7ec Unification polish: LinkedIn in the grid inline contact editor (v0.1.0:54)
The fundraising grid's per-contact editor now has a LinkedIn URL field next to
name, email, title, and location. It threads through the grid contact object and
sanitize (which preserves contact-object fields), and _upsert_contact_from_fundraising
now reads and persists linkedin_url on both the update and insert paths — so a
LinkedIn entered in the grid lands on the linked contact record.

Test: test_grid_contact_link.py extended to assert LinkedIn entered in the grid
persists to the contact (idempotent). Frontend html.parser clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:24:50 -05:00
Keysat 2afed210cb Grid/contacts unification step 1: real contact_id link + grid as front door (v0.1.0:52)
Structural fix for the duplicate-people class of bug: instead of matching a grid
contact "pill" to a contacts row heuristically by name/email (which drifted and
caused the 1406 double-count), link them by id.

Backend:
- Migration 0004: fundraising_contacts.contact_id (additive, nullable, logical FK
  to contacts(id)) + index. Paired down migration.
- sync_fundraising_relational now stores the id that _upsert_contact_from_fundraising
  already returns, so every grid contact carries its contacts-table id.
- _backfill_grid_contact_ids: one-time, idempotent backfill on startup (re-runs the
  grid sync once if any row lacks contact_id), so existing data links immediately.
- entity_resolution: grid pass prefers the explicit contact_id link (match_kind
  'grid_link') over heuristic email / name+investor, guarded by a PRAGMA check so
  older DBs without the column still work.

Frontend:
- Fundraising grid "+ Row" -> "+ Investor" (clear, single investor entry point).
- Contacts page: the "+ Add Contact" trigger is replaced by a pointer to the grid;
  the page is now a read/search/edit view (ContactDetailPanel still edits all
  fields). New people are added from the grid. No contact data is removed.

Tests: backend/ingest/test_entity_resolution.py extended (explicit-link case, 11/11)
and a new backend/test_grid_contact_link.py integration test (init_db applies 0004,
sync populates contact_id to the right contact, re-sync is idempotent). py_compile +
frontend html.parser clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:10:26 -05:00
Keysat 77e619d097 Add Architect Thesis Workshop UI (v0.1.0:49)
Frontend: ThesisWorkshopPage / ThesisWorkshopNode / ThesisWorkshopOptions —
the collaborative iteration screen where partners generate a variable number
of competing thesis options (1, 2, 3, A1/A2/A3 ...) for any node, give
feedback, and regenerate. Reuses the shared api() helper; flexible option
count is the core UX constraint.

Backend Architect agent (architect_agent.py) + routes shipped in dd25bbc;
this completes the user-facing surface and bumps the StartOS package to
0.1.0:49 (anthropic dep already in the image, key loaded from
/data/secrets/anthropic-api-key — self-disabling until present).

Also lands thesis seed iterations v3 and v5 (voice/messaging corrections).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:32:43 -05:00
Keysat 91361042e7 Entity model: investors (grid) vs people (contacts); fix double-count (0.1.0:48)
Per Grant's clarification of the real data model:
- Investor entities come from the fundraising grid, one per row, all labeled
  "investor" (drops the confusing lp/organization split). Grid is source of truth.
- People come ONLY from the contacts table. The grid's contacts (fundraising_
  contacts) are matched to a contact-person and recorded as member_of links to
  their investor, instead of creating duplicate person entities. This fixes the
  ~doubled people count (people now ≈ contacts, not contacts + grid contacts).
- System Status cards: Investors / People (resolved) / Contacts in CRM / Grid
  contacts, so resolved-vs-source is visible at a glance.

Verified on synthetic: people == contacts count (no double-count); multi-contact
investors preserved via member_of.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:05:58 -05:00
Keysat 3354a0b354 Phase 1 UI: index actions + duplicate-review queue; bump to 0.1.0:45
- frontend: System Status page extended with one-click index actions
  (update/rebuild/find-duplicates, with live job status) and a human-in-the-loop
  duplicate-review queue (approve=merge / reject=keep-separate per candidate).
- StartOS version 0.1.0:45 (image-only; schema via the in-app migration runner).

Backend + new routes verified end-to-end via the running HTTP server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:19:43 -05:00
Keysat fa2a5ce95f Phase 1 UI: Thesis review (dual approval) + System Status views
Two React-via-Babel views in the CRM SPA, reusing the existing api() helper and
conventions:
- Thesis: lists thesis lines + the in-review queue with approvals/required pills;
  version detail renders throughline/pillars/claims/objections + the reviews
  timeline; admin review form (approve/request-changes/comment + feedback) ->
  POST /api/thesis/versions/{id}/review (the dual-approval feedback loop).
- System Status: entity counts, last index sync, thesis counts, recent activity
  from the interaction log — index health visible in-app, no shell.

Backend + full approve flow verified end-to-end via the running HTTP server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:50:47 -05:00
Keysat c7ce44d963 Phase 0 foundation: canonical schema, ingest pipeline, CRM MCP server
Workstream A–C substrate for the Ten31 agentic system:
- A1: docs/crm-overview.md; CLAUDE.md conventions + guardrail #9
- A2: additive/reversible core migration (canonical_entities, entity_links,
  interaction_log, relationship_edges, soft-delete) + ledgered runner
- B1/B3: chunking + deterministic entity resolution (backend/ingest)
- B2: dense (bge-m3) + BM25 sparse ingest to Qdrant crm_chunks
- C: CRM MCP server (reads, retrieval modes, logged writes) — no outbound tools
- docs: redaction/re-hydration, Gmail enablement runbook
- synthetic test data; .env.example; housekeeping (.gitignore, untrack crm.db,
  drop legacy files + start9/0.3.5)

Verified end-to-end on synthetic data + live Sparks (hybrid > dense on entity
queries). Real backfill runs on Ten31 infra; index holds synthetic data only.
Branch snapshot also captures pre-existing working-tree changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:13:35 -05:00
MacPro 7027efd777 init local package repo 2026-02-27 12:44:50 -06:00