diff --git a/scripts/lib/install-options.py b/scripts/lib/install-options.py index ef48786..f50714f 100644 --- a/scripts/lib/install-options.py +++ b/scripts/lib/install-options.py @@ -12,7 +12,7 @@ def is_flashbang(hook): def is_qmd_warm(hook): - return any(re.search(r'(?:qmd-warm\.sh|Start-QmdWarm\.ps1)', str(hook.get(key, '')), re.I) + return any(re.search(r'(?:[/\\\s"\x27]|^)(?:qmd-warm\.sh|Start-QmdWarm\.ps1)(?:[\s"\x27]|$)', str(hook.get(key, '')), re.I) for key in ('command', 'command_windows')) @@ -165,10 +165,21 @@ def merge_toml(current_text, managed_text, statusline_enabled=True): managed_headers = {header for header, _ in managed_sections} extras = [] fields_by_header = {} - for header, block in current_sections: + owned_pattern = r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)' + for index, (header, block) in enumerate(current_sections): plugin_table = re.match(r'^\[(?:plugins|marketplaces)(?:\.|\])', header) array_table = header.startswith('[[') - owned_hook = any(re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer|qmd-warm|Start-QmdWarm)\.(?:ps1|sh)', line, re.I) for line in block) + owned_hook = any(re.search(owned_pattern, line, re.I) for line in block) + group = re.fullmatch(r'\[\[hooks\.(\w+)\]\]', header) + if group: + # A hook group header is owned when every hook table that follows it is owned (or none follows). + child = f'[[hooks.{group.group(1)}.hooks]]' + children = [] + for name, lines in current_sections[index + 1:]: + if name != child: + break + children.append(any(re.search(owned_pattern, line, re.I) for line in lines)) + owned_hook = all(children) if plugin_table and header in managed_headers: raise ValueError('Cannot merge conflicting managed plugin tables.') if (header not in managed_headers and not array_table) or (array_table and not owned_hook): diff --git a/scripts/tests/test-install-options.py b/scripts/tests/test-install-options.py index ea795e7..3bf11a1 100644 --- a/scripts/tests/test-install-options.py +++ b/scripts/tests/test-install-options.py @@ -98,6 +98,44 @@ class InstallOptionsTests(unittest.TestCase): self.assertEqual(OPTIONS.filter_instructions(text, False), 'Line one.\nLine three.\n') self.assertEqual(OPTIONS.filter_instructions(text, True), text) + def test_qmd_warm_match_is_anchored(self): + self.assertTrue(OPTIONS.is_qmd_warm({'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'})) + self.assertTrue(OPTIONS.is_qmd_warm({'command_windows': 'pwsh "C:\hooks\Start-QmdWarm.ps1"'})) + self.assertFalse(OPTIONS.is_qmd_warm({'command': 'bash "/c/hooks/scripts/my-qmd-warm.sh"'})) + + def test_merge_json_keeps_user_session_hook_and_drops_qmd_when_disabled(self): + user = {'type': 'command', 'command': 'bash "/home/me/my-start.sh"'} + pointer = {'type': 'command', 'command': 'bash "/c/hooks/scripts/show-session-state-pointer.sh"'} + qmd = {'type': 'command', 'command': 'bash "/c/hooks/scripts/qmd-warm.sh"'} + current = {'hooks': {'SessionStart': [{'hooks': [user]}]}} + managed = {'hooks': {'SessionStart': [{'hooks': [pointer]}, {'hooks': [qmd]}]}} + once = OPTIONS.merge_json(current, managed) + twice = OPTIONS.merge_json(once, managed) + self.assertEqual(twice['hooks']['SessionStart'], [{'hooks': [pointer]}, {'hooks': [qmd]}, {'hooks': [user]}]) + disabled = OPTIONS.merge_json(twice, {'hooks': {'SessionStart': [{'hooks': [pointer]}]}}) + commands = [hook['command'] for group in disabled['hooks']['SessionStart'] for hook in group['hooks']] + self.assertEqual(commands, [pointer['command'], user['command']]) + removed = OPTIONS.merge_json(twice, {'hooks': {}}) + self.assertEqual(removed['hooks']['SessionStart'], [{'hooks': [user]}]) + + def test_merge_toml_hook_groups_do_not_grow_and_user_hook_survives(self): + managed = ('[features]\nhooks = true\n\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\n' + 'command = \'bash "/c/hooks/scripts/qmd-warm.sh"\'\n\n[[hooks.Stop]]\n[[hooks.Stop.hooks]]\ntype = "command"\n' + 'command = \'bash "/c/hooks/flashbang.sh"\'\n') + user = '\n[[hooks.SessionStart]]\n[[hooks.SessionStart.hooks]]\ntype = "command"\ncommand = \'bash "/home/me/my-start.sh"\'\n' + text = managed + user + for _ in range(3): + text = OPTIONS.merge_toml(text, managed) + hooks = tomllib.loads(text)['hooks'] + self.assertEqual(len(hooks['Stop']), 1) + self.assertEqual(len(hooks['SessionStart']), 2) + commands = [hook['command'] for group in hooks['SessionStart'] for hook in group['hooks']] + self.assertEqual(sorted(commands), ['bash "/c/hooks/scripts/qmd-warm.sh"', 'bash "/home/me/my-start.sh"']) + disabled = OPTIONS.merge_toml(text, '[features]\nhooks = true\n') + hooks = tomllib.loads(disabled)['hooks'] + self.assertNotIn('Stop', hooks) + self.assertEqual([hook['command'] for group in hooks['SessionStart'] for hook in group['hooks']], ['bash "/home/me/my-start.sh"']) + def test_filter_preserves_other_claude_hooks_and_status(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'settings.json' diff --git a/scripts/tests/test-qmd-warm.mjs b/scripts/tests/test-qmd-warm.mjs index f9a3922..53e3b7e 100644 --- a/scripts/tests/test-qmd-warm.mjs +++ b/scripts/tests/test-qmd-warm.mjs @@ -23,3 +23,19 @@ test('a stale lock older than 30 minutes is replaced', async () => { await withLock(lock, async () => { runs += 1; }); assert.equal(runs, 1); }); + +test('an empty or non-numeric lock counts as stale', async () => { + for (const content of ['', 'garbage']) { + const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock'); + writeFileSync(lock, content); + let runs = 0; + await withLock(lock, async () => { runs += 1; }); + assert.equal(runs, 1); + } +}); + +test('a run never removes a lock that another run took over', async () => { + const lock = join(mkdtempSync(join(tmpdir(), 'qmd-warm-')), 'repo.lock'); + await withLock(lock, async () => { writeFileSync(lock, 'other-run'); }); + assert.equal(existsSync(lock), true); +}); diff --git a/shared/hooks/scripts/qmd-warm.sh b/shared/hooks/scripts/qmd-warm.sh index 1c06846..7659165 100644 --- a/shared/hooks/scripts/qmd-warm.sh +++ b/shared/hooks/scripts/qmd-warm.sh @@ -2,6 +2,6 @@ # SessionStart: warm the QMD index and daemon in the background; never blocks or fails the session. script="$HOME/.my-ai-configuration/qmd/qmd-warm.mjs" if [ -f "$script" ] && command -v node >/dev/null 2>&1 && git rev-parse --show-toplevel >/dev/null 2>&1; then - nohup node "$script" >/dev/null 2>&1 & + nohup node "$script" >/dev/null 2>&1 STALE_MS)) return false; - writeFileSync(lockPath, String(now())); + // An empty or non-numeric lock gives a NaN age and counts as stale. + if (Number.isFinite(age) && age <= STALE_MS) return false; + writeFileSync(lockPath, stamp); } try { await fn(); return true; } finally { - rmSync(lockPath, { force: true }); + // Remove the lock only while it is still this run's own. + try { if (readFileSync(lockPath, 'utf8') === stamp) rmSync(lockPath, { force: true }); } catch { /* already gone */ } } }