Skip to content

Project Registry & Heartbeat

The project registry is the canonical list of every project bio-Zack works on. The heartbeat system layers on top of it, maintaining a queue of topics silicon-Zack wants to discuss periodically -- project interviews, follow-ups, check-ins, and explorations. Together they give silicon-Zack instant context when a project is mentioned and a mechanism for proactively deepening understanding over time.

Why This Design?

  1. Instant project context - When bio-Zack says "the game" or "kerrow", silicon-Zack looks it up immediately. No asking "which project?" when find_project() has the answer.

  2. One source of truth - Projects were scattered across data/work/projects.json, profile/cadence_projects.json, and individual JSON files. The registry unifies them into a single file with consistent schema.

  3. Identity through projects - Bio-Zack's projects are a core expression of identity. The heartbeat system ensures silicon-Zack gradually learns the human context behind each one -- origin stories, collaborators, emotional stakes, lessons learned.

Project Registry

Data Structure

The registry lives in data/projects/registry.json with schema version 1:

{
  "schema_version": 1,
  "projects": [
    {
      "slug": "rwgps-game",
      "name": "Game (RWGPS)",
      "aliases": ["game", "gmoc", "the game", "treasure hunt"],
      "description": "Location-based quest/achievement system for cyclists.",
      "category": "work",
      "status": "active",
      "local_path": "/home/<user>/work/rwgps",
      "tech_stack": ["ruby", "rails", "react", "typescript", "mapbox", "mysql"],
      "links": {
        "github_boards": ["ridewithgps/170"],
        "github_board_url": "https://github.com/orgs/ridewithgps/projects/170",
        "precedent_path": "rwgps"
      },
      "people": ["user1", "user2"],
      "goals": [],
      "notes": "User conceived and built all Rails + UI code...",
      "last_active": "2026-02-05",
      "created_at": "2026-02-01T18:44:15.626431-08:00",
      "updated_at": "2026-02-01T18:55:21.768337-08:00"
    }
  ]
}

Schema (Per Project)

Field Type Required Description
slug string yes Unique ID, lowercase hyphenated
name string yes Human-readable display name
aliases string[] yes Alternative names for fuzzy matching
description string yes 1-2 sentence description
category enum yes work, personal, side, physical
status enum yes active, paused, archived, idea
local_path string no Absolute path on disk
tech_stack string[] no Languages, frameworks, tools
links.github_repo string no org/repo format
links.github_boards string[] no org/number format
links.precedent_path string no Precedent project path
links.url string no Production URL
people string[] no Key collaborators
goals string[] no Goal IDs from goals/active/
notes string no Freeform context (can be extensive)
pinned object[] auto Pinned resources (report/url/forge_idea/file) attached via the web UI; defaults to []
scratch_updated_at string auto ISO timestamp of the last project scratch-pad write; null until first edit
last_active string no ISO date of last activity
created_at string auto ISO timestamp
updated_at string auto ISO timestamp, updated on every change

Note: The registry has no formal migration runner. _normalize_registry() and _normalize_project() run on every load: they coerce/backfill missing fields (e.g. defaulting pinned to [], scratch_updated_at to null), normalize legacy keys (repo→links.github_repo, path→local_path, tech→tech_stack), map deprecated status strings (in_progress→active, planned→idea), dedupe slugs, and drop any entry that fails validation. schema_version is still 1.

Python API

from scripts.projects.registry import (
    find_project, add_project, update_project,
    list_projects, scan_folder, get_context_summary,
    seed_from_existing, infer_project
)

find_project(query: str) -> dict | None

Find a project by slug, name, or alias (case-insensitive). This is the primary lookup function -- silicon-Zack calls it whenever bio-Zack mentions a project.

project = find_project("the game")   # matches alias
project = find_project("vita")       # matches slug
project = find_project("Cadence")    # matches name (case-insensitive)

list_projects(status=None, category=None) -> list[dict]

List projects with optional filters.

active = list_projects(status="active")
work = list_projects(status="active", category="work")
all_projects = list_projects()

add_project(project: dict) -> dict

Add a new project. Validates required fields, checks for slug uniqueness, sets defaults for optional fields. Raises ValueError on validation failure.

add_project({
    "slug": "new-project",
    "name": "New Project",
    "description": "Something new",
    "category": "personal",
    "status": "active",
})

update_project(slug: str, updates: dict) -> dict

Update fields on an existing project. Automatically sets updated_at.

update_project("rwgps-game", {"last_active": "2026-02-20", "notes": "Updated notes"})

scan_folder(folder_path: str) -> dict

Investigate a local directory and auto-populate a project draft. Reads .git/config, package.json, pyproject.toml, Gemfile, Cargo.toml, go.mod, Package.swift, Dockerfile, and README.md to detect tech stack, GitHub repo, and description.

draft = scan_folder("/home/<user>/work/rwgps")
# Returns a populated dict ready for add_project() after user confirmation

seed_from_existing() -> list[dict]

Merge existing project data from data/work/projects.json, profile/cadence_projects.json, and data/projects/*.json (excluding registry.json) into the registry. Safe to re-run -- skips existing slugs.

infer_project(title: str, tags: list[str] | None = None) -> str

Last-resort attribution helper for automated/programmatic flows (e.g. report generation, where no agent set the project from conversation context). Lower-cases "{title} {tags}" and returns the first project whose slug, name, or any alias (length >= 3 chars) appears as a substring. Returns "" if nothing matches. Agents that have conversation context should set the project explicitly instead.

slug = infer_project("Cadence State of the Union", tags=["analytics"])  # -> "cadence"

get_context_summary() -> str

Compact text summary of active projects for session context injection.

summary = get_context_summary()
# - [W] **Growth (RWGPS)** (growth, growth board): Growth initiatives...
# - [P] **VITA** (vita, the OS, personal OS): Personal health operating system...

Integration Points

  • Precedent: links.precedent_path maps to the Today app's project system
  • GitHub boards: links.github_boards references data/work/projects.json
  • Goals: goals array links to goals/active/*.json IDs
  • Heartbeat: Topics are tagged with project slugs for filtering

REST API & Web UI

Projects are first-class entities exposed through api/src/routers/projects.py and surfaced in the web UI (web/src/routes/projects/index.tsx, web/src/routes/projects/$slug.tsx, web/src/hooks/useProjects.ts). Beyond the stored registry fields, the API computes per-project state on read: report counts (and last report date) via count_reports_for_project(), forge-idea counts via count_forge_ideas(), scratch-pad presence, and a merged activity timeline via collect_project_activity().

Method Path Purpose
GET /api/v1/projects List projects (?status=, ?category=) with computed report/forge counts; sorted by most recent activity
GET /api/v1/projects/{slug} Project detail including resolved pinned resources and computed counts
PATCH /api/v1/projects/{slug} Update mutable fields
GET /api/v1/projects/{slug}/scratch Read the project scratch pad
PUT /api/v1/projects/{slug}/scratch Write the project scratch pad (sets scratch_updated_at)
POST /api/v1/projects/{slug}/pin Pin a resource (report / url / forge_idea / file)
DELETE /api/v1/projects/{slug}/pin Unpin a resource
GET /api/v1/projects/{slug}/activity Merged activity timeline for the project

The pinned list also drives prominence β€” pinned resources surface at the top of a project's detail view, so an agent re-implementing this should treat pinned as ordering/curation state, not just metadata.

Endpoints are mounted under the API on port 33800 (./run api); the web UI is served by Vite on 33801.

Heartbeat System

The heartbeat is silicon-Zack's curiosity engine -- a scored queue of things to discuss with bio-Zack, surfaced via Telegram at intelligent intervals.

Topic Types

Type Purpose Example
project_interview Deep-dive interviews about project human context "Game: Origin story"
follow_up Check back on something bio-Zack mentioned "How did the DEXA scan go?"
check_in Periodic check on a domain or area "CEO: Time allocation check"
exploration Something silicon-Zack is curious about "Cross-project: AI as force multiplier"

Topic Data Structure

Topics live in data/heartbeat/topics.json:

{
  "topics": [
    {
      "id": "dfa066d7",
      "type": "project_interview",
      "title": "Game: Origin story",
      "prompt": "Why gamification for cyclists? What sparked the idea?",
      "desired_frequency_days": 3,
      "priority": 0.5,
      "status": "active",
      "tags": ["rwgps-game", "identity"],
      "created_at": "2026-02-01T19:31:42-08:00",
      "last_nudged_at": "2026-02-01T19:33:58-08:00",
      "last_engaged_at": "2026-02-05T05:27:09-08:00",
      "completed_at": "2026-02-05T05:27:09-08:00",
      "dismissed_at": null,
      "nudge_count": 1,
      "engage_count": 1,
      "consecutive_ignores": 0,
      "metadata": {
        "covered_by": "interview-11-projects-breadth"
      }
    }
  ]
}
Field Type Description
id string 8-char UUID prefix
type string Topic type (see above)
title string Short display title
prompt string The actual question to ask bio-Zack
desired_frequency_days float How often this should be nudged
priority float 0.0-1.0, higher = more important
status string active, completed, dismissed, paused
tags string[] Project slugs and other tags for filtering
nudge_count int Total times this topic has been nudged
engage_count int Total times bio-Zack responded
consecutive_ignores int Ignores in a row (drives backoff)
metadata object Freeform (e.g., covered_by links to interview sessions)

Scoring Algorithm

The score_topic() function determines which topics to surface. Higher score = more urgent.

score = min(overdue_ratio, 3.0)           # How overdue (capped at 3x)
      * (0.5 + priority)                  # Priority weight (0.5-1.5 multiplier)
      * 0.7^consecutive_ignores           # Exponential backoff on ignores
      * (0.8 + 0.4 * engagement_rate)     # Engagement bonus (after 2+ nudges)

Key behaviors:

  • Not due yet (overdue_ratio < 0.8): Returns 0 -- topic will not surface
  • Never nudged: Treated as 2x overdue to ensure new topics get attention
  • Consecutive ignores: Each ignore multiplies score by 0.7 (3 ignores = 0.34x)
  • High engagement: Topics bio-Zack responds to get up to 1.2x bonus
  • Variety: get_due_topics() limits to max 2 per type to spread across topic categories

Intelligent Pacing

The should_send_now() function decides whether a heartbeat nudge should fire at all, adapting to bio-Zack's current engagement level:

Bio-Zack's state Minimum interval Detection
Chatty (>60% response rate, recent activity) 4 hours Telegram response log
Normal 8 hours Default
Disengaged (<20% response rate or 3+ heartbeat ignores) 36 hours Response log + heartbeat history

Additional constraints: - Daily cap: Max 3 heartbeat nudges per calendar day - Waking hours only: Scheduler checks 8am-9pm PT - Topics must be due: If no topics score > 0, nothing sends

Heartbeat Python API

from scripts.heartbeat import (
    add_topic, get_topic, list_topics, find_by_title,
    complete_topic, dismiss_topic, pause_topic, resume_topic,
    dismiss_zero_engagement_topics,
    record_nudge, record_engagement, record_ignore,
    score_topic, get_due_topics, should_send_now,
    get_summary, get_due_summary
)

CRUD:

# Add a new topic
topic = add_topic(
    type="check_in",
    title="CEO: Weekly review",
    prompt="How did this week go from a CEO lens?",
    desired_frequency_days=7,
    priority=0.7,
    tags=["ceo-mode", "accountability"],
)

# Find by title substring
topic = find_by_title("CEO: Weekly")

# List with filters
active = list_topics(status="active")
interviews = list_topics(type="project_interview")
game_topics = list_topics(tags=["rwgps-game"])

# Lifecycle
complete_topic("dfa066d7")  # Mark as done
dismiss_topic("abc12345")   # Won't be asked again
pause_topic("xyz99999")     # Temporarily stop nudging
resume_topic("xyz99999")    # Reactivate

# Garbage-collect dead topics (the mechanism behind dismissed topics).
# Dismisses active topics with nudge_count >= min_nudges and engage_count == 0
# (push channel is clearly wrong for them). Preserves the record so resume_topic
# can revive it. Wired into the nightly /sleep run via
# flows/sleep/heartbeat_archive_dead.py (calls it with min_nudges=8).
dismissed = dismiss_zero_engagement_topics(min_nudges=8, dry_run=False)

Tracking:

# After sending a nudge
record_nudge("dfa066d7")

# When bio-Zack responds
record_engagement("dfa066d7")  # Resets consecutive_ignores to 0

# When bio-Zack ignores
record_ignore("dfa066d7")     # Increments consecutive_ignores

Scoring:

# Score a single topic
score = score_topic(topic)

# Get the top 3 due topics (with type variety)
due = get_due_topics(max_count=3)

# Check if a nudge should fire right now
if should_send_now():
    # Schedule via proactive queue
    pass

# Display summaries
print(get_summary())       # All active topics grouped by type
print(get_due_summary())   # What's due with scores

Scheduler Integration

The heartbeat loop runs in api/src/scheduler.py (_heartbeat_loop, HEARTBEAT_INTERVAL = 15 * 60). The scheduler does not call schedule_heartbeat() directly β€” there's a wrapper layer:

  1. Every 15 minutes during waking hours (8am-9pm PT, via is_time_in_window_service(start_hour=8, end_hour=21)), _heartbeat_loop invokes _run_heartbeat_check().
  2. _run_heartbeat_check() awaits run_heartbeat_check() in api/src/services/scheduler_followups.py, which runs the synchronous schedule_heartbeat() (from scripts/proactive/triggers.py) in an executor.
  3. schedule_heartbeat() checks pacing via should_send_now(); if approved it enqueues a heartbeat_nudge message in the proactive queue (deduped by a per-minute heartbeat-YYYYMMDD-HHMM key, so multiple nudges can send per day). The daily cap is enforced by should_send_now() (MAX_PER_DAY = 3) plus the ATO limits below (max 4/week, β‰₯24h between).
  4. The build_heartbeat_nudge() builder picks the top 3 due topics via get_due_topics().
  5. The message is composed through Claude for natural voice and sent via Telegram.
  6. Each picked topic gets record_nudge() called.

The builder is registered with ATO (Adaptive Telegram Outreach) with these limits: - Category: information_gathering - Priority: 0.4 - Max per week: 4 - Minimum hours between: 24

Event History

All heartbeat events are logged to data/heartbeat/history.jsonl:

{"topic_id": "dfa066d7", "event": "created", "at": "2026-02-01T19:31:42-08:00", "details": {"title": "Game: Origin story", "type": "project_interview"}}
{"topic_id": "dfa066d7", "event": "nudged", "at": "2026-02-01T19:33:58-08:00"}
{"topic_id": "dfa066d7", "event": "engaged", "at": "2026-02-05T05:27:09-08:00"}

Project Interviews

Project interviews are a specific application of the heartbeat system. They are project_interview type topics designed to capture the human context behind projects -- origin stories, build journeys, collaborator dynamics, emotional stakes.

The goal: silicon-Zack should know not just what a project is, but why it exists, what it means to bio-Zack, and what the lived experience of building it is like.

How They Work

  1. When a project is added to the registry, interview topics are generated covering key angles:
  2. Origin story: Why did you build this? What sparked the idea?
  3. Build journey: How did it evolve? Key technical decisions?
  4. People: Collaborator dynamics, division of labor
  5. Reception: User reactions, surprises, aha moments
  6. Personal stake: What does this mean to you emotionally?

  7. Topics are scored and surfaced via the heartbeat nudge system

  8. When bio-Zack engages, the conversation deepens silicon-Zack's understanding
  9. Completed interviews get covered_by metadata linking to the interview session
  10. New follow-up topics can be generated from interview insights

Cross-Project Topics

Beyond individual projects, the heartbeat includes cross-cutting topics: - Resource allocation: How does bio-Zack split time across the registry's projects (17 at last count)? - AI as force multiplier: Every project uses AI differently -- what's the mental model? - CEO mode: Weekly accountability check-ins on delegation, time allocation, strategic output

CLI Commands

Command Description
/project Show active projects from registry
/heartbeat Manage heartbeat topics -- list, add, complete, dismiss
/project-interviews View project interview topics specifically

File Locations

File/Directory Purpose
data/projects/registry.json Project registry (single source of truth)
data/heartbeat/topics.json Heartbeat topics with scoring state
data/heartbeat/history.jsonl Event log (created, nudged, engaged, ignored)
scripts/projects/registry.py Registry Python API (find, add, update, scan, seed)
scripts/heartbeat.py Heartbeat Python API (topics CRUD, scoring, pacing)
scripts/proactive/builders.py build_heartbeat_nudge() builder for Telegram
scripts/proactive/triggers.py schedule_heartbeat() trigger function
api/src/services/scheduler_followups.py run_heartbeat_check() async wrapper around schedule_heartbeat()
api/src/scheduler.py _heartbeat_loop() / _run_heartbeat_check() -- 15-minute check during waking hours
api/src/routers/projects.py REST API for projects (list/detail/scratch/pin/activity)
web/src/routes/projects/ Web UI (project list + per-slug detail)
.claude/skills/project-registry/SKILL.md Skill documentation for silicon-Zack

Known Limitations

  1. No automatic topic generation from new projects - When a project is added to the registry, interview topics are not auto-generated. They were seeded manually for existing projects.

  2. Engagement tracking is coarse - The system tracks nudge/engage/ignore counts but does not analyze the quality or depth of engagement.

  3. History file grows unbounded - history.jsonl has no pruning mechanism. Not a problem at current volume but could be if topics scale significantly.

  4. No web UI for heartbeat management - Projects now have a web UI (list + per-slug detail with scratch pads and pinned resources), but the heartbeat layer does not β€” topics are managed via CLI commands and direct file editing only.

  5. Pacing heuristics are hand-tuned - The engaged/disengaged intervals (4h/8h/36h) and backoff constants (0.7 per ignore) were set by judgment, not data analysis.