From 0f201b1f2a8f35ebc6bf86cd1bd62f28819c10e6 Mon Sep 17 00:00:00 2001 From: Fernando Montero Date: Sun, 5 Jul 2026 06:36:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(laws):=20v4.2.1=20=E2=80=94=20AD=20(Codex?= =?UTF-8?q?=20GPT-5.5)=20follow-up=20on=20v4.1/v4.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only adversarial review with Codex GPT-5.5 caught 4 real issues the Sonnet swarms and the earlier adversarial pass missed. All evaluated as real, not fluff. Fixed: - [P1] load_laws() crashed all of SessionStart on a malformed laws-meta.json (uncaught AttributeError on a non-dict schema). Now tolerant at every level; a SessionStart hook must never crash. Regression tests added. - [P1] impact.archive retention: v4.1.0 count-based keep=5 could delete chunks <90d, contradicting the "never deleted" contract; DESIGN-V4 §7 mandates 90-day. Switched to 90-day age-based pruning; reconciled the stale comments. Docs/tests: - test_session_start.sh: laws-meta robustness (malformed -> no crash) + tier split. - FEATURES.md caught up (v4.1/v4.2/v4.2.1 version-history rows, date, archive wording). Codex confirmed correct (untouched): injector .txt filter, guard w/o laws/, principle default, manual_swap_promote project-instinct search. Full suite green (39/39). Deployed v4.2.0 -> v4.2.1 and verified live. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 37 ++++++++++++++++++ docs/DESIGN-laws-v4.2.md | 11 ++++++ docs/FEATURES.md | 9 +++-- hooks/lib/impact_log.py | 5 ++- hooks/lib/storage-rotation.js | 37 ++++++++++++++++-- hooks/session-learner.js | 3 +- hooks/session-start.py | 11 +++++- install.ps1 | 2 +- install.sh | 2 +- tests/test_session_start.sh | 73 +++++++++++++++++++++++++++++++++++ 10 files changed, 177 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb2141..3ea874e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ 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.2.1] — 2026-07-05 + +**AD follow-up (Codex GPT-5.5).** After merging v4.2.0, a read-only adversarial +review with Codex GPT-5.5 was run over v4.1/v4.2 to catch what the Sonnet swarms +and the earlier adversarial pass missed. All four findings evaluated as real +(not fluff) and fixed. Detail in `docs/DESIGN-laws-v4.2.md` §v4.2.1. + +### Fixed +- **[P1] `load_laws()` crashed SessionStart on a malformed `laws-meta.json`** + (`session-start.py`): `{k: v.get('tier') for k,v in meta.get('laws',{}).items()}` + only caught `FileNotFoundError`/`JSONDecodeError`/`OSError` — a non-dict `laws` + or a non-dict entry raised an uncaught `AttributeError`, killing the entire + SessionStart injection (laws, EOD, reminders). Now tolerant at every level + (validates dicts, `except Exception` backstop, defaults to `principle`). A + SessionStart hook must never crash. Regression tests added. +- **[P1] `impact.archive` retention contradicted the append-only contract** + (`storage-rotation.js`): the v4.1.0 count-based `keep=5` could delete archive + chunks < 90 days old, contradicting the "never deleted" comments in + `impact_log.py`/`session-learner.js`. `DESIGN-V4 §7` actually mandates a + 90-day retention cap — a pre-existing internal contradiction. Fixed to + age-based 90-day pruning (`IMPACT_ARCHIVE_KEEP_DAYS`, `_pruneDirByAge`, + env `CORTEX_IMPACT_ARCHIVE_KEEP_DAYS`); the stale "never deleted" comments + reconciled to the 90-day policy. + +### Added / docs +- **[P2] Laws-meta robustness tests** in `test_session_start.sh` (malformed meta + → no crash; tier split renders `[principios]`/`[herramienta]`; no meta → single + block). +- **[P2] `docs/FEATURES.md` caught up**: version-history rows for v4.1.0 / v4.2.0 / + v4.2.1, header + "Last updated" refreshed, the impact.archive "never deleted" + wording reconciled. + +### Confirmed correct by the AD (left untouched) +- The injector does not treat `laws-meta.json` as a law id (filters `.txt` only); + the twin-law guard does not crash when `laws/` is absent; a law with no meta + entry defaults to `principle`; `manual_swap_promote` searches project instincts. + ## [4.2.0] — 2026-07-05 **Laws layer: de-dup, legibility, post-promotion audit.** Follow-up to the diff --git a/docs/DESIGN-laws-v4.2.md b/docs/DESIGN-laws-v4.2.md index ce2d698..fe83802 100644 --- a/docs/DESIGN-laws-v4.2.md +++ b/docs/DESIGN-laws-v4.2.md @@ -54,3 +54,14 @@ El contra-análisis refutó las ideas invasivas: - Test pair + `run_all.sh` verde. - Smoke `echo '{}' | python3 hooks/session-start.py` con los dos bloques. - Deploy `install.sh` + verificar dedup (0 instincts gemelos), advisor adelgazada, digest con law_audit. + +## v4.2.1 — follow-up AD (Codex GPT-5.5) + +Tras mergear v4.2.0 se pasó un AD read-only con Codex GPT-5.5 sobre v4.1 + v4.2 para cazar lo que los enjambres Sonnet y el contra-análisis previo pasaron por alto. Evaluados como reales (no paja) y corregidos: + +- **[P1] `load_laws()` crasheaba SessionStart con `laws-meta.json` malformado.** `{k: v.get('tier') for k,v in meta.get('laws',{}).items()}` solo capturaba `FileNotFoundError`/`JSONDecodeError`/`OSError`; un `laws` no-dict o un valor no-dict lanzaba `AttributeError` no capturado → moría toda la inyección de SessionStart. Fix: parseo tolerante (valida dict en cada nivel, `except Exception` de respaldo, degrada a tier `principle`). Test de regresión en `test_session_start.sh` (malformado → exit 0 + bloque presente). +- **[P1] `impact.archive` violaba el contrato "never deleted".** El `keep=5` por conteo (v4.1.0) podía borrar chunks < 90 días, contradiciendo `impact_log.py`/`session-learner.js`/`FEATURES.md`. Pero `DESIGN-V4 §7` sí decide "techo de retención 90 días" — contradicción interna previa. Fix: poda por EDAD de 90 días (`IMPACT_ARCHIVE_KEEP_DAYS`, `_pruneDirByAge`) que implementa la decisión real del diseño; comentarios "never deleted" reconciliados al 90-día. +- **[P2] `FEATURES.md` sin filas v4.1/v4.2 + fecha stale.** Actualizado header, fecha y version-history (v4.1.0/v4.2.0/v4.2.1). +- **[P2] Sin test del split por tier ni de meta corrupto.** Añadidos (Test 5/6/7 en `test_session_start.sh`). + +Codex confirmó como correctos (no tocar): el injector no cuela `laws-meta.json` como law-id (filtro `.txt`), el guard no crashea sin `laws/`, ley sin meta → `principle`, `manual_swap_promote` busca instincts de proyecto. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 9d5dfa2..477809f 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,7 +1,7 @@ -# fs-cortex v4.2.0 — Feature Reference +# fs-cortex v4.2.1 — Feature Reference > Complete inventory of all features, commands, hooks, modules, and capabilities. -> Last updated: 2026-07-02 +> Last updated: 2026-07-05 > > **v4.0.0 ("signal-first, zero-decision")** replaces the manual-judgment > command set with 7 active commands (`/cx-status`, `/cx-maintain`, @@ -223,7 +223,7 @@ or `python3 impact_log.py stats --json`. - `noise_event = feedback.noise OR follow.followed == false` - `useful_ratio = useful / inject`, `health_ratio = useful_ratio / max(noise_ratio, 0.01)` - Sprint 0.5 Go/No-Go Gate: GO ≥ 0.25 / 1.5 · PARTIAL ≥ 0.10 / 1.0 · NO-GO < 0.10 / 1.0 -- Rotation at 30 days to `~/.claude/cortex/impact.archive/impact--.jsonl` — **wired in v3.35.1** (#56.2) from session-learner Step 5f; rename-first algorithm is loss-proof under concurrent JS/Python writers and never deletes events (pre-scan no-op → atomic rename → recent events re-appended in line-aligned chunks on an `O_APPEND` fd → static archive rewritten with only old events) +- Rotation at 30 days to `~/.claude/cortex/impact.archive/impact--.jsonl` — **wired in v3.35.1** (#56.2) from session-learner Step 5f; rename-first algorithm is loss-proof under concurrent JS/Python writers and never loses events during rotation (pre-scan no-op → atomic rename → recent events re-appended in line-aligned chunks on an `O_APPEND` fd → static archive rewritten with only old events); archived chunks are pruned only after 90 days (`storage-rotation.js`, `CORTEX_IMPACT_ARCHIVE_KEEP_DAYS`, DESIGN-V4 §7) - Writes never block calling hook — best-effort with retry + silent stderr fallback ### hooks/lib/impact_log.js — JS Writer Mirror (v3.14.0) @@ -666,3 +666,6 @@ locally (not installed to `~/.claude/`): | v3.38.1 | 2026-06-22 | **`/cx-eod --auto` made fully deterministic (no model call).** Added `--write` mode to `core/_cx-eod-gather.sh`: it composes the daily summary markdown itself (project sections + `## Ejecuciones hoy` intraday trace with exact-dup dedup + `## Quick Resume`/`### For tomorrow` for `session-start.py` reinjection) and writes it atomically. Schedulers call `bash _cx-eod-gather.sh --write` directly instead of `claude -p` → zero subscription/API quota, no auth-token/Keychain friction, no risk of a stray `ANTHROPIC_API_KEY` silently billing pay-as-you-go. `examples/launchd/` + cron docs rewritten to bash-direct; `commands/cx-eod.md` documents the deterministic path. Adversarial review hardening: write-mode sanitizes untrusted strings (names/branches/files) at the source against reinjection prompt-injection, serializes read-merge-write with an O_EXCL lockfile, and cleans the temp file on failure. `test_cx_eod_gather.sh` 11 → 17. Interactive `/cx-eod` still composes with Claude. | | v3.38.0 | 2026-06-19 | **`/cx-eod` deterministic gather + intraday cron support.** New `core/_cx-eod-gather.sh` (pure Node, no LLM) scans all registered projects' last-24h observations + git per root and emits structured JSON — previously Claude scanned projects by hand at token cost. Adapted to the Cortex obs schema (`ts/ev/tool/err/pid/pname/input`, distinct from Sinapsis), cross-OS safe (basename splits `/` and `\`, foreign roots skipped, projects merged by name). `commands/cx-eod.md` Step 1 invokes it with legacy fallback. Intraday idempotency: re-runs REGENERATE the day file (24h window → no dupes) and accumulate a `## Ejecuciones hoy` trace; new `--auto` flag (and non-TTY detection) skips the overwrite prompt for cron/launchd. `examples/launchd/` ships a generic plist + install helper + README (defaults 15/19/22, Linux cron snippet). `install.sh`/`install.ps1` deploy `core/*.sh` to `~/.claude/cortex/core/`. New `tests/test_cx_eod_gather.sh` (9/9 PASS). | | 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.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/impact_log.py b/hooks/lib/impact_log.py index 72becf0..4f8827e 100755 --- a/hooks/lib/impact_log.py +++ b/hooks/lib/impact_log.py @@ -47,8 +47,9 @@ # outcome-nudge, compute_metrics, /cx-status, /cx-retro) reads at most 14 # days, but high-volume operators accumulated 60+ MB of live JSONL under # the old 30-day window. Events beyond the window are ARCHIVED to -# impact.archive/, never deleted. Floor of 15 keeps rotation outside the -# 14-day consumer window. +# impact.archive/; archived chunks older than 90 days are pruned by +# storage-rotation.js per DESIGN-V4 §7. Floor of 15 keeps rotation outside +# the 14-day consumer window. try: # v3.37.0: floor raised 15 → 18. The widest reader window is 14 days; a # 15-day live window left a 1-day margin that clock drift or an early diff --git a/hooks/lib/storage-rotation.js b/hooks/lib/storage-rotation.js index dbfee75..8d0064d 100644 --- a/hooks/lib/storage-rotation.js +++ b/hooks/lib/storage-rotation.js @@ -41,7 +41,12 @@ const FIREONCE_MAX_DAYS = Math.max(1, parseInt(process.env.CORTEX_FIREONCE_MAX_D // v3.37.0 (audit 2026-07-04) — impact.archive/ (rotated impact.jsonl chunks) // and log/timeline.jsonl had no prune path: 105MB archive dir, 5.1MB/79k-line // timeline observed. -const IMPACT_ARCHIVE_KEEP_FILES = Math.max(1, parseInt(process.env.CORTEX_IMPACT_ARCHIVE_KEEP_FILES || '5', 10) || 5); +// v4.2.1 (AD P1, 2026-07-05): count-based pruning could delete archive +// chunks younger than DESIGN-V4.md §7's 90-day retention window (e.g. a +// burst of small rotations ages out chunk #1 well before day 90). Switched +// to age-based (mtime) pruning to match the documented contract and the +// _pruneStaleLocks/_pruneBackupFiles pattern already used in this file. +const IMPACT_ARCHIVE_KEEP_DAYS = parseInt(process.env.CORTEX_IMPACT_ARCHIVE_KEEP_DAYS || '90', 10) || 90; const TIMELINE_ROTATE_MB = parseFloat(process.env.CORTEX_TIMELINE_ROTATE_MB || '2'); const TIMELINE_KEEP_LINES = Math.max(1, parseInt(process.env.CORTEX_TIMELINE_KEEP_LINES || '1000', 10) || 1000); const MARKER_NAME = '.last-storage-rotate'; @@ -120,6 +125,28 @@ function _pruneDirByCount(dir, keep, log, label, archiveDir) { log(`Storage rotation: ${label} pruned ${doomed.length} file(s) (keep newest ${keep})`); } +// Age-based prune (mtime), same walker pattern as _pruneStaleLocks / +// _pruneBackupFiles below: files are deleted once older than `maxDays`, +// regardless of how many siblings remain. Used where a retention window is +// a contractual guarantee (DESIGN-V4.md §7) rather than a storage cap, so a +// burst of small files can never evict one still inside the window. +function _pruneDirByAge(dir, maxDays, log, label) { + const cutoff = Date.now() - maxDays * DAY_MS; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isFile() && !e.name.startsWith('.')); + } catch (_) { return; } + let removed = 0; + for (const e of entries) { + const full = path.join(dir, e.name); + try { + if (fs.statSync(full).mtimeMs < cutoff) { fs.unlinkSync(full); removed++; } + } catch (_) {} + } + if (removed > 0) log(`Storage rotation: ${label} pruned ${removed} file(s) older than ${maxDays}d`); +} + function _pruneFireOnceMarkers(log) { const dir = path.join(CORTEX_DIR, '.fire-once'); let removed = 0; @@ -245,9 +272,11 @@ function maybeRotateStorage(log) { } } // impact.archive/ itself is unbounded (each rotate adds a file, never - // removed) — keep only the newest IMPACT_ARCHIVE_KEEP_FILES. + // removed) — DESIGN-V4.md §7 sets a 90-day retention window for archived + // chunks, so prune by age (IMPACT_ARCHIVE_KEEP_DAYS), not by count: a + // count cap could delete a chunk still inside the 90-day window. try { - _pruneDirByCount(path.join(CORTEX_DIR, 'impact.archive'), IMPACT_ARCHIVE_KEEP_FILES, log, 'impact.archive'); + _pruneDirByAge(path.join(CORTEX_DIR, 'impact.archive'), IMPACT_ARCHIVE_KEEP_DAYS, log, 'impact.archive'); } catch (e) { log(`Storage rotation: impact.archive prune error: ${e.message}`); } @@ -318,5 +347,5 @@ function maybeRotateStorage(log) { module.exports = { maybeRotateStorage, IMPACT_ROTATE_MB, TRACKER_PRUNE_MB, MARKER_NAME, HISTORY_ROTATE_MB, KNOWLEDGE_ROTATE_MB, DAILY_KEEP_FILES, FIREONCE_MAX_DAYS, - IMPACT_ARCHIVE_KEEP_FILES, TIMELINE_ROTATE_MB, TIMELINE_KEEP_LINES, + IMPACT_ARCHIVE_KEEP_DAYS, TIMELINE_ROTATE_MB, TIMELINE_KEEP_LINES, }; diff --git a/hooks/session-learner.js b/hooks/session-learner.js index 345b7f2..bec108d 100755 --- a/hooks/session-learner.js +++ b/hooks/session-learner.js @@ -2294,7 +2294,8 @@ async function main() { // Step 5f: Storage rotation (issue #56.2, v3.35.1) — impact.jsonl and // cross-day-tracker.jsonl had rotation code that was never called from // anywhere. Size-gated + 24h marker; impact events are archived to - // impact.archive/, never deleted. + // impact.archive/; chunks older than 90 days are pruned by + // storage-rotation.js per DESIGN-V4 §7. try { const { maybeRotateStorage } = require(path.join(__dirname, 'lib', 'storage-rotation')); maybeRotateStorage(log); diff --git a/hooks/session-start.py b/hooks/session-start.py index bb28878..570074e 100644 --- a/hooks/session-start.py +++ b/hooks/session-start.py @@ -63,9 +63,18 @@ def load_laws(): meta_tiers = {} try: meta = json.loads((LAWS_DIR / 'laws-meta.json').read_text()) - meta_tiers = {k: v.get('tier', 'principle') for k, v in meta.get('laws', {}).items()} + meta_laws = meta.get('laws', {}) if isinstance(meta, dict) else {} + if isinstance(meta_laws, dict): + for k, v in meta_laws.items(): + tier = v.get('tier', 'principle') if isinstance(v, dict) else 'principle' + meta_tiers[k] = tier if isinstance(tier, str) else 'principle' except (FileNotFoundError, json.JSONDecodeError, OSError): pass + except Exception: + # Any other malformed shape (meta not dict, .get missing, etc.) must + # never crash SessionStart — degrade to "no tiers" (all default + # 'principle' below), same as a missing/absent meta file. + meta_tiers = {} law_files = sorted(LAWS_DIR.glob('*.txt')) laws = [] diff --git a/install.ps1 b/install.ps1 index 7b98271..c77c75e 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.0" +$NewVersion = "4.2.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 5ce690d..cf89a9b 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.0" +NEW_VERSION="4.2.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_session_start.sh b/tests/test_session_start.sh index 7279400..e6bcabd 100755 --- a/tests/test_session_start.sh +++ b/tests/test_session_start.sh @@ -158,6 +158,79 @@ else fi rm -rf "$T4" +echo "" + +# ── Test 5: malformed laws-meta.json shapes → no crash, laws still load ────── +# AD [P2] finding: laws-meta.json can be malformed in shapes load_laws() must +# tolerate without crashing SessionStart. Unlike Tests 1-4 (which drive +# check_maintenance() via module import), these drive the FULL process via +# stdin pipe (`CORTEX_DIR=... python3 hooks/session-start.py`) since the tier +# split happens in main(), not in a function importable in isolation. +echo "--- Test 5: malformed laws-meta.json (v non-dict, laws non-dict, invalid JSON) ---" +run_full_hook() { + # $1 = sandbox CORTEX_DIR + echo '{}' | CORTEX_DIR="$1" python3 "$SESSION_START_PY" 2>&1 +} + +declare -a T5_CASES=( + '{"laws":{"x":"tool"}}' + '{"laws":[]}' + 'xxx' +) +T5_LABELS=( + "v non-dict" + "laws non-dict" + "invalid JSON" +) +for i in "${!T5_CASES[@]}"; do + T5="$(mktemp -d -t cortex-sstart-t5-XXXXXX)" + mkdir -p "$T5/laws" + echo "Fixture law text" > "$T5/laws/a-fixture.txt" + printf '%s' "${T5_CASES[$i]}" > "$T5/laws/laws-meta.json" + out=$(run_full_hook "$T5") + label="${T5_LABELS[$i]}" + if echo "$out" | grep -qi 'traceback\|exception'; then + fail "T5 laws-meta ($label): crashed → $out" + elif echo "$out" | grep -q 'CORTEX LAWS'; then + pass "T5 laws-meta ($label): no crash, CORTEX LAWS present" + else + fail "T5 laws-meta ($label): no crash but CORTEX LAWS missing → $out" + fi + rm -rf "$T5" +done + +# ── Test 6: well-formed laws-meta.json → tier split [principios]/[herramienta] ─ +echo "--- Test 6: well-formed laws-meta.json splits by tier ---" +T6="$(mktemp -d -t cortex-sstart-t6-XXXXXX)" +mkdir -p "$T6/laws" +echo "Principle law text" > "$T6/laws/a-principle.txt" +echo "Tool law text" > "$T6/laws/b-tool.txt" +printf '%s' '{"laws":{"a-principle":{"tier":"principle"},"b-tool":{"tier":"tool"}}}' > "$T6/laws/laws-meta.json" +out=$(run_full_hook "$T6") +if echo "$out" | grep -q '\[principios\]' && echo "$out" | grep -q '\[herramienta\]'; then + pass "T6 well-formed laws-meta: both [principios] and [herramienta] present" +else + fail "T6 well-formed laws-meta: missing tier split → $out" +fi +rm -rf "$T6" + +# ── Test 7: no laws-meta.json → single CORTEX LAWS block (retrocompat) ─────── +echo "--- Test 7: no laws-meta.json (retrocompat, single block) ---" +T7="$(mktemp -d -t cortex-sstart-t7-XXXXXX)" +mkdir -p "$T7/laws" +echo "Some law text" > "$T7/laws/a.txt" +out=$(run_full_hook "$T7") +if echo "$out" | grep -q 'CORTEX LAWS'; then + if echo "$out" | grep -q '\[principios\]\|\[herramienta\]'; then + fail "T7 no laws-meta: unexpected tier split without meta file → $out" + else + pass "T7 no laws-meta: single CORTEX LAWS block, no tier split" + fi +else + fail "T7 no laws-meta: CORTEX LAWS missing → $out" +fi +rm -rf "$T7" + echo "" echo "=== Results: $PASS passed, $FAIL failed ===" [ "$FAIL" -eq 0 ] && exit 0 || exit 1