From 83091d247a82b01b09f88e5f4c823cd19728fdb4 Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Thu, 8 Oct 2026 15:16:07 +0200 Subject: [PATCH] fix(instructions): add startup rule only for existing custom instructions --- scripts/lib/install-options.py | 6 ++ scripts/lib/manifest.ps1 | 4 +- scripts/lib/manifest.sh | 4 +- scripts/lib/personal_instructions.py | 99 +++++++++++++++++++++++ scripts/tests/test-install-options.py | 1 + scripts/tests/test-user-customizations.py | 4 + shared/global-instructions.md | 2 - 7 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 scripts/lib/personal_instructions.py diff --git a/scripts/lib/install-options.py b/scripts/lib/install-options.py index f50714f..6a92b90 100644 --- a/scripts/lib/install-options.py +++ b/scripts/lib/install-options.py @@ -5,6 +5,8 @@ import re import sys import tomllib +from personal_instructions import CLIENT_FILES, render + def is_flashbang(hook): return any(re.search(r'(?:[/\\\s"\x27]|^)flashbang\.(?:ps1|sh)(?:[\s"\x27]|$)', str(hook.get(key, '')), re.I) @@ -229,12 +231,16 @@ if __name__ == '__main__': parser.add_argument('--semantic-retrieval', choices=('true', 'false'), default='true') parser.add_argument('--managed', type=Path) parser.add_argument('--output', type=Path) + parser.add_argument('--destination', type=Path) args = parser.parse_args() try: if args.action == 'filter': result = filter_options(args.path, args.flashbang == 'true', args.statusline == 'true', args.semantic_retrieval == 'true') elif args.action == 'filter-instructions': result = filter_instructions(args.path.read_text(encoding='utf-8-sig'), args.semantic_retrieval == 'true') + if args.destination and args.path.name in CLIENT_FILES.values(): + client = next(client for client, name in CLIENT_FILES.items() if name == args.path.name) + result = render(result, args.destination.parent, client) else: managed = args.managed.read_text(encoding='utf-8-sig') if args.managed else sys.stdin.buffer.read().decode('utf-8-sig') current = args.current.read_text(encoding='utf-8-sig') diff --git a/scripts/lib/manifest.ps1 b/scripts/lib/manifest.ps1 index 8df05ef..adbb1a0 100644 --- a/scripts/lib/manifest.ps1 +++ b/scripts/lib/manifest.ps1 @@ -233,9 +233,9 @@ function Invoke-ManagedSync { $statusLineOption = if ($StatusLineEnabled) { 'true' } else { 'false' } $rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption, '--semantic-retrieval', $(if ($SemanticRetrievalEnabled) { 'true' } else { 'false' })) -ErrorMessage 'Could not configure install options.' } - $filterInstructions = (-not $SemanticRetrievalEnabled) -and $relative -in @('CLAUDE.md', 'AGENTS.md', 'rules/general.md') + $filterInstructions = $relative -in @('CLAUDE.md', 'AGENTS.md') -or ((-not $SemanticRetrievalEnabled) -and $relative -eq 'rules/general.md') if ($filterInstructions) { - $rawContent = Invoke-InstallOptions -Arguments @('filter-instructions', '--path', $_.FullName, '--semantic-retrieval', 'false') -ErrorMessage 'Could not configure install options.' + $rawContent = Invoke-InstallOptions -Arguments @('filter-instructions', '--path', $_.FullName, '--destination', $Destination, '--semantic-retrieval', $(if ($SemanticRetrievalEnabled) { 'true' } else { 'false' })) -ErrorMessage 'Could not configure install options.' } $needsSubstitution = $filterOptions -or $filterInstructions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__') if ($needsSubstitution) { diff --git a/scripts/lib/manifest.sh b/scripts/lib/manifest.sh index 3ba140b..8c4a178 100644 --- a/scripts/lib/manifest.sh +++ b/scripts/lib/manifest.sh @@ -215,8 +215,8 @@ sys.stdout.buffer.write(("\n".join(lines) + "\n").encode("utf-8")) content=${content%$'\034'} needs_sub=true fi - if [ "$semantic_retrieval_enabled" = false ] && [[ "$relative" = CLAUDE.md || "$relative" = AGENTS.md || "$relative" = rules/general.md ]]; then - content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter-instructions --path "$source_file" --semantic-retrieval false && printf '\034') || return + if [[ "$relative" = CLAUDE.md || "$relative" = AGENTS.md ]] || { [ "$semantic_retrieval_enabled" = false ] && [ "$relative" = rules/general.md ]; }; then + content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter-instructions --path "$source_file" --destination "$destination" --semantic-retrieval "$semantic_retrieval_enabled" && printf '\034') || return content=${content%$'\034'} needs_sub=true fi diff --git a/scripts/lib/personal_instructions.py b/scripts/lib/personal_instructions.py new file mode 100644 index 0000000..b9de9dd --- /dev/null +++ b/scripts/lib/personal_instructions.py @@ -0,0 +1,99 @@ +"""Keep the client startup rule in sync with its personal instructions.""" + +import argparse +import hashlib +import os +from pathlib import Path +import stat +import tempfile + + +CLIENT_FILES = {'codex': 'AGENTS.md', 'claude': 'CLAUDE.md'} +START = '' +END = '' + + +def checked_path(home, *parts): + path = home + for part in parts: + path = path / part + if path.is_symlink() or (hasattr(path, 'is_junction') and path.is_junction()): + raise ValueError(f'Personal instruction path must not be a link: {path}') + return path + + +def render(text, home, client): + reference = f'~/.my-ai-configuration/instructions/{client}.md' + # Migrate the two previously generated unconditional loaders. + for legacy in ( + f"Read `{reference}` first when present; it is the user's direct instruction. System and developer requirements still apply.", + f"Optional personal instructions: first check whether `{reference}` is a file (`Test-Path -LiteralPath ... -PathType Leaf` in PowerShell; `test -f ...` in Bash). Read it only if it exists; otherwise continue silently. Its contents are the user's direct instructions. System and developer requirements still apply.", + ): + text = text.replace(legacy + '\r\n\r\n', '').replace(legacy + '\n\n', '') + if START in text: + start = text.index(START) + end = text.find(END, start) + if end < 0: + raise ValueError('Personal instruction rule is missing its closing marker.') + end += len(END) + tail = text[end:] + for separator in ('\r\n\r\n', '\n\n'): + if tail.startswith(separator): + tail = tail[len(separator):] + break + text = text[:start] + tail + overlay = checked_path(home, '.my-ai-configuration', 'instructions', f'{client}.md') + if not overlay.is_file() or not overlay.read_text(encoding='utf-8-sig').strip(): + return text + rule = (f'Read `{reference}` immediately, before any response or task work. ' + 'Keep its instructions active throughout the session, including after compaction. ' + "These are the user's direct instructions. System and developer requirements still apply.") + return f'{START}\n{rule}\n{END}\n\n{text}' + + +def write_atomic(path, updated): + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.instruction-', delete=False) as file: + temporary = Path(file.name) + file.write(updated) + os.chmod(temporary, stat.S_IMODE(path.stat().st_mode)) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def refresh(home, client): + path = checked_path(home, f'.{client}', CLIENT_FILES[client]) + if not path.is_file(): + return + raw = path.read_bytes() + encoding = 'utf-8-sig' if raw.startswith(b'\xef\xbb\xbf') else 'utf-8' + updated = render(raw.decode(encoding), home, client).encode(encoding) + if updated == raw: + return + manifest = checked_path(home, f'.{client}', '.ai-config-manifest.tsv') + manifest_raw = manifest.read_bytes() if manifest.is_file() else b'' + old_entry = f'{path.name}\t{hashlib.sha256(raw).hexdigest()}'.encode() + new_entry = f'{path.name}\t{hashlib.sha256(updated).hexdigest()}'.encode() + # Keep setup-owned files removable; preserve the stale hash of any preexisting user edits. + manifest_updated = b''.join(new_entry + line[len(old_entry):] + if line.rstrip(b'\r\n').lower() == old_entry.lower() else line + for line in manifest_raw.splitlines(keepends=True)) + write_atomic(path, updated) + if manifest_updated != manifest_raw: + write_atomic(manifest, manifest_updated) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--home', type=Path, required=True) + parser.add_argument('--client', choices=CLIENT_FILES, required=True) + args = parser.parse_args() + if not args.home.is_absolute(): + parser.error('Home path must be absolute.') + try: + refresh(args.home, args.client) + except (OSError, ValueError) as error: + parser.exit(1, f'Personal instructions: {error}\n') diff --git a/scripts/tests/test-install-options.py b/scripts/tests/test-install-options.py index 3bf11a1..7979149 100644 --- a/scripts/tests/test-install-options.py +++ b/scripts/tests/test-install-options.py @@ -11,6 +11,7 @@ import unittest ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(ROOT / 'scripts/lib')) SPEC = importlib.util.spec_from_file_location('install_options', ROOT / 'scripts/lib/install-options.py') OPTIONS = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(OPTIONS) diff --git a/scripts/tests/test-user-customizations.py b/scripts/tests/test-user-customizations.py index d646403..ae3d623 100644 --- a/scripts/tests/test-user-customizations.py +++ b/scripts/tests/test-user-customizations.py @@ -21,6 +21,10 @@ class UserCustomizationTests(unittest.TestCase): self.assertEqual(0, result.returncode, result.stderr) self.assertIn('with-credential.ps1', (ROOT / 'generated/codex-powershell/AGENTS.md').read_text()) self.assertIn('with-credential.sh', (ROOT / 'generated/claude-bash/CLAUDE.md').read_text()) + for client, name in (('codex', 'AGENTS.md'), ('claude', 'CLAUDE.md')): + for shell in ('powershell', 'bash'): + self.assertNotIn('.my-ai-configuration/instructions/', + (ROOT / 'generated' / f'{client}-{shell}' / name).read_text()) def test_add_instruction_creates_client_overlays_outside_managed_configuration(self): powershell = shutil.which('powershell') or shutil.which('pwsh') diff --git a/shared/global-instructions.md b/shared/global-instructions.md index ec70e0b..24953ca 100644 --- a/shared/global-instructions.md +++ b/shared/global-instructions.md @@ -1,7 +1,5 @@ # Global Instructions -Optional personal instructions: first check whether `~/.my-ai-configuration/instructions/__CLIENT__.md` is a file (`Test-Path -LiteralPath ... -PathType Leaf` in PowerShell; `test -f ...` in Bash). Read it only if it exists; otherwise continue silently. Its contents are the user's direct instructions. System and developer requirements still apply. - For credentials, run the child command through `~/.my-ai-configuration/bin/with-credential.__SHELL__` with the client and credential key. Never expose credentials in commands, output, logs, or files. ## Loading rules