diff --git a/CHANGELOG.md b/CHANGELOG.md index a872a7b..9e980a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,68 @@ 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.5.0] — 2026-07-05 + +**Installer reliability.** A sandboxed 6-agent audit ran the installer against +three scenarios — fresh install, upgrade over realistic v3.37 data, v4 refresh +— and confirmed two real data-loss bugs plus a Windows corruption set. All +fixed; the v3-data migration path came back clean (v4 code self-heals legacy +formats lazily) and is now documented. Implementation: Codex (spec-driven), +verified inline. + +### Security +- `install.ps1`: the backup-archive path-traversal guard regex was + over-escaped in a single-quoted string and never matched `../` entries — + fixed to match install.sh's semantics (`(^/|\.\.)`), validated at prompt + time with `tar -tzf` like the bash installer. + +### Fixed +- **`install.sh`/`install.ps1`: CLAUDE.md user content after the Cortex + section was deleted on every rerun** (confirmed twice in the audit — the + section-replace regex swallowed to the next heading OR EOF). The section + now ships with a `` marker and the replace stops there; + legacy sections without the marker anchor on the known final section line + and never swallow to EOF (explicit warn if neither matches). +- **`install.sh`/`install.ps1`: a user hook sharing a settings.json handler + entry with a cortex hook was silently dropped** — the merge now filters at + hook level and only drops entries left empty. +- `install.sh`: interrupted installs (hooks copied, no `version` file) hit a + silent legacy branch that bypassed the downgrade guard — now warned as + "proceeding as repair"; Step 11 (CLAUDE.md) failures no longer abort the + run under `set -e`, so the version marker is always reached. +- `install.sh` Step 8b deployed the STALE `githooks/pre-push` (75 lines) + instead of `.githooks/pre-push` (149 lines, the real hook used via + `core.hooksPath`) — fixed and the stale directory removed. +- `install.ps1` (Windows corruption set): embedded Python now opens + CLAUDE.md/JSON with explicit UTF-8 (locale cp1252 mojibaked every + multibyte char); settings hooks written with the DETECTED interpreter + (`python` vs hardcoded `python3` — hooks never ran on stock Windows + Python); backup import semantics matched to install.sh (memory/reflexes + OVERWRITE from backup — was dead code; registry/instincts/evolved/daily + no-clobber — was `-Force`; `evolved/` now `-Recurse`); `$LASTEXITCODE` + checked after every native call (settings-merge failure no longer prints + success); `.repo-path` written so the deploy-drift guard works on Windows; + JSON migrations atomic (temp + `Move-Item`); memory version floor no + longer rewinds 2-part versions (`'4.0'` → 3.12.0 bug). +- `install.sh`: `reflexes.json` migration writes atomically (tmp + rename). + +### Added +- **v3→v4 adapter** in both installers: upgrading from < 4 seeds + `.last-learn-count` from the existing observation count, so the + SessionStart observations banner counts since-upgrade instead of the + entire v3 history. +- `docs/MIGRATION-V4.md` §"Automatic v3-data adaptation (v4.5.0)": what + self-heals, what resets by design, what needs action (CORTEX_DEBUG for + silently-skipped hollow YAMLs, 30d TTL on legacy pending proposals, + v3-era cron jobs on deprecated commands are no-ops). +- `core/memory.template.json` version 3.12.0 → 4.0.0 (release rule §2b). + +### Tests +- `test_install` 42→49 (user tail survives rerun, mixed-entry user hook + survives, v3 upgrade seeds marker, repair warning), `test_install_ps1` + 10→18 (interpreter, traversal regex, `.repo-path`, UTF-8, import + semantics, marker contract — runs on windows-latest CI). + ## [4.4.0] — 2026-07-05 **`/cx-curate` — the semantic curator.** Automates the judgment `/cx-downvote` diff --git a/core/claudemd-section.md b/core/claudemd-section.md index 533d8b7..83429e8 100644 --- a/core/claudemd-section.md +++ b/core/claudemd-section.md @@ -10,3 +10,4 @@ Cortex observes sessions and distills knowledge in 3 levels: ### Data: ~/.claude/cortex/ ### Laws inject automatically. Instincts inject per tool use. No action needed. + diff --git a/core/memory.template.json b/core/memory.template.json index 6ad2196..fb6fb4f 100644 --- a/core/memory.template.json +++ b/core/memory.template.json @@ -1,5 +1,5 @@ { - "version": "3.12.0", + "version": "4.0.0", "config": { "max_observations_mb": 10, "archive_days": 30, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 21eecad..557f0b6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,4 +1,4 @@ -# fs-cortex v4.4.0 — Feature Reference +# fs-cortex v4.5.0 — Feature Reference > Complete inventory of all features, commands, hooks, modules, and capabilities. > Last updated: 2026-07-05 @@ -367,6 +367,14 @@ Deterministic rules via hooks — not probabilistic instructions. Triggers are r - **Bash** (macOS/Linux): `bash install.sh` - **PowerShell** (Windows): `powershell -ExecutionPolicy Bypass -File install.ps1` +### Reliability (v4.5.0 — sandboxed audit of fresh/v3-upgrade/v4-refresh) +- **CLAUDE.md section replace is loss-proof**: the section ships with a `` marker and the replace stops there; legacy sections (no marker) anchor on the known final line and NEVER swallow to EOF — user content after the section survives every rerun (previously deleted, confirmed bug) +- **settings.json merge filters at hook level**: a user hook sharing a handler entry with a cortex hook survives (previously the whole entry was dropped) +- **Interrupted installs self-identify**: hooks dir present without `version` file → "proceeding as repair" warning instead of the silent legacy branch that bypassed the downgrade guard; Step 11 failures no longer abort mid-run +- **v3→v4 adapter**: upgrading from < 4 seeds `.last-learn-count` from the existing observation count (kills the lifetime-count banner); full adaptation notes in `docs/MIGRATION-V4.md` +- **Windows parity + corruption fixes** (install.ps1): explicit UTF-8 in every embedded Python (no more cp1252 mojibake of CLAUDE.md), hooks written with the DETECTED interpreter (`python` vs `python3`), backup import overwrite/no-clobber semantics matched to install.sh (+ `evolved/` recursed), path-traversal regex actually rejects `../`, `$LASTEXITCODE` checked after every native call, `.repo-path` written (deploy-drift guard now works on Windows), atomic JSON migrations +- `reflexes.json` migration writes atomically (tmp + rename); pre-push hook installs from `.githooks/` (the stale `githooks/` copy is gone) + ### Smart Upgrade - Version detection via `~/.claude/cortex/version` - Shows upgrade path (e.g., "v3.0.0 → v3.6.0") @@ -417,7 +425,7 @@ Deterministic rules via hooks — not probabilistic instructions. Triggers are r | `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_install.sh` | 49 | 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); **v4.5.0**: user tail after CLAUDE.md section survives rerun (marker), user hook sharing entry with cortex hook survives merge, v3 upgrade seeds `.last-learn-count`, interrupted install → "proceeding as repair" | | `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** | @@ -426,7 +434,7 @@ Deterministic rules via hooks — not probabilistic instructions. Triggers are r | `test_curate.sh` | 6 | **(v4.4.0)** `apply_confidence_downvote` (reduce+log, 0.30 floor, unknown id), `curate_snapshot()` shape on seeded sandbox, `curate_due()`/`touch_curate_marker()` gating, `curate-snapshot` CLI prints valid JSON | | `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 | -| `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_install_ps1.ps1` | 18 | PowerShell syntax, version consistency, security features, backup categories, hook config, v3.26-v3.28 source files present, **CI on windows-latest**; **v4.5.0**: detected-interpreter hooks (no hardcoded python3), traversal regex rejects `../`, `.repo-path` write, UTF-8 in embedded Python, backup overwrite/no-clobber semantics, `` marker contract | | `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 | | `test_detectors_v327.sh` | 12 | detectAgentSubtypes (emit/no-emit/rate), detectFileCoupling (emit/no-emit/missing-path, `::` in paths), detectTimeOfDayPatterns (buckets, empty, merge, reflect JSON structure, corrupted-file no-clobber) | @@ -672,6 +680,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.5.0 | 2026-07-05 | **Installer reliability — sandboxed audit of fresh install, v3-data migration and v4 refresh (6-agent workflow + Codex implementation).** Confirmed data-loss bugs fixed: CLAUDE.md user content after the Cortex section deleted on every rerun (now marker-bounded `` + legacy anchor, never swallows to EOF); user hook sharing a settings entry with a cortex hook dropped (now hook-level filter). Half-install hardening: Step 11 failures no longer abort; missing version file → "proceeding as repair" instead of silently bypassing the downgrade guard. v3→v4 adapter seeds `.last-learn-count`; `docs/MIGRATION-V4.md` documents self-heals / by-design resets / action-needed. Windows: UTF-8 everywhere (cp1252 mojibake fix), detected-interpreter hooks, backup import semantics matched to sh (+ evolved/ recursed), traversal regex fixed, `$LASTEXITCODE` checked, `.repo-path` (drift guard live on Windows), atomic migrations. Stale `githooks/` removed (pre-push installs from `.githooks/`). `core/memory.template.json` → 4.0.0. `test_install` 42→49 (audit had found the table stale at 42 vs 44 real), `test_install_ps1` 10→18. | | v4.4.0 | 2026-07-05 | **`/cx-curate` — semantic curator; `/cx-downvote` judgment automated.** The LLM half of zero-touch: Claude classifies laws (generality, redundancy vs CLAUDE.md/skills, value-per-token), demotes niche laws via the v4.3.1 cascade, promotes only trigger-less knowledge, downvotes broken instincts (`apply_confidence_downvote`, floor 0.30). Weekly `.last-curate` cadence; `[CURATE] due` banner auto-triggers execution. Engine: `curate_snapshot()` + CLI `curate-snapshot`/`downvote`, `curate_due()`/`touch_curate_marker()`, `CURATE_*` constants. Born validated: a 29-agent semantic audit recomposed the live constitution the same day (6 laws demoted to instincts; 0 promotions — every top candidate already lived as a 0.99 trigger-gated instinct, a finding folded into the PROMOTE criterion; 10 broken instincts downvoted; cap 15/15 → 9/15). Implemented by 3 parallel subagents + integration verifier. `test_curate` new 6, `test_session_start` 11→14. Active commands 7→8; `core/claudemd-section.md` refreshed to the v4 set (stale since v4.0.0). | | 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. | diff --git a/docs/MIGRATION-V4.md b/docs/MIGRATION-V4.md index 912918d..83d8bf6 100644 --- a/docs/MIGRATION-V4.md +++ b/docs/MIGRATION-V4.md @@ -92,6 +92,47 @@ touches a given file. `agent-evolution`, `error-recovery`) accumulate for `/cx-review`; the rest auto-validate inside `/cx-maintain`. +## Automatic v3-data adaptation (v4.5.0) + +The installer and runtime were audited end-to-end against a realistic v3.37 +data tree (sandboxed fresh install, v3-data upgrade, v4 re-install). Summary +of what a v3.x user gets, grouped by outcome: + +**Self-heals (no action needed)** + +- Legacy instincts without `status` inject as `confirmed`; `occurrences` is + preserved as `occurrences_legacy` (see the section above). +- Missing `laws/laws-meta.json` is tolerated — every law defaults to tier + `principle` and SessionStart renders normally (`load_laws` is tolerant of a + missing or malformed meta file since v4.2.1). +- Missing v4 artifacts (`.review-digest.json`, `proposals-history.jsonl`, + `instinct-tracking.json`, `.last-curate`) are created lazily by the first + `/cx-maintain` / learner run that needs them. +- **`.last-learn-count` is seeded by the installer** (v4.5.0) from your + existing observation count when upgrading from < 4, so the SessionStart + "N+ new observations" banner counts since-upgrade instead of your entire + v3 history. + +**By-design resets (expected, not bugs)** + +- Law-promotion maturity clocks restart: the deterministic gate counts + `occurrences_v4` from zero and requires 3+ distinct projects again + (`LAW_MIN_PROJECTS` was 1 in late v3). +- Legacy rejected proposals in `proposals-history.jsonl` become permanent + tombstones — previously rejected patterns will not resurface as pending. + +**Degrades silently / action needed** + +- Hollow or corrupt legacy instinct YAMLs (action < 30 chars, raw-JSON + fragments, unparseable frontmatter) are skipped at injection with no + operator-visible signal. Run a session with `CORTEX_DEBUG=1` to list the + skipped files, or delete/fix them. +- Pending v3 proposals in human-gated domains expire after 30 days + (`PROPOSAL_TTL_DAYS`, v4.3.0) unless reviewed via `/cx-review`. +- v3-era cron/launchd jobs invoking deprecated commands (`/cx-distill`, + `/cx-dream`, `/cx-analyze`, `/cx-validate`) exit 0 while doing nothing — + reschedule them to `bin/cx-maintain.sh` (see "Scheduling" below). + ## Deterministic law promotion (replaces manual Criteria 8) A law now promotes automatically, with no `law_eligible: true` flag to set by diff --git a/githooks/pre-push b/githooks/pre-push deleted file mode 100755 index d9f29fe..0000000 --- a/githooks/pre-push +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bash -# Pre-push hook: block pushes to main without version bump + changelog update -# -# Checks that CHANGELOG.md was modified in the commits being pushed. -# Skips check for non-main branches (feature branches can push freely). - -set -euo pipefail - -remote="$1" - -while read -r local_ref local_oid remote_ref remote_oid; do - # Only enforce on main/master - case "$remote_ref" in - refs/heads/main|refs/heads/master) ;; - *) continue ;; - esac - - # If remote_oid is all zeros, it's a new branch — compare against root - if [ "$remote_oid" = "0000000000000000000000000000000000000000" ]; then - range="$local_oid" - else - range="${remote_oid}..${local_oid}" - fi - - # Check if CHANGELOG.md was modified in the commits being pushed - changelog_changed=$(git diff --name-only "$range" -- CHANGELOG.md 2>/dev/null | wc -l | tr -d ' ') - - if [ "$changelog_changed" -eq 0 ]; then - echo "" - echo "❌ PUSH BLOCKED: No CHANGELOG.md changes detected." - echo "" - echo " Before pushing to main, you must:" - echo " 1. Bump version (patch/minor/major) in CHANGELOG.md" - echo " 2. Add a changelog entry describing the changes" - echo " 3. Update version in skills/cortex/SKILL.md if minor/major" - echo "" - echo " To bypass (emergencies only): git push --no-verify" - echo "" - exit 1 - fi - - # Check version consistency across all sources (v3.15.0+) - if [ -f "scripts/check-version-consistency.py" ]; then - if ! python3 scripts/check-version-consistency.py --quiet; then - echo "" - python3 scripts/check-version-consistency.py || true - echo "" - echo "❌ PUSH BLOCKED: version drift between install.sh / install.ps1 / CHANGELOG.md / docs/FEATURES.md" - echo " Fix the versions above and retry." - exit 1 - fi - fi - - # Check that tests exist and pass - if [ -f "tests/test_security.sh" ]; then - echo "Running security tests..." - if ! bash tests/test_security.sh > /dev/null 2>&1; then - echo "❌ PUSH BLOCKED: Security tests failed." - exit 1 - fi - fi - - if [ -f "tests/test_dream_cycle.sh" ]; then - echo "Running dream cycle tests..." - if ! bash tests/test_dream_cycle.sh > /dev/null 2>&1; then - echo "❌ PUSH BLOCKED: Dream cycle tests failed." - exit 1 - fi - fi - - echo "✅ Pre-push checks passed (changelog updated, tests green)" - -done - -exit 0 diff --git a/install.ps1 b/install.ps1 index ce12aa9..04928e7 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.4.0" +$NewVersion = "4.5.0" # v3.25.1 — explicit downgrade flag (parity with install.sh). # A behind-remote repo would silently rewind hooks otherwise. @@ -30,7 +30,15 @@ $AssumeYes = ($args -contains '-y') -or ($args -contains '--yes') -or ($args -co function Test-VersionLessThan { param([string]$A, [string]$B) try { - return [version]$A -lt [version]$B + $aParts = @($A -split '\.' | ForEach-Object { if ($_ -match '^\d+$') { [int]$_ } else { 0 } }) + $bParts = @($B -split '\.' | ForEach-Object { if ($_ -match '^\d+$') { [int]$_ } else { 0 } }) + for ($i = 0; $i -lt 3; $i++) { + $av = if ($i -lt $aParts.Count) { $aParts[$i] } else { 0 } + $bv = if ($i -lt $bParts.Count) { $bParts[$i] } else { 0 } + if ($av -lt $bv) { return $true } + if ($av -gt $bv) { return $false } + } + return $false } catch { # Non-semver fallback: string compare return $A -lt $B @@ -158,6 +166,13 @@ if ((-not $HasCortex) -and (-not $AssumeYes) -and (-not [Console]::IsInputRedire Print-Warn "Not a valid file: $ImportBackup - skipping backup import" $ImportBackup = "" } + elseif ($ImportBackup) { + tar -tzf $ImportBackup *> $null + if ($LASTEXITCODE -ne 0) { + Print-Warn "Not a valid .tar.gz archive - skipping backup import" + $ImportBackup = "" + } + } } # Step 4: Create directory structure @@ -190,14 +205,14 @@ else { Print-Warn "memory.json exists, preserving user data" # Migrate memory.json: remove dead identity block, update version (v3.12.0+) try { - $memJson = Get-Content $memoryDest -Raw | ConvertFrom-Json + $memJson = Get-Content $memoryDest -Raw -Encoding UTF8 | ConvertFrom-Json $changed = $false if ($memJson.PSObject.Properties.Name -contains 'identity') { $memJson.PSObject.Properties.Remove('identity') $changed = $true } - $curVer = ($memJson.version -split '\.') | ForEach-Object { [int]$_ } - if ($curVer.Count -lt 3 -or $curVer[0] -lt 3 -or ($curVer[0] -eq 3 -and $curVer[1] -lt 12)) { + $curRaw = if ($memJson.PSObject.Properties.Name -contains 'version') { [string]$memJson.version } else { "0.0.0" } + if (Test-VersionLessThan $curRaw "3.12.0") { $memJson.version = '3.12.0' $changed = $true } @@ -231,8 +246,8 @@ else { # Migrate reflexes: add new + update matcher/condition/action (v3.10.6+) # Preserves user runtime data: fireCount, lastFired, enabled try { - $userReflexes = Get-Content $reflexesDest -Raw | ConvertFrom-Json - $defaultReflexes = Get-Content ([IO.Path]::Combine($ScriptDir, "core", "reflexes.default.json")) -Raw | ConvertFrom-Json + $userReflexes = Get-Content $reflexesDest -Raw -Encoding UTF8 | ConvertFrom-Json + $defaultReflexes = Get-Content ([IO.Path]::Combine($ScriptDir, "core", "reflexes.default.json")) -Raw -Encoding UTF8 | ConvertFrom-Json $userById = @{} foreach ($u in $userReflexes.reflexes) { $userById[$u.id] = $u } $added = 0; $updated = 0 @@ -278,7 +293,9 @@ else { } } if ($added -gt 0 -or $updated -gt 0) { - $userReflexes | ConvertTo-Json -Depth 10 | Set-Content $reflexesDest -Encoding UTF8 + $tmpPath = "$reflexesDest.tmp.$PID" + $userReflexes | ConvertTo-Json -Depth 10 | Set-Content $tmpPath -Encoding UTF8 + Move-Item $tmpPath $reflexesDest -Force $parts = @() if ($added -gt 0) { $parts += "$added new" } if ($updated -gt 0) { $parts += "$updated updated" } @@ -355,6 +372,17 @@ if (Test-Path $libSrc) { Print-Ok "Lib modules installed to ~/.claude/hooks/cortex/lib/" } +# Step 8b: Install git pre-push hook (version+changelog enforcement) +$gitDir = Join-Path $ScriptDir ".git" +$prePushSrc = [IO.Path]::Combine($ScriptDir, ".githooks", "pre-push") +if ((Test-Path $gitDir -PathType Container) -and (Test-Path $prePushSrc)) { + $gitHooksDir = Join-Path $gitDir "hooks" + if (Test-Path $gitHooksDir -PathType Container) { + Copy-Item $prePushSrc (Join-Path $gitHooksDir "pre-push") -Force + Print-Ok "Git pre-push hook installed (version+changelog guard)" + } +} + # Step 9: Install seed instinct (only if not already present) Print-Step "Installing seed instinct..." $seedDest = [IO.Path]::Combine($CortexDir, "instincts", "global", "read-instructions-before-executing.yaml") @@ -382,10 +410,11 @@ $pyMerge = @' import json, os, tempfile settings_file = os.path.join(os.environ.get("USERPROFILE", ""), ".claude", "settings.json") +python_cmd = os.environ.get("CORTEX_INSTALL_PYTHON_CMD", "python") settings = {} if os.path.exists(settings_file): - with open(settings_file) as f: + with open(settings_file, encoding="utf-8") as f: settings = json.load(f) settings.setdefault("permissions", {}) @@ -402,30 +431,36 @@ if "~/.claude/cortex" not in settings["permissions"].get("additionalDirectories" cortex_hooks = { "SessionStart": [ - {"hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/cortex/session-start.py", "timeout": 5000}]}, - {"matcher": "compact", "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/cortex/session-start.py", "timeout": 5000}]} + {"hooks": [{"type": "command", "command": f"{python_cmd} ~/.claude/hooks/cortex/session-start.py", "timeout": 5000}]}, + {"matcher": "compact", "hooks": [{"type": "command", "command": f"{python_cmd} ~/.claude/hooks/cortex/session-start.py", "timeout": 5000}]} ], "PreToolUse": [ {"matcher": "*", "hooks": [ - {"type": "command", "command": "python3 ~/.claude/hooks/cortex/observe.py pre", "timeout": 10000, "async": True}, + {"type": "command", "command": f"{python_cmd} ~/.claude/hooks/cortex/observe.py pre", "timeout": 10000, "async": True}, {"type": "command", "command": "node ~/.claude/hooks/cortex/injector.js", "timeout": 3000} ]} ], "PostToolUse": [ - {"matcher": "*", "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/cortex/observe.py post", "timeout": 10000, "async": True}]} + {"matcher": "*", "hooks": [{"type": "command", "command": f"{python_cmd} ~/.claude/hooks/cortex/observe.py post", "timeout": 10000, "async": True}]} ], "Stop": [ {"hooks": [{"type": "command", "command": "node ~/.claude/hooks/cortex/session-learner.js", "timeout": 15000}]} ], "PreCompact": [ - {"hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/cortex/precompact.py", "timeout": 8000}]} + {"hooks": [{"type": "command", "command": f"{python_cmd} ~/.claude/hooks/cortex/precompact.py", "timeout": 8000}]} ] } existing_hooks = settings.get("hooks", {}) for event, handlers in cortex_hooks.items(): existing = existing_hooks.get(event, []) - cleaned = [h for h in existing if not any("hooks/cortex/" in str(hook.get("command", "")) for hook in h.get("hooks", []))] + cleaned = [] + for h in existing: + kept_hooks = [hook for hook in h.get("hooks", []) if "hooks/cortex/" not in str(hook.get("command", ""))] + if kept_hooks: + h = dict(h) + h["hooks"] = kept_hooks + cleaned.append(h) existing_hooks[event] = cleaned + handlers settings["hooks"] = existing_hooks @@ -441,7 +476,7 @@ if "CORTEX_AGENT_DISABLE_REFLEXES" not in settings["env"]: fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(settings_file), suffix='.tmp') try: - with os.fdopen(fd, 'w') as f: + with os.fdopen(fd, 'w', encoding="utf-8") as f: json.dump(settings, f, indent=2) f.write("\n") import stat @@ -453,7 +488,9 @@ except: '@ try { + $env:CORTEX_INSTALL_PYTHON_CMD = $PythonCmd & $PythonCmd -c $pyMerge + if ($LASTEXITCODE -ne 0) { throw "Python exited with code $LASTEXITCODE" } Print-Ok "Hooks configured in settings.json" } catch { @@ -461,6 +498,9 @@ catch { Print-Error " Inner error: $($_.Exception.Message)" exit 1 } +finally { + Remove-Item Env:CORTEX_INSTALL_PYTHON_CMD -ErrorAction SilentlyContinue +} # Step 11: Update CLAUDE.md Print-Step "Updating CLAUDE.md..." @@ -471,52 +511,108 @@ if (Test-Path $ClaudeMd) { $claudeBackup = "$ClaudeMd.backup.$(Get-Date -Format 'yyyyMMdd-HHmmss')" Copy-Item $ClaudeMd $claudeBackup -ErrorAction SilentlyContinue - $content = Get-Content $ClaudeMd -Raw + $content = Get-Content $ClaudeMd -Raw -Encoding UTF8 if ($content -match "## Cortex") { # UPGRADE: replace existing section $pyReplace = @" import re, os, tempfile, sys claude_md = os.path.join(os.environ.get('USERPROFILE', ''), '.claude', 'CLAUDE.md') section_file = sys.argv[1] -with open(claude_md) as f: +with open(claude_md, encoding='utf-8') as f: content = f.read() -with open(section_file) as f: +with open(section_file, encoding='utf-8') as f: new_section = f.read() -content = re.sub(r'\n*## Cortex \(Learning System\)\n.*?(?=\n## |\Z)', '', content, flags=re.DOTALL) -content = content.rstrip() + '\n\n' + new_section + '\n' -fd, tmp = tempfile.mkstemp(dir=os.path.dirname(claude_md), suffix='.tmp') -with os.fdopen(fd, 'w') as f: - f.write(content) -os.replace(tmp, claude_md) +header = re.search(r'(?m)^## Cortex \(Learning System\)\s*\n', content) +warn = None +if header: + start = header.start() + rest = content[start:] + marker = re.search(r'(?m)^\s*$', rest) + legacy = re.search(r'### Laws inject automatically[^\n]*\n?', rest, flags=re.DOTALL) + if marker: + end = start + marker.end() + elif legacy: + end = start + legacy.end() + else: + next_heading = re.search(r'\n## ', content[header.end():]) + if next_heading: + end = header.end() + next_heading.start() + else: + end = None + warn = 'Cortex section boundary not found; manual cleanup may be needed' + if end is not None: + if start > 0 and content[start - 1] == '\n': + start -= 1 + content = content[:start].rstrip() + '\n\n' + new_section.rstrip() + '\n' + content[end:].lstrip('\n') +else: + content = content.rstrip() + '\n\n' + new_section.rstrip() + '\n' +if warn: + print('WARN:' + warn, file=sys.stderr) +else: + content = content.rstrip() + '\n' + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(claude_md), suffix='.tmp') + with os.fdopen(fd, 'w', encoding='utf-8') as f: + f.write(content) + os.replace(tmp, claude_md) "@ - & $PythonCmd -c $pyReplace $sectionFile - Print-Ok "Cortex section updated in CLAUDE.md" + $claudeErr = Join-Path $CortexDir ".claudemd-update.err" + & $PythonCmd -c $pyReplace $sectionFile 2> $claudeErr + if ($LASTEXITCODE -ne 0) { + Print-Error "Failed to update Cortex section in CLAUDE.md; continuing" + } + elseif ((Test-Path $claudeErr) -and ((Get-Content $claudeErr -Raw -ErrorAction SilentlyContinue) -match "WARN:(.*)")) { + Print-Warn $Matches[1].Trim() + } + else { + Print-Ok "Cortex section updated in CLAUDE.md" + } + Remove-Item $claudeErr -Force -ErrorAction SilentlyContinue } else { # FRESH: append - Add-Content $ClaudeMd "`n" - Get-Content $sectionFile | Add-Content $ClaudeMd - Print-Ok "Cortex section appended to CLAUDE.md" + try { + Add-Content $ClaudeMd "`n" -Encoding UTF8 + Get-Content $sectionFile -Encoding UTF8 | Add-Content $ClaudeMd -Encoding UTF8 + Print-Ok "Cortex section appended to CLAUDE.md" + } + catch { + Print-Error "Failed to append Cortex section to CLAUDE.md; continuing" + } } } else { - Copy-Item $sectionFile $ClaudeMd - Print-Ok "Created CLAUDE.md with Cortex section" + try { + Get-Content $sectionFile -Encoding UTF8 | Set-Content $ClaudeMd -Encoding UTF8 + Print-Ok "Created CLAUDE.md with Cortex section" + } + catch { + Print-Error "Failed to create CLAUDE.md with Cortex section; continuing" + } } # Step 12: Import backup (if provided) if ($ImportBackup) { Print-Step "Importing backup..." Print-Warn "Backup import on Windows requires tar (available in Windows 10+)" + $tempDir = $null try { # Validate archive: reject entries with path traversal or absolute paths - $unsafeEntries = tar -tzf $ImportBackup 2>$null | Where-Object { $_ -match '(^\\/|\\.\\.[\\/])' } + $entries = tar -tzf $ImportBackup 2>$null + if ($LASTEXITCODE -ne 0) { + Print-Error "Not a valid .tar.gz archive. Aborting import." + throw "IMPORT_ABORTED" + } + $unsafeEntries = $entries | Where-Object { $_ -match '(^/|\.\.)' } if ($unsafeEntries) { Print-Error "Backup archive contains unsafe paths (../ or absolute). Aborting import." - return + throw "IMPORT_ABORTED" } $tempDir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())) tar -xzf $ImportBackup -C $tempDir.FullName 2>$null + if ($LASTEXITCODE -ne 0) { + Print-Error "Failed to extract backup. Continuing with fresh install." + throw "IMPORT_ABORTED" + } # Copy laws $lawsDir = Join-Path $tempDir.FullName "laws" if (Test-Path $lawsDir) { @@ -539,20 +635,21 @@ if ($ImportBackup) { $memSrc = Join-Path $tempDir.FullName "memory.json" if (Test-Path $memSrc) { $memDest = Join-Path $CortexDir "memory.json" - if (-not (Test-Path $memDest)) { Copy-Item $memSrc $memDest } + Copy-Item $memSrc $memDest -Force } # Copy reflexes.json (user customizations) $refSrc = Join-Path $tempDir.FullName "reflexes.json" if (Test-Path $refSrc) { $refDest = Join-Path $CortexDir "reflexes.json" - if (-not (Test-Path $refDest)) { Copy-Item $refSrc $refDest } + Copy-Item $refSrc $refDest -Force } # Copy projects registry $regSrc = [IO.Path]::Combine($tempDir.FullName, "projects", "registry.json") if (Test-Path $regSrc) { $regDir = Join-Path $CortexDir "projects" if (-not (Test-Path $regDir)) { New-Item -ItemType Directory -Path $regDir -Force | Out-Null } - Copy-Item $regSrc (Join-Path $regDir "registry.json") -Force + $regDest = Join-Path $regDir "registry.json" + if (-not (Test-Path $regDest)) { Copy-Item $regSrc $regDest } } # Copy project-scoped instincts $projInstDir = Join-Path $tempDir.FullName "projects" @@ -562,7 +659,10 @@ if ($ImportBackup) { if (Test-Path $projInstSrc) { $projDest = [IO.Path]::Combine($CortexDir, "projects", $_.Name, "instincts") if (-not (Test-Path $projDest)) { New-Item -ItemType Directory -Path $projDest -Force | Out-Null } - Copy-Item "$projInstSrc/*" $projDest -Force -ErrorAction SilentlyContinue + Get-ChildItem "$projInstSrc/*" -File -ErrorAction SilentlyContinue | ForEach-Object { + $dest = Join-Path $projDest $_.Name + if (-not (Test-Path $dest)) { Copy-Item $_.FullName $dest } + } } } } @@ -571,20 +671,33 @@ if ($ImportBackup) { if (Test-Path $evolvedSrc) { $evolvedDest = Join-Path $CortexDir "evolved" if (-not (Test-Path $evolvedDest)) { New-Item -ItemType Directory -Path $evolvedDest -Force | Out-Null } - Copy-Item "$evolvedSrc/*" $evolvedDest -Force -ErrorAction SilentlyContinue + Get-ChildItem "$evolvedSrc/*" -ErrorAction SilentlyContinue | ForEach-Object { + $dest = Join-Path $evolvedDest $_.Name + if (-not (Test-Path $dest)) { + Copy-Item $_.FullName $dest -Recurse + } + } } # Copy daily summaries $dailySrc = Join-Path $tempDir.FullName "daily-summaries" if (Test-Path $dailySrc) { $dailyDest = Join-Path $CortexDir "daily-summaries" if (-not (Test-Path $dailyDest)) { New-Item -ItemType Directory -Path $dailyDest -Force | Out-Null } - Copy-Item "$dailySrc/*" $dailyDest -Force -ErrorAction SilentlyContinue + Get-ChildItem "$dailySrc/*" -File -ErrorAction SilentlyContinue | ForEach-Object { + $dest = Join-Path $dailyDest $_.Name + if (-not (Test-Path $dest)) { Copy-Item $_.FullName $dest } + } } Print-Ok "Backup imported (all 8 categories)" Remove-Item $tempDir.FullName -Recurse -Force -ErrorAction SilentlyContinue } catch { - Print-Error "Failed to extract backup. Continuing with fresh install." + if ($_.Exception.Message -ne "IMPORT_ABORTED") { + Print-Error "Failed to extract backup. Continuing with fresh install." + } + if ($null -ne $tempDir -and (Test-Path $tempDir.FullName)) { + Remove-Item $tempDir.FullName -Recurse -Force -ErrorAction SilentlyContinue + } } } @@ -596,15 +709,18 @@ if (-not $HasCortex -and -not $ImportBackup) { & $PythonCmd -c @' import json, os, datetime mem_path = os.path.join(os.environ.get("USERPROFILE", ""), ".claude", "cortex", "memory.json") -with open(mem_path) as f: +with open(mem_path, encoding="utf-8") as f: mem = json.load(f) mem["stats"]["installed"] = datetime.datetime.now().strftime("%Y-%m-%d") import tempfile fd, tmp = tempfile.mkstemp(dir=os.path.dirname(mem_path), suffix=".tmp") -with os.fdopen(fd, "w") as f: +with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(mem, f, indent=2) os.replace(tmp, mem_path) '@ 2>$null + if ($LASTEXITCODE -ne 0) { + Print-Warn "Could not populate memory.json install date" + } # Copy seed laws $seedLawsDir = [IO.Path]::Combine($ScriptDir, "seeds", "laws") @@ -627,8 +743,9 @@ os.replace(tmp, mem_path) } } -# Step 14: Write version marker -Set-Content (Join-Path $CortexDir "version") $NewVersion +# Step 14: Write version marker + repo path (anti-drift guard) +Set-Content (Join-Path $CortexDir "version") $NewVersion -Encoding UTF8 +Set-Content (Join-Path $CortexDir ".repo-path") $ScriptDir -Encoding UTF8 # Step 15: Summary Write-Host "" diff --git a/install.sh b/install.sh index aa9653a..39435d6 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.4.0" +NEW_VERSION="4.5.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 @@ -122,6 +122,8 @@ if [ -d "$CORTEX_DIR" ]; then # Detect installed version if [ -f "$CORTEX_DIR/version" ]; then INSTALLED_VERSION=$(cat "$CORTEX_DIR/version" 2>/dev/null | tr -d '[:space:]') + elif [ -d "$HOOKS_DIR" ]; then + print_warn "Cortex installation state unknown (interrupted install?) — proceeding as repair" fi # Check if there's actual learned data LAW_COUNT=$(find "$CORTEX_DIR/laws" -maxdepth 1 -name "*.txt" 2>/dev/null | wc -l | tr -d ' ') @@ -249,7 +251,7 @@ else # Preserves user runtime data: fireCount, lastFired, enabled if command -v python3 >/dev/null 2>&1; then python3 -c " -import json, sys +import json, os, sys, tempfile rpath = '$CORTEX_DIR/reflexes.json' dpath = '$SCRIPT_DIR/core/reflexes.default.json' try: @@ -282,7 +284,11 @@ try: if changed: updated += 1 if added or updated: - with open(rpath, 'w') as f: json.dump(user, f, indent=2) + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(rpath), suffix='.tmp') + with os.fdopen(fd, 'w') as f: + json.dump(user, f, indent=2) + f.write('\n') + os.replace(tmp, rpath) parts = [] if added: parts.append(str(added) + ' new') if updated: parts.append(str(updated) + ' updated') @@ -346,14 +352,14 @@ if [ -d "$SCRIPT_DIR/hooks/lib" ]; then fi # Step 8b: Install git pre-push hook (version+changelog enforcement) -# v3.21.1 fix: use --absolute-git-dir; previous --git-dir returned a relative -# `.git` when invoked from outside the repo, breaking `cp` (resolved against CWD). -if git -C "$SCRIPT_DIR" rev-parse --absolute-git-dir >/dev/null 2>&1; then - GIT_HOOKS_DIR="$(git -C "$SCRIPT_DIR" rev-parse --absolute-git-dir 2>/dev/null)/hooks" - if [ -f "$SCRIPT_DIR/githooks/pre-push" ] && [ -d "$GIT_HOOKS_DIR" ]; then - cp "$SCRIPT_DIR/githooks/pre-push" "$GIT_HOOKS_DIR/pre-push" - chmod +x "$GIT_HOOKS_DIR/pre-push" - print_ok "Git pre-push hook installed (version+changelog guard)" +if [ -d "$SCRIPT_DIR/.git" ]; then + GIT_HOOKS_DIR="$SCRIPT_DIR/.git/hooks" + if [ -f "$SCRIPT_DIR/.githooks/pre-push" ] && [ -d "$GIT_HOOKS_DIR" ]; then + if cp "$SCRIPT_DIR/.githooks/pre-push" "$GIT_HOOKS_DIR/pre-push" && chmod +x "$GIT_HOOKS_DIR/pre-push"; then + print_ok "Git pre-push hook installed (version+changelog guard)" + else + print_warn "Could not install git pre-push hook; continuing" + fi fi fi @@ -479,13 +485,16 @@ cortex_hooks = { existing_hooks = settings.get("hooks", {}) for event, handlers in cortex_hooks.items(): existing = existing_hooks.get(event, []) - cleaned = [ - h for h in existing - if not any( - "hooks/cortex/" in str(hook.get("command", "")) - for hook in h.get("hooks", []) - ) - ] + cleaned = [] + for h in existing: + kept_hooks = [ + hook for hook in h.get("hooks", []) + if "hooks/cortex/" not in str(hook.get("command", "")) + ] + if kept_hooks: + h = dict(h) + h["hooks"] = kept_hooks + cleaned.append(h) existing_hooks[event] = cleaned + handlers settings["hooks"] = existing_hooks @@ -522,46 +531,80 @@ fi print_step "Updating CLAUDE.md..." if [ -f "$CLAUDE_MD" ]; then # Backup CLAUDE.md before any modification - cp "$CLAUDE_MD" "${CLAUDE_MD}.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true + cp "$CLAUDE_MD" "${CLAUDE_MD}.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || print_warn "Could not back up CLAUDE.md; continuing" if grep -q "## Cortex" "$CLAUDE_MD" 2>/dev/null; then # UPGRADE: replace existing Cortex section with latest if [ -n "$PYTHON_CMD" ]; then - "$PYTHON_CMD" -c ' + if "$PYTHON_CMD" -c ' import re, sys, os, tempfile claude_md = os.path.expanduser("~/.claude/CLAUDE.md") section_file = sys.argv[1] -with open(claude_md) as f: +with open(claude_md, encoding="utf-8") as f: content = f.read() -with open(section_file) as f: +with open(section_file, encoding="utf-8") as f: new_section = f.read() -# Remove old section (from ## Cortex to next ## or EOF) -content = re.sub( - r"\n*## Cortex \(Learning System\)\n.*?(?=\n## |\Z)", - "", - content, - flags=re.DOTALL -) -# Append new section -content = content.rstrip() + "\n\n" + new_section + "\n" -fd, tmp = tempfile.mkstemp(dir=os.path.dirname(claude_md), suffix=".tmp") -with os.fdopen(fd, "w") as f: - f.write(content) -os.replace(tmp, claude_md) -' "$SCRIPT_DIR/core/claudemd-section.md" 2>/dev/null - print_ok "Cortex section updated in CLAUDE.md" + +header = re.search(r"(?m)^## Cortex \(Learning System\)\s*\n", content) +warn = None +if header: + start = header.start() + rest = content[start:] + marker = re.search(r"(?m)^\s*$", rest) + legacy = re.search(r"### Laws inject automatically[^\n]*\n?", rest, flags=re.DOTALL) + if marker: + end = start + marker.end() + elif legacy: + end = start + legacy.end() + else: + next_heading = re.search(r"\n## ", content[header.end():]) + if next_heading: + end = header.end() + next_heading.start() + else: + end = None + warn = "Cortex section boundary not found; manual cleanup may be needed" + if end is not None: + if start > 0 and content[start - 1] == "\n": + start -= 1 + content = content[:start].rstrip() + "\n\n" + new_section.rstrip() + "\n" + content[end:].lstrip("\n") +else: + content = content.rstrip() + "\n\n" + new_section.rstrip() + "\n" +if warn: + print("WARN:" + warn, file=sys.stderr) +else: + content = content.rstrip() + "\n" + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(claude_md), suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, claude_md) +' "$SCRIPT_DIR/core/claudemd-section.md" 2>"$CORTEX_DIR/.claudemd-update.err"; then + if grep -q "WARN:" "$CORTEX_DIR/.claudemd-update.err" 2>/dev/null; then + print_warn "$(sed 's/^WARN://' "$CORTEX_DIR/.claudemd-update.err" | head -n1)" + else + print_ok "Cortex section updated in CLAUDE.md" + fi + rm -f "$CORTEX_DIR/.claudemd-update.err" + else + print_error "Failed to update Cortex section in CLAUDE.md; continuing" + rm -f "$CORTEX_DIR/.claudemd-update.err" + fi else print_warn "Cortex section exists but Python not available to update it" fi else # FRESH: append - echo "" >> "$CLAUDE_MD" - cat "$SCRIPT_DIR/core/claudemd-section.md" >> "$CLAUDE_MD" - print_ok "Cortex section appended to CLAUDE.md" + if { printf '\n' >> "$CLAUDE_MD" && cat "$SCRIPT_DIR/core/claudemd-section.md" >> "$CLAUDE_MD"; }; then + print_ok "Cortex section appended to CLAUDE.md" + else + print_error "Failed to append Cortex section to CLAUDE.md; continuing" + fi fi else - cp "$SCRIPT_DIR/core/claudemd-section.md" "$CLAUDE_MD" - print_ok "Created CLAUDE.md with Cortex section" + if cp "$SCRIPT_DIR/core/claudemd-section.md" "$CLAUDE_MD"; then + print_ok "Created CLAUDE.md with Cortex section" + else + print_error "Failed to create CLAUDE.md with Cortex section; continuing" + fi fi # Step 12: Import backup (if provided) @@ -645,6 +688,43 @@ os.replace(tmp, mem_path) fi fi +# v4 adapter: seed the learner baseline on v3 upgrades so SessionStart does +# not report lifetime observations as new work after the first v4 run. +if [ "$HAS_CORTEX" = true ]; then + NEED_LEARN_BASELINE=false + if [ "$INSTALLED_VERSION" = "none" ] || version_lt "$INSTALLED_VERSION" "4.0.0"; then + NEED_LEARN_BASELINE=true + elif [ ! -f "$CORTEX_DIR/.last-learn-count" ] && find "$CORTEX_DIR/projects" -mindepth 2 -maxdepth 2 -name observations.jsonl -print -quit 2>/dev/null | grep -q .; then + NEED_LEARN_BASELINE=true + fi + if [ "$NEED_LEARN_BASELINE" = true ]; then + if "$PYTHON_CMD" - "$CORTEX_DIR" <<'PYEOF'; then +import os, sys, tempfile +root = sys.argv[1] +projects = os.path.join(root, "projects") +total = 0 +if os.path.isdir(projects): + for dirpath, _, filenames in os.walk(projects): + if "observations.jsonl" in filenames: + path = os.path.join(dirpath, "observations.jsonl") + try: + with open(path, "rb") as f: + total += sum(1 for _ in f) + except OSError: + pass +dest = os.path.join(root, ".last-learn-count") +fd, tmp = tempfile.mkstemp(dir=root, suffix=".tmp") +with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(str(total) + "\n") +os.replace(tmp, dest) +PYEOF + print_ok "Seeded learner observation baseline" + else + print_warn "Could not seed learner observation baseline" + fi + fi +fi + # Step 14: Write version marker + repo path (anti-drift guard, v3.34.1) echo "$NEW_VERSION" > "$CORTEX_DIR/version" # Record where this repo lives so SessionStart's check_deploy_drift() can warn diff --git a/tests/test_install.sh b/tests/test_install.sh index 15d59d7..c2ab8d1 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -204,6 +204,80 @@ CORTEX_COUNT=$(grep -c "## Cortex" "$SANDBOX/.claude/CLAUDE.md" 2>/dev/null) rm -rf "$SANDBOX" echo "" +# ── TEST 3b: CLAUDE.md tail after Cortex section survives rerun ─── + +echo "--- CLAUDE.md Tail Preservation ---" +SANDBOX=$(mktemp -d) +SANDBOXES+=("$SANDBOX") +mkdir -p "$SANDBOX/.claude" +printf '\n' | HOME="$SANDBOX" bash "$PROJECT_ROOT/install.sh" > /dev/null 2>&1 || true +printf '\nUSER TAIL CONTENT\n' >> "$SANDBOX/.claude/CLAUDE.md" +TAIL_BEFORE=$(tail -n 1 "$SANDBOX/.claude/CLAUDE.md") +printf '\n' | HOME="$SANDBOX" bash "$PROJECT_ROOT/install.sh" > /dev/null 2>&1 || true +TAIL_AFTER=$(tail -n 1 "$SANDBOX/.claude/CLAUDE.md") +[ "$TAIL_AFTER" = "$TAIL_BEFORE" ] && [ "$TAIL_AFTER" = "USER TAIL CONTENT" ] && pass "CLAUDE.md user tail survives rerun" || fail "CLAUDE.md user tail changed" +grep -q "" "$SANDBOX/.claude/CLAUDE.md" 2>/dev/null && pass "CLAUDE.md Cortex section has end marker" || fail "CLAUDE.md missing end marker" +rm -rf "$SANDBOX" +echo "" + +# ── TEST 3c: settings.json keeps user hook sharing Cortex entry ─── + +echo "--- settings.json Shared Entry Preservation ---" +SANDBOX=$(mktemp -d) +SANDBOXES+=("$SANDBOX") +mkdir -p "$SANDBOX/.claude/cortex" "$SANDBOX/.claude/hooks/cortex" +echo "3.37.2" > "$SANDBOX/.claude/cortex/version" +cat > "$SANDBOX/.claude/settings.json" << 'JSON' +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + {"type": "command", "command": "python3 ~/.claude/hooks/cortex/session-start.py"}, + {"type": "command", "command": "bash ~/.claude/hooks/user-session-start.sh"} + ] + } + ] + } +} +JSON +printf 'y\n' | HOME="$SANDBOX" bash "$PROJECT_ROOT/install.sh" > /dev/null 2>&1 || true +python3 -c " +import json +with open('$SANDBOX/.claude/settings.json') as f: + s = json.load(f) +hooks = [h for entry in s.get('hooks', {}).get('SessionStart', []) for h in entry.get('hooks', [])] +assert any('user-session-start' in h.get('command', '') for h in hooks), 'user hook lost' +print('OK') +" 2>/dev/null | grep -q OK && pass "user hook preserved inside shared SessionStart entry" || fail "user hook lost from shared SessionStart entry" +rm -rf "$SANDBOX" +echo "" + +# ── TEST 3d: v3→v4 learner baseline seeded from observations ───── + +echo "--- v3 Learner Baseline Adapter ---" +SANDBOX=$(mktemp -d) +SANDBOXES+=("$SANDBOX") +mkdir -p "$SANDBOX/.claude/cortex/projects/x" "$SANDBOX/.claude/hooks/cortex" +echo "3.37.2" > "$SANDBOX/.claude/cortex/version" +for i in 1 2 3 4 5; do echo "{\"n\":$i}"; done > "$SANDBOX/.claude/cortex/projects/x/observations.jsonl" +printf 'y\n' | HOME="$SANDBOX" bash "$PROJECT_ROOT/install.sh" > /dev/null 2>&1 || true +BASELINE=$(cat "$SANDBOX/.claude/cortex/.last-learn-count" 2>/dev/null | tr -d '[:space:]') +[ "$BASELINE" = "5" ] && pass "v3 upgrade seeds .last-learn-count from observations" || fail ".last-learn-count=$BASELINE (expected 5)" +rm -rf "$SANDBOX" +echo "" + +# ── TEST 3e: interrupted install repair warning ─────────────────── + +echo "--- Interrupted Install Repair Detection ---" +SANDBOX=$(mktemp -d) +SANDBOXES+=("$SANDBOX") +mkdir -p "$SANDBOX/.claude/cortex/laws" "$SANDBOX/.claude/cortex/instincts/global" "$SANDBOX/.claude/hooks/cortex" +OUT=$(printf 'y\n' | HOME="$SANDBOX" bash "$PROJECT_ROOT/install.sh" 2>&1 || true) +printf '%s' "$OUT" | grep -q "proceeding as repair" && pass "missing version with hooks warns repair" || fail "repair warning missing" +rm -rf "$SANDBOX" +echo "" + # ── TEST 4: Path traversal protection ───────────────────────────── echo "--- Path Traversal Protection ---" diff --git a/tests/test_install_ps1.ps1 b/tests/test_install_ps1.ps1 index c4580fe..6fb5f04 100644 --- a/tests/test_install_ps1.ps1 +++ b/tests/test_install_ps1.ps1 @@ -254,6 +254,72 @@ if ($evalMissing -eq 0) { Test-Pass "evaluator.* propagation present (parity with install.sh)" } +# ── TEST 11: v4.5 installer reliability contracts ──────────────── + +Write-Host "--- v4.5 installer reliability contracts ---" +$contractMissing = 0 + +if ($content -match 'f"\{python_cmd\} ~/.claude/hooks/cortex/session-start.py"' -and + $content -notmatch '"command": "python3 ~/.claude/hooks/cortex/') { + Test-Pass "settings hook strings use detected Python interpreter" +} +else { + Test-Fail "settings hook strings still hardcode python3" + $contractMissing++ +} + +if (('../evil' -match '(^/|\.\.)') -and ('/abs' -match '(^/|\.\.)') -and -not ('safe/path' -match '(^/|\.\.)')) { + Test-Pass "path traversal regex matches ../ and absolute paths only" +} +else { + Test-Fail "path traversal regex behavior is wrong" + $contractMissing++ +} + +if ($content -match '\.repo-path') { + Test-Pass ".repo-path write present" +} +else { + Test-Fail ".repo-path write missing" + $contractMissing++ +} + +if ($content -match "encoding=['""]utf-8['""]" -and $content -match '-Encoding UTF8') { + Test-Pass "CLAUDE.md/Python reads and writes use UTF-8" +} +else { + Test-Fail "UTF-8 encoding contract missing" + $contractMissing++ +} + +if ($content -match '' -and $content -match '### Laws inject automatically') { + Test-Pass "CLAUDE.md marker and legacy anchor present" +} +else { + Test-Fail "CLAUDE.md marker or legacy anchor missing" + $contractMissing++ +} + +if ($content -match 'Copy-Item \$memSrc \$memDest -Force' -and + $content -match 'Copy-Item \$refSrc \$refDest -Force' -and + $content -match 'if \(-not \(Test-Path \$regDest\)\) \{ Copy-Item \$regSrc \$regDest \}' -and + $content -match 'Copy-Item \$_.FullName \$dest -Recurse' -and + $content -match 'if \(-not \(Test-Path \$dest\)\) \{ Copy-Item \$_.FullName \$dest \}') { + Test-Pass "backup import overwrite/no-clobber semantics present" +} +else { + Test-Fail "backup import overwrite/no-clobber semantics incomplete" + $contractMissing++ +} + +if ($content -match '\.githooks", "pre-push"' -and $content -notmatch '[^.]githooks", "pre-push"') { + Test-Pass ".githooks/pre-push source used" +} +else { + Test-Fail ".githooks/pre-push source contract missing" + $contractMissing++ +} + # ── Summary ─────────────────────────────────────────────────────── Write-Host ""