diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fcaeba..1fcefad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## v4.5.0 (2026-04-18) — unreleased + +### Added — Reflexes tier (inspired by fs-cortex, credit: Fernando Montero / MIT) +- **`seeds/reflexes.json`** (5 new passive rules): `read-before-edit`, `test-after-change`, `git-push-safety`, `git-merge-verify`, `instinct-downvote`. All carry `origin: seed:fermontero-fs-cortex` for traceability. Ported from `fs-cortex/core/reflexes.default.json` after deduping against Sinapsis's existing 6 rules. +- **`core/_reflex-merge.mjs`** (new, 90 LOC Node.js): merges seed reflexes into the user's `~/.claude/skills/_passive-rules.json`. Idempotent. **User customizations win** — rules with an existing id are preserved (no overwrite). CLI flags: `--seeds-path`, `--index-path`, `--dry-run`. Atomic write (tmp → rename). Recomputes `totalTokens`. +- **10 TDD tests** (`tests/test-reflexes.sh`): presence, seed structure, fresh merge, idempotency, user preservation, conflict resolution, token recomputation, activator integration (`read-before-edit` fires on Edit, `git-push-safety` on `git push`), `--dry-run`. 10/10 GREEN. + +### Changed +- `install.sh`: new Step 5c runs the reflex merger after copying hook scripts. Safe on upgrade — preserves user-customized rules. +- `install.bat`: mirrors Step 5c so Windows installs get the merger too (Codex review: P1). +- `_reflex-merge.mjs`: atomic write preserves existing file mode (0600 on upgraded installs, falls back to explicit 0600 on fresh install) to avoid relaxing `_passive-rules.json` to 0644 via the user's umask (Codex review: P2). +- `seeds/reflexes.json`: `test-after-change` trigger scoped to `^(Edit|Write)\s.*(...)` so it does not fire on Read/Grep and waste passive-rule slots on non-mutating tools (Codex review: P2). + +### Skipped from fs-cortex (already covered) +- `env-never-commit`, `git-commit-quality`, `api-auth-check` (→ `api-auth-reminder`), `security-headers` (→ `security-headers-check`), `capture-decision` (→ `decision-capture`). Sinapsis equivalents kept as-is. + +### Adapted +- `instinct-downvote`: fs-cortex references its own `/cx-downvote`; Sinapsis version uses the native `/downvote` command. + +### Rationale +Sinapsis shipped 6 passive rules focused on security and workflow; fs-cortex shipped 10 reflexes with broader coverage (read-before-edit discipline, test-after-change reminders, git push/merge safety). Porting the 5 non-duplicate ones gives Sinapsis users a fuller baseline without replacing their customizations. Merge pattern matches the seeds importer (Ola 1) — additive, idempotent, user-first. + +### Test badge +116 → 127 passing (suite-wide, 8 test files; +11 reflex tests including a regression guard for the Read/Grep scope fix). + +--- + ## v4.4.1 (2026-04-17) ### Fixed diff --git a/core/_reflex-merge.mjs b/core/_reflex-merge.mjs new file mode 100644 index 0000000..3202d38 --- /dev/null +++ b/core/_reflex-merge.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Sinapsis v4.5 — Reflex Merger +// +// Merges seed passive rules (seeds/reflexes.json) into the user's +// ~/.claude/skills/_passive-rules.json. Idempotent — rules with an id that +// already exists in the user's index are skipped (user customizations win). +// +// Inspired by fs-cortex reflex tier — credit: Fernando Montero (MIT, 2026). +// +// Usage: +// node core/_reflex-merge.mjs [--seeds-path ] [--index-path ] [--dry-run] + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function parseArgs(argv) { + const out = { seedsPath: null, indexPath: null, dryRun: false }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === "--seeds-path") out.seedsPath = argv[++i]; + else if (a === "--index-path") out.indexPath = argv[++i]; + else if (a === "--dry-run") out.dryRun = true; + } + return out; +} + +function defaultSeedsPath() { + const repoSeed = path.join(__dirname, "..", "seeds", "reflexes.json"); + if (fs.existsSync(repoSeed)) return repoSeed; + return null; +} + +function defaultIndexPath() { + const home = process.env.SINAPSIS_HOME || path.join(process.env.HOME || process.env.USERPROFILE, ".claude"); + return path.join(home, "skills", "_passive-rules.json"); +} + +function atomicWrite(filePath, text) { + const tmp = filePath + ".tmp"; + fs.writeFileSync(tmp, text); + // Preserve existing file mode (install.sh hardens this file to 0600). + // If target does not exist yet (fresh install), fall back to the explicit + // Sinapsis default 0o600 to match install.sh's documented protection. + try { + const mode = fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o600; + fs.chmodSync(tmp, mode); + } catch (e) { /* best-effort on platforms without POSIX modes (Windows) */ } + fs.renameSync(tmp, filePath); +} + +const args = parseArgs(process.argv); +const seedsPath = args.seedsPath || defaultSeedsPath(); +const indexPath = args.indexPath || defaultIndexPath(); + +if (!seedsPath || !fs.existsSync(seedsPath)) { + console.error(`ERROR: seeds file missing: ${seedsPath}`); + process.exit(1); +} +if (!fs.existsSync(indexPath)) { + console.error(`ERROR: passive rules index missing: ${indexPath}`); + process.exit(1); +} + +const seeds = JSON.parse(fs.readFileSync(seedsPath, "utf8")); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); + +if (!Array.isArray(index.rules)) index.rules = []; + +const existing = new Set(index.rules.map(r => r.id)); +const imported = []; +const already = []; + +for (const rule of seeds.rules || []) { + if (!rule.id) continue; + if (existing.has(rule.id)) { + already.push(rule.id); + continue; + } + index.rules.push(rule); + imported.push(rule.id); +} + +// Recompute totalTokens +const totalTokens = index.rules.reduce((sum, r) => sum + (Number(r.tokens) || 0), 0); +index.totalTokens = totalTokens; + +if (args.dryRun) { + console.log("[DRY-RUN] No files modified."); +} else if (imported.length > 0) { + atomicWrite(indexPath, JSON.stringify(index, null, 2) + "\n"); + console.log(`[OK] Wrote ${indexPath}`); +} + +console.log(`Imported (${imported.length}):`); +for (const id of imported) console.log(` + ${id}`); +if (already.length) console.log(`Already present (${already.length}): ${already.join(", ")}`); diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e86e7ba..4a147b3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,8 +1,8 @@ -# Sinapsis v4.3.3 — Feature Reference +# Sinapsis v4.5 — Feature Reference > Complete inventory of all features, commands, hooks, modules, and capabilities. > **Update this file with every feat/fix commit.** -> Last updated: 2026-04-13 +> Last updated: 2026-04-18 --- @@ -23,7 +23,7 @@ OBSERVATIONS → PROPOSALS → INSTINCTS (JSON) ``` Parallel systems (not part of the confidence pipeline): -- **Passive Rules** (6 default) — deterministic regex rules, always fire on matcher +- **Passive Rules** (11 default: 6 original + 5 fs-cortex seed reflexes) — deterministic regex rules, always fire on matcher - **Operator State** — strategic decisions persisted across all projects - **Skill Router** — on-demand skill loading from dormant catalog diff --git a/install.bat b/install.bat index a1cd1d0..a4fb7fd 100644 --- a/install.bat +++ b/install.bat @@ -150,8 +150,14 @@ copy /Y "%SCRIPT_DIR%core\_session-learner.sh" "%SKILLS_DIR%\_session-learner.sh copy /Y "%SCRIPT_DIR%core\_project-context.sh" "%SKILLS_DIR%\_project-context.sh" >nul copy /Y "%SCRIPT_DIR%core\_eod-gather.sh" "%SKILLS_DIR%\_eod-gather.sh" >nul copy /Y "%SCRIPT_DIR%core\_dream.sh" "%SKILLS_DIR%\_dream.sh" >nul +copy /Y "%SCRIPT_DIR%core\_reflex-merge.mjs" "%SKILLS_DIR%\_reflex-merge.mjs" >nul -echo OK 5 hook scripts + dream cycle installed +:: Step 5c: Merge seed reflexes (v4.5) — idempotent, preserves user customizations +if exist "%SCRIPT_DIR%seeds\reflexes.json" ( + node "%SKILLS_DIR%\_reflex-merge.mjs" --seeds-path "%SCRIPT_DIR%seeds\reflexes.json" --index-path "%SKILLS_DIR%\_passive-rules.json" 2>&1 +) + +echo OK 5 hook scripts + dream cycle + reflex merger installed echo NOTE: On Windows, hooks run via Git Bash or WSL. See README for details. :: Step 6: Configure settings.json diff --git a/install.sh b/install.sh index 7c19f3b..1a2f117 100644 --- a/install.sh +++ b/install.sh @@ -166,6 +166,7 @@ cp "$SCRIPT_DIR/core/_eod-gather.sh" "$SKILLS_DIR/_eod-gather.sh" cp "$SCRIPT_DIR/core/_dream.sh" "$SKILLS_DIR/_dream.sh" cp "$SCRIPT_DIR/core/_generate-dashboard.py" "$SKILLS_DIR/_generate-dashboard.py" cp "$SCRIPT_DIR/core/_dashboard-template.html" "$SKILLS_DIR/_dashboard-template.html" +cp "$SCRIPT_DIR/core/_reflex-merge.mjs" "$SKILLS_DIR/_reflex-merge.mjs" chmod +x "$SKILLS_DIR/_passive-activator.sh" chmod +x "$SKILLS_DIR/_instinct-activator.sh" @@ -175,7 +176,16 @@ chmod +x "$SKILLS_DIR/_eod-gather.sh" chmod +x "$SKILLS_DIR/_dream.sh" chmod +x "$SKILLS_DIR/_generate-dashboard.py" 2>/dev/null || true -echo -e "${GREEN} OK${NC} 5 hook scripts + dream cycle + dashboard generator installed" +# ── Step 5c: Merge seed reflexes (v4.5) ── +# Merge shipped passive rules (seeds/reflexes.json) into user's _passive-rules.json. +# Idempotent — rules with existing IDs are preserved (user customizations win). +if [ -f "$SCRIPT_DIR/seeds/reflexes.json" ]; then + node "$SKILLS_DIR/_reflex-merge.mjs" \ + --seeds-path "$SCRIPT_DIR/seeds/reflexes.json" \ + --index-path "$SKILLS_DIR/_passive-rules.json" 2>&1 | sed 's/^/ /' || true +fi + +echo -e "${GREEN} OK${NC} 5 hook scripts + dream cycle + dashboard generator + reflex merger installed" # ── Step 5b: Legacy file cleanup (v4.3.3) ── LEGACY_CLEANED=0 diff --git a/seeds/reflexes.json b/seeds/reflexes.json new file mode 100644 index 0000000..850efb9 --- /dev/null +++ b/seeds/reflexes.json @@ -0,0 +1,52 @@ +{ + "_comment": "Seed passive rules (reflexes). Ported from fs-cortex (MIT © Fernando Montero). Merged into ~/.claude/skills/_passive-rules.json by install.sh Step 5d. Idempotent — existing rules preserved.", + "_credit": "Fernando Montero / fs-cortex (https://github.com/fermonterom/fs-cortex)", + "_license": "MIT", + "rules": [ + { + "id": "read-before-edit", + "trigger": "^(Edit|Write)\\s", + "inject": "Before editing: verify the file was Read recently. If not, Read it first to avoid blind edits.", + "severity": "high", + "category": "quality", + "tokens": 25, + "origin": "seed:fermontero-fs-cortex" + }, + { + "id": "test-after-change", + "trigger": "^(Edit|Write)\\s.*(\\.test\\.|\\.spec\\.|route\\.ts|\\.component\\.)", + "inject": "Code modified. Consider running tests (unit + e2e) to catch regressions.", + "severity": "medium", + "category": "quality", + "tokens": 20, + "origin": "seed:fermontero-fs-cortex" + }, + { + "id": "git-push-safety", + "trigger": "git push|gh pr create", + "inject": "Before pushing: fetch + rebase first. For force pushes use --force-with-lease (never --force).", + "severity": "high", + "category": "git", + "tokens": 25, + "origin": "seed:fermontero-fs-cortex" + }, + { + "id": "git-merge-verify", + "trigger": "gh pr merge|git merge ", + "inject": "Before merging: verify all CI checks pass. Clean up local branch after merge.", + "severity": "high", + "category": "git", + "tokens": 20, + "origin": "seed:fermontero-fs-cortex" + }, + { + "id": "instinct-downvote", + "trigger": "/downvote|wrong instinct|ignore instinct|bad instinct|instinct incorrecto", + "inject": "Negative instinct feedback detected. Use /downvote to reduce confidence or archive.", + "severity": "low", + "category": "learning", + "tokens": 25, + "origin": "seed:fermontero-fs-cortex" + } + ] +} diff --git a/tests/test-reflexes.sh b/tests/test-reflexes.sh new file mode 100644 index 0000000..afada68 --- /dev/null +++ b/tests/test-reflexes.sh @@ -0,0 +1,235 @@ +#!/bin/bash +# test-reflexes.sh — TDD Unit Tests for Sinapsis reflex merger (v4.5) +# Covers: shipped seed structure, idempotency, user customizations preserved, +# activator integration (rules actually fire), dedupe on re-run. +# Run: bash tests/test-reflexes.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MERGER="$SCRIPT_DIR/core/_reflex-merge.mjs" +SEEDS_FILE="$SCRIPT_DIR/seeds/reflexes.json" +ACTIVATOR="$SCRIPT_DIR/core/_passive-activator.sh" + +# Normalize paths for native tools (Node on Windows Git Bash expects C:/... not /c/...) +if command -v cygpath >/dev/null 2>&1; then + SEEDS_NATIVE_GLOBAL="$(cygpath -m "$SEEDS_FILE")" +else + SEEDS_NATIVE_GLOBAL="$SEEDS_FILE" +fi + +PASS=0 +FAIL=0 +TOTAL=10 + +pass() { PASS=$((PASS + 1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL + 1)); echo " FAIL: $1"; } + +setup_sandbox() { + SANDBOX="$(mktemp -d 2>/dev/null || mktemp -d -t sinapsis-reflexes)" + mkdir -p "$SANDBOX/.claude/skills" + export HOME="$SANDBOX" + # Install a minimal user index with 1 existing rule to simulate upgrade path + cat > "$SANDBOX/.claude/skills/_passive-rules.json" <<'EOF' +{ + "version": "4.1", + "system": "sinapsis", + "description": "test", + "rules": [ + { + "id": "user-custom-rule", + "trigger": "custom_pattern", + "inject": "User custom rule", + "severity": "medium", + "category": "workflow", + "tokens": 15 + } + ], + "totalTokens": 15 +} +EOF + if command -v cygpath >/dev/null 2>&1; then + INDEX_NATIVE="$(cygpath -m "$SANDBOX/.claude/skills/_passive-rules.json")" + SEEDS_NATIVE="$(cygpath -m "$SEEDS_FILE")" + MERGER_NATIVE="$(cygpath -m "$MERGER")" + ACTIVATOR_NATIVE="$(cygpath -m "$ACTIVATOR")" + else + INDEX_NATIVE="$SANDBOX/.claude/skills/_passive-rules.json" + SEEDS_NATIVE="$SEEDS_FILE" + MERGER_NATIVE="$MERGER" + ACTIVATOR_NATIVE="$ACTIVATOR" + fi +} + +teardown_sandbox() { + unset HOME + rm -rf "$SANDBOX" 2>/dev/null +} + +count_rules() { + node -e "const r=JSON.parse(require('fs').readFileSync('$1','utf8')); console.log((r.rules||[]).length)" +} + +has_rule() { + node -e "const r=JSON.parse(require('fs').readFileSync('$1','utf8')); console.log((r.rules||[]).some(x => x.id === '$2') ? 'yes' : 'no')" +} + +# ─ Test 1: merger + seeds files present ─ +echo "Test 1: artifact presence" +if [ -f "$MERGER" ] && [ -f "$SEEDS_FILE" ]; then + pass "merger + seeds file present" +else + fail "missing: MERGER=$MERGER SEEDS=$SEEDS_FILE" +fi + +# ─ Test 2: seeds file is valid JSON with >= 5 rules ─ +echo "Test 2: seeds file structure" +SEED_COUNT=$(node -e "const r=JSON.parse(require('fs').readFileSync('$SEEDS_NATIVE_GLOBAL','utf8')); console.log((r.rules||[]).length)" 2>/dev/null) +if [ -n "$SEED_COUNT" ] && [ "$SEED_COUNT" -ge 5 ]; then + pass "$SEED_COUNT seed rules found" +else + fail "expected >=5 seed rules, got '$SEED_COUNT'" +fi + +# ─ Test 3: fresh merge adds all seed rules ─ +echo "Test 3: fresh merge" +setup_sandbox +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +COUNT=$(count_rules "$INDEX_NATIVE") +EXPECTED=$((1 + SEED_COUNT)) +if [ "$COUNT" = "$EXPECTED" ]; then + pass "merged $SEED_COUNT seeds into 1 existing = $COUNT total" +else + fail "expected $EXPECTED, got $COUNT" +fi +teardown_sandbox + +# ─ Test 4: idempotency — second run adds nothing ─ +echo "Test 4: idempotency" +setup_sandbox +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +C1=$(count_rules "$INDEX_NATIVE") +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +C2=$(count_rules "$INDEX_NATIVE") +if [ "$C1" = "$C2" ]; then + pass "second run unchanged ($C2 rules)" +else + fail "idempotency broken: $C1 -> $C2" +fi +teardown_sandbox + +# ─ Test 5: user customizations preserved ─ +echo "Test 5: user custom rule preserved" +setup_sandbox +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +HAS=$(has_rule "$INDEX_NATIVE" "user-custom-rule") +if [ "$HAS" = "yes" ]; then + pass "user-custom-rule still present after merge" +else + fail "user-custom-rule lost during merge" +fi +teardown_sandbox + +# ─ Test 6: conflict resolution — existing id wins over seed ─ +echo "Test 6: conflict — user rule wins" +setup_sandbox +# Override the user index with a rule id matching a seed id +cat > "$INDEX_NATIVE" <<'EOF' +{ + "version": "4.1", + "system": "sinapsis", + "description": "test", + "rules": [ + { + "id": "read-before-edit", + "trigger": "CUSTOM_OVERRIDE", + "inject": "user override", + "severity": "low", + "category": "workflow", + "tokens": 10 + } + ], + "totalTokens": 10 +} +EOF +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +TRIG=$(node -e "const r=JSON.parse(require('fs').readFileSync('$INDEX_NATIVE','utf8')); console.log(r.rules.find(x => x.id === 'read-before-edit').trigger)") +if [ "$TRIG" = "CUSTOM_OVERRIDE" ]; then + pass "user override preserved (seed did not overwrite)" +else + fail "user override lost — got trigger: $TRIG" +fi +teardown_sandbox + +# ─ Test 7: totalTokens recomputed ─ +echo "Test 7: totalTokens recomputed after merge" +setup_sandbox +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +TT=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$INDEX_NATIVE','utf8')).totalTokens)") +# Expected: 15 (user rule) + sum of seed tokens +EXPECTED_TT=$(node -e "const s=JSON.parse(require('fs').readFileSync('$SEEDS_NATIVE','utf8')); console.log(15 + s.rules.reduce((a,r) => a + (r.tokens||0), 0))") +if [ "$TT" = "$EXPECTED_TT" ]; then + pass "totalTokens = $TT (expected $EXPECTED_TT)" +else + fail "totalTokens mismatch: got $TT, expected $EXPECTED_TT" +fi +teardown_sandbox + +# ─ Test 8: activator integration — read-before-edit fires on Edit ─ +echo "Test 8: activator fires read-before-edit on Edit" +setup_sandbox +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +PAYLOAD=$(node -e 'process.stdout.write(JSON.stringify({tool_name:"Edit",tool_input:{file_path:"foo.ts"}}))') +OUT=$(echo "$PAYLOAD" | bash "$ACTIVATOR_NATIVE" 2>/dev/null) +if echo "$OUT" | grep -q "Before editing"; then + pass "read-before-edit fired on Edit" +else + fail "read-before-edit did not fire. Got: $OUT" +fi +teardown_sandbox + +# ─ Test 9: activator integration — git-push-safety fires on git push ─ +echo "Test 9: activator fires git-push-safety on git push" +setup_sandbox +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +PAYLOAD=$(node -e 'process.stdout.write(JSON.stringify({tool_name:"Bash",tool_input:{command:"git push origin main"}}))') +OUT=$(echo "$PAYLOAD" | bash "$ACTIVATOR_NATIVE" 2>/dev/null) +if echo "$OUT" | grep -q "fetch + rebase"; then + pass "git-push-safety fired" +else + fail "git-push-safety did not fire. Got: $OUT" +fi +teardown_sandbox + +# ─ Test 10: --dry-run writes nothing ─ +echo "Test 10: --dry-run" +setup_sandbox +BEFORE=$(count_rules "$INDEX_NATIVE") +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" --dry-run > /dev/null 2>&1 +AFTER=$(count_rules "$INDEX_NATIVE") +if [ "$BEFORE" = "$AFTER" ]; then + pass "--dry-run did not modify index" +else + fail "--dry-run modified index: $BEFORE -> $AFTER" +fi +teardown_sandbox + +# ─ Test 11: test-after-change does NOT fire on Read/Grep (Codex scope fix) ─ +echo "Test 11: test-after-change scoped to Edit/Write only" +setup_sandbox +node "$MERGER_NATIVE" --seeds-path "$SEEDS_NATIVE" --index-path "$INDEX_NATIVE" > /dev/null 2>&1 +READ_PAYLOAD=$(node -e 'process.stdout.write(JSON.stringify({tool_name:"Read",tool_input:{file_path:"auth.test.ts"}}))') +READ_OUT=$(echo "$READ_PAYLOAD" | bash "$ACTIVATOR_NATIVE" 2>/dev/null) +if ! echo "$READ_OUT" | grep -q "Code modified"; then + pass "test-after-change did NOT fire on Read" +else + fail "test-after-change fired on Read (false positive): $READ_OUT" +fi +teardown_sandbox + +TOTAL=11 + +echo "" +echo "═══════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed (of $TOTAL)" +echo "═══════════════════════════════════════" +if [ "$FAIL" -gt 0 ]; then exit 1; fi +exit 0