Skip to content

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:

  1. 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.
  2. Combo prefix β€” "Zack + Alex: ..." β†’ mutual.
  3. "Name's team" pattern.
  4. Structured owner field from the extraction (split on / + & , and and for multi-owner strings).
  5. Structured direction field (inbound/outbound) β€” noisier, consulted after owner.
  6. direction: / counterparty: metadata embedded in the text.
  7. 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:

  1. scheduled_date is strictly more than 30 days overdue, or
  2. created_at is strictly more than 45 days old and the item has no notes, outcome, closed_at, or resolved_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:

  1. Its status is pending, open, or active.
  2. It has a parseable scheduled_date and is strictly more than 30 days overdue. At the default threshold, 30 days is not enough; 31 is.
  3. It has never been human-touched: no non-empty notes or outcome, and no closed_at, resolved_at, or resolution.
  4. It has no proposal activity in the last 14 days across proposed_at, declined_at, or routed_at.
  5. It was not explicitly kept: proposal_status is not accepted, kept, or held, and routed_to is 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 /commitments command (.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 flat items/due/completed_at shape. 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_issues tolerates 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_issue rewrites 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:

  1. Group pending learnings by normalized target (file-slug:section-slug).
  2. Skip targets below SYNTHESIS_THRESHOLD = 3.
  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 marked superseded.
  4. Synthesize a rule from the survivors (dedup, join with ;). synthesis.py provides optional LLM refinement.
  5. Emit a ProposedEdit{target, rule, source_learnings, priority, evidence_preview} (priority high when β‰₯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.cli exist outside the package itself β€” nothing in the scheduler or /sleep runs it.
  • Only the health_summary template is actually implemented (render_health_summary in templates.py); the other six template_names are referenced but have no renderer.
  • The /prep command doc claims /now integration, but /now contains 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