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?
-
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. -
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. -
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. defaultingpinnedto[],scratch_updated_attonull), 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_versionis still1.
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_pathmaps to the Today app's project system - GitHub boards:
links.github_boardsreferencesdata/work/projects.json - Goals:
goalsarray links togoals/active/*.jsonIDs - 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:
- Every 15 minutes during waking hours (8am-9pm PT, via
is_time_in_window_service(start_hour=8, end_hour=21)),_heartbeat_loopinvokes_run_heartbeat_check(). _run_heartbeat_check()awaitsrun_heartbeat_check()inapi/src/services/scheduler_followups.py, which runs the synchronousschedule_heartbeat()(fromscripts/proactive/triggers.py) in an executor.schedule_heartbeat()checks pacing viashould_send_now(); if approved it enqueues aheartbeat_nudgemessage in the proactive queue (deduped by a per-minuteheartbeat-YYYYMMDD-HHMMkey, so multiple nudges can send per day). The daily cap is enforced byshould_send_now()(MAX_PER_DAY = 3) plus the ATO limits below (max 4/week, β₯24h between).- The
build_heartbeat_nudge()builder picks the top 3 due topics viaget_due_topics(). - The message is composed through Claude for natural voice and sent via Telegram.
- 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
- When a project is added to the registry, interview topics are generated covering key angles:
- Origin story: Why did you build this? What sparked the idea?
- Build journey: How did it evolve? Key technical decisions?
- People: Collaborator dynamics, division of labor
- Reception: User reactions, surprises, aha moments
-
Personal stake: What does this mean to you emotionally?
-
Topics are scored and surfaced via the heartbeat nudge system
- When bio-Zack engages, the conversation deepens silicon-Zack's understanding
- Completed interviews get
covered_bymetadata linking to the interview session - 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
-
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.
-
Engagement tracking is coarse - The system tracks nudge/engage/ignore counts but does not analyze the quality or depth of engagement.
-
History file grows unbounded -
history.jsonlhas no pruning mechanism. Not a problem at current volume but could be if topics scale significantly. -
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.
-
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.