From eb1666a2541c1061ec675cd2e82c5ad7e80f8172 Mon Sep 17 00:00:00 2001 From: Fernando Montero Date: Sun, 5 Jul 2026 15:05:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(maintain):=20v4.3.0=20zero-touch=20?= =?UTF-8?q?=E2=80=94=20auto-swap=20laws=20at=20cap,=2030d=20proposal=20TTL?= =?UTF-8?q?,=20informative=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline never waits on a human anymore. Reverts the v4.2 'human prunes' model at the operator's explicit request: - auto_promote_to_law(): when the cap is the sole failing gate, retire the least-impactful law (useful/(1+noise) 14d) and promote atomically. Guards: victim age >= 30d, max 2 swaps/run, productive cohorts never lose a law. Runs via _swap_promote_unlocked (extracted from manual_swap_promote — the locked wrapper would deadlock on the maintain pass's own flock). - expire_stale_proposals(): pending human-gated proposals > 30d auto-reject as cx-maintain-ttl, tombstoned in both runtimes. - SessionStart badge informative ('[cx] maintain: ...', 48h TTL); imperative '[REVIEW] N items' badge removed. /cx-review reframed as optional veto pass in command spec + README. - _archive_promoted_instinct_source dedups 3 archive sites. test_distill_v4 6->10, test_session_start 7->11, test_session_learner 60->61, runner report carries swapped=/expired=. Implementation by Codex GPT-5.3 (spec-driven), reviewed inline. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 51 ++++++++ README.md | 25 ++-- bin/cx-maintain.sh | 13 +- commands/cx-maintain.md | 22 +++- commands/cx-review.md | 15 ++- docs/FEATURES.md | 14 +- hooks/lib/distill_engine.py | 215 +++++++++++++++++++++---------- hooks/session-learner.js | 2 +- hooks/session-start.py | 36 +++++- install.ps1 | 2 +- install.sh | 2 +- tests/test_cx_maintain_runner.sh | 5 +- tests/test_distill_v4.sh | 132 +++++++++++++++++++ tests/test_session_learner.sh | 19 +++ tests/test_session_start.sh | 68 ++++++++++ 15 files changed, 516 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ace0c9..913b430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ All notable changes to fs-cortex will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [4.3.0] — 2026-07-05 + +**Zero-touch mode.** The learning pipeline no longer waits on a human anywhere: +maintenance decides with deterministic rules and the operator's only role is an +optional veto. Reverts the v4.2 "curated constitution, human prunes" model at +the operator's explicit request. Implementation delegated to Codex GPT-5.3 +(spec-driven), reviewed inline. + +### Added +- `hooks/lib/distill_engine.py` `auto_promote_to_law()`: **auto-swap at a + saturated law cap**. When an instinct passes every maturity gate and the cap + (15/15) is the only blocker, the engine retires the least-impactful law + (lowest `useful/(1+noise)` over 14d via `_find_least_impactful_law`) and + promotes in one atomic operation. Guards: victim age >= + `LAW_AUTO_SWAP_MIN_AGE_DAYS` (30), max `LAW_AUTO_SWAP_MAX_PER_RUN` (2) per + pass, productive cohorts (ratio > 1.0) never lose a law. Executes via the new + `_swap_promote_unlocked` — extracted from `manual_swap_promote` because + calling the `@_write_locked` wrapper from inside the maintain pass (which + already holds the shared flock) would deadlock. Victim archived to + `laws/archive/` (reversible), swap logged as `auto-swap` in the knowledge log. +- `hooks/lib/distill_engine.py` `expire_stale_proposals()`: pending + human-gated proposals older than `PROPOSAL_TTL_DAYS` (30) auto-reject with + `rejected_by: cx-maintain-ttl` — added to `VALIDATE_AUTHORIZED_REJECTERS` + (Python) and `TOMBSTONE_REJECTERS` (`session-learner.js`) so expired ids + never resurrect. Entries without a parseable `detected` date are never + expired blind. +- `commands/cx-maintain.md` + `bin/cx-maintain.sh` (embedded script kept in + sync): engine pass calls the TTL expiry and reports + `promoted=N (swapped=S) ... expired=E`; digest gains `swaps_last_run` + (in/out pairs) and `expired_last_run`. + +### Changed +- `hooks/session-start.py` `check_review_digest()`: the imperative + `[REVIEW] N items pendientes -> /cx-review` badge is replaced by an + informative `[cx] maintain: +law (jubilada X), N propuestas caducadas, M en + cola (caducan a 30d) — detalle opcional: /cx-review`, built from the digest's + new fields and silenced automatically 48h after the pass. Tolerant of old + digests — a malformed or pre-v4.3 digest returns no badge instead of + crashing SessionStart. +- `commands/cx-review.md` + `README.md`: `/cx-review` reframed from "weekly, + the only command with judgment" to an optional veto pass — skipping it + forever is a supported mode. +- `hooks/lib/distill_engine.py`: the three copies of the promote-then-archive + block deduplicated into `_archive_promoted_instinct_source()`. + +### Tests +- `test_distill_v4` 6→10 (auto-swap happy path, age-floor block, TTL expiry, + dry-run inertness), `test_session_start` 7→11 (informative badge, 48h + staleness, malformed digest), `test_session_learner` 60→61 (`cx-maintain-ttl` + tombstone), runner report asserts `swapped=`/`expired=`. + ## [4.2.2] — 2026-07-05 ### Fixed diff --git a/README.md b/README.md index 004641b..cf14f88 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ - **Observes** every tool call silently via async hooks (0 tokens overhead), capturing real output + error lines with anti-noise guards - **Injects** matched instincts and reflexes per tool use via PreToolUse (~120 tokens max) - **Learns** patterns automatically: instincts start as silent `draft`, earn `confirmed` (injectable) at 5+ occurrences across 3+ sessions — no manual analyze/validate step -- **Maintains** itself: `/cx-maintain` (deterministic, cron-able) promotes proven instincts to Laws, dedups, decays, rotates storage — zero questions -- **Reviews** weekly with you: `/cx-review` is the only command left with judgment — one shorthand digest, two minutes +- **Maintains** itself: `/cx-maintain` (deterministic, cron-able) promotes proven instincts to Laws — auto-swapping the least-impactful law when the cap is full (v4.3.0) — dedups, decays, expires stale proposals at 30 days, rotates storage. Zero questions +- **Reports** instead of assigning work: the SessionStart badge tells you what the last pass did; `/cx-review` is an optional veto digest — nothing ever waits on you - **Protects** with deterministic reflex hooks (not probabilistic instructions) v4.0.0 ("signal-first, zero-decision") replaced the old 20+-command, @@ -31,7 +31,7 @@ Observe (hooks, output+err_msg captured) → draft (silent tracking) auto auto draft → confirmed (occ>=5, sessions>=3) → /cx-maintain → /cx-review - auto cron-able weekly, human + auto cron-able optional veto OBSERVATIONS → INSTINCTS (draft/confirmed) → LAWS → SKILLS/COMMANDS/RULES (JSONL, 0 tok) (YAML) (TXT) (evolved/) @@ -64,7 +64,7 @@ at 5+ occurrences across 3+ distinct sessions. **Decay**: -0.05 per 30 days, applied by `/cx-maintain`. What you don't use fades. -**Feedback**: `/cx-review` closes the human loop on incorrect injections. 30%+ rejection rate → confidence reduced. +**Feedback**: `/cx-downvote` (via the optional `/cx-review` digest) vetoes incorrect injections. 30%+ rejection rate → confidence reduced. Human-gated proposals not reviewed within 30 days expire on their own (tombstoned). **Promotion**: Jaccard similarity ≥ 0.70 + 2 projects + avg confidence ≥ 0.80 → global, computed inside `/cx-maintain`. @@ -163,11 +163,12 @@ You don't configure or run anything. Just work — Cortex learns in the backgrou /cx-maintain ← decay + dedup + promotion to law + storage rotation + health check ``` -**Weekly, human** — when you see `[REVIEW] N items pendientes`: +**Optional, human veto** — the `[cx] maintain:` badge reports what the last +pass already did (law swaps, expired proposals, queue): ``` -/cx-review ← ONE shorthand digest: pending proposals, evolve drafts, law - deprecation candidates. 2 minutes, not twenty. +/cx-review ← ONE shorthand digest of what auto-swapped, what expired and + what waits. Pure veto — skipping it never blocks anything. ``` **When needed:** @@ -182,7 +183,7 @@ You don't configure or run anything. Just work — Cortex learns in the backgrou ### Daily workflow ``` -1. Open Claude Code → laws inject automatically, [REVIEW] badge if pending +1. Open Claude Code → laws inject automatically, [cx] badge reports last maintain 2. Work normally → observe.py records real output/errors, injector injects 3. Instincts mature → draft → confirmed automatically, no action from you 4. End of day → /cx-eod (optional but useful, now cumulative) @@ -191,8 +192,8 @@ You don't configure or run anything. Just work — Cortex learns in the backgrou ### Weekly maintenance ``` -/cx-maintain → /cx-review - deterministic human digest (2 min) +/cx-maintain → /cx-review (optional) + deterministic human veto digest ``` ### How knowledge evolves @@ -201,7 +202,9 @@ You don't configure or run anything. Just work — Cortex learns in the backgrou You work → Cortex captures output+errors (guarded) → instinct born as draft → 5+ occurrences across 3+ sessions → status: confirmed → starts injecting → /cx-maintain promotes proven instincts to law deterministically -→ /cx-review is your only weekly touchpoint for what's left to decide + (auto-swapping the least-impactful law when the cap is full, v4.3.0) +→ stale pending proposals expire on their own after 30 days +→ /cx-review is an optional veto pass — nothing ever waits on you ``` ## Commands (7 active + 17 deprecated) diff --git a/bin/cx-maintain.sh b/bin/cx-maintain.sh index 947cf95..cd662f1 100755 --- a/bin/cx-maintain.sh +++ b/bin/cx-maintain.sh @@ -124,7 +124,7 @@ def step(name): # public functions run_auto_distill() calls internally, but bypasses its # 24h rate limiter — a deliberate /cx-maintain run should always execute, # not silently no-op because SessionStart already ran today. -decayed = archived = promoted = candidates = [] +decayed = archived = promoted = candidates = expired = [] validated = skipped_validate = evolve_drafts = 0 engine_ok = False try: @@ -141,13 +141,16 @@ try: validated = len(validate_result.get("accepted", [])) skipped_validate = len(validate_result.get("skipped", [])) promoted, candidates = de.auto_promote_to_law(dry_run=DRY_RUN) + expired = de.expire_stale_proposals(dry_run=DRY_RUN) evolve_result = de.auto_evolve_detect(dry_run=DRY_RUN) evolve_drafts = len(evolve_result.get("drafts_generated", [])) + swapped = sum(1 for p in promoted if p.get("swapped_out")) engine_ok = True report["steps"].append({"name": "engine-pass", "ok": True, "detail": f"decayed={len(decayed)} archived={len(archived)} validated={validated} " f"skipped_validate={skipped_validate} promoted={len(promoted)} " - f"candidates={len(candidates)} evolve_drafts={evolve_drafts}"}) + f"(swapped={swapped}) candidates={len(candidates)} expired={len(expired)} " + f"evolve_drafts={evolve_drafts}"}) finally: de._lock_release(lock_fh) except Exception as e: @@ -348,6 +351,12 @@ if not DRY_RUN: "law_deprecation_candidate": health["law_deprecation_candidate"], "laws_active": health["laws_active"], "laws_cap": health["laws_cap"], + "swaps_last_run": [ + {"in": p.get("id"), "out": p.get("swapped_out")} + for p in (promoted or []) + if p.get("swapped_out") + ], + "expired_last_run": len(expired or []), } digest["total_items"] = ( digest["proposals_human_gated"] diff --git a/commands/cx-maintain.md b/commands/cx-maintain.md index 5b1717c..7d7b324 100644 --- a/commands/cx-maintain.md +++ b/commands/cx-maintain.md @@ -14,6 +14,8 @@ runs and prints a report, or a step is skipped with a one-line reason — nothin ever waits on the user. Idempotent: running it twice in a row is safe (each sub-step guards its own idempotency: `last_decay_at`, the `.last-auto-distill` marker, size-gated storage rotation, etc.). +When the law cap is saturated, promotion now auto-swaps under deterministic +guards; stale pending proposals expire after 30 days. Replaces the manual judgment calls that used to live in `/cx-analyze`, `/cx-distill`, `/cx-dream`, `/cx-promote`, `/cx-backfill` (deterministic parts @@ -24,8 +26,9 @@ criteria, or rotation thresholds. If those engines change their gates, this command's output changes with them automatically. Writes `~/.claude/cortex/.review-digest.json` — the accumulated digest that -`/cx-review` presents to a human and that `hooks/session-start.py` uses for the -one-line `[REVIEW] N items pendientes` badge. +`/cx-review` can present on demand and that `hooks/session-start.py` uses for +the informative one-line `[cx] maintain:` badge (swaps + expiries + queue; +silenced automatically 48h after the pass — it reports, it never assigns work). ## Usage @@ -84,7 +87,7 @@ LIB_DIR="${CORTEX_LIB_DIR:-$HOME/.claude/hooks/cortex/lib}" DRY_RUN=0 [ "$1" = "--dry-run" ] && DRY_RUN=1 -CORTEX_DIR="$CORTEX_DIR" LIB_DIR="$LIB_DIR" DRY_RUN="$DRY_RUN" python3 << 'PYEOF' +CORTEX_DIR="$CORTEX_DIR" LIB_DIR="$LIB_DIR" DRY_RUN="$DRY_RUN" python3 - <<'PYEOF' import json, os, re, sys, time from datetime import date, datetime, timezone from pathlib import Path @@ -114,7 +117,7 @@ def step(name): # public functions run_auto_distill() calls internally, but bypasses its # 24h rate limiter — a deliberate /cx-maintain run should always execute, # not silently no-op because SessionStart already ran today. -decayed = archived = promoted = candidates = [] +decayed = archived = promoted = candidates = expired = [] validated = skipped_validate = evolve_drafts = 0 engine_ok = False try: @@ -131,13 +134,16 @@ try: validated = len(validate_result.get("accepted", [])) skipped_validate = len(validate_result.get("skipped", [])) promoted, candidates = de.auto_promote_to_law(dry_run=DRY_RUN) + expired = de.expire_stale_proposals(dry_run=DRY_RUN) evolve_result = de.auto_evolve_detect(dry_run=DRY_RUN) evolve_drafts = len(evolve_result.get("drafts_generated", [])) + swapped = sum(1 for p in promoted if p.get("swapped_out")) engine_ok = True report["steps"].append({"name": "engine-pass", "ok": True, "detail": f"decayed={len(decayed)} archived={len(archived)} validated={validated} " f"skipped_validate={skipped_validate} promoted={len(promoted)} " - f"candidates={len(candidates)} evolve_drafts={evolve_drafts}"}) + f"(swapped={swapped}) candidates={len(candidates)} expired={len(expired)} " + f"evolve_drafts={evolve_drafts}"}) finally: de._lock_release(lock_fh) except Exception as e: @@ -338,6 +344,12 @@ if not DRY_RUN: "law_deprecation_candidate": health["law_deprecation_candidate"], "laws_active": health["laws_active"], "laws_cap": health["laws_cap"], + "swaps_last_run": [ + {"in": p.get("id"), "out": p.get("swapped_out")} + for p in (promoted or []) + if p.get("swapped_out") + ], + "expired_last_run": len(expired or []), } digest["total_items"] = ( digest["proposals_human_gated"] diff --git a/commands/cx-review.md b/commands/cx-review.md index 812319b..9afbc1d 100644 --- a/commands/cx-review.md +++ b/commands/cx-review.md @@ -1,6 +1,6 @@ --- name: cx-review -description: Weekly human review — the ONLY command with judgment. Digest of pending proposals, near-threshold drafts and law candidates in one shorthand pass. +description: Optional human veto pass — digest of what /cx-maintain decided (law swaps, expired proposals) and what waits. Zero-touch since v4.3.0, skipping it never blocks the pipeline. command: true --- @@ -8,11 +8,14 @@ command: true ## What it does -The **only** command in Cortex v4 that requires human judgment -(`docs/DESIGN-V4.md` §5, principle P2). Everything deterministic already ran -via `/cx-maintain` (decay, dedup, purge, auto-promotion, storage rotation) — -this command presents what is LEFT over that a human must decide, as ONE -consolidated shorthand list. Two minutes, not twenty. +The optional human veto surface of Cortex. Since v4.3.0 (zero-touch) nothing +waits on this command: `/cx-maintain` auto-swaps laws at a saturated cap under +deterministic guards (victim = lowest 14d impact, age >= 30d, max 2 per run) +and expires human-gated proposals pending > 30 days (tombstoned, never +resurrect). This command presents what happened and what is still in queue as +ONE consolidated shorthand list, so the operator can veto (`/cx-downvote`, +restore from archive) or fast-track an item. Skipping it forever is a valid +mode — the pipeline never blocks on a human. Fuses what used to be three separate commands: - `/cx-validate` (human-gated proposals — the domains `auto_validate_proposals` diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 6a22216..e27c81c 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,4 +1,4 @@ -# fs-cortex v4.2.2 — Feature Reference +# fs-cortex v4.3.0 — Feature Reference > Complete inventory of all features, commands, hooks, modules, and capabilities. > Last updated: 2026-07-05 @@ -252,8 +252,8 @@ mapping: [`MIGRATION-V4.md`](MIGRATION-V4.md). | Command | Purpose | Token Cost | |---|---|---| | `/cx-status` | Visual glance dashboard: 🟢🟡🔴 semaphore per section (laws, instincts, reflexes, sistema) + a SALUD GLOBAL header box (worst-of), confidence bars, top-5 projects/tracking, domain grouping collapsed to top4+resto. Same collector JSON as v3 (nothing removed, overflow moves to compact secondary lines / on-request detail). `--impact`, `--reflexes`, `--pipeline` flags unchanged from v3 | ~200 | -| `/cx-maintain` | **New in v4, deterministic, cron-able.** Single pass: engine decay/purge/auto-validate/deterministic law-promotion, Jaccard dedup by subtopic, storage rotation, proposals↔instincts reconciliation, health check, writes `.review-digest.json` for `/cx-review`. Resets the learn markers (`.last-learn-count`, `.obs-count`, `.learn-pending`) so the SessionStart "N+ new observations" banner measures since-last-maintenance (v4.2.2). Zero questions, idempotent. Also ships as `bin/cx-maintain.sh` — a plain bash mirror of the same steps for cron/launchd, no LLM call, 0 tokens | ~400 / 0 (script) | -| `/cx-review` | **New in v4, the only command with judgment, weekly.** Presents the accumulated digest (human-gated proposals + evolve drafts + law deprecation candidate) as ONE shorthand list. Fuses the old `cx-validate` + `cx-evolve` confirmation + `cx-downvote` + `cx-distill --swap` | ~600 | +| `/cx-maintain` | **New in v4, deterministic, cron-able.** Single pass: engine decay/purge/auto-validate/deterministic law-promotion, Jaccard dedup by subtopic, storage rotation, proposals↔instincts reconciliation, health check, writes `.review-digest.json` for `/cx-review`. Resets the learn markers (`.last-learn-count`, `.obs-count`, `.learn-pending`) so the SessionStart "N+ new observations" banner measures since-last-maintenance (v4.2.2). **v4.3.0 zero-touch**: auto-swaps laws at a saturated cap (victim = lowest 14d impact, age >= `LAW_AUTO_SWAP_MIN_AGE_DAYS` 30, max `LAW_AUTO_SWAP_MAX_PER_RUN` 2, only when the cap is the sole failing gate) via the deadlock-safe `_swap_promote_unlocked`; expires human-gated proposals pending > `PROPOSAL_TTL_DAYS` 30 (`expire_stale_proposals`, rejecter `cx-maintain-ttl`, tombstoned); digest carries `swaps_last_run` + `expired_last_run`. Zero questions, idempotent. Also ships as `bin/cx-maintain.sh` — a plain bash mirror of the same steps for cron/launchd, no LLM call, 0 tokens | ~400 / 0 (script) | +| `/cx-review` | **Optional human veto pass (v4.3.0 — was "weekly, only command with judgment" in v4.0-v4.2).** Presents the accumulated digest (what auto-swapped, what expired, what waits) as ONE shorthand list for veto or fast-track. Nothing blocks if never run: proposals expire at 30d, laws auto-swap. The SessionStart badge is informative (`[cx] maintain: ...`, silenced 48h after the pass) | ~600 | | `/cx-eod` | End-of-day summary. Deterministic gather (`core/_cx-eod-gather.sh`, no LLM); structurally cumulative (rereads the full day on every run); **v4**: per-project `context` field from `context.md`, `[eod-eisenhower]` Q1–Q4 keyword classification of "for tomorrow" bullets on next-day reinjection, review-digest pending count surfaced in `--write` mode | ~300 / 0 (auto) | | `/cx-gotcha` | Capture error→fix as high-priority instinct (unchanged) | ~200 | | `/cx-backup` | Create portable .tar.gz backup for machine transfer (unchanged) | ~100 | @@ -413,16 +413,17 @@ Deterministic rules via hooks — not probabilistic instructions. Triggers are r | `test_dream_cycle.sh` | 35 | Jaccard, contradictions (incl. topic-overlap gate), staleness, regex, health, decay formula, **cleanup module 6** | | `test_cx_eod_gather.sh` | 17 | **(v3.38.1)** `core/_cx-eod-gather.sh`: root/non-git detection, name-merge across subdir+root, cross-OS basename, 24h rolling window, registry hash→name, error counting, JSON shape, `_archive` skip, MultiEdit capture; `--write` mode (summary file, intraday trace accumulation, Quick Resume + For tomorrow present, default JSON writes nothing, newline-injection sanitized, no lock/tmp leftovers) | | `test_observe.sh` | 11 | Scrubbing, is_error, dedup, atomic write, e2e, perf, subagent capture; **v3.37.0**: binary/base64 output detection; **v3.37.2**: heuristic guards (network tools, 2xx, test-runner) | -| `test_session_learner.sh` | 42 | Error-fix pairs, corrections, chains, proposals, command timeline; **v3.36.1**: semantic fix summary, hollow-fix skip, writeProposals quality gate; **v3.37.0**: err_msg required, input-derived trigger validation, project scope, evidence samples, agent-pattern scoped trigger; **v3.37.1**: rejection tombstones; **v3.37.2**: isError guards + WebFetch 200-OK no-proposal e2e | +| `test_session_learner.sh` | 61 | Error-fix pairs, corrections, chains, proposals, command timeline; **v3.36.1**: semantic fix summary, hollow-fix skip, writeProposals quality gate; **v3.37.0**: err_msg required, input-derived trigger validation, project scope, evidence samples, agent-pattern scoped trigger; **v3.37.1**: rejection tombstones; **v3.37.2**: isError guards + WebFetch 200-OK no-proposal e2e; **v4.0.0**: draft/confirmed lifecycle; **v4.3.0**: `cx-maintain-ttl` accepted as tombstone rejecter | | `test_injector.sh` | 29 | Sanitization, ReDoS, limits, markers, yaml-utils, .last-instinct, engine; **v3.36.1 e2e**: category domain injects with detected stack, hollow action never injects; **v3.37.0 e2e**: per-session repeat cooldown (max 2 + suppress events), token-budget degrade keeps highest-confidence, concurrent-injector count integrity | | `test_yaml_utils.sh` | 16 | Floats, ints, strings, colon values, update, list; **v3.36.1**: literal/folded/last-field block scalars | | `test_install.sh` | 42 | Fresh install, upgrade, idempotency, **strict** path traversal (no fake-green), **v3.19.0 env merge** (CORTEX_AGENT_DISABLE_REFLEXES added, user vars preserved, idempotency, opt-out respected) | | `test_hooks_e2e.sh` | 14 | Full pipeline: observe→inject→learn, **token budget reset** | | `test_uninstall.sh` | 13 | Cleanup, backup creation, data preservation, **safety guard**, CLAUDE.md preservation, **v3.19.0 env removal** (Cortex var removed, user vars preserved, empty env block dropped) | | `test_integrity.sh` | 14 | observe.py direct, **21 commands** validated (EXPECTED_COMMANDS subset; 24 command files total as of v4.0.0 — `ls commands/*.md` — incl. new `cx-maintain`/`cx-review` not yet in EXPECTED_COMMANDS), core file schemas, **version consistency** | -| `test_distill_v4.sh` | 6 | **(v4.0.0)** `auto_promote_to_law` deterministic gate: conf/projects/occurrences_v4/noise-14d criteria, `law_eligible: false` veto, lazy `occurrences_legacy`/`occurrences_v4` migration | +| `test_distill_v4.sh` | 10 | **(v4.0.0)** `auto_promote_to_law` deterministic gate: conf/projects/occurrences_v4/noise-14d criteria, `law_eligible: false` veto, lazy `occurrences_legacy`/`occurrences_v4` migration; **v4.3.0**: auto-swap at saturated cap (law replaced + victim archived + `swapped_out` marker), age-floor 30d blocks swap, `expire_stale_proposals` TTL (40d-old rejected, fresh kept), dry-run mutates nothing | +| `test_session_start.sh` | 11 | SessionStart injection block, laws-meta tier split + malformed-meta tolerance (v4.2.1); **v4.3.0**: informative `[cx] maintain:` badge from digest `swaps_last_run`/`expired_last_run`, 48h staleness silences it, old-format digest → no crash | | `test_injector_v4.sh` | 13 | **(v4.0.0)** `status: draft` never injects, `status: confirmed`/legacy-no-field inject normally, subtopic dedup + domain soft-ceiling at conf>=0.85, JSON-fragment + prompt-injection action guards, degenerate-trigger static validation, live `reflexes.json` polling conditions | -| `test_cx_maintain_runner.sh` | 10 | **(v4.0.0, inventoried v4.2.2)** `bin/cx-maintain.sh` hermetic sandbox: exit 0 on repeated runs, decay exactly-once (same-day idempotency via `last_decay_at`), compat markers, valid `.review-digest.json`, no leftover lock dir, `--dry-run` writes nothing; **v4.2.2**: learn markers reset (`.last-learn-count` snapshot, `.obs-count` zeroed, `.learn-pending` cleared) | +| `test_cx_maintain_runner.sh` | 10 | **(v4.0.0, inventoried v4.2.2)** `bin/cx-maintain.sh` hermetic sandbox: exit 0 on repeated runs, decay exactly-once (same-day idempotency via `last_decay_at`), compat markers, valid `.review-digest.json`, no leftover lock dir, `--dry-run` writes nothing; **v4.2.2**: learn markers reset (`.last-learn-count` snapshot, `.obs-count` zeroed, `.learn-pending` cleared); **v4.3.0**: engine-pass report carries `swapped=`/`expired=` counters | | `test_install_ps1.ps1` | 10 | PowerShell syntax, version consistency, security features, backup categories, hook config, v3.26-v3.28 source files present, **CI on windows-latest** | | `test_impact.sh` | 32 | **Sprint 0/1 funnel** — schema v1, JS↔Python compat, concurrent writes (10 parallel → 0 loss), rotation, gate GO/NO-GO, formulas, input validation, **v3.17.0 source split** (user/agent ratios, gate input, legacy default), **v3.18.0 auto-eval** (3 evaluator types, no-evaluator default, reflex iid prefix) | | `test_cross_day_tracker.sh` | 10 | Roundtrip, boost tiers (1/2/4/8 days), confidence cap 0.95, Jaccard match, single-token guard, prune >365d, concurrent append | @@ -669,5 +670,6 @@ locally (not installed to `~/.claude/`): | v4.0.0 | 2026-07-02 | **"Signal-first, zero-decision" — full redesign of capture and command set.** `docs/DESIGN-V4.md` + `docs/SPEC-PORT-SINAPSIS.md` (port of Sinapsis v4.6.1, MIT, Luis Salgado). **BREAKING:** 17 of 24 commands deprecated to notice-only stubs (mapping in `docs/MIGRATION-V4.md`); 7 remain active (`cx-status`, `cx-maintain`, `cx-review`, `cx-eod`, `cx-gotcha`, `cx-backup`, `cx-restore`). New `/cx-maintain` (deterministic, cron-able: decay+purge+auto-validate+deterministic law-promotion+Jaccard dedup-by-subtopic+storage rotation+proposals↔instincts reconciliation+health check, writes `.review-digest.json`). New `/cx-review` (the only command with human judgment, weekly, fuses old validate+evolve-confirm+downvote+distill-swap into one shorthand pass). `hooks/observe.py`: per-line capture guards before trusting an error match (`[codex]`/npm-log prefixes, version listings, grep headers, `0 errors`, bare `warning:`), `output` cap 8000→10000, `err_msg` now the first non-guarded matching line. New `status: draft`/`confirmed` instinct lifecycle (`session-learner.js`, `injector-engine.js`) — drafts track silently, auto-promote at `occurrences>=5 AND sessions_seen>=3`; legacy instincts default `confirmed`. Law promotion gate replaced with a fully deterministic one (`conf>=0.95`, `projects_seen>=3`, `occurrences_v4>=10` via lazy `occurrences_legacy` migration, 0 noise 14d) — `auto-distill-candidates.md` buzón removed. Injector: JSON-fragment + prompt-injection guards at load, degenerate-trigger static validation, dedup switched from per-domain to per-subtopic (2 first words of id) with a soft 2-per-domain ceiling at conf>=0.85. `core/_cx-eod-gather.sh`: per-project `context` field, `[eod-eisenhower]` Q1-Q4 keyword classification of "for tomorrow" bullets, review-digest pending count surfaced. New test suites `test_distill_v4.sh` (6/6 PASS), `test_injector_v4.sh` (13/13 PASS). | | v4.1.0 | 2026-07-04 | **Audit hardening (multi-agent audit + adversarial verify).** `session-start.py` injects full law text (not just line 1). `session-learner.js`: tombstone gate matches by trigger vs `proposals-history.jsonl`, dup-trigger dedup, `$HOME`→`~` paths, head+tail sampling. Reflexes: re-enabled `python3-bypass-write-tool`, hardened `bash-cat-use-read`. `storage-rotation.js`: prunes `cross-day-tracker` past 1.5× threshold, stale `.lock`/`.bak` cleanup, `timeline.jsonl` rotation. `distill_engine.py`: `prune_instinct_tracking()` + `reap_stale_nudge_state()` + CLI subcommands. `injector-engine.js`: session token budget 8000→12000 with a visible suppression warning, optional per-reflex `domains` filter. | | v4.2.0 | 2026-07-05 | **Laws layer: de-dup, tier legibility, post-promotion audit** (`docs/DESIGN-laws-v4.2.md`; 3-agent adversarial pass). Fixed double injection (`read-instructions` 41×, `pref-fix` 17× redundant PreToolUse) — `injector-engine.js` skips instinct candidates with a `laws/{id}.txt` twin; `manual_swap_promote` archives the source instinct like `auto_promote_to_law`. `advisor-escalation` trimmed 1505→717 chars. Reverted the v4.1.0 `bash-cat-use-read` lookahead regression (broke `test_reflex_matchers`). Added law presentation split by tier (`[principios]`/`[herramienta]`, `core/laws-meta.default.json`) + `law_audit()` + `law-audit` CLI. | +| v4.3.0 | 2026-07-05 | **Zero-touch mode — the pipeline never waits on a human.** Reverts the v4.2 "human prunes" model at the operator's request. (1) `auto_promote_to_law` auto-swaps at a saturated cap: victim = `_find_least_impactful_law` (lowest useful/(1+noise) 14d), age >= 30d (`LAW_AUTO_SWAP_MIN_AGE_DAYS`), max 2 swaps/run, only when the cap is the sole failing gate; executes via `_swap_promote_unlocked` (extracted from `manual_swap_promote` — calling the locked wrapper from inside the maintain lock would deadlock on the shared flock). (2) `expire_stale_proposals()`: pending human-gated proposals older than 30d (`PROPOSAL_TTL_DAYS`) auto-reject as `cx-maintain-ttl` (added to both tombstone whitelists — never resurrect). (3) SessionStart badge informative: `[cx] maintain: +law (jubilada X), N caducadas, M en cola` from digest `swaps_last_run`/`expired_last_run`, silenced 48h after the pass; the imperative `[REVIEW] N items -> /cx-review` badge is gone. (4) `/cx-review` reframed as optional veto pass. Shared `_archive_promoted_instinct_source` helper dedups 3 archive sites. `test_distill_v4` 6→10, `test_session_start` 7→11, `test_session_learner` 60→61, runner report carries `swapped=`/`expired=`. Implementation delegated to Codex GPT-5.3 (spec-driven), reviewed inline. | | v4.2.2 | 2026-07-05 | **Learn markers reset in `/cx-maintain` Step 7.** Nothing cleared `.learn-pending` after `/cx-analyze` retired in v4, so the SessionStart "N+ new observations, run /cx-maintain" banner nagged forever. Step 7 (both `commands/cx-maintain.md` and `bin/cx-maintain.sh`) now snapshots the obs total into `.last-learn-count`, zeroes `.obs-count` and drops the flag. `test_cx_maintain_runner` 9→10 + suite inventoried in the tests table (gap since v4.0.0). | | v4.2.1 | 2026-07-05 | **AD follow-up (Codex GPT-5.5 review of v4.1/v4.2).** `load_laws()` (`session-start.py`) now tolerant of a malformed `laws-meta.json` — a non-dict schema no longer crashes the whole SessionStart injection. `impact.archive` retention fixed from count-based `keep=5` (could delete chunks < 90 days old, contradicting the append-only "never deleted" contract) to 90-day age-based per `DESIGN-V4 §7`; the stale "never deleted" comments in `impact_log.py`/`session-learner.js` reconciled. New laws-meta robustness tests; version history caught up. | diff --git a/hooks/lib/distill_engine.py b/hooks/lib/distill_engine.py index d308ddf..73cd370 100644 --- a/hooks/lib/distill_engine.py +++ b/hooks/lib/distill_engine.py @@ -149,6 +149,9 @@ # accumulated impact data would have ratio=0 # and be marked for immediate deprecation # before getting a chance to be exercised. +LAW_AUTO_SWAP_MIN_AGE_DAYS = 30 +LAW_AUTO_SWAP_MAX_PER_RUN = 2 +PROPOSAL_TTL_DAYS = 30 LAW_JACCARD_THRESHOLD = 0.50 LAW_MAX_CHARS = 200 # v3.35.2 (#56.1): was 120 — mid-sentence cuts shipped laws with incomplete instructions # v3.29.0 §4.16: minimum distinct sessions (UUIDs) where an instinct must @@ -223,6 +226,7 @@ VALIDATE_AUTHORIZED_REJECTERS = { "cx-validate", # manual /cx-validate "cx-auto-validate", # auto_validate_proposals (this module) + "cx-maintain-ttl", # expire_stale_proposals (this module) "cx-cleanup", # ops cleanup "v3.28.9-cleanup", # bulk-reject we did in v3.28.9 None, # legacy: pre-Sprint-7 acceptances @@ -812,8 +816,36 @@ def _find_least_impactful_law( return best_iid -@_write_locked -def manual_swap_promote( +def _archive_promoted_instinct_source( + instinct_path: Path, + iid: str, + today: _dt.date, + source: str, + detail_prefix: str = "promoted to law", +) -> None: + """Archive the source YAML after a successful law promotion.""" + if not instinct_path.exists(): + return + archive_dir = instinct_path.parent / "archive" + ts_date = today.strftime("%Y%m%d") + archive_dest = archive_dir / f"{iid}.promoted-to-law-{ts_date}.yaml" + try: + archive_dir.mkdir(parents=True, exist_ok=True) + instinct_path.rename(archive_dest) + _log_knowledge( + "archived", iid, + f"{detail_prefix}; source archived as {archive_dest.name}", + source=source, + ) + except OSError as e: + _log_knowledge( + "archive-failed", iid, + f"law promoted but source archive failed: {e}", + source=source, + ) + + +def _swap_promote_unlocked( new_iid: str, deprecate_iid: str, dry_run: bool = False, @@ -911,27 +943,10 @@ def manual_swap_promote( # law-creation path, not just auto_promote_to_law. Pre-fix, manual swap # promotion left the source instinct YAML live in instincts/global/ after # writing the law file, so it kept firing as a PreToolUse instinct AND - # injecting at every SessionStart as a law — the exact double-injection - # bug DESIGN-laws-v4.2.md §"Contexto y evidencia" measured (41 + 17 - # redundant injections). Same archive convention auto_promote_to_law uses. - if new_instinct_path.exists(): - archive_dir = new_instinct_path.parent / "archive" - ts_date = today.strftime("%Y%m%d") - archive_dest = archive_dir / f"{new_iid}.promoted-to-law-{ts_date}.yaml" - try: - archive_dir.mkdir(parents=True, exist_ok=True) - new_instinct_path.rename(archive_dest) - _log_knowledge( - "archived", new_iid, - f"promoted to law via swap; source archived as {archive_dest.name}", - source="cx-distill-swap", - ) - except OSError as e: - _log_knowledge( - "archive-failed", new_iid, - f"law promoted via swap but source archive failed: {e}", - source="cx-distill-swap", - ) + # injecting at every SessionStart as a law. + _archive_promoted_instinct_source( + new_instinct_path, new_iid, today, "cx-distill-swap", "promoted to law via swap" + ) _log_knowledge( "swap-promoted", new_iid, @@ -944,6 +959,17 @@ def manual_swap_promote( ) +@_write_locked +def manual_swap_promote( + new_iid: str, + deprecate_iid: str, + dry_run: bool = False, + today: _dt.date | None = None, +) -> tuple[bool, str]: + """Locked public wrapper for atomic law swap promotion.""" + return _swap_promote_unlocked(new_iid, deprecate_iid, dry_run, today) + + @_write_locked def demote_law_to_domain( law_id: str, @@ -1334,6 +1360,7 @@ def auto_promote_to_law( tracking_data = _load_instinct_tracking() promoted: list[dict] = [] candidates: list[dict] = [] + swaps_this_run = 0 # Pre-load existing law content for Jaccard check existing_laws: list[tuple[str, str]] = [] # [(law_id, content)] @@ -1420,36 +1447,63 @@ def auto_promote_to_law( break # ── Structural: active law count < LAW_MAX_ACTIVE ───────────────── - # v3.32.0 §4.5, kept in v4: when saturated, propose a deprecation - # candidate (lowest useful/(1+noise) ratio, age >= 7d) so the - # operator knows which law to retire via the existing swap - # mechanism. Engine never auto-swaps — only the operator confirms. + # v4.3.0: when saturated, auto-swap only after every maturity and + # duplicate criterion above passed. This function may run while + # /cx-maintain already holds LOCK_FILE, so it must call the unlocked + # swap helper, never manual_swap_promote(). if active_laws >= LAW_MAX_ACTIVE: - candidate = _find_least_impactful_law(impact) - if candidate: - failed_reasons.append( - f"laws == {active_laws}/{LAW_MAX_ACTIVE} saturated; " - f"would deprecate {candidate} via " - f"/cx-distill --swap {candidate} {iid} --confirm" - ) - else: - failed_reasons.append( - f"laws == {active_laws}/{LAW_MAX_ACTIVE} saturated; " + victim = _find_least_impactful_law(impact) + blocked_reason = None + if not victim: + blocked_reason = ( f"no deprecation candidate (all productive OR < " f"{LAW_DEPRECATE_MIN_AGE_DAYS}d age)" ) - # v3.37.0 — surface the stall in the knowledge timeline (once - # per run). Saturated cap + nothing deprecable means the - # promotion pipeline is deadlocked until laws age past - # LAW_DEPRECATE_MIN_AGE_DAYS; before this entry the operator - # had no persistent signal that promotions were being dropped. - if not _LAW_CAP_STALL_LOGGED.get("done"): - _log_knowledge( - "law-cap-stall", iid, - f"laws {active_laws}/{LAW_MAX_ACTIVE} saturated, no deprecation candidate", - source="cx-auto-distill", + elif failed_reasons: + blocked_reason = f"would deprecate {victim} after other gates pass" + elif swaps_this_run >= LAW_AUTO_SWAP_MAX_PER_RUN: + blocked_reason = f"auto-swap blocked: max {LAW_AUTO_SWAP_MAX_PER_RUN}/run reached" + else: + victim_path = LAWS_DIR / f"{victim}.txt" + victim_age = _law_age_days(victim_path, today) + if victim_age < LAW_AUTO_SWAP_MIN_AGE_DAYS: + blocked_reason = ( + f"auto-swap blocked: {victim} age {victim_age}d < " + f"{LAW_AUTO_SWAP_MIN_AGE_DAYS}d" ) - _LAW_CAP_STALL_LOGGED["done"] = True + else: + ok, reason = _swap_promote_unlocked(iid, victim, dry_run=dry_run, today=today) + if ok: + promoted.append({ + "id": iid, + "confidence": round(conf, 4), + "swapped_out": victim, + }) + swaps_this_run += 1 + existing_laws = [ + (law_id, content) for law_id, content in existing_laws + if law_id != victim + ] + existing_laws.append((iid, _derive_law_line(fields))) + if not dry_run: + _log_knowledge( + "auto-swap", iid, + f"promoted, retired {victim} (ratio-based)", + source="cx-maintain", + ) + continue + blocked_reason = f"auto-swap failed for {victim}: {reason}" + + failed_reasons.append( + f"laws == {active_laws}/{LAW_MAX_ACTIVE} saturated; {blocked_reason}" + ) + if not victim and not _LAW_CAP_STALL_LOGGED.get("done"): + _log_knowledge( + "law-cap-stall", iid, + f"laws {active_laws}/{LAW_MAX_ACTIVE} saturated, no deprecation candidate", + source="cx-auto-distill", + ) + _LAW_CAP_STALL_LOGGED["done"] = True if failed_reasons: candidates.append({ @@ -1475,23 +1529,7 @@ def auto_promote_to_law( # instinct after a successful promotion, same convention # the 2026-07-02 manual cleanup used # (`.promoted-to-law-.yaml`). - archive_dir = path.parent / "archive" - ts = today.strftime("%Y%m%d") - archive_dest = archive_dir / f"{iid}.promoted-to-law-{ts}.yaml" - try: - archive_dir.mkdir(parents=True, exist_ok=True) - path.rename(archive_dest) - _log_knowledge( - "archived", iid, - f"promoted to law; source archived as {archive_dest.name}", - source="cx-auto-promote", - ) - except OSError as e: - _log_knowledge( - "archive-failed", iid, - f"law promoted but source archive failed: {e}", - source="cx-auto-promote", - ) + _archive_promoted_instinct_source(path, iid, today, "cx-auto-promote") return promoted, candidates @@ -1521,6 +1559,53 @@ def _save_proposals(proposals: list[dict]) -> None: _atomic_write(PROPOSALS_FILE, content) +def expire_stale_proposals( + dry_run: bool = False, + ttl_days: int = PROPOSAL_TTL_DAYS, +) -> list[dict]: + """Reject pending proposals whose detected date exceeded the TTL.""" + proposals = _load_proposals() + if not proposals: + return [] + + today = _dt.datetime.now(_dt.timezone.utc).date() + expired: list[dict] = [] + mutated = False + for entry in proposals: + if not isinstance(entry, dict) or entry.get("status") != "pending": + continue + detected_raw = entry.get("detected") + if not isinstance(detected_raw, str): + continue + try: + detected = _dt.date.fromisoformat(detected_raw[:10]) + except ValueError: + continue + age_days = (today - detected).days + if age_days <= ttl_days: + continue + + iid = str(entry.get("id") or "") + expired.append({"id": iid, "detected": detected_raw}) + if dry_run: + continue + entry["status"] = "rejected" + entry["rejected_by"] = "cx-maintain-ttl" + entry["rejected_at"] = today.isoformat() + entry["reject_reason"] = f"TTL: pending > {ttl_days}d without review" + entry["rejected_reason"] = entry["reject_reason"] + mutated = True + _log_knowledge( + "ttl-expired", iid, + f"detected={detected_raw} age_days={age_days} ttl_days={ttl_days}", + source="cx-maintain", + ) + + if mutated: + _save_proposals(proposals) + return expired + + def _instinct_exists(iid: str) -> bool: """Return True if an instinct YAML with this id already exists (global or project).""" global_path = CORTEX_DIR / "instincts" / "global" / f"{iid}.yaml" diff --git a/hooks/session-learner.js b/hooks/session-learner.js index bec108d..2962cad 100755 --- a/hooks/session-learner.js +++ b/hooks/session-learner.js @@ -1384,7 +1384,7 @@ function updateReflexes(observations) { // (live corpus showed the same gotcha id rejected 9 times across weeks — // the core of the 6-week noise loop). A proposal id rejected by an // authorized rejecter is now a permanent tombstone. -const TOMBSTONE_REJECTERS = new Set(['cx-validate', 'cx-auto-validate', 'cx-cleanup', 'v3.28.9-cleanup']); +const TOMBSTONE_REJECTERS = new Set(['cx-validate', 'cx-auto-validate', 'cx-maintain-ttl', 'cx-cleanup', 'v3.28.9-cleanup']); function loadRejectedTombstones() { const ids = new Set(); diff --git a/hooks/session-start.py b/hooks/session-start.py index 570074e..75b12d9 100644 --- a/hooks/session-start.py +++ b/hooks/session-start.py @@ -280,8 +280,8 @@ def check_maintenance(): def check_review_digest(): """v4 item (b) — cheap read of `.review-digest.json`, written by /cx-maintain and consumed interactively by /cx-review. Returns a one-line - `[REVIEW] N items pendientes -> /cx-review` reminder, or None when the - digest is missing, unreadable, or already empty (total_items <= 0). + informative maintain badge, or None when the digest is missing, stale, + unreadable, or has nothing useful to report. Deliberately does NOT recompute anything — SessionStart must stay cheap; /cx-maintain is the only place that does the actual counting work.""" digest_file = CORTEX_DIR / '.review-digest.json' @@ -289,12 +289,38 @@ def check_review_digest(): return None try: data = json.loads(digest_file.read_text(encoding='utf-8')) - n = int(data.get('total_items', 0) or 0) + generated_raw = data.get('generated_at') + if not isinstance(generated_raw, str) or not generated_raw: + return None + generated = datetime.fromisoformat(generated_raw.replace('Z', '+00:00')) + if generated.tzinfo is not None: + generated = generated.replace(tzinfo=None) + if datetime.utcnow() - generated > timedelta(hours=48): + return None + + parts = [] + swaps = data.get('swaps_last_run') or [] + if isinstance(swaps, list): + for item in swaps: + if not isinstance(item, dict): + continue + incoming = item.get('in') + outgoing = item.get('out') + if incoming and outgoing: + parts.append(f'+{incoming} (jubilada {outgoing})') + + expired = int(data.get('expired_last_run', 0) or 0) + if expired > 0: + parts.append(f'{expired} propuestas caducadas') + + pending = int(data.get('proposals_human_gated', 0) or 0) + if pending > 0: + parts.append(f'{pending} en cola (caducan a 30d)') except Exception: return None - if n <= 0: + if not parts: return None - return f'[REVIEW] {n} items pendientes -> /cx-review' + return f'[cx] maintain: {", ".join(parts)} — detalle opcional: /cx-review' def _file_older_than(filepath, days): diff --git a/install.ps1 b/install.ps1 index e448279..e77a5e4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -20,7 +20,7 @@ $CommandsDir = Join-Path $ClaudeDir "commands" $HooksDir = Join-Path (Join-Path $ClaudeDir "hooks") "cortex" $SettingsFile = Join-Path $ClaudeDir "settings.json" $ClaudeMd = Join-Path $ClaudeDir "CLAUDE.md" -$NewVersion = "4.2.2" +$NewVersion = "4.3.0" # v3.25.1 — explicit downgrade flag (parity with install.sh). # A behind-remote repo would silently rewind hooks otherwise. diff --git a/install.sh b/install.sh index 8fd5685..9bd726a 100755 --- a/install.sh +++ b/install.sh @@ -21,7 +21,7 @@ COMMANDS_DIR="$CLAUDE_DIR/commands" HOOKS_DIR="$CLAUDE_DIR/hooks/cortex" SETTINGS_FILE="$CLAUDE_DIR/settings.json" CLAUDE_MD="$CLAUDE_DIR/CLAUDE.md" -NEW_VERSION="4.2.2" +NEW_VERSION="4.3.0" # v3.25.1 — explicit downgrade flag. The installer is a copy-not-merge of # hooks/commands, so running an older `install.sh` over a newer install diff --git a/tests/test_cx_maintain_runner.sh b/tests/test_cx_maintain_runner.sh index 94053eb..8a3f41a 100755 --- a/tests/test_cx_maintain_runner.sh +++ b/tests/test_cx_maintain_runner.sh @@ -82,7 +82,8 @@ RC1=$? # ── Test 2: report shows the stale instinct decayed exactly once ──────────── echo "--- Test 2: first run decays the stale instinct ---" echo "$OUT1" | grep -q "engine-pass: decayed=1" \ - && pass "First run reports decayed=1" \ + && echo "$OUT1" | grep -q "expired=0" \ + && pass "First run reports decayed=1 and expired=0" \ || { fail "Expected decayed=1 in first-run report"; echo "$OUT1" | grep "engine-pass"; } # ── Test 3: compat markers touched ─────────────────────────────────────────── @@ -112,7 +113,7 @@ if [ -f "$CDIR/.review-digest.json" ]; then DIGEST_OK=$(python3 -c " import json d = json.load(open('$CDIR/.review-digest.json')) -required = ['generated_at', 'total_items', 'laws_active', 'laws_cap'] +required = ['generated_at', 'total_items', 'laws_active', 'laws_cap', 'swaps_last_run', 'expired_last_run'] print('ok' if all(k in d for k in required) else 'missing-keys') " 2>&1) [ "$DIGEST_OK" = "ok" ] && pass "Digest JSON valid with required keys" || fail "Digest malformed: $DIGEST_OK" diff --git a/tests/test_distill_v4.sh b/tests/test_distill_v4.sh index 950bb20..480f13f 100755 --- a/tests/test_distill_v4.sh +++ b/tests/test_distill_v4.sh @@ -220,6 +220,138 @@ else fail "lazy-occurrences-migration: got '$result'" fi +# ── Test 6: saturated law cap auto-swaps a mature zero-impact victim ──────── +echo "--- Test 6: saturated-cap-auto-swap ---" +T6="$SANDBOX/t6" +mkdir -p "$T6/laws" +OLD_TS=$(python3 -c "import datetime; print((datetime.datetime.now() - datetime.timedelta(days=40)).strftime('%Y%m%d%H%M'))") +for i in 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14; do + echo "Existing law $i unique content" > "$T6/laws/law-$i.txt" + touch -t "$OLD_TS" "$T6/laws/law-$i.txt" +done +make_instinct "$T6/instincts/global" "t6-auto-swap" "0.9600" \ + "occurrences_v4: 12 +projects_seen: + - proj-alpha + - proj-beta + - proj-gamma" + +result=$(python3 -c " +$(py_preamble "$T6") +promoted, candidates = de.auto_promote_to_law() +p = promoted[0] if promoted else {} +victim = p.get('swapped_out') +new_law = (de.LAWS_DIR / 't6-auto-swap.txt').exists() +victim_gone = victim and not (de.LAWS_DIR / f'{victim}.txt').exists() +archive_hit = victim and list((de.LAWS_DIR / 'archive').glob(f'{victim}.*.txt')) +print(p.get('id') == 't6-auto-swap', bool(victim), new_law, bool(victim_gone), bool(archive_hit)) +") +if echo "$result" | grep -q "^True True True True True$"; then + pass "saturated-cap-auto-swap: new law written, victim archived, swapped_out reported" +else + fail "saturated-cap-auto-swap: got '$result'" +fi + +# ── Test 7: victim younger than 30d blocks auto-swap and surfaces candidate ── +echo "--- Test 7: saturated-cap-young-victim-blocked ---" +T7="$SANDBOX/t7" +mkdir -p "$T7/laws" +YOUNG_TS=$(python3 -c "import datetime; print((datetime.datetime.now() - datetime.timedelta(days=10)).strftime('%Y%m%d%H%M'))") +for i in 00 01 02 03 04 05 06 07 08 09 10 11 12 13 14; do + echo "Young law $i unique content" > "$T7/laws/law-$i.txt" + touch -t "$YOUNG_TS" "$T7/laws/law-$i.txt" +done +make_instinct "$T7/instincts/global" "t7-young-blocked" "0.9700" \ + "occurrences_v4: 12 +projects_seen: + - proj-alpha + - proj-beta + - proj-gamma" + +result=$(python3 -c " +$(py_preamble "$T7") +promoted, candidates = de.auto_promote_to_law() +cand = next((c for c in candidates if c['id'] == 't7-young-blocked'), None) +reason = ' '.join(cand.get('reasons', [])) if cand else '' +print(not promoted, cand is not None, 'age' in reason and '< 30d' in reason, not (de.LAWS_DIR / 't7-young-blocked.txt').exists()) +") +if echo "$result" | grep -q "^True True True True$"; then + pass "saturated-cap-young-victim-blocked: no swap, candidate reason mentions age < 30d" +else + fail "saturated-cap-young-victim-blocked: got '$result'" +fi + +# ── Test 8: expire_stale_proposals rejects only old pending proposals ─────── +echo "--- Test 8: expire-stale-proposals ---" +T8="$SANDBOX/t8" +mkdir -p "$T8" +OLD_DETECTED=$(python3 -c "import datetime; print((datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=40)).strftime('%Y-%m-%d'))") +TODAY_DETECTED=$(python3 -c "import datetime; print(datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d'))") +python3 - < "$T9/laws/law-$i.txt" + touch -t "$OLD_TS" "$T9/laws/law-$i.txt" +done +make_instinct "$T9/instincts/global" "t9-dry-swap" "0.9800" \ + "occurrences_v4: 12 +projects_seen: + - proj-alpha + - proj-beta + - proj-gamma" +python3 - < p.id).sort(); +console.log(JSON.stringify(ids) === JSON.stringify(['gotcha-ttl-fresh']) ? 'OK' : 'FAIL:' + JSON.stringify(ids)); +process.exit(0); +") +[ "$result" = "OK" ] && pass "tombstone gate: cx-maintain-ttl rejected id never resurrects" || fail "ttl tombstone: $result" + # v3.37.2: isError heuristic guards — WebFetch 200-OK bodies and test-runner # output are not errors (real false positives gotcha-WebFetch-c8b45df1 and # gotcha-Bash-560c85ee). Explicit err flag always wins. diff --git a/tests/test_session_start.sh b/tests/test_session_start.sh index e6bcabd..74c242e 100755 --- a/tests/test_session_start.sh +++ b/tests/test_session_start.sh @@ -158,6 +158,74 @@ else fi rm -rf "$T4" +# ── Test 4b: fresh maintain digest emits informative badge ───────────────── +echo "--- Test 4b: fresh maintain digest badge ---" +T4B="$(mktemp -d -t cortex-sstart-t4b-XXXXXX)" +python3 - <48h) emits no badge ─────────────────── +echo "--- Test 4c: stale maintain digest ignored ---" +T4C="$(mktemp -d -t cortex-sstart-t4c-XXXXXX)" +python3 - <