| name | state-log |
| description | State + Log convention for sourced facts. Every deal note, person note, org note follows this shape. Use when migrating notes, writing new entity notes, or auditing for unsourced claims. |
State + Log convention
Every entity note (deal, person, org, project hub) has the same shape:
---
<frontmatter: stable identifiers + cached projection of ## State for downstream tooling>
---
# Title
## Product / Subject ← stable spec, optional
## State ← current facts, every bullet has src:
## Log ← append-only timeline, reverse-chronological, every entry has src:
Layer rules
Frontmatter is a denormalized cache. Holds stable identifiers (lead, referrer,
owner, product, deal_size, deal_type, value) plus mirrors of mutable State fields
that downstream tooling reads (stage, last_contact, follow_up, next_action).
No provenance here. When frontmatter and State disagree, State wins and the
cache gets updated.
Downstream readers that depend on frontmatter cache:
gateway/lib/status_collector.py — Deals dashboard, Followups overdue calc.
.claude/skills/vox-crm/SKILL.md — CRM updates.
scripts/silence-detector.py — stale-deal flagging.
## State is the canonical truth. Every bullet:
- **<key>:** <value> · src: `<source-uri>` · <YYYY-MM-DD> · score: <0.0-10.0>
Source URIs:
| Channel | URI |
|---|
| Telegram (vadimgest canonical) | vadimgest://telegram/<chat_id>_<msg_id> |
| Telegram (legacy / manual) | tg://<chat_id>/<msg_id> |
| Signal | signal://<group>/<ts> or signal://<person>/<date> |
| WhatsApp | whatsapp://<chat>/<msg_id> |
| iMessage | imessage://<chat>/<rowid> |
| Hlopya call | hlopya://<meeting-slug-or-id> |
| Gmail | gmail://<msg_id> |
| GitHub | gh://<owner>/<repo>/issue/<n> |
| Calendar | gcal://<event_id> |
| Browser observation | browser://<host> |
| Executor session | executor://<YYYY-MM-DD> |
| Vadim verbal | vadim-said://<YYYY-MM-DD> |
| Obsidian internal | obsidian://<path>#<heading> |
Preferred URI form: when writing facts from a heartbeat session, use the source_uri field directly from the vadimgest JSONL record (e.g. vadimgest://telegram/748756603_996703). This is the canonical form that heartbeat agents should always use. The tg:// form is acceptable for manually entered facts or when the vadimgest record isn't available.
Placeholder URI upgrade rule: prior heartbeats sometimes write State bullets with approximate/placeholder URIs when the real JSONL URI wasn't immediately at hand. Reflection should scan every State bullet modified in the current session for any of these forms and upgrade them. Known placeholder forms:
-
Name-date form: vadimgest://telegram/alina_2026-06-22_reply instead of vadimgest://telegram/380716662_997694
-
Timestamp form: vadimgest://telegram/259146850_13:51 (chat_id + time) instead of vadimgest://telegram/259146850_999688 (chat_id + msg_id)
-
Person-shorthand Signal form: vadimgest://signal/pufit_2026-06-30_aws instead of the real conversation URI
-
Legacy Signal form: signal://dima-abramov/2026-05-15 (bare name-date, no conversation IDs)
-
Synthetic UUID form (Signal): vadimgest://signal/0d3f6e11-2c5a-4b8b-abc1-000000000001_... — the all-zeros suffix (000000000001) is a dead giveaway of a hand-crafted stub, not a real Signal conversation UUID. Real Signal conversation UUIDs have full entropy across all segments (e.g. e0886f27-b985-459c-a0ea-b28cb814048c). Detection: if the UUID ends in -000000000001 or similar low-entropy suffix, it's synthetic — look up the real one via JSONL chat search.
-
Name-only Telegram form (no chat_id): vadimgest://telegram/rishabh-stability_1002128 instead of vadimgest://telegram/7125585292_1002109. The canonical form always uses a numeric chat_id before the underscore. If the segment before _ is not all digits, it's a placeholder. Look up the chat_id via JSONL: python3 -c "import json; [print(r.get('meta',{}).get('chat_id',''), r.get('chat','')) for r in (json.loads(l) for l in open('/srv/codex-klava/data/vadimgest/sources/telegram.jsonl') if l.strip()) if 'chatname' in str(r.get('chat','')).lower()]" (replace 'chatname' with the display name).
The timestamp form is easy to miss because it looks plausibly canonical — but real message IDs are large integers (6+ digits), not time strings like 13:51. When you see HH:MM after the underscore, it's a placeholder.
When a subsequent heartbeat or reflection pass encounters a placeholder URI, find the real one via JSONL body-text search:
script = '''
import json
with open('/srv/codex-klava/data/vadimgest/sources/signal.jsonl') as f:
for i, line in enumerate(f):
if i < 5940: continue # seek close to expected line range
if i > 5970: break
r = json.loads(line.strip())
msgs = r.get('messages', [])
for m in msgs:
body = str(m.get('body', '') or m.get('text', '') or '')
if 'YOUR_SEARCH_TEXT' in body: # use a distinctive phrase from the message
print(f"Found: {r.get('source_uri','')} | {body[:80]}")
'''
with open('/tmp/find_uri.py', 'w') as f: f.write(script)
Upgrade the placeholder to canonical form in the same write pass. Do not leave placeholder URIs in State when the real one is in hand — they break traceability and fail the linter's _needs upgrade_ check.
Session example (2026-06-27): Ivan Panfilovich note had vadimgest://telegram/259146850_13:51 — the 13:51 is the message timestamp, not a message ID. Upgraded to vadimgest://telegram/259146850_999695 when the follow-up conversation arrived.
Double-src State bullet fix (reflection grooming pattern): Heartbeats occasionally chain two src: fields in one bullet, producing:
- **last_contact:** 2026-06-26 · src: `executor://2026-06-25` · 2026-06-26 · src: `frontmatter` · _needs upgrade_
This happens when a heartbeat writes a real URI but also leaves the migration-stub src: frontmatter · _needs upgrade_ trailer. Fix: collapse to the most recent non-placeholder URI, drop the trailing stub:
old = 'DATE · src: `executor://DATE2` · DATE · src: `frontmatter` · _needs upgrade_'
new = 'DATE · src: `executor://DATE2` · DATE'
content = content.replace(old, new, 1)
Reflection should scan every modified note for this pattern and fix it in the same pass.
content = content.replace(
'src: `vadimgest://telegram/alina_2026-06-22_reply`',
'src: `vadimgest://telegram/380716662_997687`',
1
)
Weak provenance — src: frontmatter — is acceptable as a migration stub but
flagged by the linter as _needs upgrade_. Heartbeat / manual edits convert
these into real URIs over time.
Structural keys (artifacts, links, related, channels, people) are
allowed without src: — they index into other sourced content rather than
asserting a fact themselves.
## Log is append-only. Format:
### YYYY-MM-DD — <short title>
- **src:** `<source-uri>`
- **mentions:** [[Entity]], [[Another Entity]]
- **summary:** what happened, key quotes, context
- **facts-touched:** key1, key2 (or — if pure observation)
- **fact-scores:** `key1=8.1(state_log)`, `key2=4.7(log_only)`
Reverse chronological — newest first. Always wikilink entities mentioned;
that's how the backlink graph stays alive.
Mentions enforcement rule: every named person, org, deal, or topic that appears in summary: must also appear in mentions: as a [[wikilink]]. Check after writing: scan the summary for proper nouns and confirm each is in the mentions line. Gaps are common when summaries reference indirect actors (e.g., "Sasha bringing a hardware engineer" — Sasha must be in mentions even if the summary focuses on the engineer).
Session examples of missed mentions (2026-06-21):
- Ivan Kudryavtsev Jun 21 entry: summary referenced Sasha Pokras,
[[Sasha Pokras]] missing from mentions → patched
- Max Choly Jun 21 entry: summary referenced Roko product,
[[Roko]] missing from mentions → patched
score is write-time memory salience, not source truth. New heartbeat writes
must score each atomic fact using importance, confidence, durability, and the
source prior defined in the heartbeat skill. Existing unscored facts remain
valid and need no bulk migration. Retrieval treats unscored notes as neutral.
Catchall folders are NOT scoped notes
People/, Organizations/, Topics/, Inbox/, Meetings/, archive/
contain entity notes but don't get the State+Log treatment as a folder —
only individual notes inside them do (e.g. People/Pufit.md has State+Log,
the People/ folder itself does not).
Tools
Live in ~/Documents/GitHub/claude/scripts/:
-
state_log_write.py — canonical transactional writer for new sourced fact
batches. It accepts one scored JSON payload, locks the note, upserts the Log
entry by source URI, updates State, syncs cached frontmatter, preserves
reverse chronology, and atomically replaces the file. Use this instead of
separate string replacements for routine heartbeat/backfill writes.
python3 scripts/state_log_write.py \
--note ~/Documents/MyBrain/People/Name.md \
--payload /tmp/fact-batch.json \
--apply
Run without --apply for a dry-run report. A repeated identical payload is
idempotent (changed: false); a later payload with the same src merges new
mentions, summary evidence, fact scores, and State changes into the existing
Log entry.
-
migrate_to_state_log.py — idempotent, non-destructive migration. Pulls
dated sub-headings out of any section, sorts reverse-chronologically into
## Log. Preserves existing State bullets verbatim; adds weak mirrors only
for keys not already covered. Dry-run by default, --apply to write.
python3 scripts/migrate_to_state_log.py \
--vault ~/Documents/MyBrain \
--glob 'Vox Lab/Deals/**/*.md' \
--apply
-
lint_state_facts.py — checks: presence of State + Log, every State bullet
has src:, frontmatter cache matches State leading values, log strictly
reverse-chronological, no duplicate ## History headers.
python3 scripts/lint_state_facts.py \
--vault ~/Documents/MyBrain \
--glob '**/*.md' \
--fail-on hard
Severity: hard blocks commits / CI. soft is the _needs upgrade_
baseline — track it down over time, don't gate on it.
Tagging
Migration script applies to notes with frontmatter tag:
vox-deal — Vox Lab sales pipeline notes.
personal-deal — Vadim's personal deal notes (not Vox).
Add new tags here as the convention spreads to other entity types
(person, org, project-hub).
When to write what
-
New fact arrives (Signal message, Hlopya call, email, etc.):
- Append a new
### YYYY-MM-DD — <title> to ## Log with src:,
mentions:, summary, and per-fact scores.
- If the new fact changes a State field, update the corresponding bullet
in
## State with the new value and new src: pointing at the same
log entry's source. List the touched keys in facts-touched:.
- If a cached field (
stage, last_contact, follow_up, next_action)
changed, also update frontmatter so the dashboard sees it. The linter
will catch drift.
-
Verbal info from Vadim with no upstream record:
- Source URI:
vadim-said://<YYYY-MM-DD> — weaker than a message, still
attributed. Linter accepts it; you can upgrade later if a downstream
log surfaces.
-
Anti-hallucination rule: never write a fact to ## State without a
src:. If you have no source, it doesn't go in. The linter enforces.
-
Notes missing ## Log section — older notes may have ## State and
## Observations but no ## Log. Use the safe universal insertion pattern:
log_idx = content.find('## Log')
if log_idx != -1:
log_section_start = log_idx + len('## Log\n')
content = content[:log_section_start] + new_entry + content[log_section_start:]
else:
content = content + '\n## Log\n' + new_entry
write_file('/path/to/note.md', content)
This handles both compliant notes (Log exists, insert at top) and legacy
notes (no Log, append new section at end) without a branch failure.
ALL-CAPS slang burst = breakthrough / blocker resolved
When a close contact (Sasha, Vladik, Pufit) sends an ALL-CAPS message with exclamation marks in response to something Vadim shared, treat it as a major breakthrough confirmation — find the underlying blocker and update its State bullet.
Recognized forms:
"ХУЯРИМ / THIS IS NOT A DRILL / HUYARIM" = critical blocker resolved, action now unblocked
"ЧТО ЗА ХУЙНЯ" + explicit surprise = unexpected obstacle surfaced
"ПОГНАЛИ / ПОЕХАЛИ" after a specific trigger = green light, deal advancing
Triage rule: when this fires, update the project/deal State bullet from blocked/pending to CONFIRMED/ACTIVE. The contact's ALL-CAPS burst is the primary source URI for the confirmation. Do NOT just update last_contact and move on.
Do NOT treat "already exists" as a reason to skip: if an existing State/Log entry covers the same date but doesn't include the confirmation detail, extend it via targeted .replace() on a partial anchor. A Jul 4 entry that says "launch work still hot, AWS Activate follow-up" is incomplete if the credit confirmation happened that same day — extend with the specific confirmation fact.
Session example (2026-07-03): Sasha Pokras: "ХУЯРИМ / THIS IS NOT A DRILL / HUYARIM" after forwarding AWS credits email showing 200K weekend spend covered retroactive to Jul 1 — Silent Speech Caterpillar State bullet updated from BLOCKER to ACTIVE.
Casual confirmation phrases = deal/decision closed
Founders and contacts in Vadim's network often confirm important decisions (office deals, partnerships, calls) with very casual phrasing. Do NOT wait for formal language to update State. These phrases mean "confirmed":
| Phrase | Context | Meaning |
|---|
| "nope all good / yeah sounds good" | After Vadim asks for a decision | Confirmed, no objections |
| "go make deal blyat :)" | Vadim asked about a business deal | Push to close, Toms approves |
| "ah / yeah" | After clarification | Confirmed, brevity = certainty |
| "blyat / yeah" | After urgency check | Not urgent, but affirmed |
| "уже? ну все погнали / нахер их" | Vadim stated a fact, team member reacted with surprise + go-ahead | Fact confirmed AND action unlocked — update State bullet to RESOLVED |
| "давай просто X мувнем" | After Vadim mentioned a storage/infra plan | Infra decision locked — update State bullet |
Pattern: When Vadim asks a yes/no question or deadline question to a contact, and the contact replies with any variant of casual affirmation (even slang/emoji), update the relevant State bullet immediately. Do NOT wait for the contact to use formal business language.
State update rule: When a casual confirmation arrives, update the State bullet from tentative/TBD to CONFIRMED with src pointing to the casual message — not just the prior tentative bullet. The linter accepts colloquial summary text in State bullet values.
Session example (2026-06-27): Vadim asked Toms "Could you tell decision to Lev like tomorrow or on Sunday, or we'll search for other offices." Toms replied: "nope all good / yeah sounds good" — this is the confirmation. State updated: office_decision: CONFIRMED Jun 27.
Extends-not-creates: Vadim timing questions mid-conversation
When a Log entry ends with a confirmed state (e.g., call scheduled) and the next intake delivers Vadim asking a follow-up timing question ("Это через полчаса?"), extend the existing Log entry — do not create a new one. The timing question is additive context to the same event. Pattern:
old = "call confirmed for X AM Moscow. Loop closed."
new = "call confirmed for X AM Moscow. **Vadim replied HH:MM UTC:** 'Это через полчаса?' — note: X MSK = Y UTC, Z min from message time, not 30 min. Possible timezone confusion. Bro has not yet replied."
content = content.replace(old, new, 1)
Also update last_contact src to the new message ID. Do NOT mark the call "uncertain" purely from Vadim's question — the confirmed time is still the live state.
Silence alert escalation: proposal→direct task at deadline
When a [PROPOSAL] has been pending beyond its stated deadline (typically written into the silence alert as "if still un-actioned by DATE, convert to direct Google Task"), reflection must act on that trigger — not just re-report it.
Pattern:
- Silence alert says: "if still un-actioned at Jun-26, convert to direct Google Task"
- Today is Jun 25 (one day before deadline)
- Action: create the direct task NOW, so it shows in Vadim's inbox before the deadline passes
Task creation via Python:
import sys
sys.path.insert(0, '/srv/codex-klava/repos/claude')
from tasks.queue import create_task
body = (
"## Context\n"
"PROPOSAL [ID] has been pending N nights.\n\n"
"## What to do\n"
"Review [PROPOSAL] in the Deck and either:\n"
"1. Approve -> send reply\n"
"2. Modify -> adjust terms and send\n"
"3. Reject -> decide on alternative approach\n"
)
task = create_task(
title='[Contact] - approve [action] by [DATE] (Nd pending)',
priority='high',
source='reflection',
body=body
)
Do NOT use em-dashes (—) or smart quotes in Python strings passed to create_task — they cause SyntaxError: invalid character when the script is written to /tmp and executed. Use plain ASCII dashes (-) and straight quotes.
Session example (2026-06-25): HighTower [PROPOSAL] YjhNamk1RnVCR0NNMnhyRg (25% node discount) had been pending 9+ nights. Jun 24 silence alert said convert at Jun-26. Created direct task Jun 25. ✓
Duplicate RESULT task cleanup during reflection
Long-running monitoring jobs (Phase2 continuation monitors, SSI training launchers) can accumulate many identical [RESULT] tasks over time if each run creates a new result card without closing the previous one. Detection and cleanup:
import sys
sys.path.insert(0, '/srv/codex-klava/repos/claude')
from tasks.queue import list_tasks, complete_task
from collections import Counter
tasks = list_tasks(include_completed=False)
result_tasks = [t for t in tasks if '[RESULT]' in str(t.title or '') and t.type != 'proposal']
title_counts = Counter(str(t.title or '')[:80] for t in result_tasks)
for title, count in title_counts.items():
if count > 1:
dupes = [t for t in result_tasks if str(t.title or '')[:80] == title]
for t in dupes[:-1]:
complete_task(t.id)
complete_task() signature: complete_task(task_id: str, list_id: str = None) — no note= kwarg.
Session example (2026-06-25): 23 duplicate "Phase2 16h continuations monitor" RESULT tasks. Closed 22, kept 1. ✓
Multi-part src: replacement pitfall — partial match leaves stray bullet
When a State bullet has two src: URIs joined with + (common in deal notes where an event was captured from both a call recording and a document):
- **last_contact:** 2026-06-16 · src: `codex-chat://2026-06-16` + `obsidian://path/doc.docx` · 2026-06-16
A str.replace() that only includes the first URI in old_string will match and leave the + obsidian://... segment as a dangling line, especially if you also change the key name. Result:
- **last_contact:** 2026-06-27 · src: `hlopya://2026-06-27` · 2026-06-27
- **last_contact_oldsuffix:** + `obsidian://path/doc.docx` · 2026-06-16 ← stray
Fix: always include the FULL bullet line in old_string — from - **key:** through to the trailing date including all + <uri> segments. After any State bullet replacement, assert no stray key variation remains:
assert '- **last_contact_' not in content
Session example (2026-06-28 reflection): max+Mahir Bansal deal note — bullet had src: codex-chat://2026-06-16 + obsidian://.... Replacement matched only first part, left - **last_contact_jun16:** as stray line. Detected via grep and removed.
📋 klava-personal/references/multi-deal-cross-write-patterns.md — multi-deal cross-write pattern (one meeting → two deal notes) and follow_up rolling requiring both frontmatter and State bullet updates.
Stale next_action scanning (Phase 3 companion to follow_up rolling)
When rolling follow_up on a deal or person note, also scan the next_action frontmatter field. It goes stale the same way follow_up does — heartbeats write it in the moment ("send reply today Jun 30") but never update it when that task closes.
Detection: next_action contains a date expression older than today ("today Jun 30", "this week Jun 25", "by Jul 3") AND no "Resolved" Log entry confirms the task closed.
Fix: Replace with the current open loop from the most recent Log entry. The new next_action should describe what Vadim needs to do now.
old_na = 'next_action: "Data broker meeting today Jun 30 - check in after meeting..."'
new_na = 'next_action: "Send finalized blog draft to Sasha for review (open loop since Jul 3); follow up on Monday launch timing"'
content = content.replace(old_na, new_na, 1)
Session example (2026-07-04): Sasha Pokras next_action said "Data broker meeting today Jun 30". Updated to reflect the Jul 3 blog-review open loop.
Deal follow_up rolling in reflection (Phase 3)
When silence detector shows a deal overdue AND it is in stage 5+ (negotiating, legal-cleared, invoicing, contract), roll follow_up during reflection — do not just note it in the silence alert. Two required writes:
- Frontmatter
follow_up: date
## State follow_up bullet with an explanation of why it was rolled
raw = open(deal_path, 'r').read()
raw = raw.replace('follow_up: OLD_DATE', 'follow_up: NEW_DATE', 1)
old = '- **follow_up:** OLD_DATE · src: `...'
new = '- **follow_up:** NEW_DATE — rolled by reflection; <reason> · src: `...'
raw = raw.replace(old, new, 1)
write_file(deal_path, raw)
Priority targets: any deal stage 5+ overdue >2 days, or any deal with an explicit passed deadline in State (e.g. deadline_2: Fri Jun 26 10 AM that has passed).
Session example (Jun 27): xAI Composer ($30M, stage 5-negotiating, Jun 26 10AM sample deadline passed) → follow_up rolled Jun 26 → Jun 28. Apple Video Images ($1M, stage 5-legal-cleared, 7d overdue) → follow_up rolled Jun 20 → Jun 28.
Sub-case: follow_up falls on "today" but deadline already passed. When the follow_up date is exactly today (the reflection firing date) AND a deadline mentioned in MEMORY.md or the deal note has already passed, this means prior reflections rolled to this date but never actually checked and rolled past. Do NOT treat today's follow_up date as "already handled." Roll it forward and add a Log entry explaining what the current status is (deadline passed, no new signal, next check in N days).
Session example (2026-07-05): xAI Composer had follow_up: 2026-07-05 exactly. Jun 29 delivery deadline mentioned in Hermes MEMORY.md had passed 6 days prior. Rolled to 2026-07-08 + added Log entry: "Jun 29 deadline passed, deal at 5-negotiating, no new Albert signal since Jul 1."
follow_up staleness: roll forward during heartbeat AND reflection
When a person note's follow_up frontmatter date is in the past AND the contact has been active (daily messages in the last 7d), roll it forward — do not leave stale dates. Stale follow_up dates cause false positive silence detector alerts that crowd out real dead deals.
Rolling rules:
- Daily active contact (personal) → +7d from today
- Meeting confirmed, debrief pending → meeting_date + 1d
- Open question or deal loop → +2d (force re-check soon)
- Cold/silent contact → do NOT roll; leave for silence detector to surface
Heartbeat rule (fires during normal triage writes): When you write a Log entry to a People note, also check if follow_up is stale (date < today). If the contact was just active (they appear in this intake), roll follow_up in the same write pass. Do NOT defer this to reflection — it creates a one-cycle false positive alert window. Pattern:
raw = open(note_path, 'r').read()
if 'follow_up: 2026-07-02' in raw:
raw = raw.replace('follow_up: 2026-07-02', 'follow_up: 2026-07-10', 1)
write_file(note_path, raw)
Reflection rule (fires during Phase 3 cross-link sweep): Detection: silence-detector output at days_overdue < 5 for a person you know was active in the last 1-2 heartbeat cycles = stale follow_up, roll it.
Session examples:
- (2026-06-24): Signum Incredibili had
follow_up: 2026-06-22 (2d stale) despite daily "Люблю тебя" exchanges. Rolled to 2026-06-30 during reflection.
- (2026-07-08 heartbeat): Donald Jewkes had
follow_up: 2026-07-02 (6d stale) despite active Signal messages that day. Should have been rolled to 2026-07-10 in the same heartbeat write pass — caught as a missed step.
Heartbeat-created stubs: reflection grooming pass
📋 references/reflection-phase3-grooming-checklist.md in klava-personal skill — full checklist covering frontmatter/State drift, scheduled→completed event promotion, interview_conducted bullet, cross-link mentions completeness, Log→State promotion, aliases, and follow_up staleness.
Four most-missed checks (added 2026-06-25):
-
Frontmatter/State drift — heartbeat updates last_contact: in frontmatter but not the ## State bullet. Silence-detector reads State, so the stale bullet causes false positive alerts. grep -n 'last_contact' note.md and compare both lines every reflection.
-
Scheduled→Completed promotion — when a _scheduled State bullet's date is past AND a later Log entry confirms the event happened (post-event ride-share, thank-you message, debrief), rename to _completed and update src/date. Signal phrases: "Что, когда ты едешь?" after a banya = banya happened; "Спасибо, хорошо пообщались" = meeting happened.
-
Missing interview_conducted — when interview_slot_confirmed is in the past and Log has a "Zoom conducted" entry, add interview_conducted State bullet. Don't leave the slot bullet as live state after the interview ran.
-
Mentions completeness — after reviewing any Log entry, scan summary prose for all proper nouns (people, companies) and confirm each is in mentions: as [[wikilink]]. Common gap: summary says "related to Gleb/Toms thread" but mentions only has [[Subject]].
Log→State promotion (commonly missed): When a heartbeat writes a good Log entry that contains a risk signal or operational fact, it often skips the corresponding State bullet. Reflection must check that every significant Log entry has a matching State bullet. Common gaps:
| Log fact type | Missing State bullet |
|---|
| IP signed without NDA | ip_nda_signal: |
| Team member tension (inferred) | <person>_team_tension: |
| Unresolved question with a contact | <topic>_question: |
| Strategic opportunity surfaced | <topic>_opportunity: |
Alias coverage gap: When a note was created under a short alias (e.g. Kevin J.md) but identity was resolved during the same cycle, add aliases: ["Full Name", "Short Name"] to frontmatter rather than renaming the file. Renaming mid-reflection breaks existing wikilinks in all other notes.
Heartbeat agents create new People notes quickly, often omitting src: from State bullets (writing - **key:** value instead of - **key:** value · src: \...` · YYYY-MM-DD). These notes pass a superficial read but fail the linter at --fail-on hard`.
During reflection's cross-link sweep, for every note modified today:
- Scan all
## State bullets for missing src: fields.
- If the note was freshly created this cycle (check
created: frontmatter = today), reformat the entire State section to add src: from the note's source: frontmatter field or the Log entry's src:.
- Use the
open() + write_file() pattern (not patch) for full reformats since many bullets need simultaneous changes.
Fix pattern for a heartbeat-created stub:
import re
from hermes_tools import write_file
raw = open('/srv/codex-klava/data/MyBrain/People/Person.md', 'r').read()
lines = raw.split('\n')
content = '\n'.join(m.group(1) if (m := re.match(r'^\d+\|(.*)$', line)) else line for line in lines)
new_content = """---
...frontmatter with last_contact...
---
# Name
## State
- **key:** value · src: `vadimgest://telegram/chat_msg` · YYYY-MM-DD
## Log
### YYYY-MM-DD — First contact
- **src:** `vadimgest://telegram/chat_msg`
- **mentions:** [[Related Entity]]
- **summary:** What happened.
- **facts-touched:** key1, key2
"""
write_file('/srv/codex-klava/data/MyBrain/People/Person.md', new_content)
Session example (2026-06-23): Paul Han created by heartbeat with naked State bullets — reflection reformatted to full convention in one write.
State section boundary: notes with multiple ## Log headings
Some large deal notes (e.g. Catallax) have two ## Log sections — one early (term sheet / strategy section logs) and one late (the main operational State+Log block). When the note layout is:
## Log ← line 240 (early, part of term sheet section)
...
## State ← line 26923 (main State block)
...
## Log ← line 88115 (main Log block)
A naive content.find('## Log') returns the FIRST match (line 240), not the main one after State. The State section is then computed as [state_pos:first_log_pos] = empty (State comes after the first Log, so the slice is backwards).
Safe pattern — always use heading-position math to find the Log that FOLLOWS State:
import re
content = open('/path/to/note.md', 'r').read()
headings = [(m.group(), m.start()) for m in re.finditer(r'^## .+', content, re.MULTILINE)]
found_state = False
state_pos = None
second_log_pos = None
for h, pos in headings:
if h == '## State':
found_state = True
state_pos = pos
elif found_state and h == '## Log':
second_log_pos = pos
break
state_section = content[state_pos:second_log_pos]
print(f"State section: {len(state_section)} chars")
Detection of the bug: if len(state_section) == 0 or is very small (< 500 chars) on a large deal note, you found the wrong Log. Print all headings and their positions to confirm.
Appending a bullet at the end of State:
insert_pos = second_log_pos - 2
new_bullet = '\n- **new_key:** value · src: `uri` · YYYY-MM-DD'
content = content[:insert_pos] + new_bullet + content[insert_pos:]
Verification: after insertion, confirm '**new_key:**' in content[state_pos:second_log_pos].
Session example (2026-07-06 reflection): Catallax deal note — aws_quota_blocker State bullet first went into the Log's facts-touched: line because the text anchor '\n\n\n## Log\n\n### 2026-07-05...' didn't match. Second pass using heading-position math correctly placed it at second_log_pos - 2.
What this kills
- Freeform prose updates buried in unstructured notes.
- "Last_contact" silently going stale because nothing updates it.
- Facts contradicting frontmatter without anyone noticing.
- Backlinks rotting because writers forget
[[wikilinks]] (every log entry
mentions field forces them).
- Hallucinated claims surviving because no source check was done.