# Ocas Praxis

> 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).

- Skill: `indigokarasu/ocas-praxis` (Agent Skill, multi-file: 8 files)
- Install (CLI): `npx skillmds@latest add indigokarasu/ocas-praxis`
- Raw SKILL.md: https://api.skillmd.com/api/skills/indigokarasu/ocas-praxis/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- License: MIT
- Author: indigokarasu (https://skillmd.com/u/indigokarasu)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/indigokarasu/ocas-praxis

---


# 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.
1. **Record** — Capture task outcomes as evidence records
2. **Extract** — Identify micro-lessons from repeated patterns
3. **Consolidate** — Merge lessons into active behavior shifts (capped)
4. **Apply** — Apply shifts at runtime
5. **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

1. 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:

1. 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.
2. Persist events, lessons, shifts, and debriefs to local JSONL files
3. **Shift merge pass** — Before checking cap, scan active shifts for semantic overlap. Merge overlapping shifts before proposing any new shift.
4. Log material decisions to `decisions.jsonl`
5. Write journal via `praxis.journal`
6. **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):

1. **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`.
2. **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`.
3. **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']`).
4. **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.
5. **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`.
6. **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`.
7. **Stale script cleanup** — If >10 `.py` files exist in data root (outside `scripts/`), remove them. Never delete from `scripts/` subdirectory.

8. **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:
1. `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.
2. `confidence: "high"` (Pass 2 grounding always upgrades to high — doesn't indicate genuine signal)
3. 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):**

1. **Domain+phase overlap** — Does an active shift already target the same skill/domain AND failure phase? If yes, merge.
2. **Text similarity** — If two shifts have nearly identical `shift_text`, consolidate into a single cross-skill shift.
3. **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`:

1. Create `{agent_root}/commons/data/ocas-praxis/` and subdirectories
2. Write default `config.json` if absent
3. Create empty JSONL files
4. Create journal directory
5. Register cron jobs: `praxis:journal_ingest` (every 30min), `praxis:decay_check` (noon daily), `praxis:debrief` (6am daily), `praxis:update` (midnight daily)
6. 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.

```bash
grep -q "mentor-light-20260624T044239Z" <hermes-home>/profiles/indigo/commons/data/ocas-praxis/journals_evaluated.jsonl
# If exit code 0: already evaluated, write no-op journal and exit silently
```

## 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:

1. **Load active shifts** from `shifts.jsonl` — filter `status == "active"`, count reinforcement, compute age from `activated_at` or `last_reinforced_at`
2. **Scan for decay risk** — shifts with `reinforcement_count == 0` and age > 10 days are "approaching decay" (flag in debrief)
3. **Scan for overlap** — group active shifts by domain+phase; flag shifts sharing >3 words as potential consolidation candidates
4. **Count recent events** — last 200 events by signal_type to identify emerging patterns
5. **Check cap headroom** — if active shifts ≥ 10, flag "approaching cap" with weakest shift identified for potential manual expiry
6. **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`
7. **NEVER use `write_file` on JSONL files** — it overwrites. Use `terminal("python3 -c ...")` or append via `open(..., 'a')`

Debrief JSON structure:
```json
{
  "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 <operator>". 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_sig

…(truncated)
