Capture, Commitments & Issues
vita has four lightweight tracking systems that share a design philosophy: filing is remembering. When bio-zack mentions a thought, a promise, a preference, or a bug, the right thing happens to a flat file on disk without ceremony. None of them is a heavyweight app β each is a JSON/JSONL store plus a few functions, wired into Telegram, the executive loop, and /sleep.
This page covers:
| System | Store | Purpose |
|---|---|---|
| Quick Capture | data/assistant/capture.json |
stash a thought / idea / article / reminder for later |
| Commitments Pipeline | data/assistant/commitments.json |
bridge meeting-extracted promises into Precedent tasks via conversational proposals |
| Local Issue Tracker | data/issues/issues.jsonl |
bug tracker for vita's own development |
| Learnings | data/assistant/learnings.jsonl |
capture durable behavioral preferences and integrate them into CLAUDE.md / skills |
A fifth subsystem β the Anticipatory Preparation Engine β is documented at the bottom as a built-but-idle capability.
Framing. These systems exist because silicon-zack does not persist across sessions. The next instance boots from files, not from conversation context. What gets written to disk is what survives. Capture/commitments/learnings are the mechanisms by which a stateless agent accumulates state.
Quick Capture
The simplest of the four. /capture <text> stashes a one-off item for later processing. It is intentionally low-friction: no confirmation, no required structure.
Categorization
The capture text is parsed by prefix into one of five categories. Items prefixed Learning: or Remember: are routed away to the Learnings system; everything else lands in capture.json.
| Prefix detected | Category | Destination |
|---|---|---|
Gift idea: |
gift-idea |
capture.json |
Article: or contains a URL |
article |
capture.json |
Reminder: |
reminder |
capture.json |
Learning: / Remember: |
learning |
learnings.jsonl (different schema) |
| (anything else) | thought |
capture.json |
Data model
capture.json is a single object: {"schema_version": 1, "items": [...]}. Each item:
{
"id": "uuid",
"created": "ISO-8601",
"category": "gift-idea|article|reminder|thought",
"text": "original capture text",
"tags": ["#tag", "@person"],
"status": "pending|processed|archived"
}
Writes go through safe_read_json + atomic_write (see Data Integrity). Captures are also mirrored as a bullet under a ## Captures heading in data/scratch/daily.md so they show up in the daily working notes.
Tip: Tags are extracted from the text β
#hashtags,@mentions, and inferred topic tags. They are advisory; nothing enforces a controlled vocabulary.
The command definition (parsing rules, schemas, processing notes) lives at .claude/commands/capture.md. Quick Capture is the light layer; durable behavioral preferences belong in Learnings, durable tasks belong in Precedent.
Commitments Pipeline
The commitments pipeline is the most elaborate of the four. It exists to solve a specific problem: meetings produce promises ("I'll send the deck", "you'll review the runbook"), and those promises need to become tracked tasks without bio-zack hand-editing a JSON file or drowning in other people's action items.
The pipeline never asks him to open a form. Instead it surfaces a batch conversationally over Telegram, interprets his natural-language reply, and routes approved items into Precedent.
Architecture
meeting extraction
β
βΌ
ingest.py ββββ counterparty attribution βββΊ commitments.json (append-only)
β
βΌ
proposer.py ββ enqueue_prompt() βββΊ Telegram session (silicon-zack)
β
bio-zack replies in natural language
β
βΌ
router.py (approve / decline / defer / done)
β
approve β Precedent task created
βββββββββββββββββββββββββββββββββββββββ
β
radar.py βββΊ visibility for /now, pre-1:1 briefs, /sleep
staleness_decay.py βββΊ nightly drain of stale/untriaged items
All modules live under scripts/commitments/. The store is data/assistant/commitments.json, an object with {"schema_version": 2, "small_tasks": [...], "commitments": [...]}. Bio-zack never edits it directly β every status change happens through the router CLI driven by the Telegram session.
Ledger record
build_commitment_record() in ingest.py produces each entry:
{
"id": "cmt_ab12cd34",
"goal_id": null,
"text": "send the planning deck",
"owner": "zack",
"direction": "outbound",
"counterparty": "<other-party-or-null>",
"source": "meeting:<normalized-source-key>",
"status": "pending",
"scheduled_date": "YYYY-MM-DD",
"follow_up_date": "YYYY-MM-DD",
"created_at": "ISO-8601",
"closed_at": null,
"outcome": null,
"notes": null
}
Proposal/routing add fields over the lifecycle: proposal_id, proposed_at, proposal_status, routed_to (the Precedent task key), routed_at, routed_project, routed_due, declined_at, decline_reason.
Stage 1 β Ingest & counterparty attribution
ingest_commitments(items, source, ...) is append-only: it never modifies or deletes existing rows, and skips any item whose text already exists (case-insensitive). Its key job is attribution β deciding whether each promise is bio-zack's to do or someone else's, so other people's action items don't inflate his pending queue.
classify_attribution() applies signals strongest-first:
- Name prefix in the text β
"Alex: write the runbook"or"Alex to fix login". Only names in a known roster count as counterparties; arbitrary leading words ("Review: ...") carry no signal. - Combo prefix β
"Zack + Alex: ..."β mutual. "Name's team"pattern.- Structured
ownerfield from the extraction (split on/ + & ,andandfor multi-owner strings). - Structured
directionfield (inbound/outbound) β noisier, consulted after owner. direction:/counterparty:metadata embedded in the text.- Default: bio-zack-owed. Ambiguous items are conservatively kept as his pending β a false-positive pending is safer than silently dropping a real commitment.
The three outcomes map to stored fields:
| Attribution | owner |
direction |
|---|---|---|
| bio-zack owes | zack |
outbound |
| counterparty owes | <other> |
inbound |
| mutual | zack/<other> (zack-first) |
outbound |
Mutual items are written zack-first deliberately so downstream filters that check owner.startswith("zack") keep them in his bucket. This attribution logic mirrors the triage layer (scripts/consolidation/commitment_triage.py) so the ledger stops refilling with other people's commitments at the source rather than only filtering them out later.
Stage 2 β Propose (conversational surfacing)
proposer.py turns a batch of commitment IDs into a Telegram session. It does not send user-facing text itself β it builds a structured payload (cleaned text, inferred due date, inferred project, inferred tags, counterparty, meeting label) and calls enqueue_prompt() (see Proactive Outreach / Scheduling) with instructions for a silicon-zack session to raise the batch naturally.
Key entry points:
| Function | Use |
|---|---|
enqueue_proposal(ids, reason, delay_minutes, topic_id) |
core: tag the batch and enqueue the session |
propose_new_from_meeting(source, created_since) |
called right after meeting ingest; picks up new zack-owned items |
propose_batch_by_counterparty(counterparty, limit) |
backlog triage in digestible per-person chunks |
refresh_stale_proposals(max_age_days=3) |
re-propose anything awaiting_response too long; runs during /sleep preprocess |
When a batch is enqueued, each commitment is tagged proposal_id, proposed_at, and proposal_status="awaiting_response". The enqueued prompt carries the batch as a markdown block plus explicit guardrails: never create a task for an item bio-zack hasn't acknowledged, treat silence as not approval, hold if a meeting is in progress. refresh_stale_proposals cancels the stale queue.db rows (matched by dedup_key = commitment-proposal-<id>), resets the markers, and re-enqueues fresh batches staggered across business hours so open loops can't rot.
Stage 3 β Route (act on the reply)
router.py is the CLI the Telegram session calls. It never parses natural language itself β that's the session's job; the router just executes atomic, audited decisions.
python scripts/commitments/router.py approve cmt_ab12cd34
python scripts/commitments/router.py approve cmt_ab12cd34 --due 2026-04-25 --project vita --text "rewritten text"
python scripts/commitments/router.py decline cmt_ab12cd34 --reason "already done"
python scripts/commitments/router.py defer cmt_ab12cd34 --until 2026-05-01
python scripts/commitments/router.py done cmt_ab12cd34
python scripts/commitments/router.py list-awaiting [--proposal prop_xxxxxxxx]
python scripts/commitments/router.py show cmt_ab12cd34
| Decision | Effect on the record |
|---|---|
approve |
shells out to the precedent CLI to create a task; sets routed_to, routed_at, routed_project, routed_due, status="active", proposal_status="routed". Idempotent β re-approving a routed item returns the existing key. |
decline |
status="cancelled", proposal_status="declined", records decline_reason |
defer |
pushes follow_up_date, clears proposal_id so it can be re-proposed, appends a DEFERRED β¦ note |
done |
status="done", proposal_status="done_without_precedent", sets closed_at (bio-zack already did it) |
On approve, fields default through inference.py: clean_text, infer_project, infer_due_date, inferred_tags. Any can be overridden via flags. All output is JSON on stdout so the calling session can parse it. The skill that drives this from the session side is .claude/skills/commitment-routing/.
Stage 4 β Radar (visibility)
radar.py provides read-only views for /now, pre-meeting briefs, and /sleep so unrouted commitments stay visible without opening JSON:
| Function | Returns |
|---|---|
summary() |
aggregate counts: zack-pending, awaiting-response, untracked, untracked-overdue, routed-active |
awaiting_response(limit) |
proposed but not yet acted on |
untracked(limit, min_age_days) |
zack-owned pending, never proposed (oldest first) |
inbound_for(person) |
someone else's commitments β useful before a 1:1 |
render_now_block() |
one-line **commitments:** β¦ string for /now, or "" when nothing is notable |
Stage 5 β Staleness decay (the drain)
The pipeline's failure mode is a write-only ledger that only ever grows. staleness_decay.py is the antibody. The nightly /sleep step now has two deliberately different legs: a broad, non-mutating candidate stage followed by a much narrower conservative apply.
Nightly stage: broad review surface
flows/sleep/commitment_drain_staging.py first calls run_backfill(..., dry_run=True). This leg writes data/commitments/drain_staging-<date>.json and does not mutate the ledger. An active item (status in pending|open|active) is staged if either rule fires:
scheduled_dateis strictly more than 30 days overdue, orcreated_atis strictly more than 45 days old and the item has no notes, outcome,closed_at, orresolved_at.
The staging artifact also previews meeting-source normalization candidates. Rule 2 caps the structural backlog floor, but its candidates remain judgment calls: they are never auto-applied merely because they are old by creation date.
Nightly apply: conservative archive
After staging succeeds, the same /sleep step calls apply_drain(). An item is auto-archived only when all of these gates pass:
- Its status is
pending,open, oractive. - It has a parseable
scheduled_dateand is strictly more than 30 days overdue. At the default threshold, 30 days is not enough; 31 is. - It has never been human-touched: no non-empty
notesoroutcome, and noclosed_at,resolved_at, orresolution. - It has no proposal activity in the last 14 days across
proposed_at,declined_at, orrouted_at. - It was not explicitly kept:
proposal_statusis notaccepted,kept, orheld, androuted_tois empty.
The apply leg changes status to archived; it never deletes the record. It records archived_by="drain", archived_at, drain_staging_ref, closed_at, and a machine-written outcome. Before the ledger write it copies commitments.json to a sibling named commitments.bak-<date>-drain-apply.json. When at least one item is archived it also writes data/commitments/drain_apply-<date>.json, containing the run summary and a restore index (id, text preview, source, scheduled date, and days overdue), then appends an honest one-line summary to data/scratch/daily.md for the morning brief. Apply failure is guarded and cannot invalidate the already-written staging artifact.
Restore a drain-archived item by id:
PYTHONPATH=scripts uv run python -m commitments.staleness_decay --restore <id>
Restore refuses records whose archived_by is not drain. A successful restore returns the item to pending, clears the drain closure/outcome fields, stamps restored_at, and appends a restore note.
Manual backfill is broader
The older run_backfill() CLI remains available, but it is not the conservative nightly apply. In non-dry-run mode it can normalize meeting source keys, archive every item matching either supplied age rule, and physically collapse exact duplicates (same source + normalized text). It uses the separate commitments.bak-<date>-staleness-decay.json backup naming convention. Preview it before any manual apply:
# broad preview only
PYTHONPATH=scripts uv run python -m commitments.staleness_decay \
--dry-run --threshold-days 60 --created-at-threshold-days 45 \
--staging-file data/commitments/manual-drain-preview.json
# conservative apply-leg preview (same gate family as the nightly apply)
PYTHONPATH=scripts uv run python -m commitments.staleness_decay \
--apply-drain --dry-run --threshold-days 30
Note: There is a separate, simpler
/commitmentscommand (.claude/commands/commitments.md) that predates the meeting-ingest pipeline. It documents a standalone tracker (/commitments add,/commitments done) that shares the same file but uses a flatitems/due/completed_atshape. The ingestβproposeβrouteβradarβdrain pipeline above is the live system; treat the legacy command as historical.PII: the commitment ledger contains named coworkers and real promises. Document the mechanism; never surface contents.
Local Issue Tracker
A deliberately tiny bug tracker for vita's own development β distinct from any external issue tracker. It lets bio-zack say "file a bug" in chat and have it land somewhere durable. Implementation: scripts/issues/__init__.py, store: data/issues/issues.jsonl (append-only JSONL).
API
from scripts.issues import create_issue, list_issues, get_issue, update_issue, resolve_issue, infer_label
issue = create_issue(title, description="", label=infer_label(title), source="cli")
open_issues = list_issues(status="open")
one = get_issue(issue_id) # exact or prefix match
update_issue(issue_id, **fields) # rewrites the whole JSONL
resolve_issue(issue_id, note="") # status -> resolved, stamps resolved_at
Record
{
"id": "uuid",
"title": "first 120 chars of the report",
"description": "",
"label": "ios|api|scripts|web|system",
"status": "open|in_progress|resolved|wontfix",
"source": "cli|telegram|...",
"created_at": "ISO-8601",
"resolved_at": null
}
infer_label() is a keyword heuristic: ios/app/swift/iphone/mobile β ios; api/endpoint/router/backend/server β api; web/dashboard/browser/frontend/ui β web; default scripts. (system is a valid label but not auto-inferred.) An unknown label passed to create_issue falls back to scripts.
Robustness detail:
list_issuestolerates double-encoded legacy rows (a JSON string wrapping a JSON object) and skips non-dict rows β a single malformed line used to crash the whole listing.update_issuerewrites the entire file, which is fine at this scale.
Telegram integration
The bot registers /bug and /issue (both β cmd_bug in scripts/telegram_bot.py). With no argument it lists open issues; with text it files one tagged source="telegram" and confirms with the short ID and inferred label. Filing is autonomous β per CLAUDE.md, when bio-zack says "file a bug" or describes something broken, an issue gets created without asking.
Learnings
Learnings are the system's behavior-change channel. Where observation capture models who bio-zack is, learnings change how vita behaves β they are durable preferences ("be concise on Telegram", "always use atomic writes") that, once enough accumulate for one target, get integrated directly into CLAUDE.md or a skill file.
Implementation lives under scripts/learnings/; data under data/assistant/.
| File | Role |
|---|---|
learnings.jsonl |
append-only store of captured learnings |
learnings_index.json |
rebuilt index: by-id offsets, by-target buckets, content-hash dedup, stats |
integrations.jsonl |
log of every rule integrated into a file (for rollback) |
backups/ |
pre-edit backups of every integrated target |
.learnings.lock |
fcntl lock guarding all mutations |
Lifecycle
capture βββΊ pending βββΊ (3+ for one target) βββΊ synthesize rule
β β
β integrate_rule()
β β
ββ dedup by content hash edit CLAUDE.md / skill + log + backup
β
status: integrated
(rollback_integration restores backup
and flips learnings back to pending)
Capture
append_learning(learning) (in storage.py) is the core write. It is thread-safe (fcntl.flock), assigns id = lrn_<YYYYMMDD>_<hex6>, stamps ts, sets status="pending", and computes a content hash (sha256 of file:section:learning, lowercased) β duplicates return None and are silently skipped. After append it rebuilds the index under the same lock.
A learning record:
{
"id": "lrn_20260106_ab12cd",
"type": "correction|preference|pattern|explicit",
"source": {"kind": "conversation|explicit|proposal_comment", "ref": ""},
"target": {"file": "CLAUDE.md", "section": "Tone", "normalized": "claude-md:tone"},
"learning": "be concise on telegram",
"evidence": ["<quote that justifies it>"],
"confidence": "high|medium|low",
"status": "pending|integrated|rejected|superseded",
"superseded_by": null,
"content_hash": "sha256:β¦",
"ts": "ISO-8601",
"v": 1
}
schema.py::validate_learning enforces the required fields, the enum values, a non-empty evidence list, and a minimum learning length. There are three capture entry points:
| Entry point | Notes |
|---|---|
/learn <text> |
explicit, high-confidence; infers target via scripts/learnings/extract.py::infer_target, confirms, captures with type=explicit |
/capture Learning: β¦ / Remember: β¦ |
routes the capture text into the learnings store |
CLI: python scripts/learnings/capture_cli.py capture --type β¦ --file β¦ --section β¦ --learning-b64 β¦ --evidence-b64 β¦ |
base64 flags keep shell-special characters safe |
Processing & integration
LearningsProcessor.process_pending() (processor.py) does the synthesis:
- Group pending learnings by normalized target (
file-slug:section-slug). - Skip targets below
SYNTHESIS_THRESHOLD = 3. - Detect & resolve conflicts β a small table of contradictory term pairs (e.g.
verbose/concise,always/never,morning/evening). The winner is decided by confidence (high > medium > low), then recency; the loser is markedsuperseded. - Synthesize a rule from the survivors (dedup, join with
;).synthesis.pyprovides optional LLM refinement. - Emit a
ProposedEdit{target, rule, source_learnings, priority, evidence_preview}(priorityhighwhen β₯5 learnings).
integrator.py::integrate_rule(edit) applies an approved edit: it maps the target slug to a real path (CLAUDE.md or one of a few skill SKILL.md files, defaulting to CLAUDE.md), takes a timestamped backup into backups/, inserts the rule as a bullet under the matching ##/### section (creating the section at EOF if absent), atomically writes, appends an integrations.jsonl record, and flips the source learnings to integrated. rollback_integration(int_id) restores the backup and reverts the learnings to pending.
Commands
| Command | Action |
|---|---|
/learn <text> |
capture an explicit preference |
/learnings |
summary: pending counts per target, which targets meet the threshold |
/learnings list [--target X] |
list pending learnings |
/learnings show <id> |
full detail for one |
/learnings integrate |
review-and-apply ready targets |
/learnings reject <id> |
mark not-useful (won't be re-proposed) |
/learnings rollback <int_id> |
undo an integration from backup |
Processing also runs automatically during /sleep when learnings accumulate.
Anticipatory Preparation Engine (built but idle)
Warning: This subsystem is dormant. The code exists and is internally coherent, but it is not wired into the scheduler or
/now, only one of its templates is implemented, and its command doc describes integrations that no longer exist. Document it as a built-but-idle capability, not a live feature.
The Anticipatory Preparation Engine (APE), exposed via /prep (.claude/commands/prep.md), was designed to scan the calendar for events needing health-related prep, classify them, schedule prep jobs at category-specific lead times, and render summaries to data/preparation/output/.
The classifier (scripts/preparation/classifier.py) is the most complete part. It recognizes seven categories via keyword regexes, each with a lead time and reminder schedule, plus title-tag overrides:
| Category | Lead time | Template name |
|---|---|---|
| Medical appointment | 48h | health_summary |
| Medical specialist | 72h | specialist_summary |
| Periodic review | 24h | trends_digest |
| Travel | 72h | travel_prep |
| High stress | 72h | stress_optimization |
| Fitness event | 168h (7d) | fitness_taper |
| Social milestone | 48h | social_context |
Override tags in an event title bypass auto-classification: [no-prep], [prep:medical], [prep:travel], [prep:stress], [prep:review].
Why it's dormant:
- No callers of
scripts.preparation.cliexist outside the package itself β nothing in the scheduler or/sleepruns it. - Only the
health_summarytemplate is actually implemented (render_health_summaryintemplates.py); the other sixtemplate_names are referenced but have no renderer. - The
/prepcommand doc claims/nowintegration, but/nowcontains no preparation hook.
The mechanism (calendar scan β classify β schedule at lead time β render) is a reasonable blueprint for an anticipatory health-prep feature if revived.
File Locations
| Path | Contents |
|---|---|
data/assistant/capture.json |
quick-capture items (schema_version:1, items[]) |
data/assistant/commitments.json |
commitment ledger (schema_version:2, commitments[], small_tasks[]) |
data/assistant/learnings.jsonl |
learnings store |
data/assistant/learnings_index.json |
learnings index (offsets, buckets, hashes, stats) |
data/assistant/integrations.jsonl |
applied-rule log (rollback source) |
data/issues/issues.jsonl |
local issue tracker |
data/commitments/drain_staging-<date>.json |
nightly drain review surface |
data/commitments/drain_apply-<date>.json |
conservative apply report and restore index (written when items are archived) |
data/assistant/commitments.bak-<date>-drain-apply.json |
pre-apply commitment-ledger backup |
scripts/commitments/ |
ingest, proposer, router, radar, staleness_decay, inference, backfill, dedup |
scripts/issues/__init__.py |
issue tracker API |
scripts/learnings/ |
storage, processor, integrator, schema, extract, synthesis, capture_cli, bootstrap |
scripts/preparation/ |
dormant APE: classifier, queue, templates, cli |
.claude/commands/ |
capture.md, commitments.md, learn.md, learnings.md, prep.md |
.claude/skills/commitment-routing/ |
skill that drives router from a Telegram session |
flows/sleep/commitment_drain_staging.py |
nightly broad stage plus guarded conservative apply |
Where to go next
- Precedent β where approved commitments become tasks
- Proactive Outreach / Scheduling β
enqueue_prompt/queue.dbthat proposals ride on - Identity β observation capture (models identity, complements learnings)
- Improve & /sleep β nightly processing that drives the drain and learnings integration
- Stream Capture β inline tagging that feeds auto-filing