From 33a83c4691b9ce6cdbed42d31eab1bc741bd3de1 Mon Sep 17 00:00:00 2001 From: Fernando Montero Date: Sun, 5 Jul 2026 16:15:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(laws):=20v4.3.1=20=E2=80=94=20retired=20law?= =?UTF-8?q?s=20cascade=20to=20instincts=20instead=20of=20archive-only=20de?= =?UTF-8?q?ath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator feedback on v4.3.0: retiring a law removed the knowledge from BOTH layers at once (promotion had archived the source instinct to prevent double injection; retirement archived the law). - _restore_backing_instinct(): shared by the auto-swap retirement and demote_law_to_domain. Victim's backing YAML returns to instincts/global/ with law_eligible:false (no re-promotion ping-pong), so the knowledge keeps injecting via PreToolUse and ages out through normal instinct decay if it truly stopped mattering. - _find_backing_instinct_yaml(): also fixes demote_law_to_domain's lookup, which searched instincts/archive/.yaml while every v4 promotion archives as instincts/global/archive/.promoted-to-law- .yaml — different name AND directory; v4-promoted laws could never be demoted back. - No-backing laws (manually seeded, no trigger) stay archive-only: a trigger is never invented. Logged as retired-archive-only. test_distill_v4 10->12; law_tier/runner/integrity/security/dream green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 +++++++++ README.md | 5 +- commands/cx-maintain.md | 4 +- docs/FEATURES.md | 7 +-- hooks/lib/distill_engine.py | 95 +++++++++++++++++++++++++++---------- install.ps1 | 2 +- install.sh | 2 +- tests/test_distill_v4.sh | 72 ++++++++++++++++++++++++++++ 8 files changed, 178 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 913b430..72984c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ 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.1] — 2026-07-05 + +### Fixed +- **Retired laws now cascade to the instinct pool instead of dying in + `laws/archive/`** (operator feedback on v4.3.0). The auto-swap retirement + and `demote_law_to_domain` share the new `_restore_backing_instinct()`: + the victim's backing YAML is restored into `instincts/global/` with + `law_eligible: false` (blocks the re-promotion ping-pong), so the knowledge + keeps injecting via PreToolUse and decays/archives through the normal + instinct lifecycle if it truly stopped mattering. Knowledge-cascade instead + of two-layer loss: promotion had archived the source instinct (v4.2.0 + anti-double-injection), so retiring the law used to remove the knowledge + from BOTH layers at once. +- **`demote_law_to_domain` lookup could never find v4-promoted instincts**: + it searched `instincts/archive/.yaml` while every promotion path since + v4.0 archives as `instincts/global/archive/.promoted-to-law-.yaml` + — different name AND different directory. The shared + `_find_backing_instinct_yaml()` checks live global/, the promotion-archive + name (newest wins) and the legacy path. +- Laws with no backing YAML (manually seeded, no trigger) stay archive-only — + a trigger is never invented, an instinct without one would silently never + inject. Logged as `retired-archive-only`. `test_distill_v4` 10→12. + ## [4.3.0] — 2026-07-05 **Zero-touch mode.** The learning pipeline no longer waits on a human anywhere: diff --git a/README.md b/README.md index cf14f88..e69edfe 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - **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 — 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 +- **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); the retired law falls back to the instinct pool instead of dying (v4.3.1) — 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) @@ -202,7 +202,8 @@ pass already did (law swaps, expired proposals, queue): 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 - (auto-swapping the least-impactful law when the cap is full, v4.3.0) + (auto-swapping the least-impactful law when the cap is full, v4.3.0; + the retired law falls back to the instinct pool, v4.3.1) → stale pending proposals expire on their own after 30 days → /cx-review is an optional veto pass — nothing ever waits on you ``` diff --git a/commands/cx-maintain.md b/commands/cx-maintain.md index 7d7b324..0d1126b 100644 --- a/commands/cx-maintain.md +++ b/commands/cx-maintain.md @@ -15,7 +15,9 @@ 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. +guards; the retired law cascades back to the instinct pool (`law_eligible: +false`, v4.3.1) instead of dying in archive; 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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e27c81c..4aa23c1 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,4 +1,4 @@ -# fs-cortex v4.3.0 — Feature Reference +# fs-cortex v4.3.1 — Feature Reference > Complete inventory of all features, commands, hooks, modules, and capabilities. > Last updated: 2026-07-05 @@ -252,7 +252,7 @@ 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). **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-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`; the retired law cascades to the instinct pool (v4.3.1 — backing YAML restored with `law_eligible: false`, archive-only when no backing/trigger exists); 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 | @@ -420,7 +420,7 @@ Deterministic rules via hooks — not probabilistic instructions. Triggers are r | `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` | 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_distill_v4.sh` | 12 | **(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; **v4.3.1**: retired law cascades to instinct (backing YAML restored, `law_eligible: false`, trigger intact), no-backing victim stays archive-only | | `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); **v4.3.0**: engine-pass report carries `swapped=`/`expired=` counters | @@ -670,6 +670,7 @@ 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.1 | 2026-07-05 | **Retired laws cascade to instincts (operator feedback on v4.3.0).** Auto-swap retirement and `demote_law_to_domain` share `_restore_backing_instinct()`: the victim's backing YAML returns to `instincts/global/` with `law_eligible: false` (no re-promotion ping-pong), keeping the knowledge injecting via PreToolUse and subject to normal decay. Fixes the two-layer loss (promotion archives the instinct; retirement used to archive the law → knowledge gone from both). Also fixes `demote_law_to_domain`'s lookup, blind to the `.promoted-to-law-.yaml` name every v4 promotion writes. No-backing laws stay archive-only (never invent a trigger). `test_distill_v4` 10→12. | | 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 73cd370..0e0122e 100644 --- a/hooks/lib/distill_engine.py +++ b/hooks/lib/distill_engine.py @@ -845,6 +845,56 @@ def _archive_promoted_instinct_source( ) +def _find_backing_instinct_yaml(iid: str) -> tuple[Path | None, bool]: + """Locate the YAML backing a law: live instincts/global/.yaml, the + promotion archive (.promoted-to-law-*.yaml, newest wins — the name + every promotion path writes since v4.0/v4.2), or the legacy + instincts/archive/.yaml location. Returns (path, needs_restore). + Pre-v4.3.1 the demote lookup only knew the legacy name, so any law + promoted by the v4 paths could never be demoted back.""" + live = INSTINCTS_DIR / f"{iid}.yaml" + if live.exists(): + return live, False + promo = sorted((INSTINCTS_DIR / "archive").glob(f"{iid}.promoted-to-law-*.yaml")) + if promo: + return promo[-1], True + legacy = INSTINCTS_DIR.parent / "archive" / f"{iid}.yaml" + if legacy.exists(): + return legacy, True + return None, False + + +def _restore_backing_instinct(iid: str) -> tuple[bool, str]: + """Restore a retired law's backing YAML into instincts/global/ with + law_eligible:false, so the knowledge keeps injecting via PreToolUse + instead of dying in laws/archive/. The flag blocks the re-promotion + ping-pong (auto_promote_to_law skips it). Refuses when no backing YAML + with a usable trigger exists — a trigger is never invented (an instinct + without one would silently never inject). Returns (ok, reason).""" + src, needs_restore = _find_backing_instinct_yaml(iid) + if src is None: + return False, "no backing instinct yaml" + result = _read_instinct(src) + if result is None: + return False, f"backing yaml unreadable: {src.name}" + fields, text = result + if not str(fields.get("trigger", "")).strip(): + return False, f"backing yaml {src.name} has no trigger" + new_text = _set_frontmatter_field(text, "law_eligible", "false") + INSTINCTS_DIR.mkdir(parents=True, exist_ok=True) + dest = INSTINCTS_DIR / f"{iid}.yaml" + try: + _atomic_write(dest, new_text) + except OSError as e: + return False, f"restore write failed: {e}" + if needs_restore and src != dest: + try: + src.unlink() + except OSError: + pass # stale archive copy is harmless + return True, f"instinct restored from {src.name} (law_eligible:false)" + + def _swap_promote_unlocked( new_iid: str, deprecate_iid: str, @@ -948,14 +998,24 @@ def _swap_promote_unlocked( new_instinct_path, new_iid, today, "cx-distill-swap", "promoted to law via swap" ) + # v4.3.1 — cascade instead of kill: the retired law falls back to the + # instinct pool (law_eligible:false) so its knowledge keeps injecting via + # PreToolUse. Laws with no backing YAML (manually seeded, no trigger) + # stay archive-only — a trigger is never invented. + demoted_ok, demote_note = _restore_backing_instinct(deprecate_iid) + _log_knowledge( + "demoted-to-instinct" if demoted_ok else "retired-archive-only", + deprecate_iid, demote_note, source="cx-distill-swap", + ) + _log_knowledge( "swap-promoted", new_iid, f"archived={deprecate_iid} archive_file={archive_path.name}", source="cx-distill-swap", ) return True, ( - f"swapped: {deprecate_iid} → archive/{archive_path.name}; " - f"{new_iid} promoted (conf={conf:.2f})" + f"swapped: {deprecate_iid} → archive/{archive_path.name} " + f"({demote_note}); {new_iid} promoted (conf={conf:.2f})" ) @@ -993,16 +1053,10 @@ def demote_law_to_domain( if not law_path.exists() or "archive" in str(law_path): return False, f"law not found: {law_id}.txt" - instincts_archive = INSTINCTS_DIR.parent / "archive" - global_yaml = INSTINCTS_DIR / f"{law_id}.yaml" - archive_yaml = instincts_archive / f"{law_id}.yaml" - src_yaml: Path | None = None - restore_from_archive = False - if global_yaml.exists(): - src_yaml = global_yaml - elif archive_yaml.exists(): - src_yaml = archive_yaml - restore_from_archive = True + # v4.3.1 — shared lookup that also finds the .promoted-to-law-*.yaml + # name the v4 promotion paths write (the old exact-name lookup could never + # demote a law promoted after v4.0). + src_yaml, _needs_restore = _find_backing_instinct_yaml(law_id) if src_yaml is None: return False, ( f"no instinct yaml backing for {law_id}; materialize it (with a " @@ -1025,18 +1079,11 @@ def demote_law_to_domain( f"with law_eligible:false" ) - # 1. Ensure the instinct yaml is in global/ flagged law_eligible:false. - new_text = _set_frontmatter_field(text, "law_eligible", "false") - INSTINCTS_DIR.mkdir(parents=True, exist_ok=True) - try: - _atomic_write(global_yaml, new_text) - except OSError as e: - return False, f"write instinct yaml failed: {e}" - if restore_from_archive: - try: - archive_yaml.unlink() - except OSError: - pass # archive copy left behind is harmless + # 1. Restore the backing instinct into global/ flagged law_eligible:false + # (shared with the auto-swap retirement cascade, v4.3.1). + restored_ok, restore_note = _restore_backing_instinct(law_id) + if not restored_ok: + return False, f"restore instinct failed: {restore_note}" # 2. Archive the law .txt (reversible — kept in laws/archive/). archive_dir = LAWS_DIR / "archive" diff --git a/install.ps1 b/install.ps1 index e77a5e4..35c9f4c 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.3.0" +$NewVersion = "4.3.1" # 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 9bd726a..21e7ca8 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.3.0" +NEW_VERSION="4.3.1" # 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_distill_v4.sh b/tests/test_distill_v4.sh index 480f13f..25e1f90 100755 --- a/tests/test_distill_v4.sh +++ b/tests/test_distill_v4.sh @@ -352,6 +352,78 @@ else fail "dry-run-no-mutations: got '$result'" fi +# ── Test 10: retired law cascades to instinct when a backing YAML exists ──── +# v4.3.1 — the victim of an auto-swap must fall back to the instinct pool +# (law_eligible:false) instead of dying in laws/archive/. The backing YAML +# uses the .promoted-to-law-.yaml name every v4 promotion writes. +echo "--- Test 10: retired-law-demotes-to-instinct ---" +T10="$SANDBOX/t10" +mkdir -p "$T10/laws" +VETERAN_TS=$(python3 -c "import datetime; print((datetime.datetime.now() - datetime.timedelta(days=50)).strftime('%Y%m%d%H%M'))") +for i in 00 01 02 03 04 05 06 07 08 09 10 11 12 13; do + echo "Cascade law $i unique content" > "$T10/laws/law-$i.txt" + touch -t "$OLD_TS" "$T10/laws/law-$i.txt" +done +echo "Veteran law unique content" > "$T10/laws/law-veteran.txt" +touch -t "$VETERAN_TS" "$T10/laws/law-veteran.txt" +make_instinct "$T10/instincts/global/archive" "law-veteran" "0.9500" +mv "$T10/instincts/global/archive/law-veteran.yaml" \ + "$T10/instincts/global/archive/law-veteran.promoted-to-law-20260101.yaml" +make_instinct "$T10/instincts/global" "t10-cascade" "0.9600" \ + "occurrences_v4: 12 +projects_seen: + - proj-alpha + - proj-beta + - proj-gamma" + +result=$(python3 -c " +$(py_preamble "$T10") +promoted, candidates = de.auto_promote_to_law() +p = promoted[0] if promoted else {} +victim = p.get('swapped_out') +restored = de.INSTINCTS_DIR / 'law-veteran.yaml' +fields = de._read_instinct(restored)[0] if restored.exists() else {} +promo_consumed = not list((de.INSTINCTS_DIR / 'archive').glob('law-veteran.promoted-to-law-*.yaml')) +print(victim == 'law-veteran', restored.exists(), str(fields.get('law_eligible', '')).strip().lower() == 'false', fields.get('trigger') == 'SomeTool', promo_consumed) +") +if echo "$result" | grep -q "^True True True True True$"; then + pass "retired-law-demotes-to-instinct: backing YAML restored with law_eligible:false, trigger intact" +else + fail "retired-law-demotes-to-instinct: got '$result'" +fi + +# ── Test 11: victim without backing YAML stays archive-only (no invention) ── +echo "--- Test 11: retired-law-no-backing-archive-only ---" +T11="$SANDBOX/t11" +mkdir -p "$T11/laws" +for i in 00 01 02 03 04 05 06 07 08 09 10 11 12 13; do + echo "Solo law $i unique content" > "$T11/laws/law-$i.txt" + touch -t "$OLD_TS" "$T11/laws/law-$i.txt" +done +echo "Solo veteran unique content" > "$T11/laws/law-solo.txt" +touch -t "$VETERAN_TS" "$T11/laws/law-solo.txt" +make_instinct "$T11/instincts/global" "t11-no-backing" "0.9600" \ + "occurrences_v4: 12 +projects_seen: + - proj-alpha + - proj-beta + - proj-gamma" + +result=$(python3 -c " +$(py_preamble "$T11") +promoted, candidates = de.auto_promote_to_law() +p = promoted[0] if promoted else {} +victim = p.get('swapped_out') +archive_hit = victim and list((de.LAWS_DIR / 'archive').glob(f'{victim}.*.txt')) +no_invented = victim and not (de.INSTINCTS_DIR / f'{victim}.yaml').exists() +print(victim == 'law-solo', bool(archive_hit), bool(no_invented)) +") +if echo "$result" | grep -q "^True True True$"; then + pass "retired-law-no-backing-archive-only: swap succeeded, no instinct invented" +else + fail "retired-law-no-backing-archive-only: got '$result'" +fi + echo echo "=== Results: $PASS passed, $FAIL failed ===" if [ "$FAIL" -gt 0 ]; then