diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 6c4736858..1ac13472a 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -5,6 +5,29 @@ const path = require('path'); const { writeInstallState } = require('../install-state'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); +const { rewriteNamespaceLinks } = require('./rewrite-namespace-links'); + +const CLAUDE_ECC_NAMESPACE = 'ecc'; + +// Claude home/project installs inject `skills/ecc/` and `rules/ecc/`. Markdown +// copied under those namespaced roots may carry source-relative links to a +// sibling top-level dir that break post-install; rewrite them on copy. +function getNamespaceLinkRewrite(plan, destinationPath) { + if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) { + return null; + } + if (!plan.targetRoot || !destinationPath.toLowerCase().endsWith('.md')) { + return null; + } + const namespacedRoots = [ + path.join(plan.targetRoot, 'skills', CLAUDE_ECC_NAMESPACE) + path.sep, + path.join(plan.targetRoot, 'rules', CLAUDE_ECC_NAMESPACE) + path.sep, + ]; + if (!namespacedRoots.some(root => destinationPath.startsWith(root))) { + return null; + } + return CLAUDE_ECC_NAMESPACE; +} function readJsonObject(filePath, label) { let parsed; @@ -149,6 +172,16 @@ function applyInstallPlan(plan) { continue; } + const namespace = operation.kind === 'copy-file' + ? getNamespaceLinkRewrite(plan, operation.destinationPath) + : null; + if (namespace) { + const original = fs.readFileSync(operation.sourcePath, 'utf8'); + const rewritten = rewriteNamespaceLinks(original, namespace); + fs.writeFileSync(operation.destinationPath, rewritten, 'utf8'); + continue; + } + fs.copyFileSync(operation.sourcePath, operation.destinationPath); } diff --git a/scripts/lib/install/rewrite-namespace-links.js b/scripts/lib/install/rewrite-namespace-links.js new file mode 100644 index 000000000..220f7f38e --- /dev/null +++ b/scripts/lib/install/rewrite-namespace-links.js @@ -0,0 +1,48 @@ +'use strict'; + +// The Claude home/project installers inject a namespace segment into the +// destination layout: `skills/` -> `skills//` and `rules/` -> `rules//`. +// Skills that link to a sibling top-level dir using a source-relative path +// (e.g. `../../rules/react/hooks.md`) break after install, because the extra +// `/` level changes what `../../` resolves to and the target dir is itself +// namespaced. +// +// This rewrites such links so they remain valid post-install: prepend one `../` +// (to climb out of the injected namespace level) and insert the namespace +// segment after the managed top-level dir name. Only links that climb with at +// least two `../` and reference a namespaced managed dir (`rules`/`skills`) are +// touched; intra-skill links (a single `../`) and absolute/bare paths are left +// alone. + +const NAMESPACED_DIRS = ['rules', 'skills']; + +function buildLinkPattern() { + const dirs = NAMESPACED_DIRS.join('|'); + // group 1: the `../` climb (>= 2), group 2: managed dir, group 3: trailing `/` + return new RegExp(`((?:\\.\\./){2,})(${dirs})(/)`, 'g'); +} + +/** + * Rewrite source-relative links in a namespaced skill/rule markdown file so they + * resolve correctly after the installer injects `/`. + * @param {string} content - file contents + * @param {string} namespace - injected namespace segment (e.g. "ecc") + * @returns {string} rewritten contents (unchanged if no matching links) + */ +function rewriteNamespaceLinks(content, namespace) { + if (typeof content !== 'string' || !namespace) { + return content; + } + + const pattern = buildLinkPattern(); + return content.replace(pattern, (match, climb, dir, slash, offset, fullText) => { + // Already namespaced (`../../rules/ecc/...`) — leave untouched (idempotent). + const rest = fullText.slice(offset + match.length); + if (rest.startsWith(`${namespace}/`)) { + return match; + } + return `../${climb}${dir}${slash}${namespace}/`; + }); +} + +module.exports = { rewriteNamespaceLinks, NAMESPACED_DIRS }; diff --git a/scripts/lib/session-manager.js b/scripts/lib/session-manager.js index 0b58698a2..a4b900058 100644 --- a/scripts/lib/session-manager.js +++ b/scripts/lib/session-manager.js @@ -26,6 +26,18 @@ const { // "2026-02-01-ChezMoi_2-session.tmp" const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_-]*))?-session\.tmp$/; +/** + * Resolve a file's creation time, preferring birthtime but falling back to + * ctime when birthtime is unavailable. Some filesystems (e.g. overlayfs in + * containers) report birthtime as epoch 0; a Date object is always truthy, so + * `birthtime || ctime` would never fall back. Compare on milliseconds instead. + * @param {import('fs').Stats} stats + * @returns {Date} + */ +function resolveCreatedTime(stats) { + return stats.birthtimeMs > 0 ? stats.birthtime : stats.ctime; +} + /** * Parse session filename to extract metadata * @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp") @@ -116,7 +128,7 @@ function getSessionCandidates(options = {}) { hasContent: stats.size > 0, size: stats.size, modifiedTime: stats.mtime, - createdTime: stats.birthtime || stats.ctime + createdTime: resolveCreatedTime(stats) }); } } @@ -151,7 +163,7 @@ function buildSessionRecord(sessionPath, metadata) { hasContent: stats.size > 0, size: stats.size, modifiedTime: stats.mtime, - createdTime: stats.birthtime || stats.ctime + createdTime: resolveCreatedTime(stats) }; } diff --git a/skills/continuous-learning-v2/agents/observer.md b/skills/continuous-learning-v2/agents/observer.md index f29bcb0fd..c74eacc11 100644 --- a/skills/continuous-learning-v2/agents/observer.md +++ b/skills/continuous-learning-v2/agents/observer.md @@ -121,7 +121,7 @@ Validate and sanitize all user input before processing. When creating instincts, determine scope based on these heuristics: -> **Scope Decision Guide** – See the canonical table in `skills/continuous-learning-v2/SKILL.md` (lines 271‑282). +> **Scope Decision Guide** – See the canonical table under the "Scope Decision Guide" heading in `skills/continuous-learning-v2/SKILL.md`. **When in doubt, default to `scope: project`** — it's safer to be project-specific and promote later than to contaminate the global space. @@ -142,7 +142,7 @@ Confidence adjusts over time: An instinct should be promoted from project-scoped to global when: 1. The **same pattern** (by id or similar trigger) exists in **2+ different projects** -2. Each instance has confidence **>= 0.8** +2. The **average** confidence across those instances is **>= 0.8** (matching `PROMOTE_CONFIDENCE_THRESHOLD` in `instinct-cli.py`) 3. The domain is in the global-friendly list (security, general-best-practices, workflow) Promotion is handled by the `instinct-cli.py promote` command or the `/evolve` analysis. diff --git a/skills/continuous-learning-v2/agents/start-observer.sh b/skills/continuous-learning-v2/agents/start-observer.sh index c3ada3140..bbdb6fbd5 100755 --- a/skills/continuous-learning-v2/agents/start-observer.sh +++ b/skills/continuous-learning-v2/agents/start-observer.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Continuous Learning v2 - Observer Agent Launcher # # Starts the background observer agent that analyzes observations @@ -37,7 +37,7 @@ PYTHON_CMD="${CLV2_PYTHON_CMD:-}" # shellcheck disable=SC1091 . "${SKILL_ROOT}/scripts/lib/homunculus-dir.sh" -CONFIG_DIR="$(_ecc_resolve_homunculus_dir)" +CONFIG_DIR="$(_clv2_resolve_homunculus_dir)" if [ -n "${CLV2_CONFIG:-}" ]; then CONFIG_FILE="$CLV2_CONFIG" elif [ -f "${CONFIG_DIR}/config.json" ]; then @@ -215,8 +215,13 @@ case "$ACTION" in CLV2_OBSERVER_PROMPT_PATTERN="$CLV2_OBSERVER_PROMPT_PATTERN" \ "$OBSERVER_LOOP_SCRIPT" >> "$LOG_FILE" 2>&1 & - # Wait for PID file - sleep 2 + # Wait for the PID file to appear (poll up to ~10s; exits early once ready). + # A fixed sleep is either too short on loaded/slow filesystems or wastes + # time on healthy ones. + for _i in $(seq 1 50); do + [ -f "$PID_FILE" ] && break + sleep 0.2 + done # Check for confirmation-seeking output in the observer log if tail -n +"$((start_line + 1))" "$LOG_FILE" 2>/dev/null | grep -E -i -q "$CLV2_OBSERVER_PROMPT_PATTERN"; then diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 45d962971..49713957b 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Continuous Learning v2 - Observation Hook # # Captures tool use events for pattern analysis. @@ -135,7 +135,7 @@ fi # shellcheck disable=SC1091 . "$(dirname "$0")/../scripts/lib/homunculus-dir.sh" -CONFIG_DIR="$(_ecc_resolve_homunculus_dir)" +CONFIG_DIR="$(_clv2_resolve_homunculus_dir)" # Skip if disabled (check both default and CLV2_CONFIG-derived locations) if [ -f "$CONFIG_DIR/disabled" ]; then @@ -279,11 +279,11 @@ _SECRET_RE = re.compile( ) import signal -def _ecc_bail(*_): +def _clv2_bail(*_): print("[observe] SIGALRM timeout: parse-error fallback observation dropped before write (#2300)", file=sys.stderr) sys.exit(0) try: - signal.signal(signal.SIGALRM, _ecc_bail) + signal.signal(signal.SIGALRM, _clv2_bail) signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278) except Exception: pass @@ -317,11 +317,11 @@ echo "$PARSED" | "$PYTHON_CMD" -c ' import json, sys, os, re import signal -def _ecc_bail(*_): +def _clv2_bail(*_): print("[observe] SIGALRM timeout: in-flight observation dropped before write (#2300)", file=sys.stderr) sys.exit(0) try: - signal.signal(signal.SIGALRM, _ecc_bail) + signal.signal(signal.SIGALRM, _clv2_bail) signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278) except Exception: pass @@ -493,7 +493,7 @@ touch "$ACTIVITY_FILE" 2>/dev/null || true # the lazy-start path above. Both wrap the same read-modify-write below. should_signal=0 -_ecc_bump_signal_counter() { +_clv2_bump_signal_counter() { if [ -f "$SIGNAL_COUNTER_FILE" ]; then counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0) # Guard against a corrupt counter file: a non-integer value would abort the @@ -518,7 +518,7 @@ if command -v flock >/dev/null 2>&1 && exec 8>"$SIGNAL_COUNTER_LOCK" 2>/dev/null # blocks indefinitely, and only bump the counter while the lock is held -- on # a timeout we skip the tick rather than doing an unlocked read-modify-write. if flock -w 2 8 2>/dev/null; then - _ecc_bump_signal_counter + _clv2_bump_signal_counter flock -u 8 2>/dev/null || true fi exec 8>&- 2>/dev/null || true @@ -547,7 +547,7 @@ else done if [ "$_signal_lock_held" -eq 1 ]; then # Bump only under the held lock -- never an unlocked read-modify-write. - _ecc_bump_signal_counter + _clv2_bump_signal_counter rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true trap - EXIT INT TERM fi diff --git a/skills/continuous-learning-v2/scripts/detect-project.sh b/skills/continuous-learning-v2/scripts/detect-project.sh index dbe9c5edc..05bc20852 100755 --- a/skills/continuous-learning-v2/scripts/detect-project.sh +++ b/skills/continuous-learning-v2/scripts/detect-project.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Continuous Learning v2 - Project Detection Helper # # Shared logic for detecting current project context. @@ -21,7 +21,7 @@ # shellcheck disable=SC1091 . "$(dirname "${BASH_SOURCE[0]}")/lib/homunculus-dir.sh" -_CLV2_HOMUNCULUS_DIR="$(_ecc_resolve_homunculus_dir)" +_CLV2_HOMUNCULUS_DIR="$(_clv2_resolve_homunculus_dir)" _CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects" _CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json" diff --git a/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh b/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh index 9f1e926a7..27f9adb84 100644 --- a/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh +++ b/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh @@ -6,7 +6,7 @@ # 2. XDG_DATA_HOME/ecc-homunculus, when XDG_DATA_HOME is absolute # 3. HOME/.local/share/ecc-homunculus -_ecc_resolve_homunculus_dir() { +_clv2_resolve_homunculus_dir() { if [ -n "${CLV2_HOMUNCULUS_DIR:-}" ]; then case "$CLV2_HOMUNCULUS_DIR" in /*) printf '%s\n' "$CLV2_HOMUNCULUS_DIR"; return 0 ;; diff --git a/skills/continuous-learning-v2/scripts/migrate-homunculus.sh b/skills/continuous-learning-v2/scripts/migrate-homunculus.sh index b6c19cacd..3453b9294 100755 --- a/skills/continuous-learning-v2/scripts/migrate-homunculus.sh +++ b/skills/continuous-learning-v2/scripts/migrate-homunculus.sh @@ -7,7 +7,7 @@ OLD="${HOME}/.claude/homunculus" # shellcheck disable=SC1091 . "$(dirname "$0")/lib/homunculus-dir.sh" -NEW="$(_ecc_resolve_homunculus_dir)" +NEW="$(_clv2_resolve_homunculus_dir)" if [ "$NEW" = "$OLD" ]; then echo "Resolved destination equals source ($OLD); nothing to migrate." diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index 290ff9114..f58c58853 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -19,7 +19,6 @@ import os import sys from pathlib import Path from types import SimpleNamespace -from unittest import mock import pytest diff --git a/tests/hooks/observe-signal-counter-race.test.js b/tests/hooks/observe-signal-counter-race.test.js index 2fb978aac..55c9aea77 100644 --- a/tests/hooks/observe-signal-counter-race.test.js +++ b/tests/hooks/observe-signal-counter-race.test.js @@ -104,7 +104,7 @@ function buildSandbox() { path.join(scriptsLibDir, 'homunculus-dir.sh'), [ '#!/bin/bash', - '_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }', + '_clv2_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }', '' ].join('\n') ); diff --git a/tests/hooks/observe-signal-timeout.test.js b/tests/hooks/observe-signal-timeout.test.js index 2f7e48047..00ff23b86 100644 --- a/tests/hooks/observe-signal-timeout.test.js +++ b/tests/hooks/observe-signal-timeout.test.js @@ -3,14 +3,14 @@ * * observe.sh arms a signal.SIGALRM alarm (8s) inside its inline-Python blocks so * the observation writer self-terminates before the async hook's 10s timeout can - * orphan it (#2278). Before #2300 the handler `_ecc_bail` called sys.exit(0) with + * orphan it (#2278). Before #2300 the handler `_clv2_bail` called sys.exit(0) with * no logging, so a timeout silently dropped the in-flight observation: nothing was * logged and the shell saw a clean exit. The fix adds a stderr visibility line to * each handler while keeping exit 0 (changing to a non-zero exit would make the * Claude hook report a block, per the repo's "always exit 0; log to stderr" rule). * * Two checks: - * 1. Static regression guard — every `_ecc_bail` handler in observe.sh writes to + * 1. Static regression guard — every `_clv2_bail` handler in observe.sh writes to * sys.stderr before sys.exit(0). * 2. Behavioral check — the REAL handler text extracted from observe.sh, when its * alarm fires, exits 0 and emits the `[observe]` visibility token on stderr @@ -73,14 +73,14 @@ const observeShPath = path.join( const observeSrc = fs.readFileSync(observeShPath, 'utf8'); -// Extract each `_ecc_bail` handler body: the `def` line plus the indented lines +// Extract each `_clv2_bail` handler body: the `def` line plus the indented lines // that follow it, up to (and including) the first dedented `sys.exit(0)` line at // the same indentation as the def's body. function extractHandlers(src) { const lines = src.split('\n'); const handlers = []; for (let i = 0; i < lines.length; i += 1) { - if (/^def _ecc_bail\(\*_\):\s*$/.test(lines[i])) { + if (/^def _clv2_bail\(\*_\):\s*$/.test(lines[i])) { const body = [lines[i]]; for (let j = i + 1; j < lines.length; j += 1) { // Stop when we hit a line that is not indented (next top-level stmt). @@ -103,15 +103,15 @@ const handlers = extractHandlers(observeSrc); // The #2300 timeout handlers are the ones that log the `[observe] SIGALRM // timeout` marker. Selecting by marker (rather than by array index) keeps the // behavioral check pinned to the timeout handlers even if an unrelated -// `_ecc_bail` is ever added elsewhere in observe.sh. +// `_clv2_bail` is ever added elsewhere in observe.sh. const timeoutHandlers = handlers.filter(body => body.includes('[observe] SIGALRM timeout') ); -test('observe.sh defines at least two _ecc_bail timeout handlers', () => { +test('observe.sh defines at least two _clv2_bail timeout handlers', () => { assert.ok( handlers.length >= 2, - `expected >= 2 _ecc_bail handlers, found ${handlers.length}` + `expected >= 2 _clv2_bail handlers, found ${handlers.length}` ); assert.ok( timeoutHandlers.length >= 2, @@ -119,7 +119,7 @@ test('observe.sh defines at least two _ecc_bail timeout handlers', () => { ); }); -test('every _ecc_bail handler logs to stderr before exiting (regression guard)', () => { +test('every _clv2_bail handler logs to stderr before exiting (regression guard)', () => { handlers.forEach((body, idx) => { const stderrIdx = body.indexOf('file=sys.stderr'); const exitIdx = body.indexOf('sys.exit(0)'); @@ -142,7 +142,7 @@ test('every _ecc_bail handler logs to stderr before exiting (regression guard)', }); }); -test('_ecc_bail handlers keep exit code 0 (no exit 2 / block regression)', () => { +test('_clv2_bail handlers keep exit code 0 (no exit 2 / block regression)', () => { handlers.forEach((body, idx) => { assert.ok( /sys\.exit\(0\)/.test(body), @@ -160,7 +160,7 @@ function runHandlerTimeout(python, handler) { const program = [ 'import sys, signal, time', handler, - 'signal.signal(signal.SIGALRM, _ecc_bail)', + 'signal.signal(signal.SIGALRM, _clv2_bail)', 'signal.alarm(1)', 'time.sleep(3)', 'print("REACHED_END_SHOULD_NOT_HAPPEN")', @@ -178,7 +178,7 @@ function runHandlerTimeout(python, handler) { // the worst case. A behavioral check on only one handler would not catch a // regression that silenced another. timeoutHandlers.forEach((handler, idx) => { - test(`real _ecc_bail timeout handler #${idx + 1}: SIGALRM fire emits stderr token and exits 0`, () => { + test(`real _clv2_bail timeout handler #${idx + 1}: SIGALRM fire emits stderr token and exits 0`, () => { const python = findPython(); if (!python) { // Fail fast rather than returning (which the harness would record as a diff --git a/tests/hooks/observer-memory.test.js b/tests/hooks/observer-memory.test.js index 9bfbdde09..86c324c46 100644 --- a/tests/hooks/observer-memory.test.js +++ b/tests/hooks/observer-memory.test.js @@ -375,7 +375,7 @@ test('observe.sh creates counter file and increments on each call', () => { path.join(scriptsLibDir, 'homunculus-dir.sh'), [ '#!/bin/bash', - '_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }', + '_clv2_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }', '' ].join('\n') ); diff --git a/tests/lib/rewrite-namespace-links.test.js b/tests/lib/rewrite-namespace-links.test.js new file mode 100644 index 000000000..819625e16 --- /dev/null +++ b/tests/lib/rewrite-namespace-links.test.js @@ -0,0 +1,81 @@ +/** + * Tests for scripts/lib/install/rewrite-namespace-links.js (#2340) + */ + +const assert = require('assert'); + +const { rewriteNamespaceLinks } = require('../../scripts/lib/install/rewrite-namespace-links'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing rewrite-namespace-links.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('rewrites a markdown link to a sibling rules dir', () => { + assert.strictEqual( + rewriteNamespaceLinks('See [hooks](../../rules/react/hooks.md) here', 'ecc'), + 'See [hooks](../../../rules/ecc/react/hooks.md) here' + ); + })) passed++; else failed++; + + if (test('rewrites a bare rules dir link', () => { + assert.strictEqual( + rewriteNamespaceLinks('Rules: [r](../../rules/react/)', 'ecc'), + 'Rules: [r](../../../rules/ecc/react/)' + ); + })) passed++; else failed++; + + if (test('handles deeper skill nesting (3+ climbs)', () => { + assert.strictEqual( + rewriteNamespaceLinks('link ../../../rules/x/y.md', 'ecc'), + 'link ../../../../rules/ecc/x/y.md' + ); + })) passed++; else failed++; + + if (test('leaves intra-skill single-climb links untouched', () => { + const input = 'See ../sibling/file.md and ./local.md'; + assert.strictEqual(rewriteNamespaceLinks(input, 'ecc'), input); + })) passed++; else failed++; + + if (test('is idempotent on already-namespaced links', () => { + const input = 'See [x](../../../rules/ecc/react/hooks.md)'; + assert.strictEqual(rewriteNamespaceLinks(input, 'ecc'), input); + })) passed++; else failed++; + + if (test('rewrites multiple links in one document', () => { + assert.strictEqual( + rewriteNamespaceLinks('(../../rules/a) and (../../rules/b)', 'ecc'), + '(../../../rules/ecc/a) and (../../../rules/ecc/b)' + ); + })) passed++; else failed++; + + if (test('rewrites sibling skills-dir links symmetrically', () => { + assert.strictEqual( + rewriteNamespaceLinks('[s](../../skills/other/SKILL.md)', 'ecc'), + '[s](../../../skills/ecc/other/SKILL.md)' + ); + })) passed++; else failed++; + + if (test('returns input unchanged when namespace is missing', () => { + const input = 'See [x](../../rules/react/)'; + assert.strictEqual(rewriteNamespaceLinks(input, ''), input); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/session-manager.test.js b/tests/lib/session-manager.test.js index bdf164178..6de91b5f2 100644 --- a/tests/lib/session-manager.test.js +++ b/tests/lib/session-manager.test.js @@ -1165,16 +1165,21 @@ src/main.ts })) passed++; else failed++; if (test('createdTime falls back to ctime when birthtime is epoch-zero', () => { - // This tests the || fallback logic: stats.birthtime || stats.ctime - // On some FS, birthtime may be epoch 0 (falsy as a Date number comparison - // but truthy as a Date object). The fallback is defensive. + // Some filesystems (e.g. overlayfs in containers) report birthtime as + // epoch 0. A Date object is always truthy, so `birthtime || ctime` would + // never fall back; the source compares birthtimeMs > 0 instead. Verify the + // resolved createdTime is always a non-zero Date regardless of birthtime. const stats = fs.statSync(r33FilePath); - // Both birthtime and ctime should be valid Dates on any modern OS assert.ok(stats.ctime instanceof Date, 'ctime should exist'); - // The fallback expression `birthtime || ctime` should always produce a valid Date - const fallbackResult = stats.birthtime || stats.ctime; - assert.ok(fallbackResult instanceof Date, 'Fallback should produce a Date'); - assert.ok(fallbackResult.getTime() > 0, 'Fallback date should be non-zero'); + const expected = stats.birthtimeMs > 0 ? stats.birthtime : stats.ctime; + assert.ok(expected.getTime() > 0, 'Resolved created time should be non-zero'); + const session = sessionManager.getSessionById('r33birth'); + assert.ok(session, 'Should find the session'); + assert.strictEqual( + session.createdTime.getTime(), + expected.getTime(), + 'createdTime should fall back to ctime when birthtime is epoch-zero' + ); })) passed++; else failed++; // Cleanup Round 33 HOME override diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index cfe8816b2..da1a258b4 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -123,6 +123,40 @@ function runTests() { } })) passed++; else failed++; + if (test('rewrites namespaced skill links to the ecc/ rules path (#2340)', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const result = run(['typescript'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + + const claudeRoot = path.join(homeDir, '.claude'); + const skillPath = path.join(claudeRoot, 'skills', 'ecc', 'react-patterns', 'SKILL.md'); + assert.ok(fs.existsSync(skillPath), 'react-patterns SKILL.md should be installed'); + + const content = fs.readFileSync(skillPath, 'utf8'); + assert.ok( + content.includes('../../../rules/ecc/react/'), + 'source-relative rules link should be rewritten for the ecc/ namespace' + ); + assert.ok( + !content.includes('](../../rules/'), + 'no un-namespaced ](../../rules/ links should remain' + ); + + // The rewritten link must resolve to a file that actually exists on disk. + const linkTarget = path.join( + path.dirname(skillPath), + '../../../rules/ecc/react/hooks.md' + ); + assert.ok(fs.existsSync(linkTarget), 'rewritten link target should exist'); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('installs Cursor configs and writes install-state', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');