fix(instructions): add startup rule only for existing custom instructions

This commit is contained in:
Julian lechner committed 2026-10-08 15:16:07 +02:00
1 parent 6a981bd7f2
commit 83091d247a
7 files changed
+114 -6

No files matched your search

+6
View File
@@ -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')
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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
+99
View File
@@ -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 = '<!-- ai-config:personal-instructions -->'
END = '<!-- /ai-config:personal-instructions -->'
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')