| license | MIT |
| name | ocas-praxis |
| description | Bounded behavioral refinement loop. Records outcomes, extracts micro-lessons from repeated patterns, consolidates them into capped active behavior shifts, applies shifts at runtime, and generates plain-language debriefs. Use for recording task outcomes, extracting lessons from repeated patterns, managing active behavior shifts, generating runtime briefs, or producing debriefs. Not for: general memory (use Chronicle), preference tracking (use Taste), real-time task execution, content generation, system health monitoring (use Custodian), or skill evaluation scoring (use Mentor). |
| source | https://github.com/indigokarasu/praxis |
| includes | ["references/**","scripts/**"] |
| metadata | {"author":"Indigo Karasu (indigokarasu)","version":"3.2.4","hermes":{"tags":["behavioral-refinement","lessons","outcomes","OCAS-core"],"category":"productivity"}} |
| tags | ["behavioral-refinement","lessons","outcomes","OCAS-core"] |
| triggers | ["behavioral refinement","micro-lessons","pattern consolidation","outcome recording","praxis loop"] |
Praxis
Praxis is the system's behavioral self-improvement loop — it records real task outcomes, waits for patterns to emerge across multiple events, and consolidates validated lessons into a small capped set of active behavior shifts that influence every future run. The cap of 12 active shifts is a hard constraint that prevents unbounded rule accumulation, and every shift must trace back to recorded events so nothing changes without an auditable reason.
When to Use
- Recording outcomes from skill executions
- Extracting lessons from repeated patterns
- Reviewing or managing active behavior shifts
- Generating the current runtime brief (active shifts only)
- Producing a debrief explaining what changed and why
- Running scheduled cron ingest (praxis:journal_ingest) — use the production-proven pattern in
scripts/ingest_cron_YYYYMMDD.py and references/ingest-script-pattern.md. After running the production script, run skills/ocas-praxis/scripts/gap_backfill.py to catch journals the date filter missed (typically ~25% miss rate). Script path: Both praxis_ingest_run.py and gap_backfill.py live at skills/ocas-praxis/scripts/, NOT at commons/data/ocas-praxis/scripts/. Always use the skill directory path. IMPORTANT: The production script has three known bugs (narrow date filter, full-history lesson reprocessing, eval ID format mismatch). The post-ingest checklist (gap backfill, noise lesson cleanup, state update, journal write, decay-risk scan) is MANDATORY — not optional. See references/cron-execution-checklist.md.
- Running shift cleanup/consolidation — use
scripts/shift_cleanup_YYYYMMDD.py pattern
- Running lesson noise cleanup — use
scripts/lesson_cleanup_YYYYMMDD.py pattern
- Running praxis review pass — use
skills/ocas-praxis/scripts/praxis_review.py to review behavioral patterns over a time period (e.g., --since-hours 24). Script path: praxis_review.py lives at skills/ocas-praxis/scripts/, NOT at commons/data/ocas-praxis/scripts/. Always use the skill directory path.
- Generating daily debrief — use
scripts/debrief_YYYYMMDD.py template
When NOT to Use
- General knowledge storage — use memory tool
- Preference tracking — use Taste
- One-off trivia or domain facts
- Broad autobiographical summaries
- Silent personality mutation
Workflow
The praxis workflow operates as a continuous loop: Record → Extract → Consolidate → Apply → Debrief. This workflow exists because behavioral refinement requires systematic repetition, not ad-hoc adjustments.
- Record — Capture task outcomes as evidence records
- Extract — Identify micro-lessons from repeated patterns
- Consolidate — Merge lessons into active behavior shifts (capped)
- Apply — Apply shifts at runtime
- Debrief — Generate plain-language summary
Example: a task repeatedly fails due to timeout → Praxis extracts "increase timeout for this endpoint" → consolidates into active shift → applies on future runs → debriefs the improvement.
Responsibility Boundary
Praxis owns bounded behavioral refinement: events, lessons, shifts, and debriefs. Error handling follows the recovery contract — see Recovery Behavior section below.
Praxis does not own: general memory (use memory tool), preference persistence (Taste), pattern discovery (Finch), communications (Dispatch), skill evaluation (Mentor).
Praxis reads journals from all skills to extract behavioral signals. Praxis decides whether to act on each signal found in any skill's journal output.
Ontology Types
- Concept/Event — recorded outcomes, task completions, failures, corrections, and behavioral signals
- Concept/Idea — extracted lessons, behavior shifts, and refinements
Praxis does not extract or emit Chronicle signals. Lessons remain isolated to the bounded refinement loop.
Commands
praxis.event.record — record a completed event or outcome with evidence
praxis.lesson.extract — derive micro-lessons from recorded events
praxis.shift.propose — propose a new behavior shift from lessons
praxis.shift.list — list all shifts with status
praxis.shift.activate — activate a proposed shift (enforces cap)
praxis.shift.expire — expire or reject a shift with reason
praxis.runtime.brief — generate runtime brief with active shifts only
praxis.debrief.generate — produce a plain-language debrief
praxis.status — event count, active shifts, cap usage, last debrief
praxis.journal — write journal for the current run; called at end of every run
praxis.update — pull latest from GitHub source; journals and data preserved
Core Loop
- Record event → 2. Extract lessons (if pattern detected) → 3. Upgrade lessons — mandatory second pass to add causal grounding (what/why/when) and set
confidence: high → 4. Dedup lessons against active shifts — before writing new lessons, check if an active shift already covers the same (signal_type, failure_phase) key; if yes, skip lesson creation (the shift already encodes it) → 5. Propose shift (check domain+phase overlap, handle mixed schemas) → 6. Activate (if cap allows) → 7. Generate debrief
Two-pass lesson extraction is mandatory. Pass 1 groups events by signal_type+phase and produces lesson stubs. Pass 2 adds full causal grounding (what/why/when) and upgrades confidence to high. Without Pass 2, no lessons can produce shifts. See references/ingest-script-pattern.md for the production-proven script.
Lesson extraction scope: NEW EVENTS ONLY. Pass 1 must group only events added in the current ingest run (or since the last lesson extraction), NOT the entire events.jsonl history. Re-processing all 2,500+ events every run causes: (a) stale lessons re-created for patterns that are no longer active, (b) unknown-domain lessons from legacy events that lack a skill field, (c) noise lessons (no_active_watches, system_memory_drop) that pass the ≥2 event threshold from historical accumulation. Track last_lesson_extraction_event_id in the ingest state and filter all_events to only events with event_id greater than that marker before grouping. See references/session_20260618_ingest_cron_d.md.
Ingest state file (ingest_state.json) — create if missing. The state file at {agent_root}/commons/data/ocas-praxis/ingest_state.json tracks last_lesson_extraction_event_id for scoped lesson extraction. If the file doesn't exist, create it with all required fields on first run (see references/inline-examples.md §Ingest state bootstrap for the canonical dict and references/support-file-map.md for the When-to-read signal). If the file exists but is missing fields (e.g., last_lesson_extraction_event_id), populate them from defaults before using. Confirmed 2026-06-25: state file had only last_ingest_run and last_dispatch_run, causing the scoping mechanism to be non-functional until fields were added.
Fixing last_lesson_extraction_event_id after sessions with no events: If the ingest state shows last_lesson_extraction_event_id: null or "" (empty string) but events.jsonl has entries, the scoping mechanism is broken — lesson extraction will re-process the full history every run, producing stale lessons. The empty string variant ("") is equally broken as null — both fail the event_id > marker comparison in the lesson extraction scope filter. PITFALL — empty string vs null: After any run that produces 0 events (all no_signal), the post-ingest script MUST explicitly set last_lesson_extraction_event_id to the last existing event in events.jsonl — NOT leave it as "". Fix by setting it to the last event's ID — see references/inline-examples.md §Repair last_lesson_extraction_event_id for the exact bash snippet and references/support-file-map.md for the When-to-read signal.
After 2026-06-21 dispatch (0 events from 4 mentor-light journals), the state should be set to the last existing event in events.jsonl (e.g., evt-20260621...).
Run Completion
After every Praxis command:
- Scan all skill journals at
{agent_root}/commons/journals/*/YYYY-MM-DD/ for new journal entries (not in journals_evaluated.jsonl). Track consumed journal_id values.
- Persist events, lessons, shifts, and debriefs to local JSONL files
- Shift merge pass — Before checking cap, scan active shifts for semantic overlap. Merge overlapping shifts before proposing any new shift.
- Log material decisions to
decisions.jsonl
- Write journal via
praxis.journal
- Update
ingest_state.json — Update last_ingest_run to current timestamp, increment journals_processed by new journal count, set last_ingest_events_added, last_ingest_journals_evaluated, last_evaluated_count (incremented), last_ingest_file_count, last_event_id (if events recorded), increment total_ingests. The production script does NOT do this — it must be done by the caller.
Cron Execution Checklist
After running praxis_ingest_run.py in cron mode, the caller must complete these steps (the script does NOT update state, write journals, or do gap backfill):
-
Update ingest_state.json — Set last_ingest_run to current timestamp, increment journals_processed and total_ingests, set last_ingest_events_added, last_ingest_journals_evaluated, last_inget_file_count, and note.
-
Gap journal backfill — Run skills/ocas-praxis/scripts/gap_backfill.py to scan for journals NOT in journals_evaluated.jsonl with mtime > last_ingest_run. The script filters dispatch-wave meta-artifacts and phantom .json files automatically. This catches: (a) journals the date filter missed, (b) concurrent-cron collisions, (c) post-ingest gaps. ⚠️ Path: The script is at skills/ocas-praxis/scripts/gap_backfill.py, NOT commons/data/ocas-praxis/scripts/gap_backfill.py.
-
Update ingest_state.json with backfill count — Ingest_state.json: increment journals_processed by the number of journals backfilled (as reported by gap_backfill.py output or via state['eval_gaps_backfilled']).
-
Noise lesson cleanup — Remove all lessons produced by Bug 2. The production script's lesson-scoping bug produces 13-15 noise lessons every run from stale historical patterns. Cleanup criteria (expanded 2026-06-28): Remove lessons where ANY of these are true: (a) confidence: "low", (b) signal_type is "?"/""/null, (c) ALL events from the current run are no_signal (making ALL co-produced lessons noise regardless of individual fields). See references/recurring-noise-lesson-cleanup.md for the cleanup procedure.
-
Write Praxis journal — Write to {agent_root}/commons/journals/ocas-praxis/YYYY-MM-DD/praxis-cron-{timestamp}Z.json with run_type: "cron_ingest", metrics, and not_activity_reason explaining the run.
- Shell heredoc double-Z pitfall: When using shell heredoc, the timestamp shell variable already ends in
Z. Template ${TS}Z.json produces double-Z. Fix: Strip trailing Z: TS_SHORT="${TS%Z}" then use ${TS_SHORT}Z.json, or fix with post-write mv.
-
Decay-risk scan — Check active shifts for those with reinforcement_count == 0 and age > 7 days. Flag in journal.
6a. Stale proposed-shift cleanup — After checking active shifts, scan for status: "proposed" shifts that have been in limbo ≥10 days without activation. These are typically artifacts from rebuilds or bulk proposals that never got activated. Expire them with reason decay_check: proposed shift never activated after Nd. Use full file rewrite from canonical in-memory state (see Cap enforcement must use full file rewrite). Confirmed 2026-06-30: 15 proposed shifts from the 2026-06-18 rebuild sat idle for 11 days — none had >5 source events, many had 0. See references/session-20260630-decay-check-stale-proposed.md.
-
Stale script cleanup — If >10 .py files exist in data root (outside scripts/), remove them. Never delete from scripts/ subdirectory.
-
Verify post-write (mandatory closure) — Before declaring the run done, validate the three artifacts the pipeline just wrote. Silent corruption here is invisible to gap backfill and only surfaces as a broken state file next run:
- State JSON parses —
json.load(open('ingest_state.json')) succeeds; confirm journals_processed, total_ingests, last_ingest_run, and last_lesson_extraction_event_id advanced to expected values. A non-parsing state file means the load→modify→dump update failed silently.
- Journal JSON valid — The new
praxis-cron-*.json parses; run_id matches the filename and ends in a single Z (no double-Z). A double-Z filename still works via gap backfill but is a known cosmetic bug (Bug 4) — fix with mv if caught here. Verification-script pitfall: when asserting single-Z programmatically, check the run_id (or the timestamp substring before .json), NOT the full filename — filename.endswith('Z') is ALWAYS False because the filename ends in .json. Use run_id.endswith('Z') and not run_id.endswith('ZZ') (or 'ZZ' not in run_id). Confirmed 2026-07-13: a closure-verification assert on filename.endswith('Z') falsely failed a valid single-Z journal (praxis-cron-20260713T043913Z.json); the journal was correct, the check was wrong.
- lessons.jsonl byte count —
os.path.getsize(lessons.jsonl) == 0 after cleanup. A non-zero size means cleanup did not truncate; re-run cleanup_noise_lessons.py. NOTE: finding lessons.jsonl NON-ZERO at the start of a future run is expected steady-state carryover (a prior cleanup truncation didn't persist, or the production script re-read historical lessons) — it is NOT an error; that run's cleanup step will re-archive and re-truncate. Do not treat start-of-run non-zero as a failure.
See references/cron-execution-checklist.md for the production-proven script pattern with all steps including third-wave mitigation and noise cleanup. See references/session-20260627-cron-ingest-2032.md for the inline mtime-based alternative when the production script's bugs cause misses.
Gap journal backfill (mandatory post-run step): After running the production script, run skills/ocas-praxis/scripts/gap_backfill.py to catch journals the date filter missed. The script walks the profile journals directory, finds unevaluated journals with mtime > last_ingest_run, filters out dispatch-wave meta-artifacts and phantom .json files (empty filename from shell write bugs), classifies remaining journals, and appends them to the eval file. The script syncs the state counter to the actual eval file line count after backfill. See references/session-20260627-cron-ingest-1804.md for the production-proven gap backfill script.
Large one-time gap backfill (expected after eval file backlog): If the eval file has a significant backlog of unevaluated journals (e.g., from before Praxis was fully integrated, or from runs where eval writes failed), the first gap backfill after fixing the eval file can produce a large batch of backfill entries (5,000–10,000+). This is a one-time catchup, not a recurring pattern. After the initial catchup, subsequent runs should see near-zero gap journals. Log the backfill count in ingest_state.json:gap_journals_backfilled and the journal not_activity_reason for audit trail. Confirmed 2026-06-29: 5,817 gap journals backfilled in a single run (eval file grew from 42,357 to 48,176 entries).
Known Production Script Bugs (ACTIVE)
Four confirmed bugs remain in scripts/praxis_ingest_run.py and scripts/praxis_common.py as of 2026-06-30. The cron checklist workarounds prevent them from causing failures, but they waste compute and occasionally miss journals.
Bug 1: Date filter too narrow (praxis_ingest_run.py §Step 2)
Script only scans today/yesterday date directories (if today in cid or yesterday in cid). Journals in other date dirs are invisible. Workaround: Gap backfill step catches these post-run.
Bug 2: Lesson extraction processes full event history (praxis_ingest_run.py §Step 5)
Script loads ALL events from events.jsonl (3,300+) every run. last_lesson_extraction_event_id in state file is NOT used. Produces noise lessons from stale events. Impact: Low — dedup prevents duplicate lessons, but wastes compute. Operational note: When script output shows "NEW LESSONS" with high event counts (n=9, n=10, etc.) or events from dates before today, these are historical noise — not genuine new patterns. The post-ingest noise lesson cleanup (Step 5 of cron checklist) removes these. Do not propose shifts for confidence: low lessons.
Bug 2 cleanup scope expansion (2026-06-28, updated 2026-06-29): The cleanup step must remove ALL lessons produced by Bug 2, not just confidence: low ones. Bug 2's full-history reprocessing produces lessons with three identifying traits:
signal_type is "?", "", null, or missing entirely (key not present in lesson dict — .get("signal_type") returns None). This is the most dangerous variant because les.get("signal_type", "") returns None (not ""), and None != "?" passes the filter.
confidence: "high" (Pass 2 grounding always upgrades to high — doesn't indicate genuine signal)
- High event counts (n=9, n=11, n=18, n=51) from historical accumulation
Decision rule for cleanup: If ALL events recorded in the current run are no_signal (no genuine behavioral events), then ALL lessons produced in the same run are Bug 2 noise — remove them entirely. Do not rely on confidence or signal_type presence alone.
Bug 2 noise when exactly 1 genuine single-instance event is recorded (2026-07-07): The fast pre-filter (--all-no-signal) only fires when EVERY event is no_signal. When the run records exactly 1 genuine event (e.g., a single failure_keyword) alongside no_signal heartbeats, the pre-filter does NOT fire — and the per-lesson criteria will KEEP high-confidence lessons with real signal_type values (failure_keyword, escalation, execution_error) — but these are STILL Bug 2 full-history noise. Why: lesson extraction requires ≥2 events of a (signal_type, phase) group within the NEW-event scope; a single genuine new event cannot ground any lesson, so every lesson in lessons.jsonl came from Bug 2's full-history reprocessing (n-counts like n=53, n=20 are historical accumulation, not new patterns). Action: archive lessons.jsonl to commons/data/ocas-praxis/lessons_noise_archive_<UTC_DATE>.jsonl, then truncate lessons.jsonl to 0 lines. Sanity check: at steady state lessons.jsonl is 0 bytes before each run (prior cleanup removes all); if it was empty, clearing all extracted lessons is correct. Do NOT leave the high-confidence historical lessons in lessons.jsonl — they re-accumulate every run and are not new learnings. The durable behavioral store is shifts.jsonl, which persists across runs regardless of lessons.jsonl being emptied. The cleanup script now supports --new-genuine-events N (N = genuine non-no_signal events recorded this run); pass N<2 to auto-clear all lessons. As of 2026-07-07 both fast-paths (--all-no-signal and --new-genuine-events) and the per-lesson path archive removed lessons to lessons_noise_archive_<UTC_DATE>.jsonl automatically before clearing — the manual fallback below is only needed if the script is unavailable. Manual fallback if not using the flag: python3 -c "import json,datetime,os; p='commons/data/ocas-praxis/lessons.jsonl'; ls=[json.loads(l) for l in open(p) if l.strip()]; open('commons/data/ocas-praxis/lessons_noise_archive_'+datetime.date.today().isoformat()+'.jsonl','a').writelines(json.dumps(x)+'\n' for x in ls); open(p,'w').close()".
Fast pre-filter (dispatch + cron): Before iterating lessons individually, check the event stream: if every event recorded in the current run has signal_type matching no_signal/empty/null/?, skip per-lesson inspection entirely and clear all lessons produced in the same run. This is the most common steady-state outcome (confirmed 2026-06-30 dispatch: 5 events all no_signal → 13 lessons removed in one operation).
Critical filter fix (2026-06-29): The signal_type key may be entirely absent from Bug-2 noise lessons — not set to "?" but simply not present in the dict. Any cleanup filter MUST check all four conditions (see references/inline-examples.md §Bug 2 noise-lesson classifier for the exact is_bug2_noise_lesson function and references/support-file-map.md for the When-to-read signal). Do NOT use les.get("signal_type", "") == "?" alone — it misses the missing-key variant. Confirmed 2026-06-29: 13 Bug-2 lessons produced with NO signal_type key at all (Pass 2 grounding didn't add it when source events had no signal_type field), bypassed the existing == "?" filter.
Bug 3: Eval file ID format mismatch (praxis_common.py §dedup_eval_file)
Eval file stores IDs as skill/YYYY-MM-DD/filename.json (with .json), but legacy entries may lack the extension. The dedup normalizes to journal_id field but doesn't generate both forms for comparison. Impact: Occasional re-scanning of evaluated journals; gap backfill catches these.
Bug 4: Double-Z timestamp in journal filenames (praxis_ingest_run.py §journal output)
Journal filenames occasionally get double-Z suffixes (e.g., praxis-cron-20260630T092758ZZ.json). Root cause: timestamp composition applies .rstrip('Z') + 'Z' to a value already ending in Z. Impact: Cosmetic — journal is still written and discoverable by gap backfill. No data loss. Confirmed recurring: 2026-06-26, 2026-06-28, 2026-06-30. Fix: Check ts.endswith('Z') before appending Z in the journal output section.
- Dispatcher's
new_files may list phantom files — The dispatcher's file scan may capture files that are deleted or never materialize on disk by the time the dispatch runs. These appear in details.new_files but os.path.exists() returns False. This is expected and must be handled silently.
Hard Constraints
- No autonomous identity rewriting
- No silent safety boundary changes
- No unlimited behavior rule accumulation
- Only active shifts influence runtime
- Maximum 12 active shifts (configurable)
- Every shift must trace to recorded events
- Every lesson must include causal grounding (the "why" — not just "what")
- Shifts without decay review expire automatically (configurable, default 14 days)
Capping and Consolidation
Default cap: 12 active shifts. When at cap and a new shift is proposed: merge overlapping shifts, replace a weaker shift, or reject the new shift.
Shift activation dedup and merge (mandatory before cap check):
- Domain+phase overlap — Does an active shift already target the same skill/domain AND failure phase? If yes, merge.
- Text similarity — If two shifts have nearly identical
shift_text, consolidate into a single cross-skill shift.
- Only after merge — check if cap is exceeded. If still at cap, expire the oldest/lowest-reinforced-count shift.
Shift decay: Active shifts not reinforced in 14+ days auto-expire. Reinforcement extends half-life. Debriefs should flag shifts at 10+ days without reinforcement as "approaching decay" — on 2026-06-14, all 11 active shifts were 12-13 days old with 0 reinforcements, one day from mass expiry, but the debrief reported no action needed.
Elaborative interrogation: Lessons must capture WHAT happened, WHY, and WHEN. Format: [LESSON] What: <pattern>. Why: <cause>. When: <conditions>
Failure-phase tagging: Tag each event with the task phase (Planning, Execution, Response). See references/gotcha_failure_phase_tagging.md.
Data Model and Storage
See references/data_model.md for full storage layout, JSON schemas, default config, and OKRs.
Key storage paths:
- Data:
{agent_root}/commons/data/ocas-praxis/
- Journals:
{agent_root}/commons/journals/ocas-praxis/YYYY-MM-DD/{run_id}.json
Inter-skill Interfaces
All skills → Praxis (cooperative read): Praxis scans journal output from every skill. Consumed journal_id values tracked in journals_evaluated.jsonl.
Known journal-producing skills: ocas-spot, ocas-rally, ocas-taste, ocas-finch, ocas-fellow, ocas-scout, ocas-bones, ocas-bower, ocas-vibes, ocas-voyage, ocas-imagine, ocas-weave, ocas-vesper, ocas-dispatch, ocas-mentor, ocas-lucid, ocas-sands, ocas-sift, ocas-reach, ocas-look, ocas-multipass, ocas-forge, ocas-haiku, ocas-custodian.
See references/journal_ingestion.md for journal schema and ingestion rules.
Recovery Behavior
Implements the recovery contract from spec-ocas-recovery.md.
- Evidence: Every run writes an evidence record including no-op runs.
not_activity_reason mandatory.
- Gap detection: If gap exceeds expected cadence, logs
gap_detected.
- Degraded mode: When journal directories unavailable, logs
degraded: journals.
- Log compaction: 30 days (no-op) / 90 days (error/gap). Last 7 days retained.
Initialization
On first invocation, run praxis.init:
- Create
{agent_root}/commons/data/ocas-praxis/ and subdirectories
- Write default
config.json if absent
- Create empty JSONL files
- Create journal directory
- Register cron jobs:
praxis:journal_ingest (every 30min), praxis:decay_check (noon daily), praxis:debrief (6am daily), praxis:update (midnight daily)
- Log initialization as DecisionRecord
Second-Wave Detection (Already Evaluated)
When triggered by the dispatcher, always check journals_evaluated.jsonl for the journal filename before running mtime-based discovery. If the journal is already present (regardless of action_taken), skip silently — it was already evaluated by a prior Praxis run in the same or previous dispatch wave. This is the correct no-op and prevents duplicate re-ingestion, unnecessary gap backfill, and evidence log bloat.
grep -q "mentor-light-20260624T044239Z" /root/.hermes/profiles/indigo/commons/data/ocas-praxis/journals_evaluated.jsonl
Dispatch / Cron Integration
When triggered by the dispatcher (dispatcher.py) as part of a multi-skill dispatch, Praxis owns:
journals_evaluated.jsonl — append-only log of all evaluated journals
ingest_state.json — last_ingest_run timestamp and counters
See references/dispatch-ingest.md for the full ingest procedure, decision table (genuine vs second-wave), and pitfalls.
Single-skill dispatch (Praxis only): Follow the standard journal ingest workflow. Use templates/dispatch_ingest_template.py with CAPTURED_TS — never write inline scripts.
Multi-skill dispatch (Forge + Mentor + Praxis): Read for the full cross-pipeline procedure including second/third/fourth-wave mitigation, concurrent cron gap handling, and cold-start initialization.
Key rules:
- Capture
last_ingest_run BEFORE Mentor runs (Mentor heartbeat advances it)
- Third-wave mitigation is mandatory: add ALL dispatch-output journals to eval file and advance state
- Gap journal backfill after every run (catches concurrency gaps + date filter misses)
execute_code is blocked in cron mode — use terminal() with scripts written via write_file()
- Never do
ts.isoformat() + "+00:00" — double suffix breaks fromisoformat()
- Large gap backfill (80+ entries) is normal at steady-state — cron pipelines write ~10 journals/minute. Between dispatch waves (7-8 min apart), expect 50-80 gap entries. This is expected, not a failure. See
references/session-20260629-dispatch-1030Z-praxis-second-wave-gap-backfill.md
- Cold-start: initialize state with CURRENT timestamp, not epoch
- Pure eval-registration dispatch (confirmed 2026-06-30T11:25Z): When ALL
new_files are already in praxis eval (just missing from dispatch eval) or are prior-wave artifacts, the Praxis pipeline does NOT need to run. Register directly from the dispatch pipeline, advance last_ingest_run, do NOT increment journals_evaluated_count. See references/session-20260630-dispatch-1125Z-praxis.md.
- CAPTURED_TS calibration (verified 2026-07-10, Mentor 2.8.23): The light heartbeat did NOT advance
ingest_state.json:last_ingest_run in this deployment. Before applying the CAPTURED_TS override, check last_ingest_run AFTER Mentor runs. If it is unchanged from the pre-Mentor value, run the ingest WITHOUT CAPTURED_TS — mtime discovery still finds the new journals (the override is only needed when the state timestamp actually moved forward). Applying CAPTURED_TS unnecessarily is harmless but adds an avoidable env-var step and a date-format footgun.
- No
praxis-dispatch journal from the template (verified 2026-07-10): templates/dispatch_ingest_template.py does not write a praxis-dispatch-*.json journal (unlike older production pipelines). The dispatch-output journals to bridge into the DISPATCH eval during third-wave mitigation are therefore: every journal the ingest just evaluated (all of them — the forge-scan output, the mentor-light heartbeat output, and any other cross-skill journals it registered) PLUS the dispatch-wave-* journal you write. Do NOT look for or fabricate a praxis-dispatch journal; bridge the full set of ingest-evaluated journal_ids instead.
Journal Outputs
Action Journal — every event recording, lesson extraction, shift change, and debrief generation. Include entities_observed, relationships_observed, preferences_observed with user_relevance field.
Debrief Generation
When running praxis.debrief.generate outside the scheduled cron:
- Load active shifts from
shifts.jsonl — filter status == "active", count reinforcement, compute age from activated_at or last_reinforced_at
- Scan for decay risk — shifts with
reinforcement_count == 0 and age > 10 days are "approaching decay" (flag in debrief)
- Scan for overlap — group active shifts by domain+phase; flag shifts sharing >3 words as potential consolidation candidates
- Count recent events — last 200 events by signal_type to identify emerging patterns
- Check cap headroom — if active shifts ≥ 10, flag "approaching cap" with weakest shift identified for potential manual expiry
- Write structured debrief to
debriefs.jsonl with fields: debrief_id, generated_at, period, active_shift_count, cap_usage, new_shifts, expired_shifts, new_lessons, findings, recommendations
- NEVER use
write_file on JSONL files — it overwrites. Use terminal("python3 -c ...") or append via open(..., 'a')
Debrief JSON structure:
{
"debrief_id": "debrief-YYYYMMDDTHHMMSS",
"generated_at": "ISO timestamp",
"period": "YYYY-MM-DD to YYYY-MM-DD",
"active_shift_count": 12,
"cap_usage": "12/12 (at cap)",
"new_shifts": 0,
"expired_shifts": 0,
"new_lessons": 1,
"findings": ["finding 1", "finding 2"],
"recommendations": ["rec 1", "rec 2"],
"events_ingested": 0,
"lessons_extracted": 0,
"shifts_proposed": 0,
"shifts_activated": 0,
"shifts_expired": 0
}
Gotchas — Critical
Key gotchas (see references/gotchas-praxis.md for the full catalog):
-
Dedup key must be (source_journal, signal_type) — Using source_journal alone as the dedup key in events.jsonl post-write dedup collapses multiple distinct signals from the same journal into one event. In ingest_20260606_v3, finch scan-1800 produced both cron_errors and auth_failure signals, but only the first survived dedup — the second had to be recovered manually. This matches the known limitation documented in ingest-script-pattern.md §Post-Write Dedup. Always dedup by (source_journal, signal_type), not just source_journal.
-
Shift cap enforcement requires proactive merge-before-cap, not just reject-at-cap — When proposing shifts, the merge-overlap check (domain+phase) MUST happen BEFORE the cap check. In the 2026-06-17 ingest, 5 new shifts were proposed and activated before the cap was hit, but 2 were duplicates of existing active shifts (same signal_type+phase). The merge logic caught them during cleanup, but the original ingestion didn't merge at proposal time — it just let them fill the cap. Fix: The shift proposal loop must check domain+phase overlap against ALL active shifts and merge/reinforce instead of proposing new shifts when overlap exists. This prevents cap saturation with duplicates.
-
Noise signal types must be filtered at lesson creation, not just shift proposal — The 2026-06-17 ingest produced 5 noise lessons (routine, no_signal, cron_error, forge_activity, no_op, success) with confidence: high that then produced shifts. The NOISE_SIGNAL_TYPES filter exists in the ingest script but wasn't applied during lesson extraction Pass 2. Fix: Apply NOISE_SIGNAL_TYPES = {"", "unknown", "?", "no_op", "forge_activity", "routine", "no_signal", "cron_error", "cron_errors", "observation", "success", "mentor_light"} filter immediately after Pass 2 grounding, BEFORE writing to lessons.jsonl. This prevents noise from ever entering the lesson pool.
-
Mentor-light low_coverage is a measurement artifact — filter at extraction time — The evaluation_coverage metric in mentor-light heartbeats (0.14–0.30) only counts skills with new journal entries in the scan window, NOT total active skills (which is 20+). The mentor correctly reports active_skills_30d: 20 alongside evaluation_coverage: 0.3 because only ~6 of 20 skills had new files. This is expected scan-yield behavior, NOT a system failure. When mentor-light journals produce low_coverage as their only non-success signal, emit no_signal instead of recording a low_coverage event. Do NOT add low_coverage globally to NOISE_SIGNAL_TYPES — it may be legitimate from other sources. Filter specifically: if source_journal matches mentor-light-* and signal_type == "low_coverage" and outcome == "success", skip event recording. Discovered 2026-06-18: mentor-light low_coverage reached 11 events and produced a lesson + shift that is semantically meaningless. See references/session_20260618_ingest_cron_z.md.
-
Mentor-light gap_detected with outcome: "success" is a routine measurement — filter at extraction time — The gap_detected flag in mentor-light heartbeats fires when the time since the last scan exceeds a threshold (typically 25-30 minutes). This is normal cron cadence behavior, NOT a system failure. The gap_minutes field (e.g., 27.2) is within expected range for 30-minute cron intervals. When mentor-light journals produce gap_detected: true with outcome: "success" and no other failure signals, emit no_signal instead of recording a gap_detected event. Filter specifically: if source_journal matches mentor-light-* and signal_type == "gap_detected" and outcome == "success", skip event recording. Adding gap_detected globally to NOISE_SIGNAL_TYPES would hide genuine gap detections from other sources (e.g., custodian). The existing active shift gap_detected | ocas-mentor | Execution already covers gap detection behavior; adding routine cron-cadence events only creates duplicate noise. Discovered 2026-06-20: mentor-light gap_detected produced an event from a 27.2-minute gap that was pure cron cadence.
-
Mentor-light failure_keyword from generic summary scanner is a false positive — filter at extraction time — Mentor-light heartbeat journals with outcome: "success" (or no outcome field) contain summary text like "0 errors detected", "2 historical error records in evidence", or "0 active anomalies". The generic summary scanner picks up the word "error" and emits a failure_keyword signal — but the journal is reporting SUCCESS, not failure. When mentor-light journals have outcome in ("success", "", None) and no explicit failure indicators (gap_detected: true or metrics.errors > 0), skip ALL generic signal extraction and return no_signal. Do NOT rely on the signal_type field alone — these journals may not have one, and the generic path assigns failure_keyword from summary text. Filter at the journal level, not the signal level. Discovered 2026-06-20: 8 false-positive failure_keyword events from mentor-light journals in a single ingest run. See references/session_20260620_ingest.md.
-
Mentor-light correction from routine data updates is a false positive — filter at extraction time — Mentor-light heartbeat journals with outcome: "success" may contain summary text like "active_skills_30d corrected 14→18" or "Script succeeded on all 3 writes". The signal extraction emits a correction signal — but this is a routine data correction (count update), not a behavioral failure. When mentor-light journals have outcome in ("success", "", None) and the only non-success signal is correction, skip event recording and return no_signal. This is a distinct false-positive source from failure_keyword — the same filter gate (mentor-light + success outcome) catches both. Confirmed 2026-06-22: mentor-light journal produced correction event from routine active_skills count update.
-
Dispatch-wave correction from routine count updates is a false positive — filter at extraction time — Dispatch-wave journals (source matching dispatch-wave-*) with summary text like "Mentor corrected 8→22" or "eval gaps corrected" emit a correction signal — but this reports that a downstream skill (Mentor, Forge) updated a count during its run, not that a behavioral correction occurred. The dispatch wave is orchestrating; the counts it reports are routine operational results from child skills, not system corrections. When a dispatch-wave journal has type: "dispatch.wave" and its only non-success signal is correction, skip event recording and return no_signal. This applies the same logic as the mentor-light correction false-positive filter. Confirmed 2026-06-29: dispatch-wave journal produced correction event from "Mentor corrected 8→22" in summary. See references/session_20260622_ingest_cron_0409.md.
-
Dispatch-wave mixed_genuine_no_op is a routine orchestration outcome — filter at extraction time — Dispatch-wave journals with outcome: "mixed_genuine_no_op" describe a dispatch that processed routine cron output with no actionable signals. The term "genuine" refers to the eval registration being genuinely needed (not second-wave re-detection), not to a behavioral event being detected. When a dispatch-wave journal has type: "dispatch.wave" and outcome contains no_op (e.g., mixed_genuine_no_op, second_wave_no_op), skip event recording and return no_signal. The dispatch pipeline completed successfully with no behavioral signals — this is the expected steady-state for routine cron output. Confirmed 2026-06-30: dispatch-wave journal with outcome: "mixed_genuine_no_op" was incorrectly recorded as a mixed_genuine_no_op event by Praxis ingest, then required manual cleanup.
-
Dispatch-wave escalation echoing an already-evaluated Praxis-internal signal is a false positive — filter at extraction time — Dispatch-wave journals (schema dispatch-wave-v1) may carry an escalations[] array whose entry source points at a Praxis cron journal (or any journal already processed by a prior Praxis run) with a status like "tier1 fix applied; already evaluated by praxis ingest; no personal input required from Jared". This is a second-wave echo of a signal already handled by an earlier Praxis run — NOT a new behavioral event. The generic signal scanner keys off the word "escalation" in the escalations[] array and emits a weak escalation event (summary "Unknown —"), which pollutes events.jsonl and double-counts the underlying issue. When a dispatch-wave journal's escalations[] entry has source matching praxis-cron-* (or any already-evaluated journal) AND status indicates already-handled/no-personal-input, skip event recording and return no_signal. If the event was already written by the production script, remove it from events.jsonl by event_id (post-hoc manual cleanup — established pattern for already-written false positives). Confirmed 2026-07-07: dispatch-20260707T103730Z.json produced an escalation event (event_id evt-20260707104141463939-0947) from an escalations[] entry whose source was praxis-cron-20260707T084657Z.json (already evaluated); removed during cleanup, leaving 0 genuine behavioral events for the run.
-
Production ingest script may record events from phantom (non-existent on disk) source journals — verify os.path.exists() and remove — The production praxis_ingest_run.py can emit an event whose source_journal path resolves to a file that does NOT exist on disk, even though that journal_id is present in journals_evaluated.jsonl (marked evaluated). Root cause: the script's file-discovery or journal-list reference includes a journal that was deleted, rotated, or never materialized, yet it still reads/derives a signal from it and records an event. Confirmed 2026-07-07: the script recorded an escalation event (event_id evt-20260707131835467668-14860) attributed to ocas-custodian/light-scan-2026-07-07T131135.json; test -f + search_files confirmed the file is MISSING on disk, though journals_evaluated.jsonl carried 1 entry for it. The event was a false positive — there was no real custodian escalation journal at that timestamp (the actual custodian light-scan at 12:07 had escalation_needed: true, but it is a tracked user-gated fault and NOT the source of this event). Detection: after the production run, for any non-no_signal event (escalation/failure_keyword/execution_error), resolve source_journal to {agent_root}/commons/journals/<source_journal> and check os.path.exists(). Cleanup (post-hoc, established pattern): if the source file is missing, remove the event from events.jsonl by event_id using a Python heredoc — NOT cat file | python3, which trips the pipe-to-interpreter security scanner in cron mode. Removing the phantom leaves the run's genuine behavioral event count at 0, which then triggers the Bug-2 --new-genuine-events 0 fast-path to clear ALL extracted lessons as full-history noise. Recommended script fix: praxis_ingest_run.py should os.path.exists()-guard each resolved source_journal path immediately before recording an event and skip events whose source file is absent (prevents the phantom from ever entering events.jsonl). This is distinct from the dispatch-wave escalation echo (which references an already-evaluated journal that DOES exist on disk) — here the referenced journal file is simply absent.
-
Custodian action journals with error mentions in summary are routine operational reports — filter at extraction time — Custodian light-scan/action journals (type: "action") routinely contain summary text like "All other error jobs are either transient (429), no-op exits, disabled, or already tracked" when reporting on known cron job states. The generic summary scanner picks up "error" and emits a failure_keyword signal — but the journal is reporting on known/tracked issues, not a new behavioral failure. When a custodian journal has type: "action" and the summary contains "error" but escalation_needed is absent or the journal is a routine scan (no new findings with severity: "critical"), skip generic signal extraction and return no_signal. The existing observation type filter only covers type == "observation" — the action type with error mentions is a distinct false-positive source. Discovered 2026-06-21: custodian light-scan action journal produced a failure_keyword event from summary text about known error jobs. A single event won't produce a lesson (needs ≥2), but it pollutes the event stream. See references/session-20260621-dispatch-9.md.
-
Custodian type: "observation" is a routine scan — emit no_signal — Custodian journals with type: "observation" are routine platform scans that check gateway status, disk usage, and job health. They do not represent behavioral signals. When a custodian journal has type: "observation", emit no_signal and skip signal extraction. This is distinct from custodian deep-scan or light-scan types which may contain genuine signals. Confirmed 2026-06-21: custodian observation journal produced no actionable signals.
-
Dispatch-triage journals are email triage records, not behavioral signals — filter at extraction time — ocas-dispatch journals with triage in the filename (e.g., dispatch-triage-*.json) are records of email inbox triage decisions (action: none, informational). They don't represent behavioral failures or system issues. When signal extraction encounters a journal from ocas-dispatch/ with triage in the filename, emit no_signal and skip. Confirmed 2026-06-28: dispatch-triage journal was incorrectly falling through to generic signal extraction in inline scripts.
-
Custodian journals without a type field are routine operational reports — filter at extraction time — Some custodian light-scan journals (post-2026-06-22) lack a type key entirely. The existing type: "action" filter doesn't catch these. Check by run_id pattern (light-scan or deep-scan in run_id) + no escalation_needed + no persistent_failures in cron_registry. When all three conditions are met, emit no_signal and skip signal extraction. The is_false_positive_journal() function in praxis_ingest_run.py was patched on 2026-06-22 to handle this variant. Confirmed 2026-06-22: custodian light-scan journal with no type field produced a false-positive failure_keyword event from summary text about "19 error jobs" that were all first-occurrence/known patterns.
-
Custodian light-scan with UUID-style run_id and not_activity_reason containing transient/stale errors is a no-op — filter at extraction time — Custodian light-scans with 10-character hex run_id (e.g., 01e29333-454) use the post-2026-05 schema where not_activity_reason explains the verdict. When not_activity_reason contains "all transient" or "all...are transient or stale" AND tier1_fixes_applied: 0 AND issues_escalated: 0 AND no type field, emit no_signal and skip signal extraction — even if the summary or observations.transient_errors contains the word "error". The is_false_positive_journal() pre-filter must check not_activity_reason for phrases like "all error jobs are transient", "all transient or stale", "all...are transient" combined with zero fixes/escalations; if matched, return no_signal before keyword scanning. Without this filter, journals reporting all-transient error states produce false-positive failure_keyword events (the keyword "error" appears in not_activity_reason and observations.transient_errors[].fingerprint descriptions). Confirmed 2026-06-26: 01e29333-454.json produced a failure_keyword event from "4 error jobs, all transient or stale" in summary; the is_false_positive_journal() handler only checked for type field presence/custodian action sub-type, not the typeless light-scan + all-transient verdict pattern. This event was caught and removed by post-ingest review, but it should never have been recorded.
-
Custodian esc-loop / escalation-execution-loop journals with escalation_needed: true are GENUINE escalation signals — do NOT filter — Custodian's scheduled escalation loop (scan_type: "escalation-execution-loop", run_id like esc-loop-*.json) reviews tracked user-gated issues, pauses burning cron jobs, and sets escalation_needed: true when issues remain that require Jared's action (billing / API-key rotation / skill-internal model / interactive Google re-auth). This is a real behavioral signal, NOT one of the routine/healthy custodian false-positive variants (observation / action / typeless light-scan / all-transient light-scan). When a custodian journal has escalation_needed: true, record a genuine escalation event (do not skip). The is_false_positive_journal() pre-filter already excludes esc-loop (it only matches observation, action, and typeless/all-transient light-scan), so it passes through correctly — but this is intentional, not an omission; do not "fix" it by adding esc-loop to the filter. Always still apply the phantom-file guard: verify os.path.exists() on the resolved source_journal before trusting the event. Confirmed 2026-07-07: esc-loop-20260707T173625Z.json produced a verified genuine escalation event (source file present on disk, escalation_needed: true, 4 user-gated issues confirmed).
-
Lesson content dedup required — The lesson_id includes a random/timestamp component, so dedup by lesson_id alone does NOT prevent semantic duplicates. Each ingest run generates different IDs for the same (signal_type, phase) group. Always dedup by (signal_type, failure_phase) content fingerprint before writing lessons. See ingest-script-pattern.md §Lesson Content Dedup. Without this, lessons.jsonl grows by ~9-49 duplicate entries per run.
-
Lesson dedup key must normalize failure_phase to lowercase — The (signal_type, failure_phase) dedup key is case-sensitive. Existing lessons may have failure_phase: "Planning" (capitalized) while new lessons produce failure_phase: "planning" (lowercase), causing the dedup to miss the match and create a semantic duplicate. In the 2026-06-22 ingest, a coverage_gap lesson was duplicated because "Planning" != "planning". Fix: Normalize both sides to lowercase before comparison — see references/inline-examples.md §Lesson dedup key for the lesson_dedup_key function and references/support-file-map.md for the When-to-read signal. Apply this to both the new lesson AND when building the existing-lesson dedup set. Also apply in Pass 1 event grouping — normalize failure_phase before grouping to prevent split groups across case variants. See references/session_20260622_ingest_followup.md.
-
Active shift cap is hard — 12-shift cap enforced on every activation
-
Lessons require causal grounding — "do X because Y" not just "do X"
-
Forge result field has multiple no-op variants — Forge scan journals use result: "no_op", result: "clean", "no-op" (hyphenated), "NO_UNPROCESSED_FILES" (uppercase), and longer strings like "clean — no pending VariantProposal or VariantDecision files" or "clean — no unprocessed VariantProposal or VariantDecision files found" to indicate routine success (nothing to process). All are healthy system states. Fix: Define FORGE_NO_OP_RESULTS = {"no_op", "clean", "no-op", "no_unprocessed_files"} and check result.lower().strip() in FORGE_NO_OP_RESULTS. Do NOT rely on exact string matching against "no_op" alone. The status-less forge schema variant (no result key, no status key, or empty summary string) is also a no_signal — treat as routine no-op when no other failure indicators are present. See references/session_20260616_ingest_cron_afternoon2.md for the full variant catalog.
-
Forge no-op filter must use startswith, not exact match — Forge result strings routinely include trailing detail text after the no-op keyword (e.g., "clean \u2014 no unprocessed VariantProposal or VariantDecision files found"). The FORGE_NO_OP_RESULTS set check with result.lower().strip() in FORGE_NO_OP_RESULTS does NOT match these longer strings, causing false-positive forge_error events. In the 2026-06-16 18:26 ingest, 2 false-positive events and 1 false lesson were produced before manual cleanup. Fix: Replace the exact-match check with a startswith loop:
def is_forge_no_op(result_val):
if not result_val:
return False
r = str(result_val).lower().strip()
return any(r.startswith(prefix) for prefix in FORGE_NO_OP_RESULTS)
Or split on the em-dash/whitespace and check only the first token: r.split('\u2014')[0].strip().split()[0] in FORGE_NO_OP_RESULTS.
-
Forge no-op FORGE_NO_OP_RESULTS must include "no unprocessed" (with spaces) — The set typically includes "no_unprocessed_files" (underscores), but forge journals write natural language with spaces: "No unprocessed VariantProposal...". The startswith check against "no_unprocessed_files" does NOT match "no unprocessed variant...". Fix: Add "no unprocessed" to the prefix set: FORGE_NO_OP_RESULTS = {"no_op", "clean", "no-op", "no_unprocessed_files", "no unprocessed"}.
-
Forge actions_taken can be an empty list [] with no result field — Some forge journal variants (post-2026-06-19) have actions_taken: [] (empty list), no result key, no status key, and findings: {unprocessed_proposals: 0, ...}. The forge no-op filter that only checks result and status fields misses this variant, causing it to fall through to signal extraction (where it produces no signals but is classified as no_signal instead of forge_no_op). Fix: In is_forge_no_op(), also check: (1) actions_taken as a string with startswith against FORGE_NO_OP_RESULTS, and (2) actions_taken as an empty list combined with zero findings. See references/session_20260619_ingest_cron_c.md.
-
Finch action.result instead of top-level result — Newer forge journals (post-2026-06-16) nest the result under action.result (e.g., {"action": {"result": "no_new_files"}}). The forge no-op pre-filter only checks data.get("result", "") at the top level. Fix: Also check data.get("action", {}).get("result", "") in is_forge_no_op(). But note: action can also be a string (e.g., "No unprocessed VariantProposal or VariantDecision files found...") — always guard with isinstance(action, dict) before .get("result").
-
finch_actionable_email is a legitimate signal type — NOT noise — Finch scan journals produce actionable email counts when new emails require attention (job opportunities, application updates, etc.). This is a genuine positive signal, not a no-op. Do NOT add finch_actionable_email to NOISE_SIGNAL_TYPES. The signal should produce events and, when ≥2 events accumulate, lessons. The only filter: if actionable == 0, skip (no new emails to act on). Discovered 2026-06-20: 12 finch_actionable_email events from 10 scans produced the first finch lesson.
-
Finch new_tasks_added is a list, not an int — Finch scan journals store new_tasks_added as a list of task dicts, NOT as an integer. Checking data.get("new_tasks_added", 0) > 0 crashes with TypeError. Fix: Use len(new_tasks) if isinstance(new_tasks, list) else (new_tasks if isinstance(new_tasks, int) else 0).
-
Initialize ALL accumulator variables before any loop or conditional — truly_new, remaining_proposals, and any accumulator must be initialized before the if/for block that might define it. A variable assigned only inside a for/else body does not exist when the loop iterates 0 times, causing NameError after data writes have already completed.
-
Inline Python heredoc variable shadowing in terminal() — When writing dispatch ingest logic as inline Python inside terminal(), a variable like new_journals built in one scope (e.g., a set-difference loop) can be silently shadowed by a same-named variable in a later block (e.g., the eval-write loop that rebuilds it from scratch). The result: diagnostic counters report 0 even though writes succeeded (the file grows correctly). The fix: use distinct variable names for each logical stage (discovered_journals, eval_written_count) and always verify writes with wc -l post-run rather than trusting inline counters. Confirmed 2026-06-28 dispatch: eval file grew from 42,241 to 42,248 (+7) but script reported journals_evaluated: 0.
-
Eval entry source field must be set explicitly — When writing to journals_evaluated.jsonl from inline Python, the source field defaults to empty/missing if not explicitly included in the entry dict. Future gap analysis grep checks (grep "source" eval_file) then show ? or empty. Always include 'source': 'dispatch-mtime-discovery' (or appropriate source tag) in every eval entry dict. Confirmed 2026-06-28: 7 entries written with missing source field.
-
Cap enforcement must use a separate counter, not the mutable list — When activating shifts in a loop, do NOT check len(active_shifts) if you're appending to active_shifts inside the same loop. The list grows on every iteration and the cap is never enforced. Use a separate active_count variable computed once before the loop, and increment it manually on each activation.
-
Cap enforcement must use full file rewrite, not append-only — When the cap is exceeded and a shift is expired in-memory to make room for a new one, the expired shift's status change is NOT persisted if you only append new shifts to shifts.jsonl. The expired shift remains active on disk. Fix: After all in-memory modifications (reinforce, expire, activate), do a FULL rewrite of shifts.jsonl from the canonical in-memory state. Track new shifts separately and only append those if you must use append-only — but prefer full rewrite. See references/session_20260618_ingest.md for the repair procedure.
-
Shift file rewrite must not double-write — When rewriting shifts.jsonl after modifying active shifts in memory, do NOT write existing_shifts (which includes all old shifts, already expired ones and all) AND then append new_shifts. Either: (a) rewrite the entire file from the merged in-memory list, or (b) append only new shifts to the existing file (don't re-write existing entries). In the 2026-06-18 repair, the rewrite wrote existing_shifts (78 entries) + new_shifts (12 entries) = 90 entries total. A subsequent re-read showed 78 + 12 = 90, but the old active shifts were already expired in-memory so the file was correct by accident. Be explicit: either full rewrite from canonical in-memory state, or append-only for new entries.
-
Domain must be the skill name, not the signal_type — When proposing shifts from events, domain must be set to the skill that produced the events (e.g., ocas-mentor), NOT to the signal_type (e.g., gap_detected). Setting domain = signal_type produces meaningless shifts like "In gap_detected during Planning: gap_detected recurs" instead of "In ocas-mentor during Planning: gap_detected recurs". Fix: Use the skill field from the events that contributed to the lesson, or use the most common skill in the event group as the domain. Only fall back to signal_type as domain if no skill information is available.
-
Lesson extraction must filter events with null/None/empty failure_phase — Before grouping events for lesson extraction, filter out events where failure_phase is None, null, "", or "MISSING". These produce meaningless lessons like "Monitor and address X during None phase". In the 2026-06-16 ingest, 90 events with invalid phases produced 26 bad lessons. Fix: Add valid_events = [e for e in all_events if e.get('failure_phase') and str(e.get('failure_phase')).lower() not in ('none', 'null', '', 'missing')] before the grouping loop.
-
Writing complex Python scripts — use heredoc, not write_file — write_file() and inline python3 -c silently corrupt multi-line Python (merged lines, mangled quotes, unterminated strings). For scripts >20 lines, use cat > /tmp/script.py << 'EOF' in terminal(), then run with python3 /tmp/script.py. Confirmed 2026-06-29: 3 consecutive write_file attempts all produced SyntaxError; heredoc worked on first try. See references/ingest-script-pattern.md §Writing complex Python scripts.
-
patch corrupts multi-line JSON replacements in ingest_state.json (2026-07-01) — The patch tool's fuzzy matching can mangle JSON structure when replacing multi-line blocks. During this cron ingest, a patch call targeting lines 50-52 of ingest_state.json successfully replaced the targeted fields but dropped the stale_script_cleanup sub-object that immediately followed, producing invalid JSON that wouldn't parse. Root cause: fuzzy matching matched and replaced a block boundary that included context from the next object, and the new_string didn't re-declare it. Fix: For multi-line edits to ingest_state.json (or any nested JSON state file), prefer full file rewrite via write_file() over patch(). If patch must be used, ensure the old_string includes ALL content between the target lines and the start of the next top-level key — or better, verify JSON validity with python3 -c "import json; json.load(open(...))" immediately after applying. Confirmed 2026-07-01: 2-step patch (journal path + decay timestamp) broke the file; had to recover via full write_file rewrite.
-
ingest_state.json has two gap-backfill counters — read eval_gaps_backfilled, not gaps_backfilled — After gap_backfill.py runs, its stdout prints gaps_backfilled=N, but the field it actually writes is eval_gaps_backfilled. The separate gaps_backfilled key is a stale duplicate that stays at 0 and is NOT updated by the script. When you read state and see gaps_backfilled: 0 immediately after a backfill that printed gaps_backfilled=26, that is NOT corruption — eval_gaps_backfilled holds the real cumulative count. Always read eval_gaps_backfilled for the authoritative backfill total; treat the bare gaps_backfilled key as dead. Confirmed 2026-07-07: gap_backfill printed gaps_backfilled=26; on-disk eval_gaps_backfilled became 26 while gaps_backfilled stayed 0 — the discrepancy looked like state clobbering until the two-field split was identified.
-
Rewrite ingest_state.json via Python load→modify→dump, not hand-typed JSON — The patch-corruption pitfall above says prefer a full file rewrite over patch(); correct, BUT hand-authoring the entire 58-field JSON into write_file() is itself error-prone — it is trivial to omit a nested sub-object (e.g., stale_script_cleanup) and silently lose a counter or produce invalid JSON. Safest pattern: read current state with json.load, mutate only the fields you need (s['last_ingest_run'] = ..., s['journals_processed'] = s.get('journals_processed',0)+N, etc.), then json.dump(s, open(f,'w'), indent=2). This preserves every other field automatically. Use a terminal() Python heredoc for the multi-line logic (not write_file for the JSON body). Confirmed 2026-07-07: full ingest-state update done this way — all 58 keys preserved, no field dropped.
-
os.walk can return phantom files that don't exist (2026-06-29) — During gap backfill, os.walk may list files deleted by concurrent processes between the directory listing and your os.stat() call. Always guard with os.path.exists(fpath) before stat or gap classification. A phantom gap entry that can't be opened is a race artifact, not a real gap — skip silently.
-
Bug 2 noise lessons can have signal_type key MISSING entirely — not just "?" (2026-06-29) — The production script's Pass 2 grounding produces lessons that lack a signal_type key altogether when source events have no signal_type field. Cleanup filters that only check signal_type == "?" or signal_type == "" miss this variant. Any noise cleanup MUST also check les.get("signal_type") is None. Confirmed 2026-06-29: 13 Bug-2 lessons bypassed the existing filter because the key was absent, not set to "?".
-
Decay age computation: use last_reinforced_at, NOT activated_at (2026-07-01) — When computing shift age for decay analysis, the clock resets on every reinforcement. A shift activated 12 days ago that was last reinforced 2 days ago has ~12 days remaining (at 14-day TTL), NOT ~2 days. Using activated_at as the decay baseline produces false "approaching decay" warnings and pollutes the debrief with incorrect findings. Always use last_reinforced_at as the primary age field; fallback to activated_at only if last_reinforced_at is missing entirely. The decay-risk flag (reinforcement_count == 0 AND age > 10 days) is already correct in scripts/praxis_debrief.py. This pitfall is for anyone writing inline debrief logic (cron mode, dispatch) that computes ages manually. Confirmed 2026-07-01: inline debrief incorrectly flagged all 3 shifts as approaching decay when they had been reinforced 2 days prior with ~12 days remaining.
-
Shift records carry DUPLICATE reinforcement fields — read the canonical one (confirmed 2026-07-11) — shifts.jsonl entries contain BOTH reinforced_count (appears early in the dict, frequently 0) AND reinforcement_count (appears later, authoritative), and BOTH last_reinforced and last_reinforced_at. A decay scan that reads s.get('reinforced_count', 0) instead of s.get('reinforcement_count', 0) will wrongly treat a reinforced shift as reinforcement_count == 0, flagging it as decay-risk and risking a force-expire of a live shift. Confirmed 2026-07-11: active shift shf-rebuild-20260618T063158-0009 has reinforced_count: 0 (early) but reinforcement_count: 1 (late) with last_reinforced_at: 2026-06-28 — correctly NOT in decay_risk because reinforcement_count (not reinforced_count) is canonical. Always read reinforcement_count and last_reinforced_at; treat reinforced_count/last_reinforced as legacy aliases to ignore.
Double-Z timestamp bug in praxis-cron journals (STILL ACTIVE 2026-06-30) — The praxis_ingest_run.py script occasionally produces journal filenames with double-Z suffixes (e.g., praxis-cron-20260630T092758ZZ.json). Root cause: timestamp composition applies .rstrip('Z') + 'Z' to a value already ending in Z, OR two ISO timestamp components get concatenated. Mitigation: gap backfill and dispatch pipeline treat these filename as-is for eval registration — no rename needed at eval time. Fix needed: audit praxis_ingest_run.py journal output section to check ts.endswith('Z') before appending Z. Confirmed recurring: 2026-06-26, 2026-06-28, 2026-06-30.
-
Shell heredoc journal writing also produces double-Z (2026-07-01) — When using shell heredoc (cat > file << EOF) to write journal files in cron, the timestamp shell variable typically ends in Z (e.g., TS="20260701T101349Z"). If the filename template appends another Z — ${TS}Z.json — the result is ...ZZ.json. This is a DIFFERENT source from Bug 4 (production script double-Z). Fix: Strip trailing Z from the timestamp variable before using it in the filename template: TS_SHORT="${TS%Z}" then use ${TS_SHORT}Z.json. Or, always check and fix with post-write rename. Confirmed 2026-07-01: shell heredoc journal produced praxis-cron-20260701T101349ZZ.json, fixed with mv.
-
JSON journal writing in cron: prefer shell heredoc over inline Python — When writing JSON journal files via python3 << 'PYEOF' heredoc, dict literals with double-quotes get corrupted: smart-quote conversion, variable name truncation after closing quotes, and SyntaxError: invalid decimal literal from mangled dicts. Fix: Use shell heredoc (cat > file << EOF with $TS and $NOW variables) to write JSON journal files. Reserve Python heredocs for eval file reads/writes with programmatic content. Confirmed 2026-06-30T11:25Z: 6 consecutive inline Python heredoc failures before switching to shell heredoc worked.
-
Proposed shifts are invisible to standard decay checks — they accumulate indefinitely — The decay check only scans status: "active" shifts for reinforcement TTL. Proposed shifts that never get activated sit in shifts.jsonl forever, bloating the file with dead entries. Confirmed 2026-06-30: 15 proposed shifts from the 2026-06-18 rebuild sat idle for 11 days. Fix: Every decay check must also scan status: "proposed" entries and expire any ≥10 days old. See references/session-20260630-decay-check-stale-proposed.md.
-
Follow explicit script paths — When a user provides an explicit script path for running ingest or other Praxis scripts, use that exact path. Do not substitute or assume alternative locations, even if they seem equivalent. Failure to use the provided path can result in script-not-found errors and failed runs. Always verify the path before execution.
-
Gap backfill MUST run BEFORE overwriting last_ingest_run (cron checklist ordering trap) — gap_backfill.py thresholds its scan on mtime > last_ingest_run read from ingest_state.json. The Cron Execution Checklist lists "Update ingest_state.json (set last_ingest_run to current timestamp)" as step 1 and "Gap journal backfill" as step 2 — taken literally, step 1 sets last_ingest_run = now, so step 2's scan (mtime > now) catches ZERO journals silently (no error, just 0 backfilled). The correct order: run gap_backfill.py FIRST (while last_ingest_run still holds the PRIOR run's timestamp), THEN update ingest_state.json including last_ingest_run = now. gap_backfill.py only mutates backfill counters, never last_ingest_run, so running it early is safe and is the only way it can catch post-ingest / date-filter-missed journals. Confirmed 2026-07-07: running gap backfill with the prior last_ingest_run (10:37:30) correctly reported 0 missed journals; updating last_ingest_run first would have made it a silent no-op.
-
Pipe-to-interpreter commands hang autonomous cron jobs (security scan → approval_pending) — Commands that pipe file contents into an interpreter (cat file | python3 -c ..., tail -1 file | python3 ..., grep x file | python3 ...) trip the environment's tirith:pipe_to_interpreter security scanner, which routes them to approval_pending status. A scheduled cron job has no user present to approve, so the command silently blocks and the run stalls (no error, no completion). Fix (cron mode): never pipe files into python3/jq/etc. Use (a) the dedicated read_file / search_files tools, (b) a plain wc -l file or grep terminal call with no pipe to an interpreter, or (c) execute_code with from hermes_tools import read_file, terminal when you must parse/transform content programmatically. Confirmed 2026-07-07: two cat file | python3 and tail file | python3 calls in this ingest both hit approval_pending and had to be rerouted to read_file + execute_code.
-
Re-read ingest_state.json AFTER gap_backfill.py before your state update — gap_backfill.py writes its own fields back to the state file (eval_lines, and increments eval_gaps_backfilled). If you snapshot the state once at the start of the run and later apply your load→modify→dump update, you will clobber gap backfill's writes. Fix: re-read ingest_state.json immediately before the final state update so your changes compose on top of the backfill's counters. Confirmed 2026-07-07: gap backfill advanced journals_evaluated_count 49232→49240 between the initial read and the update step; re-reading preserved it. (Pairs with the load→modify→dump pattern — never hand-type the JSON body, which risks dropping nested keys like stale_script_cleanup.)
OKRs
See references/okrs-praxis.md for full OKR definitions and targets.
Key OKRs: event_coverage (≥0.90), lesson_extraction_precision (≥0.80), shift_activation_accuracy (≥0.75), shift_decay_compliance (≥0.95), cap_efficiency (≥0.80).
Self-Update
See references/self-update-praxis.md.
Support File Map
references/praxis-ingest-cli-pitfall.md — Before invoking praxis_ingest_run.py in dispatch/cron: --help is not guaranteed non-mutating; inspect source for interface details and treat any invocation as a real ingest with side effects.
See references/support-file-map.md for the full file registry with "When to read" column.
| scripts/praxis_debrief.py | Manual debrief generation; use when running praxis.debracket.generate outside cron. NOTE: Any new fields added to debrief JSON schema must also be added to references/debrief_workflow.md for consistency. |
| scripts/cleanup_noise_lessons.py | Noise lesson cleanup script; removes Bug 2 noise lessons using fast pre-filter + per-lesson criteria (missing signal_type, low confidence, noise signal types). Rewritten 2026-07-01 to fix write_file corruption and add full Bug 2 logic. Patched 2026-07-01 to fix path resolution bug: ROOT computed as ../.. from script dir (landed at {profile}/skills/); fixed to ../../.. (lands at {profile}/). Patched 2026-07-07: added --new-genuine-events N fast-path — when N<2 (run recorded 0–1 genuine non-no_signal events), clears all lessons as Bug 2 full-history noise even though the all-no_signal pre-filter didn't fire. See references/noise_lesson_cleanup.md for details. |
| references/debrief_workflow.md | Debrief generation steps including shift-population collapse audit, lessons pipeline health check, and JSON schema. Read before modifying debrief logic. |
| references/session-20260629-cron-ingest-0207.md | 2026-06-29 cron ingest: 5,817 gap backfill catchup, 14 noise lessons cleaned |
| references/session-20260630-dispatch-0103Z-praxis.md | Dispatch 2026-06-30 Praxis pipeline: Multi-skill dispatch, routine no-op. 9 journals ingested, 5 no_signal events, 13 Bug-2 noise lessons with missing signal_type key cleaned. Fast pre-filter confirmed. |
| references/session-20260630-dispatch-1125Z-praxis.md | Dispatch 2026-06-30T11:25Z: Pure eval-registration dispatch. All new_files already in praxis prior-wave artifacts — Praxis NOT loaded. JSON journal writing pitfall (shell heredoc vs inline Python, 6 failures). New mixed_genuine_no_op shortcut. |
| references/session-20260629-dispatch-1030Z-praxis-second-wave-gap-backfill.md | Second-wave no-op + 83 gap backfill. Cron pipelines write 50-80 journals between dispatch waves. Expected steady-state rate. Backfill procedure for second-wave dispatches. |
| references/session-20260629-cron-ingest-0308.md | 2026-06-29 cron ingest: phantom gap journal detected (os.walk race), 14 Bug-2 noise lessons cleaned |
| references/session-20260629-cron-ingest-1231.md | 2026-06-29 cron ingest: 13 Bug-2 noise lessons with MISSING signal_type key — cleanup filter bug discovered and fixed |
| references/session-20260629-dispatch-1221Z.md | Genuine dispatch with gap_backfill.py path resolution fix. Concurrent cron gap pattern confirmed. Script lives at skills/ocas-praxis/scripts/ not commons/data/ocas-praxis/scripts/. |
| references/session-20260629-cron-ingest-1404.md | Cron ingest: Bug-2 filter confirmation. All 13 lessons had signal_type=None (key missing). Fast pre-filter ("all events no_signal → all lessons noise") validated. |
| references/session-20260630-cron-ingest-0140.md | Cron ingest 2026-06-30 0140Z: Another fast pre-filter confirmation. 7 journals, 5 no_signal events, 13 Bug-2 lessons with signal_type=None (key missing), 2 gap backfill. |
| references/session-20260630-decay-check-stale-proposed.md | Decay check 2026-06-30: 15 stale proposed shifts expired (11d, never activated). Proposed-shift TTL pattern — decay check should scan proposed status, not just active. |
| references/noise_lesson_cleanup.md | Guide for cleaning up noise lessons in the Praxis behavioral refinement loop. |
| references/session-20260701-cron-ingest-0735Z.md | Cron ingest 2026-07-01 0735Z: Confirmed last_lesson_extraction_event_id: "" is as broken as null. Phantom finch journal produced unverifiable event. Fast pre-filter vs per-lesson comparison. |
| references/session-20260701-cron-ingest-1012Z.md | Cron ingest 2026-07-01 1012Z: cleanup_noise_lessons.py restored (write_file corruption fixed). Shell heredoc double-Z pitfall documented. |
| references/session-20260701-cron-ingest-1134Z.md | Cron ingest 2026-07-01 1134Z: Routine steady-state. patch corrupts multi-line JSON in ingest_state.json — prefer write_file rewrite. 14 Bug-2 noise lessons cleaned. Decay scan: 3 healthy. |
| references/session-20260712-cron-ingest-0402.md | Cron ingest 2026-07-12 0402Z: Exactly 1 genuine custodian escalation + 2 no_signal mentor events; --new-genuine-events 1 correctly cleared 14 Bug-2 historical lessons because <2 new genuine events cannot ground a lesson. Verified gap-backfill-before-state-update and post-write closure checks. |