Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/DESIGN-laws-v4.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 6 additions & 3 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
@@ -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`,
Expand Down Expand Up @@ -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-<ts>-<pid>.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-<ts>-<pid>.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)
Expand Down Expand Up @@ -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. |
5 changes: 3 additions & 2 deletions hooks/lib/impact_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 33 additions & 4 deletions hooks/lib/storage-rotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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,
};
3 changes: 2 additions & 1 deletion hooks/session-learner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion hooks/session-start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
2 changes: 1 addition & 1 deletion install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading