Compare commits
26
Commits
83e921301b
...
b110b6d6cd
No files matched your search
@@ -1,4 +1,7 @@
|
||||
generated/
|
||||
generated.build-*/
|
||||
generated.old-*/
|
||||
generated.lock
|
||||
.superpowers/
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
@@ -172,7 +172,9 @@ Bash:
|
||||
|
||||
Rebuild when shared definitions, adapters, or generation logic change. Choose other verification according to the
|
||||
change's risk; documentation-only edits do not require a build. Building writes reproducible packages below
|
||||
`generated/` and may use temporary validation files; it does not install global user configuration.
|
||||
`generated/` and may use temporary validation files; it does not install global user configuration. Each build writes
|
||||
into its own `generated.build-*` directory and replaces `generated/` only after validation, so a failed or parallel
|
||||
build never leaves it partial.
|
||||
|
||||
## Installation Script
|
||||
|
||||
@@ -224,6 +226,8 @@ Every managed file, including `config.toml`, `settings.json`, and the manifest,
|
||||
file and then renamed over the target, so an interrupted run never leaves a partly written file. Each destination is
|
||||
locked through `.ai-config.lock` while it syncs; a parallel install or update fails instead of interleaving writes.
|
||||
A lock left by an exited process of the same shell on the same host is replaced automatically.
|
||||
Before its first write, each sync records its planned changes in `.ai-config-transaction.json`; if a run is interrupted,
|
||||
the next install or update first rolls that destination back from the backups (`ROLLBACK <destination> <stamp>`).
|
||||
Dry runs still rebuild `generated/`, but do not modify installation destinations.
|
||||
|
||||
Selecting Codex also installs shared skills to `.agents/skills`. An interactive extension list includes plugins and
|
||||
|
||||
@@ -4,6 +4,7 @@ sandbox_mode = "workspace-write"
|
||||
|
||||
[sandbox_workspace_write]
|
||||
network_access = true
|
||||
writable_roots = ['__AI_CONFIG_ROOT__/../.my-ai-configuration/semantic-retrieval/data']
|
||||
|
||||
[windows]
|
||||
sandbox = "unelevated"
|
||||
|
||||
+2
-2
@@ -18,9 +18,9 @@ On Windows, installation and update repair the known Caveman 1.0.0 hook launcher
|
||||
|
||||
[QMD](https://github.com/tobi/qmd) requires Node.js 22 or newer and `npm`. Selecting it installs `@tobilu/qmd` globally. Claude receives QMD's official `qmd@qmd` plugin; Codex can use the `qmd` CLI. Deselecting QMD removes setup-owned integrations and uninstalls the global package when the Codex-owned QMD record is removed.
|
||||
|
||||
Normal installation and update ask whether to enable Flashbang, apply the custom status line, and enable the optional Qwen3-Embedding-0.6B semantic retrieval layer. Retrieval uses a per-user virtual environment and index and is invoked through the installed `server.py search` CLI, disabled by default. Quickupdate reuses the saved shell, client, status line, Flashbang, retrieval, and extension choices without opening menus. Disabling the status line removes its custom setting and setup-owned Claude script; disabling retrieval removes its setup-owned runtime and index. Only successful runs save selections; dry runs do not update extensions or retrieval. Install and update do not modify the Codex or Claude CLI installations.
|
||||
Normal installation and update ask whether to enable Flashbang, apply the custom status line, and enable the optional Qwen3-Embedding-0.6B semantic retrieval layer. Retrieval uses a per-user virtual environment and index and is invoked through the installed `server.py search` CLI, disabled by default. While it is enabled, both clients receive the `semantic-search` skill, which the general rules point to for concept questions; disabling retrieval removes the skill. Codex may write only the retrieval `data/` directory from its sandbox (`sandbox_workspace_write.writable_roots`); the sandbox forbids the daemon's pipes, so each Codex search loads the models in-process and takes about half a minute. Quickupdate reuses the saved shell, client, status line, Flashbang, retrieval, and extension choices without opening menus. Disabling the status line removes its custom setting and setup-owned Claude script; disabling retrieval removes its setup-owned runtime and index. Only successful runs save selections; dry runs do not update extensions or retrieval. Install and update do not modify the Codex or Claude CLI installations.
|
||||
|
||||
The `server.py search`/`rebuild` CLI transparently reuses a background daemon (spawned automatically on first use per `--data-dir`) that keeps the embedding and reranking models loaded in memory, so only the first query after a cold start or an idle period pays the model-loading cost; every call falls back to loading the models directly in-process if the daemon cannot be reached or started. The daemon exits on its own after 30 minutes of inactivity. Search requires a Git repository and indexes tracked files plus untracked files that Git does not ignore. Indexing skips files larger than 512 KB and, per repository, stops after 2000 indexable files by default; set `SEMANTIC_RETRIEVAL_MAX_FILES` to raise that cap for larger monorepos. When the cap is reached, the CLI prints an explicit warning to stderr rather than silently omitting files.
|
||||
The `server.py search`/`rebuild` CLI transparently reuses a background daemon (spawned automatically on first use per `--data-dir` and code version, so an updated `server.py` never reuses an older daemon) that keeps the embedding and reranking models loaded in memory, so only the first query after a cold start or an idle period pays the model-loading cost; every call falls back to loading the models directly in-process if the daemon cannot be reached or started. The daemon exits on its own after 30 minutes of inactivity. Search requires a Git repository and indexes tracked files plus untracked files that Git does not ignore. Indexing covers common source, markup, style, SQL, XAML, project, and configuration file types, uses bfloat16 on GPUs that support it, and skips files larger than 512 KB and, per repository, stops after 2000 indexable files by default; set `SEMANTIC_RETRIEVAL_MAX_FILES` to raise that cap for larger monorepos. When the cap is reached, the CLI prints an explicit warning to stderr rather than silently omitting files.
|
||||
|
||||
Plugin installation requires the corresponding client CLI, network access, and any authentication required by the plugin. Context7 may open an OAuth flow. Codex or plugin hooks may require a trust review in `/hooks`. The scripts never install plugins during a build or doctor run and never install them when `--dry-run` or `-DryRun` is used.
|
||||
|
||||
|
||||
+2
-1
@@ -23,7 +23,7 @@ Run scripts from the repository root. PowerShell and Bash commands have equivale
|
||||
| `commands/doctor.ps1`, `commands/doctor.sh` | Check generated output and installed configuration. |
|
||||
| `commands/prompt-budget.ps1`, `commands/prompt-budget.sh` | Track permanent context, skill metadata, the rule catalog, and agent metadata; warn above 10,000 permanent-context tokens, fail when any token estimate exceeds its baseline by 10%, or permanent context exceeds 15,000 tokens. |
|
||||
| `commands/context-report.ps1`, `commands/context-report.sh` | Report the context installed Claude and Codex actually load, from Claude `/context`, `/skill-doctor`, and its init event, and Codex `debug prompt-input`: categories, active plugins, listed skills, installed skills missing from the listing, and skills listed without description context. `--check` fails on missing or description-less skills. No model request is made. |
|
||||
| `commands/benchmark.ps1`, `commands/benchmark.sh` | Run the ten tasks under `scripts/benchmarks/tasks/` with and without the installed setup and compare pass rate, rework turns, duration, tokens, tool calls, cost, and workflow checks (no planning ceremony for a small change, targeted verification for a bugfix, bounded delegation for a larger change). Starts real model sessions; use `--dry-run` to list runs. The Codex baseline needs one `codex login` with `CODEX_HOME` set to `--codex-baseline-home`. |
|
||||
| `commands/benchmark.ps1`, `commands/benchmark.sh` | Run the Python, C#/ASP.NET, and TypeScript tasks under `scripts/benchmarks/tasks/` with and without the installed setup. Both arms of a client use one pinned model and reasoning effort; a preflight records what each arm actually loads in `provenance.json` and stops when the arms are not comparable; `--arms setup setup-no-search --tasks 11-* 12-* 13-* --warm-index` compares Claude with and without semantic search on concept-search tasks in a large generated repository, and compare pass rate, rework turns, duration, tokens, tool calls, cost, and workflow checks (no planning ceremony for a small change, targeted verification for a bugfix, bounded delegation for a larger change). Starts real model sessions; use `--dry-run` to list runs. The Codex baseline needs one `codex login` with `CODEX_HOME` set to `--codex-baseline-home`. |
|
||||
| `commands/repository-context.ps1`, `commands/repository-context.sh` | Refresh the local repository-context cache. |
|
||||
| `commands/session-state.ps1`, `commands/session-state.sh` | Read or update the local agent session state. |
|
||||
| `commands/telemetry.ps1`, `commands/telemetry.sh` | Append a local telemetry event. |
|
||||
@@ -52,6 +52,7 @@ Gitea Actions runs the repository checks from `.gitea/workflows/ci.yml`.
|
||||
| `tests/test-build-validation.ps1`, `tests/test-build-validation.sh` | Validate build failures and generated output. |
|
||||
| `tests/test-capabilities.ps1` | Guard PowerShell capability detection. |
|
||||
| `tests/test-install-guards.ps1`, `tests/test-install-guards.sh` | Validate install and update guard conditions. |
|
||||
| `tests/test-config-effects.py` | Check that generated limits and permissions take effect: Codex keeps the V1 multi-agent implementation that honors `agents.max_depth`; with `AI_CONFIG_LIVE_TESTS=1`, short Haiku sessions confirm that Claude withholds the Agent tool from subagents, refuses subagents above the concurrency limit, and blocks `git reset --hard` in every shell tool. |
|
||||
| `tests/test-client-smoke.py` | Install a package into a temporary home with foreign settings and ask the installed Claude and Codex CLIs which skills, agents, and hooks they discover, without completing a model request. Skips a client whose CLI is unavailable unless `AI_CONFIG_REQUIRE_CLIENTS=1`, which the CI `client-smoke` job sets. |
|
||||
| `tests/test-install-options.py` | Validate interactive option handling and generated manifest changes. |
|
||||
| `tests/test-managed-extensions.py` | Validate plugin, skill, and CLI ownership handling. |
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ Skills are reusable workflows. Rules are persistent constraints and agents are s
|
||||
The current structure is organized by purpose:
|
||||
|
||||
- `planning/`: impact analysis and work-item definition
|
||||
- `research/`: technical and repository research
|
||||
- `research/`: technical and repository research, plus `semantic-search`, which is installed only while semantic retrieval is enabled
|
||||
- `reviews/`: security, performance, WPF, UI/UX, business, API, and EF Core reviews
|
||||
- `implementation/`: SQL, unit-test, and integration-test writing
|
||||
- `verification/`: facts and work-item verification
|
||||
|
||||
+237
-22
@@ -8,9 +8,14 @@ Arms:
|
||||
setup the installed configuration (CLAUDE.md, settings, plugins, skills, MCP servers).
|
||||
baseline Claude with --setting-sources "" --strict-mcp-config (no user settings, plugins,
|
||||
user skills, CLAUDE.md, or MCP servers); Codex with CODEX_HOME set to --codex-baseline-home,
|
||||
an empty home where you ran `codex login` once.
|
||||
an empty home where you ran `codex login` once, with every user and plugin skill disabled.
|
||||
|
||||
Both arms of a client use the same pinned model and reasoning effort. Before any run, a preflight
|
||||
records what each arm actually loads (model, effort, skills by source, plugins, MCP servers,
|
||||
instructions) in <out>/provenance.json and stops if the arms are not comparable.
|
||||
"""
|
||||
import argparse
|
||||
import fnmatch
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import json
|
||||
import os
|
||||
@@ -28,7 +33,8 @@ import time
|
||||
TASKS = Path(__file__).resolve().parent / 'tasks'
|
||||
CHECK = [sys.executable, '-m', 'unittest', 'discover', '-s', 'bench_hidden', '-t', '.']
|
||||
CLAUDE_TOOLS = ['Read', 'Edit', 'Write', 'Glob', 'Grep', 'Agent', 'Skill', 'TodoWrite', 'Bash(python *)', 'Bash(git *)',
|
||||
'Bash(ls *)', 'PowerShell(python *)', 'PowerShell(git *)', 'PowerShell(Get-ChildItem *)']
|
||||
'Bash(ls *)', 'Bash(dotnet *)', 'Bash(node *)', 'PowerShell(python *)', 'PowerShell(git *)', 'PowerShell(Get-ChildItem *)',
|
||||
'PowerShell(dotnet *)', 'PowerShell(node *)']
|
||||
|
||||
|
||||
def clean_env(**values):
|
||||
@@ -69,13 +75,26 @@ def run_jsonl(command, cwd, env, timeout):
|
||||
return events, time.monotonic() - start, timed_out
|
||||
|
||||
|
||||
def claude_turn(prompt, cwd, arm, model, session, timeout):
|
||||
command = [shutil.which('claude'), '-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', model,
|
||||
'--permission-mode', 'auto', '--allowedTools', *CLAUDE_TOOLS]
|
||||
def claude_flags(arm, args):
|
||||
flags = ['--model', args.claude_model, '--effort', args.claude_effort]
|
||||
if arm == 'baseline':
|
||||
command += ['--setting-sources', '', '--strict-mcp-config']
|
||||
flags += ['--setting-sources', '', '--strict-mcp-config']
|
||||
if arm == 'setup-no-search':
|
||||
flags += ['--disallowedTools', 'Skill(semantic-search)', 'Bash(*semantic-retrieval*)', 'PowerShell(*semantic-retrieval*)',
|
||||
'--append-system-prompt', 'The semantic-search skill and the semantic retrieval CLI are unavailable in this session.']
|
||||
return flags
|
||||
|
||||
|
||||
def codex_env(arm, args):
|
||||
return clean_env(CODEX_HOME=str(args.codex_baseline_home)) if arm == 'baseline' else clean_env()
|
||||
|
||||
|
||||
def claude_turn(prompt, cwd, arm, args, session):
|
||||
command = [shutil.which('claude'), '-p', prompt, '--output-format', 'stream-json', '--verbose',
|
||||
'--permission-mode', 'auto', '--allowedTools', *CLAUDE_TOOLS, *claude_flags(arm, args)]
|
||||
if session:
|
||||
command += ['--resume', session]
|
||||
timeout = args.timeout
|
||||
events, seconds, timed_out = run_jsonl(command, cwd, clean_env(), timeout)
|
||||
result = next((event for event in reversed(events) if event.get('type') == 'result'), {})
|
||||
usage = result.get('usage', {})
|
||||
@@ -89,21 +108,21 @@ def claude_turn(prompt, cwd, arm, model, session, timeout):
|
||||
'tool_calls': len(calls), 'agents': sum(call.get('name') == 'Agent' for call in calls),
|
||||
'skills': [str(call.get('input', {}).get('skill', '')) for call in calls if call.get('name') == 'Skill'],
|
||||
'commands': commands, 'final': str(result.get('result', '')),
|
||||
'models': sorted({str(event['message']['model']) for event in events
|
||||
if event.get('type') == 'assistant' and event.get('message', {}).get('model')}),
|
||||
'error': str(result.get('result', '') or 'no result event')[:300] if result.get('is_error') or not result else '',
|
||||
}
|
||||
|
||||
|
||||
def codex_turn(prompt, cwd, arm, model, session, timeout, baseline_home):
|
||||
def codex_turn(prompt, cwd, arm, args, session):
|
||||
command = [shutil.which('codex'), 'exec']
|
||||
if session:
|
||||
command += ['resume', session]
|
||||
command += ['--json', '--skip-git-repo-check', '--sandbox', 'workspace-write']
|
||||
if model:
|
||||
command += ['-m', model]
|
||||
command += ['--json', '--skip-git-repo-check', '--sandbox', 'workspace-write',
|
||||
'-m', args.codex_model, '-c', f'model_reasoning_effort="{args.codex_effort}"']
|
||||
command += ['-C', str(cwd)] if not session else []
|
||||
command.append(prompt)
|
||||
env = clean_env(CODEX_HOME=str(baseline_home)) if arm == 'baseline' else clean_env()
|
||||
events, seconds, timed_out = run_jsonl(command, cwd, env, timeout)
|
||||
events, seconds, timed_out = run_jsonl(command, cwd, codex_env(arm, args), args.timeout)
|
||||
items = [event.get('item', {}) for event in events if event.get('type') == 'item.completed']
|
||||
tools = [item for item in items if item.get('type') not in ('agent_message', 'reasoning', 'error')]
|
||||
usage = [event.get('usage', {}) for event in events if event.get('type') == 'turn.completed']
|
||||
@@ -115,16 +134,187 @@ def codex_turn(prompt, cwd, arm, model, session, timeout, baseline_home):
|
||||
'tool_calls': len(tools), 'agents': sum(item.get('type') == 'collab_tool_call' for item in tools),
|
||||
'skills': [], 'commands': [str(item.get('command', '')) for item in tools if item.get('type') == 'command_execution'],
|
||||
'final': next((str(item.get('text', '')) for item in reversed(items) if item.get('type') == 'agent_message'), ''),
|
||||
'models': [args.codex_model],
|
||||
'error': next((str(event.get('message', event.get('error', '')))[:300] for event in events if event.get('type') in ('error', 'turn.failed')
|
||||
and not str(event.get('message', '')).startswith('Reconnecting')), '') if not usage else '',
|
||||
}
|
||||
|
||||
|
||||
class AppServer:
|
||||
"""Minimal JSON-RPC client for `codex app-server`; every wait has a deadline."""
|
||||
|
||||
def __init__(self, env, cwd):
|
||||
self.process = subprocess.Popen([shutil.which('codex'), 'app-server'], cwd=cwd, env=env, stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding='utf-8')
|
||||
self.lines, self.next_id = queue.Queue(), 0
|
||||
threading.Thread(target=lambda: [self.lines.put(line) for line in self.process.stdout], daemon=True).start()
|
||||
self.call('initialize', {'clientInfo': {'name': 'ai-config-benchmark', 'version': '1'}})
|
||||
self.process.stdin.write(json.dumps({'jsonrpc': '2.0', 'method': 'initialized'}) + '\n')
|
||||
|
||||
def call(self, method, params):
|
||||
self.next_id += 1
|
||||
self.process.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': self.next_id, 'method': method, 'params': params}) + '\n')
|
||||
self.process.stdin.flush()
|
||||
while True:
|
||||
message = json.loads(self.lines.get(timeout=120))
|
||||
if message.get('id') == self.next_id:
|
||||
if 'error' in message:
|
||||
raise RuntimeError(f'{method}: {message["error"]}')
|
||||
return message['result']
|
||||
|
||||
def close(self):
|
||||
kill_tree(self.process)
|
||||
|
||||
|
||||
def codex_configured(env, cwd):
|
||||
server = AppServer(env, cwd)
|
||||
try:
|
||||
return server.call('config/read', {})['config']
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
def codex_provenance(arm, args, cwd):
|
||||
server = AppServer(codex_env(arm, args), cwd)
|
||||
try:
|
||||
config = server.call('config/read', {})['config']
|
||||
listing = lambda: server.call('skills/list', {'cwds': [str(cwd)], 'forceReload': True})['data'][0]['skills']
|
||||
skills = listing()
|
||||
if arm == 'baseline':
|
||||
# Codex reads ~/.agents/skills from the real profile even with another CODEX_HOME; disable those skills there.
|
||||
for skill in skills:
|
||||
if skill.get('enabled', True) and (skill.get('scope') == 'user' or skill.get('pluginId')):
|
||||
server.call('skills/config/write', {'path': skill['path'], 'enabled': False})
|
||||
skills = listing()
|
||||
finally:
|
||||
server.close()
|
||||
prompt = subprocess.run([shutil.which('codex'), 'debug', 'prompt-input', '-c', f'model="{args.codex_model}"',
|
||||
'-c', f'model_reasoning_effort="{args.codex_effort}"', 'provenance probe'],
|
||||
cwd=cwd, env=codex_env(arm, args), capture_output=True, text=True, encoding='utf-8', timeout=180).stdout
|
||||
setup_agents = Path.home() / '.codex' / 'AGENTS.md'
|
||||
marker = next((line.strip() for line in setup_agents.read_text(encoding='utf-8-sig').splitlines() if line.strip()), '') if setup_agents.is_file() else ''
|
||||
enabled = [skill for skill in skills if skill.get('enabled', True)]
|
||||
return {
|
||||
'model': args.codex_model, 'effort': args.codex_effort,
|
||||
'configured_model': config.get('model'), 'configured_effort': config.get('model_reasoning_effort'),
|
||||
'sandbox_mode': config.get('sandbox_mode'), 'approval_policy': config.get('approval_policy'),
|
||||
'skills_by_scope': {scope: sum(skill.get('scope') == scope and not skill.get('pluginId') for skill in enabled)
|
||||
for scope in sorted({skill.get('scope') for skill in enabled})},
|
||||
'plugin_skills': sorted(skill['name'] for skill in enabled if skill.get('pluginId')),
|
||||
'user_skills': sorted(skill['name'] for skill in enabled if skill.get('scope') == 'user' and not skill.get('pluginId')),
|
||||
'mcp_servers': sorted(config.get('mcp_servers') or {}),
|
||||
'global_instructions_loaded': bool(marker) and marker in prompt,
|
||||
'prompt_input_tokens_estimate': len(prompt.encode('utf-8')) // 4,
|
||||
}
|
||||
|
||||
|
||||
def claude_provenance(arm, args, cwd):
|
||||
claude = shutil.which('claude')
|
||||
process = subprocess.Popen([claude, '-p', 'provenance probe', '--output-format', 'stream-json', '--verbose', *claude_flags(arm, args)],
|
||||
cwd=cwd, env=clean_env(), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
text=True, encoding='utf-8')
|
||||
lines = queue.Queue()
|
||||
threading.Thread(target=lambda: [lines.put(line) for line in process.stdout], daemon=True).start()
|
||||
try:
|
||||
init = {}
|
||||
while init.get('subtype') != 'init':
|
||||
init = json.loads(lines.get(timeout=180))
|
||||
finally:
|
||||
kill_tree(process)
|
||||
context = json.loads(subprocess.run([claude, '-p', '/context', '--output-format', 'json', *claude_flags(arm, args)], cwd=cwd, env=clean_env(),
|
||||
capture_output=True, text=True, encoding='utf-8', timeout=180).stdout).get('result', '')
|
||||
|
||||
def rows(heading):
|
||||
section = re.search(rf'(?ms)^### {re.escape(heading)}\s*$(.*?)(?=^### |\Z)', context)
|
||||
cells = [[cell.strip() for cell in line.strip().strip('|').split('|')] for line in (section.group(1) if section else '').splitlines() if line.startswith('|')]
|
||||
return [row for row in cells[2:] if row and row[0]]
|
||||
|
||||
sources = {}
|
||||
for row in rows('Skills'):
|
||||
source = row[1].split(' (')[0] if len(row) > 1 else '?'
|
||||
sources[source] = sources.get(source, 0) + 1
|
||||
model = re.search(r'\*\*Model:\*\*\s*(\S+)', context)
|
||||
return {
|
||||
'model': init.get('model') or (model.group(1) if model else None), 'effort': args.claude_effort,
|
||||
'permission_mode': init.get('permissionMode'),
|
||||
'skills_by_source': sources, 'skills_listed': len(init.get('skills', [])),
|
||||
'plugins': sorted(plugin.get('name', str(plugin)) if isinstance(plugin, dict) else str(plugin) for plugin in init.get('plugins', [])),
|
||||
'mcp_servers': sorted(server.get('name', str(server)) if isinstance(server, dict) else str(server) for server in init.get('mcp_servers', [])),
|
||||
'memory_files': [row[1] for row in rows('Memory Files') if len(row) > 1],
|
||||
}
|
||||
|
||||
|
||||
def preflight(args, clients, arms):
|
||||
"""Record what each arm loads and return the problems that make the comparison invalid."""
|
||||
provenance, problems = {}, []
|
||||
with tempfile.TemporaryDirectory(prefix='ai-bench-preflight-', ignore_cleanup_errors=True) as directory:
|
||||
subprocess.run(['git', 'init', '-q'], cwd=directory, check=True, timeout=60)
|
||||
for client in clients:
|
||||
for arm in arms:
|
||||
if client == 'codex' and arm == 'setup-no-search':
|
||||
continue
|
||||
data = (claude_provenance if client == 'claude' else codex_provenance)(arm, args, Path(directory))
|
||||
provenance[f'{client}/{arm}'] = data
|
||||
if client == 'claude' and arm == 'baseline':
|
||||
if set(data['skills_by_source']) - {'Built-in'}:
|
||||
problems.append(f'claude baseline loads non-built-in skills: {data["skills_by_source"]}')
|
||||
if data['memory_files'] or data['mcp_servers']:
|
||||
problems.append(f'claude baseline loads memory files {data["memory_files"]} or MCP servers {data["mcp_servers"]}')
|
||||
if client == 'codex' and arm == 'baseline':
|
||||
if data['user_skills'] or data['plugin_skills']:
|
||||
problems.append(f'codex baseline still enables user or plugin skills: {data["user_skills"] + data["plugin_skills"]}')
|
||||
if data['global_instructions_loaded'] or data['mcp_servers']:
|
||||
problems.append('codex baseline loads the global AGENTS.md or MCP servers')
|
||||
models = {provenance[key]['model'] for key in provenance if key.startswith(client + '/')}
|
||||
if len(models) > 1:
|
||||
problems.append(f'{client} arms resolve to different models: {sorted(models)}')
|
||||
return provenance, problems
|
||||
|
||||
|
||||
RETRIEVAL = Path.home() / '.my-ai-configuration/semantic-retrieval'
|
||||
FILLER_TOPICS = [
|
||||
('statement', 'download', 'Render a statement download for the export queue.'),
|
||||
('trial', 'banner', 'Show the trial banner text in the marketing header.'),
|
||||
('upload', 'progress', 'Track upload progress for the dashboard widget.'),
|
||||
('network', 'status', 'Describe the network status shown in the footer.'),
|
||||
('invoice', 'label', 'Format the invoice label printed on envelopes.'),
|
||||
('retry', 'hint', 'Return the retry hint shown next to a failed form field.'),
|
||||
('cent', 'display', 'Format a cent amount for the price badge.'),
|
||||
('customer', 'greeting', 'Pick the greeting for a customer newsletter.'),
|
||||
]
|
||||
|
||||
|
||||
def generate_filler(work, count):
|
||||
"""Write deterministic distractor modules that share vocabulary with the task prompts but not their logic."""
|
||||
for number in range(count):
|
||||
noun, verb, doc = FILLER_TOPICS[number % len(FILLER_TOPICS)]
|
||||
name = f'{noun}_{verb}_{number:03d}'
|
||||
path = work / 'app' / 'modules' / f'{name}.py'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = [f'def {name}(value, locale="en"):', f' """{doc}"""', ' text = str(value).strip()',
|
||||
f' return f"{{locale}}:{noun}:{verb}:{{text}}"', '']
|
||||
path.write_text('\n'.join(body), encoding='utf-8')
|
||||
(work / 'app' / 'modules' / '__init__.py').write_text('', encoding='utf-8')
|
||||
|
||||
|
||||
def warm_index(work):
|
||||
"""Build the semantic index before the session, as in a repository that was searched before."""
|
||||
server = RETRIEVAL / 'server.py'
|
||||
if server.is_file():
|
||||
subprocess.run([sys.executable, str(server), 'rebuild', '--root', str(work), '--data-dir', str(RETRIEVAL / 'data'),
|
||||
'--model-cache', str(RETRIEVAL / 'model-cache')], capture_output=True, timeout=1800)
|
||||
|
||||
|
||||
def check(work, task):
|
||||
hidden = work / 'bench_hidden'
|
||||
shutil.rmtree(hidden, ignore_errors=True)
|
||||
shutil.copytree(task / 'hidden', hidden)
|
||||
result = subprocess.run(CHECK, cwd=work, capture_output=True, text=True, timeout=300)
|
||||
spec = json.loads((task / 'task.json').read_text(encoding='utf-8'))
|
||||
command = CHECK
|
||||
if spec.get('check'):
|
||||
program, *arguments = spec['check']
|
||||
command = [sys.executable if program == 'python' else shutil.which(program) or program, *arguments]
|
||||
result = subprocess.run(command, cwd=work, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600)
|
||||
shutil.rmtree(hidden, ignore_errors=True)
|
||||
return result.returncode == 0, (result.stdout + result.stderr)[-3000:]
|
||||
|
||||
@@ -152,14 +342,15 @@ def run_one(job, args):
|
||||
with tempfile.TemporaryDirectory(prefix=f'ai-bench-{task.name[:2]}-', ignore_cleanup_errors=True) as directory:
|
||||
work = Path(directory)
|
||||
shutil.copytree(task / 'repo', work, dirs_exist_ok=True)
|
||||
if spec.get('generate', {}).get('filler_modules'):
|
||||
generate_filler(work, spec['generate']['filler_modules'])
|
||||
for git in (['init', '-q'], ['add', '-A'], ['-c', 'user.name=bench', '-c', 'user.email=bench@example.invalid', 'commit', '-qm', 'fixture']):
|
||||
subprocess.run(['git', '-c', 'core.autocrlf=false', *git], cwd=work, check=True, timeout=60)
|
||||
if args.warm_index and arm == 'setup':
|
||||
warm_index(work)
|
||||
turns, prompt, session = [], spec['prompt'], None
|
||||
for round_number in range(1 + args.rework):
|
||||
if client == 'claude':
|
||||
turn = claude_turn(prompt, work, arm, args.claude_model, session, args.timeout)
|
||||
else:
|
||||
turn = codex_turn(prompt, work, arm, args.codex_model, session, args.timeout, args.codex_baseline_home)
|
||||
turn = (claude_turn if client == 'claude' else codex_turn)(prompt, work, arm, args, session)
|
||||
turns.append(turn)
|
||||
session = turn['session']
|
||||
if turn['error']:
|
||||
@@ -180,6 +371,9 @@ def run_one(job, args):
|
||||
'cost_usd': round(sum(turn['cost_usd'] for turn in turns), 4), 'tool_calls': sum(turn['tool_calls'] for turn in turns),
|
||||
'agents': sum(turn['agents'] for turn in turns), 'skills': [skill for turn in turns for skill in turn['skills']],
|
||||
'commands': [command for turn in turns for command in turn['commands']],
|
||||
'models': sorted({model for turn in turns for model in turn['models']}),
|
||||
'searches': sum('semantic-retrieval' in command for turn in turns for command in turn['commands'])
|
||||
+ sum(skill == 'semantic-search' for turn in turns for skill in turn['skills']),
|
||||
'timed_out': any(turn['timed_out'] for turn in turns), 'workflow_kind': spec['workflow'].get('kind'),
|
||||
'workflow_violations': workflow_violations(spec['workflow'], turns), 'check_output': '' if passed else output,
|
||||
}
|
||||
@@ -209,15 +403,20 @@ def summarize(results):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
|
||||
parser.add_argument('--clients', nargs='+', choices=('claude', 'codex'), default=['claude', 'codex'])
|
||||
parser.add_argument('--arms', nargs='+', choices=('setup', 'baseline'), default=['setup', 'baseline'])
|
||||
parser.add_argument('--tasks', nargs='+', help='task directory names (default: all)')
|
||||
parser.add_argument('--arms', nargs='+', choices=('setup', 'baseline', 'setup-no-search'), default=['setup', 'baseline'],
|
||||
help='setup-no-search is the setup with the semantic-search skill and CLI blocked (Claude only)')
|
||||
parser.add_argument('--warm-index', action='store_true', help='build the semantic index before setup-arm sessions, outside the timing')
|
||||
parser.add_argument('--tasks', nargs='+', help='task directory names or patterns such as 11-* (default: all)')
|
||||
parser.add_argument('--repetitions', type=int, default=3)
|
||||
parser.add_argument('--first-repetition', type=int, default=1, help='number of the first repetition, to rerun selected repetitions')
|
||||
parser.add_argument('--rework', type=int, default=1, help='rework turns after a failed check')
|
||||
parser.add_argument('--jobs', type=int, default=2)
|
||||
parser.add_argument('--timeout', type=int, default=900, help='seconds per client turn')
|
||||
parser.add_argument('--claude-model', default='sonnet')
|
||||
parser.add_argument('--codex-model', help='default: the CLI default, identical for both arms')
|
||||
parser.add_argument('--claude-effort', default='medium', choices=('low', 'medium', 'high', 'xhigh', 'max'))
|
||||
parser.add_argument('--codex-model', help="default: the setup's configured model, pinned for both arms")
|
||||
parser.add_argument('--codex-effort', help="default: the setup's configured reasoning effort, pinned for both arms")
|
||||
parser.add_argument('--allow-unisolated', action='store_true', help='run even when the preflight finds the arms not comparable')
|
||||
parser.add_argument('--codex-baseline-home', type=Path, default=Path.home() / '.my-ai-configuration/benchmark/codex-home')
|
||||
parser.add_argument('--out', type=Path, default=Path(tempfile.gettempdir()) / f'ai-config-ab-{time.strftime("%Y%m%d-%H%M%S")}')
|
||||
parser.add_argument('--dry-run', action='store_true', help='list the runs without starting clients')
|
||||
@@ -234,14 +433,30 @@ def main():
|
||||
merged[key] = result
|
||||
print(summarize(list(merged.values())))
|
||||
return 0
|
||||
tasks = [TASKS / name for name in args.tasks] if args.tasks else sorted(path for path in TASKS.iterdir() if path.is_dir())
|
||||
tasks = [path for path in sorted(TASKS.iterdir()) if path.is_dir() and (not args.tasks or any(fnmatch.fnmatch(path.name, pattern) for pattern in args.tasks))]
|
||||
for client in args.clients:
|
||||
if not shutil.which(client):
|
||||
parser.error(f'{client} CLI unavailable')
|
||||
if 'codex' in args.clients and 'baseline' in args.arms and not (args.codex_baseline_home / 'auth.json').is_file():
|
||||
parser.error(f'Codex baseline needs a login: run CODEX_HOME="{args.codex_baseline_home}" codex login')
|
||||
jobs = [(client, arm, task, repetition) for repetition in range(args.first_repetition, args.first_repetition + args.repetitions)
|
||||
for task in tasks for client in args.clients for arm in args.arms]
|
||||
for task in tasks for client in args.clients for arm in args.arms if not (client == 'codex' and arm == 'setup-no-search')]
|
||||
if 'codex' in args.clients and not (args.codex_model and args.codex_effort):
|
||||
configured = codex_configured(clean_env(), Path.cwd())
|
||||
args.codex_model = args.codex_model or configured.get('model')
|
||||
args.codex_effort = args.codex_effort or configured.get('model_reasoning_effort') or 'medium'
|
||||
if not args.codex_model:
|
||||
parser.error('the setup configures no Codex model; pass --codex-model')
|
||||
provenance, problems = preflight(args, args.clients, args.arms)
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
(args.out / 'provenance.json').write_text(json.dumps({'provenance': provenance, 'problems': problems}, indent=2), encoding='utf-8')
|
||||
for key, data in provenance.items():
|
||||
print(f'{key}: model={data["model"]} effort={data["effort"]} ' + ' '.join(f'{name}={value}' for name, value in data.items()
|
||||
if name in ('skills_by_source', 'skills_by_scope', 'plugins', 'mcp_servers')))
|
||||
for problem in problems:
|
||||
print(f'NOT COMPARABLE: {problem}')
|
||||
if problems and not args.allow_unisolated:
|
||||
return 2
|
||||
print(f'{len(jobs)} runs -> {args.out}')
|
||||
if args.dry_run:
|
||||
for client, arm, task, repetition in jobs:
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,14 @@
|
||||
import unittest
|
||||
from datetime import date
|
||||
from app.accounts import Account
|
||||
from app.ledger.archive import render_period_summary
|
||||
|
||||
|
||||
class Hidden(unittest.TestCase):
|
||||
def test_expired_evaluation_blocked(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
render_period_summary(Account('evaluation', date(2026, 1, 1)), '2026-01', today=date(2026, 2, 1))
|
||||
|
||||
def test_running_evaluation_and_paid_allowed(self):
|
||||
self.assertIsInstance(render_period_summary(Account('evaluation', date(2026, 3, 1)), '2026-01', today=date(2026, 2, 1)), bytes)
|
||||
self.assertIsInstance(render_period_summary(Account('paid'), '2026-01', today=date(2026, 2, 1)), bytes)
|
||||
Whitespace-only changes.
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
tier: str # "evaluation" or "paid"
|
||||
evaluation_until: date | None = None
|
||||
entries: list = field(default_factory=list)
|
||||
Whitespace-only changes.
@@ -0,0 +1,8 @@
|
||||
from datetime import date
|
||||
|
||||
|
||||
def render_period_summary(account, period, today=None):
|
||||
"""Build the period summary document for an account."""
|
||||
today = today or date.today()
|
||||
lines = [f"{entry.label}: {entry.amount:.2f}" for entry in account.entries if entry.period == period]
|
||||
return "\n".join(lines).encode()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"prompt": "Customers whose free trial has ended can still download their monthly statements. Block that by raising PermissionError, and keep it working for paying customers and for customers whose trial is still running.",
|
||||
"generate": {
|
||||
"filler_modules": 250
|
||||
},
|
||||
"workflow": {
|
||||
"kind": "concept search in a large repository"
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,32 @@
|
||||
import unittest
|
||||
from app.transport.courier import dispatch
|
||||
|
||||
|
||||
class Channel:
|
||||
def __init__(self, failures, error=ConnectionResetError):
|
||||
self.failures, self.error, self.calls = failures, error, 0
|
||||
|
||||
def send(self, blob):
|
||||
self.calls += 1
|
||||
if self.calls <= self.failures:
|
||||
raise self.error('flaky')
|
||||
return 'receipt'
|
||||
|
||||
|
||||
class Hidden(unittest.TestCase):
|
||||
def test_retries_transient(self):
|
||||
channel = Channel(2)
|
||||
self.assertEqual(dispatch(b'x', channel), 'receipt')
|
||||
self.assertEqual(channel.calls, 3)
|
||||
|
||||
def test_gives_up_after_three(self):
|
||||
channel = Channel(5)
|
||||
with self.assertRaises(ConnectionResetError):
|
||||
dispatch(b'x', channel)
|
||||
self.assertEqual(channel.calls, 3)
|
||||
|
||||
def test_no_retry_for_other_errors(self):
|
||||
channel = Channel(1, ValueError)
|
||||
with self.assertRaises(ValueError):
|
||||
dispatch(b'x', channel)
|
||||
self.assertEqual(channel.calls, 1)
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
def dispatch(blob, channel):
|
||||
"""Hand a blob to the channel and return the receipt."""
|
||||
return channel.send(blob)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"prompt": "Uploads that fail with a temporary network error are never retried. Make at most three attempts in total before giving up with the last error; other errors must not be retried.",
|
||||
"generate": {
|
||||
"filler_modules": 250
|
||||
},
|
||||
"workflow": {
|
||||
"kind": "concept search in a large repository"
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,10 @@
|
||||
import unittest
|
||||
from app.fin.tally import consolidate
|
||||
|
||||
|
||||
class Hidden(unittest.TestCase):
|
||||
def test_half_up(self):
|
||||
self.assertEqual(float(consolidate([1.005])), 1.01)
|
||||
self.assertEqual(float(consolidate([2.675])), 2.68)
|
||||
self.assertEqual(float(consolidate([1.0, 0.004])), 1.0)
|
||||
self.assertEqual(float(consolidate([0.1, 0.2])), 0.3)
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
def consolidate(lines):
|
||||
"""Sum line amounts and return the figure for the document footer."""
|
||||
return round(sum(lines), 2)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"prompt": "Invoice totals are sometimes one cent too low, for example 1.005 shows as 1.00. Round half amounts up to the next cent.",
|
||||
"generate": {
|
||||
"filler_modules": 250
|
||||
},
|
||||
"workflow": {
|
||||
"kind": "concept search in a large repository"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\..\src\Shop\Shop.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
var app = Shop.AppHost.Build(new[] { "--urls", "http://127.0.0.1:0" });
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(address) };
|
||||
var failures = new List<string>();
|
||||
async Task Expect(string json, int status)
|
||||
{
|
||||
var response = await client.PostAsync("/orders", new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
if ((int)response.StatusCode != status) failures.Add($"{json} -> {(int)response.StatusCode}, expected {status}");
|
||||
else if (status == 400 && response.Content.Headers.ContentType?.MediaType != "application/problem+json") failures.Add($"{json} -> 400 without problem details");
|
||||
}
|
||||
await Expect("{\"sku\":\"A1\",\"quantity\":2}", 201);
|
||||
await Expect("{\"sku\":\"A1\",\"quantity\":0}", 400);
|
||||
await Expect("{\"sku\":\"A1\",\"quantity\":-3}", 400);
|
||||
await Expect("{\"sku\":\"\",\"quantity\":1}", 400);
|
||||
await app.StopAsync();
|
||||
foreach (var failure in failures) Console.Error.WriteLine("FAIL " + failure);
|
||||
return failures.Count == 0 ? 0 : 1;
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Shop;
|
||||
|
||||
public static class AppHost
|
||||
{
|
||||
// Integration tests start the application through Build.
|
||||
public static WebApplication Build(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var app = builder.Build();
|
||||
var orders = new List<Order>();
|
||||
app.MapPost("/orders", (Order order) =>
|
||||
{
|
||||
orders.Add(order);
|
||||
return Results.Created($"/orders/{orders.Count}", order);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
public record Order(string Sku, int Quantity);
|
||||
@@ -0,0 +1 @@
|
||||
Shop.AppHost.Build(args).Run();
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prompt": "POST /orders accepts orders with a zero or negative quantity and orders with an empty SKU. Reject those with HTTP 400 and a validation problem response; valid orders must still return 201.",
|
||||
"check": [
|
||||
"dotnet",
|
||||
"run",
|
||||
"--project",
|
||||
"bench_hidden/Check",
|
||||
"--nologo"
|
||||
],
|
||||
"workflow": {
|
||||
"kind": "C#/ASP.NET change"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Reports\Reports.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics;
|
||||
using Reports;
|
||||
|
||||
var failures = new List<string>();
|
||||
var builder = new ReportBuilder();
|
||||
var complete = await builder.BuildAsync(new[] { "a", "b" });
|
||||
if (complete != "## a\n## b") failures.Add($"unexpected output {complete}");
|
||||
using var source = new CancellationTokenSource(TimeSpan.FromMilliseconds(150));
|
||||
var watch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await builder.BuildAsync(Enumerable.Range(0, 50).Select(number => number.ToString()), source.Token);
|
||||
failures.Add("no cancellation");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (watch.ElapsedMilliseconds > 1000) failures.Add($"cancelled only after {watch.ElapsedMilliseconds} ms");
|
||||
}
|
||||
foreach (var failure in failures) Console.Error.WriteLine("FAIL " + failure);
|
||||
return failures.Count == 0 ? 0 : 1;
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Reports;
|
||||
|
||||
public sealed class ReportBuilder
|
||||
{
|
||||
public async Task<string> BuildAsync(IEnumerable<string> sections, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var section in sections)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
parts.Add($"## {section}");
|
||||
}
|
||||
return string.Join("\n", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prompt": "Cancelling a report build does nothing: BuildAsync keeps running until every section is rendered. Make it honor cancellation promptly by throwing OperationCanceledException.",
|
||||
"check": [
|
||||
"dotnet",
|
||||
"run",
|
||||
"--project",
|
||||
"bench_hidden/Check",
|
||||
"--nologo"
|
||||
],
|
||||
"workflow": {
|
||||
"kind": "C#/ASP.NET change"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<ProjectReference Include="..\..\src\Crm\Crm.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
var app = Crm.AppHost.Build(new[] { "--urls", "http://127.0.0.1:0" });
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(address) };
|
||||
var failures = new List<string>();
|
||||
var known = await client.GetAsync("/customers/1");
|
||||
if (known.StatusCode != HttpStatusCode.OK || !(await known.Content.ReadAsStringAsync()).Contains("Ada")) failures.Add($"known customer -> {(int)known.StatusCode}");
|
||||
var unknown = await client.GetAsync("/customers/42");
|
||||
if (unknown.StatusCode != HttpStatusCode.NotFound) failures.Add($"unknown customer -> {(int)unknown.StatusCode}");
|
||||
await app.StopAsync();
|
||||
foreach (var failure in failures) Console.Error.WriteLine("FAIL " + failure);
|
||||
return failures.Count == 0 ? 0 : 1;
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Crm;
|
||||
|
||||
public static class AppHost
|
||||
{
|
||||
// Integration tests start the application through Build.
|
||||
public static WebApplication Build(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var app = builder.Build();
|
||||
var customers = new Dictionary<int, Customer> { [1] = new(1, "Ada") };
|
||||
app.MapGet("/customers/{id:int}", (int id) => customers[id]);
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
public record Customer(int Id, string Name);
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
Crm.AppHost.Build(args).Run();
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prompt": "Requesting an unknown customer with GET /customers/{id} returns HTTP 500. Return 404 instead, and keep known customers working.",
|
||||
"check": [
|
||||
"dotnet",
|
||||
"run",
|
||||
"--project",
|
||||
"bench_hidden/Check",
|
||||
"--nologo"
|
||||
],
|
||||
"workflow": {
|
||||
"kind": "C#/ASP.NET change"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { overlaps } from '../src/booking.ts';
|
||||
|
||||
test('back-to-back bookings do not overlap', () => {
|
||||
assert.equal(overlaps({ start: 9, end: 10 }, { start: 10, end: 11 }), false);
|
||||
assert.equal(overlaps({ start: 10, end: 11 }, { start: 9, end: 10 }), false);
|
||||
});
|
||||
|
||||
test('real overlaps are detected', () => {
|
||||
assert.equal(overlaps({ start: 9, end: 11 }, { start: 10, end: 12 }), true);
|
||||
assert.equal(overlaps({ start: 9, end: 12 }, { start: 10, end: 11 }), true);
|
||||
assert.equal(overlaps({ start: 9, end: 10 }, { start: 11, end: 12 }), false);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "booking",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface Booking {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function overlaps(a: Booking, b: Booking): boolean {
|
||||
return a.start <= b.end && b.start <= a.end;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prompt": "Back-to-back bookings, where one ends exactly when the next starts, are wrongly reported as overlapping. Fix overlaps() without breaking real overlaps.",
|
||||
"check": [
|
||||
"node",
|
||||
"--experimental-strip-types",
|
||||
"--no-warnings",
|
||||
"--test",
|
||||
"bench_hidden/check.test.ts"
|
||||
],
|
||||
"workflow": {
|
||||
"kind": "TypeScript change"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mergeConfig } from '../src/config.ts';
|
||||
|
||||
test('nested objects merge deeply', () => {
|
||||
const base = { server: { host: 'a', port: 1, tls: { on: true } }, tags: ['x'] };
|
||||
const override = { server: { port: 2, tls: { cert: 'c' } }, tags: ['y', 'z'] };
|
||||
assert.deepEqual(mergeConfig(base, override), { server: { host: 'a', port: 2, tls: { on: true, cert: 'c' } }, tags: ['y', 'z'] });
|
||||
});
|
||||
|
||||
test('inputs are not mutated', () => {
|
||||
const base = { a: { b: 1 } };
|
||||
const override = { a: { c: 2 } };
|
||||
mergeConfig(base, override);
|
||||
assert.deepEqual(base, { a: { b: 1 } });
|
||||
assert.deepEqual(override, { a: { c: 2 } });
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "config",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type Config = { [key: string]: unknown };
|
||||
|
||||
export function mergeConfig(base: Config, override: Config): Config {
|
||||
return { ...base, ...override };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prompt": "mergeConfig() drops nested settings from the base configuration when the override sets only some keys of a nested object. Merge nested objects deeply; arrays from the override replace base arrays, and neither input may be mutated.",
|
||||
"check": [
|
||||
"node",
|
||||
"--experimental-strip-types",
|
||||
"--no-warnings",
|
||||
"--test",
|
||||
"bench_hidden/check.test.ts"
|
||||
],
|
||||
"workflow": {
|
||||
"kind": "TypeScript change"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { debounce } from '../src/debounce.ts';
|
||||
|
||||
test('trailing call uses the latest arguments', () => {
|
||||
mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const calls: number[][] = [];
|
||||
const run = debounce((value: number) => calls.push([value]), 50);
|
||||
run(1); run(2); run(3);
|
||||
mock.timers.tick(49);
|
||||
assert.deepEqual(calls, []);
|
||||
mock.timers.tick(1);
|
||||
assert.deepEqual(calls, [[3]]);
|
||||
run(4);
|
||||
mock.timers.tick(50);
|
||||
assert.deepEqual(calls, [[3], [4]]);
|
||||
mock.timers.reset();
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "timing",
|
||||
"type": "module",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function debounce<T extends unknown[]>(fn: (...args: T) => void, wait: number): (...args: T) => void {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending: T | undefined;
|
||||
return (...args: T) => {
|
||||
if (pending === undefined) {
|
||||
pending = args;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const call = pending as T;
|
||||
pending = undefined;
|
||||
fn(...call);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prompt": "debounce() calls the wrapped function with the arguments of the first call in a burst instead of the last one. Fix it so the trailing call uses the latest arguments.",
|
||||
"check": [
|
||||
"node",
|
||||
"--experimental-strip-types",
|
||||
"--no-warnings",
|
||||
"--test",
|
||||
"bench_hidden/check.test.ts"
|
||||
],
|
||||
"workflow": {
|
||||
"kind": "TypeScript change"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ param([switch]$Summary)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$shared = Join-Path $root 'shared'
|
||||
$output = Join-Path $root 'generated'
|
||||
$final = Join-Path $root 'generated'
|
||||
# Keep the name short: it lengthens every generated path, and Windows PowerShell 5.1 fails beyond 260 characters.
|
||||
$output = Join-Path $root ("generated.build-ps$PID-" + [Guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
# A trap covers the whole script scope, so every terminating error discards this run's tree.
|
||||
trap { Remove-Item -LiteralPath $output -Recurse -Force -ErrorAction SilentlyContinue; break }
|
||||
$sandboxSupported = [Environment]::OSVersion.Platform -eq [PlatformID]::Unix
|
||||
if ($sandboxSupported) {
|
||||
$kernel = (& uname -r 2>$null | Out-String).Trim()
|
||||
@@ -32,8 +36,16 @@ foreach ($entry in @(Import-Csv -LiteralPath $capabilityManifest -Delimiter ([ch
|
||||
$agentTools[$entry.role] = $entry.tools
|
||||
}
|
||||
|
||||
Remove-Item $output -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item $output -ItemType Directory -Force | Out-Null
|
||||
# Build into a private sibling directory and swap it into place only after validation, so a failed
|
||||
# or parallel build never leaves generated/ partial. Leftovers of crashed runs are removed here:
|
||||
# old trees always, build trees only when their PowerShell owner process has exited.
|
||||
# ponytail: Bash-owned leftovers are left to the Bash build, since PIDs are not comparable across shells.
|
||||
foreach ($leftover in @(Get-ChildItem -LiteralPath $root -Directory -Filter 'generated.*')) {
|
||||
if ($leftover.Name -like 'generated.old-*' -or ($leftover.Name -match '^generated\.build-ps(\d+)-' -and -not (Get-Process -Id ([int]$Matches[1]) -ErrorAction SilentlyContinue))) {
|
||||
Remove-Item -LiteralPath $leftover.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
New-Item $output -ItemType Directory | Out-Null
|
||||
|
||||
function Copy-Directory($source, $destination) {
|
||||
New-Item $destination -ItemType Directory -Force | Out-Null
|
||||
@@ -190,11 +202,8 @@ foreach ($shell in @('powershell','bash')) {
|
||||
$script = if ($shell -eq 'powershell') { 'flashbang.ps1' } else { 'flashbang.sh' }
|
||||
$statusLineScript = if ($shell -eq 'powershell') { 'statusline.ps1' } else { 'statusline.sh' }
|
||||
$content = Get-Content -LiteralPath $path -Raw
|
||||
$denyRules = if ($shell -eq 'powershell') {
|
||||
@('PowerShell(git reset --hard*)','PowerShell(git clean*)','PowerShell(Remove-Item *-Recurse*)')
|
||||
} else {
|
||||
@('Bash(git reset --hard*)','Bash(git clean*)','Bash(rm -rf*)')
|
||||
}
|
||||
# Claude can offer both shell tools on Windows, so every package denies the destructive commands in both.
|
||||
$denyRules = @('PowerShell(git reset --hard*)','PowerShell(git clean*)','PowerShell(Remove-Item *-Recurse*)','Bash(git reset --hard*)','Bash(git clean*)','Bash(rm -rf*)')
|
||||
$denyRulesJson = ConvertTo-Json -InputObject $denyRules -Compress
|
||||
$shellTool = if ($shell -eq 'powershell') { 'PowerShell' } else { 'Bash' }
|
||||
$allowRulesJson = ConvertTo-Json -InputObject @('Agent', "$shellTool(mcporter call context7.*)") -Compress
|
||||
@@ -220,5 +229,30 @@ $leftoverPlaceholders = @(Get-ChildItem $output -File -Recurse | ForEach-Object
|
||||
})
|
||||
if ($leftoverPlaceholders.Count) { throw "Unresolved template placeholders in: $($leftoverPlaceholders -join ', ')" }
|
||||
|
||||
$lockPath = Join-Path $root 'generated.lock'
|
||||
$lockDeadline = (Get-Date).AddSeconds(30)
|
||||
while ($true) {
|
||||
try { [System.IO.File]::Open($lockPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None).Dispose(); break }
|
||||
catch [System.IO.IOException], [System.UnauthorizedAccessException] {
|
||||
if ((Get-Date) -gt $lockDeadline) { throw "Another build holds $lockPath; wait for it to finish, or remove the file if no build is running." }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
}
|
||||
$retired = $null
|
||||
try {
|
||||
if (Test-Path -LiteralPath $final) {
|
||||
$retired = Join-Path $root ("generated.old-ps$PID-" + [Guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
[System.IO.Directory]::Move($final, $retired)
|
||||
}
|
||||
try { [System.IO.Directory]::Move($output, $final) }
|
||||
catch {
|
||||
if ($retired) { [System.IO.Directory]::Move($retired, $final); $retired = $null }
|
||||
throw
|
||||
}
|
||||
} finally {
|
||||
[System.IO.File]::Delete($lockPath)
|
||||
}
|
||||
if ($retired) { Remove-Item -LiteralPath $retired -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
if ($Summary) { Write-Output 'Build: PASS | 4 packages' } else { Write-Output 'PASS build: codex-powershell, claude-powershell, codex-bash, claude-bash' }
|
||||
exit 0
|
||||
@@ -10,7 +10,7 @@ done
|
||||
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
shared="$root/shared"
|
||||
output="$root/generated"
|
||||
final="$root/generated"
|
||||
sandbox_supported=false
|
||||
platform=$(uname -s 2>/dev/null || true)
|
||||
case "$platform" in
|
||||
@@ -66,7 +66,27 @@ if [ "$summary" = true ]; then
|
||||
else
|
||||
printf '%s\n' "$session_output"
|
||||
fi
|
||||
rm -rf -- "$output"
|
||||
# Build into a private sibling directory and swap it into place only after validation, so a failed
|
||||
# or parallel build never leaves generated/ partial. Leftovers of crashed runs are removed here:
|
||||
# old trees always, build trees only when their Bash owner process has exited.
|
||||
# ponytail: PowerShell-owned leftovers are left to the PowerShell build, since PIDs are not comparable across shells.
|
||||
for leftover in "$root"/generated.old-* "$root"/generated.build-sh*; do
|
||||
[ -d "$leftover" ] || continue
|
||||
if [[ "$leftover" == */generated.build-sh* ]]; then
|
||||
leftover_pid=${leftover##*/generated.build-sh}
|
||||
leftover_pid=${leftover_pid%%-*}
|
||||
! kill -0 "$leftover_pid" 2>/dev/null || continue
|
||||
fi
|
||||
rm -rf -- "$leftover"
|
||||
done
|
||||
lock="$root/generated.lock"
|
||||
locked=false
|
||||
retired=''
|
||||
output=''
|
||||
trap 'rm -rf -- ${output:+"$output"}; [ "$locked" != true ] || rm -f -- "$lock"' EXIT
|
||||
# Keep the name short: it lengthens every generated path, which matters on Windows.
|
||||
output=$(mktemp -d "$root/generated.build-sh$$-XXXXXX")
|
||||
chmod "$(printf '%o' $((0777 & ~$(umask))))" "$output"
|
||||
|
||||
agent_dirs=("$shared"/agents/*/)
|
||||
declare -A agent_seen
|
||||
@@ -184,11 +204,11 @@ for client in codex claude; do
|
||||
fi
|
||||
path="$output/$client-$shell/$file"
|
||||
content=$(<"$path")
|
||||
# Claude can offer both shell tools on Windows, so every package denies the destructive commands in both.
|
||||
deny_rules='["PowerShell(git reset --hard*)","PowerShell(git clean*)","PowerShell(Remove-Item *-Recurse*)","Bash(git reset --hard*)","Bash(git clean*)","Bash(rm -rf*)"]'
|
||||
if [ "$shell" = powershell ]; then
|
||||
deny_rules='["PowerShell(git reset --hard*)","PowerShell(git clean*)","PowerShell(Remove-Item *-Recurse*)"]'
|
||||
allow_rules='["Agent","PowerShell(mcporter call context7.*)"]'
|
||||
else
|
||||
deny_rules='["Bash(git reset --hard*)","Bash(git clean*)","Bash(rm -rf*)"]'
|
||||
allow_rules='["Agent","Bash(mcporter call context7.*)"]'
|
||||
fi
|
||||
content=${content//__HOOK_COMMAND__/$command}
|
||||
@@ -231,4 +251,21 @@ if [ -n "$leftover_tokens" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for ((attempt = 0; attempt < 150; attempt++)); do
|
||||
if (set -o noclobber; : > "$lock") 2>/dev/null; then locked=true; break; fi
|
||||
sleep 0.2
|
||||
done
|
||||
[ "$locked" = true ] || { printf 'Another build holds %s; wait for it to finish, or remove the file if no build is running.\n' "$lock" >&2; exit 1; }
|
||||
if [ -e "$final" ]; then
|
||||
retired="$root/generated.old-sh$$-$RANDOM"
|
||||
mv -- "$final" "$retired"
|
||||
fi
|
||||
if ! mv -- "$output" "$final"; then
|
||||
[ -z "$retired" ] || mv -- "$retired" "$final"
|
||||
exit 1
|
||||
fi
|
||||
rm -f -- "$lock"
|
||||
locked=false
|
||||
[ -z "$retired" ] || rm -rf -- "$retired"
|
||||
|
||||
if [ "$summary" = true ]; then printf 'Build: PASS | 4 packages\n'; else printf 'PASS build: codex-powershell, claude-powershell, codex-bash, claude-bash\n'; fi
|
||||
@@ -41,7 +41,7 @@ $plugins = Select-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -C
|
||||
|
||||
foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Shell $shell -Client $Client)) {
|
||||
Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp `
|
||||
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -ClaudeConcurrency $claudeConcurrency -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -DryRun:$DryRun -Summary:$Summary
|
||||
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -ClaudeConcurrency $claudeConcurrency -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -SemanticRetrievalEnabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
||||
}
|
||||
|
||||
Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected
|
||||
|
||||
@@ -58,7 +58,7 @@ select_configured_plugins "$root" "$client" Install "$dry_run" selected_plugins
|
||||
while IFS='|' read -r source destination; do
|
||||
ai_config_root=$destination
|
||||
command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination")
|
||||
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" "$claude_concurrency" "$statusline"
|
||||
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" "$claude_concurrency" "$statusline" "$semantic_retrieval"
|
||||
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
|
||||
|
||||
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary"
|
||||
|
||||
@@ -51,7 +51,7 @@ $plugins = if ($Quick) {
|
||||
|
||||
foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath -Shell $shell -Client $Client)) {
|
||||
Sync-ManagedDestination -Source $item.Source -Destination $item.Destination -Stamp $stamp `
|
||||
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -DryRun:$DryRun -Summary:$Summary
|
||||
-AiConfigRoot $item.Destination.Replace('\','/') -ShellCommand $shellCommand -PowerShellCommand $powerShellCommand -FlashbangEnabled $options.flashbang -StatusLineEnabled $options.statusline -SemanticRetrievalEnabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
||||
}
|
||||
|
||||
$previousPluginNonInteractive = $script:PluginNonInteractive
|
||||
|
||||
@@ -63,7 +63,7 @@ fi
|
||||
while IFS='|' read -r source destination; do
|
||||
ai_config_root=$destination
|
||||
command -v cygpath >/dev/null && ai_config_root=$(cygpath -m "$destination")
|
||||
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" 5 "$statusline"
|
||||
sync_managed_destination "$source" "$destination" "$stamp" "$ai_config_root" "$shell_command" "$powershell_command" "$dry_run" "$summary" "$flashbang" 5 "$statusline" "$semantic_retrieval"
|
||||
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
|
||||
|
||||
if [ "$quick" = true ]; then
|
||||
|
||||
+83
-26
@@ -142,6 +142,7 @@ function Sync-ManagedDestination {
|
||||
[string]$ClaudeConcurrency = '5',
|
||||
[bool]$FlashbangEnabled = $true,
|
||||
[bool]$StatusLineEnabled = $true,
|
||||
[bool]$SemanticRetrievalEnabled = $true,
|
||||
[switch]$DryRun,
|
||||
[switch]$Summary
|
||||
)
|
||||
@@ -169,6 +170,7 @@ function Invoke-ManagedSync {
|
||||
[string]$ClaudeConcurrency = '5',
|
||||
[bool]$FlashbangEnabled = $true,
|
||||
[bool]$StatusLineEnabled = $true,
|
||||
[bool]$SemanticRetrievalEnabled = $true,
|
||||
[switch]$DryRun,
|
||||
[switch]$Summary
|
||||
)
|
||||
@@ -178,6 +180,34 @@ function Invoke-ManagedSync {
|
||||
$oldManifest = Read-ManagedManifest $manifestPath
|
||||
$newManifest = @{}
|
||||
$backupRoot = Get-ManagedBackupRoot $Destination
|
||||
$tally = @{ Created = 0; Updated = 0; Unchanged = 0; Removed = 0; Warned = 0 }
|
||||
$journalPath = Resolve-ManagedPath -Destination $Destination -Relative '.ai-config-transaction.json'
|
||||
if (Test-Path -LiteralPath $journalPath) {
|
||||
# An earlier run was interrupted after it started writing; undo its changes before syncing.
|
||||
$journal = [System.IO.File]::ReadAllText($journalPath, (New-Object System.Text.UTF8Encoding($false))) | ConvertFrom-Json
|
||||
if ($DryRun) {
|
||||
Write-Output "DRYRUN ROLLBACK $Destination $($journal.stamp)"
|
||||
} else {
|
||||
foreach ($entry in @($journal.files)) {
|
||||
$target = Resolve-ManagedPath -Destination $Destination -Relative $entry.path
|
||||
if ($entry.action -eq 'create') {
|
||||
if (-not (Test-Path -LiteralPath $target)) { continue }
|
||||
if ((Get-Sha256Hash $target) -eq "$($entry.sha256)".ToLowerInvariant()) { [System.IO.File]::Delete($target) }
|
||||
else { Write-Output "WARN $target was created by an interrupted run and has changed since; it was left in place."; $tally.Warned++ }
|
||||
} elseif ($entry.action -in @('update', 'remove')) {
|
||||
# A missing backup means the interrupted run never reached this file.
|
||||
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative $entry.backup
|
||||
if (-not (Test-Path -LiteralPath $backup)) { continue }
|
||||
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
|
||||
Write-ManagedFileAtomically -Path $target -Bytes ([System.IO.File]::ReadAllBytes($backup))
|
||||
} else {
|
||||
throw "Invalid transaction journal action in ${journalPath}: $($entry.action)"
|
||||
}
|
||||
}
|
||||
Write-Output "ROLLBACK $Destination $($journal.stamp)"
|
||||
[System.IO.File]::Delete($journalPath)
|
||||
}
|
||||
}
|
||||
$oldBackupRoot = Resolve-ManagedPath -Destination $Destination -Relative 'backups'
|
||||
if ($backupRoot -ne $oldBackupRoot -and (Test-Path -LiteralPath $oldBackupRoot)) {
|
||||
if ($DryRun) {
|
||||
@@ -187,12 +217,14 @@ function Invoke-ManagedSync {
|
||||
Move-Item -LiteralPath $oldBackupRoot -Destination (Join-Path $backupRoot ('legacy-' + [Guid]::NewGuid().ToString('N')))
|
||||
}
|
||||
}
|
||||
$tally = @{ Created = 0; Updated = 0; Unchanged = 0; Removed = 0; Warned = 0 }
|
||||
|
||||
# Plan every change before writing anything, so the journal can describe the whole run.
|
||||
$plan = New-Object System.Collections.ArrayList
|
||||
Get-ChildItem $Source -File -Recurse | ForEach-Object {
|
||||
$relative = $_.FullName.Substring($Source.Length).TrimStart([char[]]@('\','/')).Replace('\','/')
|
||||
if ((Split-Path $Destination -Leaf) -eq '.codex' -and $relative.StartsWith('skills/')) { return }
|
||||
if ((Split-Path $Destination -Leaf) -eq '.claude' -and -not $StatusLineEnabled -and $relative.StartsWith('statusline/')) { return }
|
||||
if (-not $SemanticRetrievalEnabled -and $relative -match '(^|/)semantic-search/') { return }
|
||||
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
|
||||
$rawContent = Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8
|
||||
$filterOptions = ((-not $FlashbangEnabled) -or (-not $StatusLineEnabled)) -and $relative -in @('settings.json', 'config.toml')
|
||||
@@ -221,42 +253,67 @@ function Invoke-ManagedSync {
|
||||
$newManifest[$relative] = $newHash
|
||||
|
||||
$exists = Test-Path -LiteralPath $target
|
||||
if ($exists -and (Get-Sha256Hash $target) -eq $newHash) { $tally.Unchanged++; if (-not $Summary) { Write-Output "UNCHANGED $target" }; return }
|
||||
|
||||
$action = if ($exists) { 'UPDATE' } else { 'CREATE' }
|
||||
if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN $action $target" }; if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }; return }
|
||||
if ($exists) {
|
||||
$wasManaged = $oldManifest.ContainsKey($relative)
|
||||
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative "$Stamp/$relative"
|
||||
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
|
||||
Copy-Item -LiteralPath $target $backup -Force
|
||||
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
|
||||
if (-not $wasManaged) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten."; $tally.Warned++ }
|
||||
}
|
||||
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
|
||||
Write-ManagedFileAtomically -Path $target -Bytes $newBytes
|
||||
if (-not $Summary) { Write-Output "$action $target" }
|
||||
if ($action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }
|
||||
$action = if (-not $exists) { 'CREATE' } elseif ((Get-Sha256Hash $target) -eq $newHash) { 'UNCHANGED' } else { 'UPDATE' }
|
||||
$null = $plan.Add([pscustomobject]@{ Relative = $relative; Target = $target; Action = $action; Bytes = $newBytes; Hash = $newHash })
|
||||
}
|
||||
|
||||
foreach ($relative in @($oldManifest.Keys | Where-Object { -not $newManifest.ContainsKey($_) })) {
|
||||
$target = Resolve-ManagedPath -Destination $Destination -Relative $relative
|
||||
if (-not (Test-Path -LiteralPath $target)) { continue }
|
||||
if ($DryRun) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++; continue }
|
||||
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative "$Stamp/$relative"
|
||||
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
|
||||
Copy-Item -LiteralPath $target $backup -Force
|
||||
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
|
||||
if ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) {
|
||||
# A stale file changed locally is backed up but kept; only an unmodified one is removed.
|
||||
$action = if ((Get-Sha256Hash $target) -eq $oldManifest[$relative]) { 'REMOVE' } else { 'KEEP' }
|
||||
$null = $plan.Add([pscustomobject]@{ Relative = $relative; Target = $target; Action = $action; Bytes = $null; Hash = $null })
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
$journalEntries = @(foreach ($item in $plan) {
|
||||
switch ($item.Action) {
|
||||
'CREATE' { [ordered]@{ path = $item.Relative; action = 'create'; sha256 = $item.Hash } }
|
||||
'UPDATE' { [ordered]@{ path = $item.Relative; action = 'update'; backup = "$Stamp/$($item.Relative)"; sha256 = $item.Hash } }
|
||||
'REMOVE' { [ordered]@{ path = $item.Relative; action = 'remove'; backup = "$Stamp/$($item.Relative)" } }
|
||||
}
|
||||
})
|
||||
$journalJson = ConvertTo-Json -InputObject ([ordered]@{ stamp = $Stamp; files = $journalEntries }) -Depth 4
|
||||
New-Item $Destination -ItemType Directory -Force | Out-Null
|
||||
Write-ManagedFileAtomically -Path $journalPath -Bytes ((New-Object System.Text.UTF8Encoding($false)).GetBytes($journalJson + "`n"))
|
||||
}
|
||||
|
||||
foreach ($item in $plan) {
|
||||
$target = $item.Target
|
||||
if ($item.Action -eq 'UNCHANGED') { $tally.Unchanged++; if (-not $Summary) { Write-Output "UNCHANGED $target" }; continue }
|
||||
$isRemoval = $item.Action -in @('REMOVE', 'KEEP')
|
||||
if ($DryRun) {
|
||||
if ($isRemoval) { if (-not $Summary) { Write-Output "DRYRUN REMOVE $target" }; $tally.Removed++ }
|
||||
else { if (-not $Summary) { Write-Output "DRYRUN $($item.Action) $target" }; if ($item.Action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ } }
|
||||
continue
|
||||
}
|
||||
if ($item.Action -ne 'CREATE') {
|
||||
$backup = Resolve-ManagedPath -Destination $backupRoot -Relative "$Stamp/$($item.Relative)"
|
||||
New-Item (Split-Path $backup -Parent) -ItemType Directory -Force | Out-Null
|
||||
# Atomic, so a rollback after an interruption never restores a truncated backup.
|
||||
Write-ManagedFileAtomically -Path $backup -Bytes ([System.IO.File]::ReadAllBytes($target))
|
||||
if (-not $Summary) { Write-Output "BACKUP $target -> $backup" }
|
||||
}
|
||||
if ($item.Action -eq 'REMOVE') {
|
||||
Remove-Item -LiteralPath $target -Force
|
||||
if (-not $Summary) { Write-Output "REMOVE $target" }
|
||||
$tally.Removed++
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if ($item.Action -eq 'KEEP') {
|
||||
Write-Output "WARN $target was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed."
|
||||
$tally.Warned++
|
||||
continue
|
||||
}
|
||||
if ($item.Action -eq 'UPDATE' -and -not $oldManifest.ContainsKey($item.Relative)) { Write-Output "WARN $target existed before this installation but was not tracked by a previous run; it was backed up before being overwritten."; $tally.Warned++ }
|
||||
New-Item (Split-Path $target -Parent) -ItemType Directory -Force | Out-Null
|
||||
Write-ManagedFileAtomically -Path $target -Bytes $item.Bytes
|
||||
if (-not $Summary) { Write-Output "$($item.Action) $target" }
|
||||
if ($item.Action -eq 'CREATE') { $tally.Created++ } else { $tally.Updated++ }
|
||||
}
|
||||
|
||||
if (-not $DryRun) { Write-ManagedManifest $manifestPath $newManifest }
|
||||
if (-not $DryRun) {
|
||||
Write-ManagedManifest $manifestPath $newManifest
|
||||
[System.IO.File]::Delete($journalPath)
|
||||
}
|
||||
if ($Summary) { Write-Output ("SYNC {0}: {1} created, {2} updated, {3} unchanged, {4} removed, {5} warnings" -f $Destination, $tally.Created, $tally.Updated, $tally.Unchanged, $tally.Removed, $tally.Warned) }
|
||||
}
|
||||
+147
-60
@@ -76,6 +76,12 @@ write_managed_file() {
|
||||
{ cat -- "$content_file" > "$temporary" && chmod "$mode" "$temporary" && mv -f -- "$temporary" "$target"; } || { rm -f -- "$temporary"; return 1; }
|
||||
}
|
||||
|
||||
# json_string <value>: print value as a JSON string. Managed paths and stamps never contain control characters.
|
||||
json_string() {
|
||||
local value=${1//\\/\\\\}
|
||||
printf '"%s"' "${value//\"/\\\"}"
|
||||
}
|
||||
|
||||
# write_managed_manifest <manifest-file> <assoc-array-name>
|
||||
write_managed_manifest() {
|
||||
local path=$1 content
|
||||
@@ -120,9 +126,9 @@ sync_managed_destination() {
|
||||
return "$status"
|
||||
}
|
||||
|
||||
# run_managed_sync <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <powershell-command> <dry-run: true|false> [summary: true|false] [flashbang] [claude-concurrency] [statusline]
|
||||
# run_managed_sync <source-dir> <destination-dir> <stamp> <ai-config-root> <shell-command> <powershell-command> <dry-run: true|false> [summary: true|false] [flashbang] [claude-concurrency] [statusline] [semantic-retrieval]
|
||||
run_managed_sync() {
|
||||
local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 powershell_command=$6 dry_run=$7 summary=${8:-false} flashbang_enabled=${9:-true} claude_concurrency=${10:-5} statusline_enabled=${11:-true}
|
||||
local source=$1 destination=$2 stamp=$3 ai_config_root=$4 shell_command=$5 powershell_command=$6 dry_run=$7 summary=${8:-false} flashbang_enabled=${9:-true} claude_concurrency=${10:-5} statusline_enabled=${11:-true} semantic_retrieval_enabled=${12:-true}
|
||||
[ "$summary" = true ] || printf 'SOURCE %s -> %s\n' "$source" "$destination"
|
||||
local manifest
|
||||
manifest=$(manifest_path "$destination")
|
||||
@@ -133,6 +139,52 @@ run_managed_sync() {
|
||||
local created=0 updated=0 unchanged=0 removed=0 warned=0
|
||||
local backup_root old_backup_root
|
||||
backup_root=$(managed_backup_root "$destination") || return 1
|
||||
local journal
|
||||
journal=$(managed_target "$destination" '.ai-config-transaction.json') || return 1
|
||||
if [ -f "$journal" ]; then
|
||||
# An earlier run was interrupted after it started writing; undo its changes before syncing.
|
||||
local entries entry entry_action entry_path entry_backup entry_hash entry_target
|
||||
local -a journal_lines
|
||||
# One stamp line, then one unit-separated line per file (an empty field must survive read).
|
||||
entries=$(python3 -c '
|
||||
import json, sys
|
||||
journal = json.load(open(sys.argv[1], encoding="utf-8-sig"))
|
||||
lines = [str(journal["stamp"])]
|
||||
for entry in journal.get("files") or []:
|
||||
lines.append("\x1f".join([entry["action"], entry["path"], entry.get("backup", ""), entry.get("sha256", "")]))
|
||||
sys.stdout.buffer.write(("\n".join(lines) + "\n").encode("utf-8"))
|
||||
' "$journal") || { printf 'Could not read transaction journal %s\n' "$journal" >&2; return 1; }
|
||||
mapfile -t journal_lines <<< "$entries"
|
||||
if [ "$dry_run" = true ]; then
|
||||
printf 'DRYRUN ROLLBACK %s %s\n' "$destination" "${journal_lines[0]}"
|
||||
else
|
||||
for entry in "${journal_lines[@]:1}"; do
|
||||
IFS=$'\x1f' read -r entry_action entry_path entry_backup entry_hash <<< "$entry"
|
||||
entry_target=$(managed_target "$destination" "$entry_path") || return 1
|
||||
case "$entry_action" in
|
||||
create)
|
||||
[ -f "$entry_target" ] || continue
|
||||
if [ "$(sha256_of_file "$entry_target")" = "$(printf '%s' "$entry_hash" | tr '[:upper:]' '[:lower:]')" ]; then
|
||||
rm -f -- "$entry_target"
|
||||
else
|
||||
printf 'WARN %s was created by an interrupted run and has changed since; it was left in place.\n' "$entry_target"
|
||||
warned=$((warned + 1))
|
||||
fi
|
||||
;;
|
||||
update|remove)
|
||||
# A missing backup means the interrupted run never reached this file.
|
||||
entry_backup=$(managed_target "$backup_root" "$entry_backup") || return 1
|
||||
[ -f "$entry_backup" ] || continue
|
||||
mkdir -p -- "$(dirname -- "$entry_target")"
|
||||
write_managed_file "$entry_target" "$entry_backup" "$entry_backup" || return 1
|
||||
;;
|
||||
*) printf 'Invalid transaction journal action in %s: %s\n' "$journal" "$entry_action" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
printf 'ROLLBACK %s %s\n' "$destination" "${journal_lines[0]}"
|
||||
rm -f -- "$journal"
|
||||
fi
|
||||
fi
|
||||
old_backup_root=$(managed_target "$destination" backups) || return 1
|
||||
if [ "$backup_root" != "$old_backup_root" ] && [ -e "$old_backup_root" ]; then
|
||||
if [ "$dry_run" = true ]; then
|
||||
@@ -146,11 +198,14 @@ run_managed_sync() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Plan every change before writing anything, so the journal can describe the whole run.
|
||||
local -a plan_relative=() plan_target=() plan_action=() plan_hash=() plan_source=() plan_sub=() plan_content=()
|
||||
while IFS= read -r -d '' source_file; do
|
||||
local relative target content needs_sub new_hash exists current_hash action backup
|
||||
relative=${source_file#"$source/"}
|
||||
if [ "${destination##*/}" = .codex ] && [[ "$relative" = skills/* ]]; then continue; fi
|
||||
if [ "${destination##*/}" = .claude ] && [ "$statusline_enabled" = false ] && [[ "$relative" = statusline/* ]]; then continue; fi
|
||||
if [ "$semantic_retrieval_enabled" = false ] && [[ "$relative" =~ (^|/)semantic-search/ ]]; then continue; fi
|
||||
target=$(managed_target "$destination" "$relative") || return 1
|
||||
content=$(cat -- "$source_file" && printf '\034') || return
|
||||
content=${content%$'\034'}
|
||||
@@ -185,47 +240,15 @@ run_managed_sync() {
|
||||
fi
|
||||
new_manifest[$relative]=$new_hash
|
||||
|
||||
exists=false
|
||||
[ -f "$target" ] && exists=true
|
||||
if [ "$exists" = true ]; then
|
||||
current_hash=$(sha256_of_file "$target")
|
||||
if [ "$current_hash" = "$new_hash" ]; then
|
||||
unchanged=$((unchanged + 1))
|
||||
[ "$summary" = true ] || printf 'UNCHANGED %s\n' "$target"
|
||||
continue
|
||||
fi
|
||||
action=CREATE
|
||||
if [ -f "$target" ]; then
|
||||
action=UPDATE
|
||||
else
|
||||
action=CREATE
|
||||
current_hash=$(sha256_of_file "$target")
|
||||
[ "$current_hash" != "$new_hash" ] || action=UNCHANGED
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = true ]; then
|
||||
[ "$summary" = true ] || printf 'DRYRUN %s %s\n' "$action" "$target"
|
||||
[ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$exists" = true ]; then
|
||||
backup=$(managed_target "$backup_root" "$stamp/$relative") || return 1
|
||||
mkdir -p "$(dirname -- "$backup")"
|
||||
cp -- "$target" "$backup"
|
||||
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
|
||||
if [ -z "${old_manifest[$relative]+x}" ]; then
|
||||
printf 'WARN %s existed before this installation but was not tracked by a previous run; it was backed up before being overwritten.\n' "$target"
|
||||
warned=$((warned + 1))
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$(dirname -- "$target")"
|
||||
if [ "$needs_sub" = true ]; then
|
||||
local rendered
|
||||
rendered=$(mktemp) || return 1
|
||||
printf '%s' "$content" > "$rendered" && write_managed_file "$target" "$rendered" "$source_file" || { rm -f -- "$rendered"; return 1; }
|
||||
rm -f -- "$rendered"
|
||||
else
|
||||
write_managed_file "$target" "$source_file" "$source_file" || return 1
|
||||
fi
|
||||
[ "$summary" = true ] || printf '%s %s\n' "$action" "$target"
|
||||
[ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1))
|
||||
[ "$needs_sub" = true ] || content=''
|
||||
plan_relative+=("$relative"); plan_target+=("$target"); plan_action+=("$action"); plan_hash+=("$new_hash")
|
||||
plan_source+=("$source_file"); plan_sub+=("$needs_sub"); plan_content+=("$content")
|
||||
done < <(find "$source" -type f -print0)
|
||||
|
||||
local relative target current_hash backup
|
||||
@@ -233,27 +256,91 @@ run_managed_sync() {
|
||||
[ -z "${new_manifest[$relative]+x}" ] || continue
|
||||
target=$(managed_target "$destination" "$relative") || return 1
|
||||
[ -f "$target" ] || continue
|
||||
if [ "$dry_run" = true ]; then
|
||||
[ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target"
|
||||
removed=$((removed + 1))
|
||||
continue
|
||||
fi
|
||||
backup=$(managed_target "$backup_root" "$stamp/$relative") || return 1
|
||||
mkdir -p "$(dirname -- "$backup")"
|
||||
cp -- "$target" "$backup"
|
||||
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
|
||||
current_hash=$(sha256_of_file "$target")
|
||||
if [ "$current_hash" = "${old_manifest[$relative]}" ]; then
|
||||
rm -f -- "$target"
|
||||
[ "$summary" = true ] || printf 'REMOVE %s\n' "$target"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
printf 'WARN %s was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed.\n' "$target"
|
||||
warned=$((warned + 1))
|
||||
fi
|
||||
# A stale file changed locally is backed up but kept; only an unmodified one is removed.
|
||||
action=KEEP
|
||||
[ "$(sha256_of_file "$target")" != "${old_manifest[$relative]}" ] || action=REMOVE
|
||||
plan_relative+=("$relative"); plan_target+=("$target"); plan_action+=("$action"); plan_hash+=('')
|
||||
plan_source+=(''); plan_sub+=(false); plan_content+=('')
|
||||
done
|
||||
|
||||
if [ "$dry_run" != true ]; then write_managed_manifest "$manifest" new_manifest || return 1; fi
|
||||
local index entry journal_content first_entry=true
|
||||
if [ "$dry_run" != true ]; then
|
||||
journal_content=$(mktemp) || return 1
|
||||
{
|
||||
printf '{"stamp": %s, "files": [' "$(json_string "$stamp")"
|
||||
for index in "${!plan_action[@]}"; do
|
||||
case "${plan_action[$index]}" in
|
||||
CREATE) entry=$(printf '{"path": %s, "action": "create", "sha256": "%s"}' "$(json_string "${plan_relative[$index]}")" "${plan_hash[$index]}") ;;
|
||||
UPDATE) entry=$(printf '{"path": %s, "action": "update", "backup": %s, "sha256": "%s"}' "$(json_string "${plan_relative[$index]}")" "$(json_string "$stamp/${plan_relative[$index]}")" "${plan_hash[$index]}") ;;
|
||||
REMOVE) entry=$(printf '{"path": %s, "action": "remove", "backup": %s}' "$(json_string "${plan_relative[$index]}")" "$(json_string "$stamp/${plan_relative[$index]}")") ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ "$first_entry" = true ] || printf ', '
|
||||
first_entry=false
|
||||
printf '%s' "$entry"
|
||||
done
|
||||
printf ']}\n'
|
||||
} > "$journal_content" && mkdir -p -- "$destination" && write_managed_file "$journal" "$journal_content" || { rm -f -- "$journal_content"; return 1; }
|
||||
rm -f -- "$journal_content"
|
||||
fi
|
||||
|
||||
for index in "${!plan_action[@]}"; do
|
||||
relative=${plan_relative[$index]} target=${plan_target[$index]} action=${plan_action[$index]}
|
||||
if [ "$action" = UNCHANGED ]; then
|
||||
unchanged=$((unchanged + 1))
|
||||
[ "$summary" = true ] || printf 'UNCHANGED %s\n' "$target"
|
||||
continue
|
||||
fi
|
||||
if [ "$dry_run" = true ]; then
|
||||
case "$action" in
|
||||
REMOVE|KEEP) [ "$summary" = true ] || printf 'DRYRUN REMOVE %s\n' "$target"; removed=$((removed + 1)) ;;
|
||||
*) [ "$summary" = true ] || printf 'DRYRUN %s %s\n' "$action" "$target"; [ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1)) ;;
|
||||
esac
|
||||
continue
|
||||
fi
|
||||
if [ "$action" != CREATE ]; then
|
||||
backup=$(managed_target "$backup_root" "$stamp/$relative") || return 1
|
||||
mkdir -p "$(dirname -- "$backup")"
|
||||
# Atomic, so a rollback after an interruption never restores a truncated backup.
|
||||
write_managed_file "$backup" "$target" "$target" || return 1
|
||||
[ "$summary" = true ] || printf 'BACKUP %s -> %s\n' "$target" "$backup"
|
||||
fi
|
||||
case "$action" in
|
||||
REMOVE)
|
||||
rm -f -- "$target"
|
||||
[ "$summary" = true ] || printf 'REMOVE %s\n' "$target"
|
||||
removed=$((removed + 1))
|
||||
continue
|
||||
;;
|
||||
KEEP)
|
||||
printf 'WARN %s was managed by a previous installation and has changed locally; it was backed up but left in place instead of being removed.\n' "$target"
|
||||
warned=$((warned + 1))
|
||||
continue
|
||||
;;
|
||||
UPDATE)
|
||||
if [ -z "${old_manifest[$relative]+x}" ]; then
|
||||
printf 'WARN %s existed before this installation but was not tracked by a previous run; it was backed up before being overwritten.\n' "$target"
|
||||
warned=$((warned + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$(dirname -- "$target")"
|
||||
if [ "${plan_sub[$index]}" = true ]; then
|
||||
local rendered
|
||||
rendered=$(mktemp) || return 1
|
||||
printf '%s' "${plan_content[$index]}" > "$rendered" && write_managed_file "$target" "$rendered" "${plan_source[$index]}" || { rm -f -- "$rendered"; return 1; }
|
||||
rm -f -- "$rendered"
|
||||
else
|
||||
write_managed_file "$target" "${plan_source[$index]}" "${plan_source[$index]}" || return 1
|
||||
fi
|
||||
[ "$summary" = true ] || printf '%s %s\n' "$action" "$target"
|
||||
[ "$action" = CREATE ] && created=$((created + 1)) || updated=$((updated + 1))
|
||||
done
|
||||
|
||||
if [ "$dry_run" != true ]; then
|
||||
write_managed_manifest "$manifest" new_manifest || return 1
|
||||
rm -f -- "$journal"
|
||||
fi
|
||||
if [ "$summary" = true ]; then
|
||||
printf 'SYNC %s: %s created, %s updated, %s unchanged, %s removed, %s warnings\n' "$destination" "$created" "$updated" "$unchanged" "$removed" "$warned"
|
||||
fi
|
||||
|
||||
@@ -97,7 +97,7 @@ def check_runtime(target, state):
|
||||
if path.name == 'data' and (not path.is_dir() or any(
|
||||
child.is_symlink() or not (
|
||||
child.is_file() and (child.name in ('daemon.key', 'daemon.starting') or re.fullmatch(
|
||||
r'[0-9a-f]{64}\.sqlite3(?:-(?:wal|shm|journal))?', child.name))
|
||||
r'[0-9a-f]{64}\.sqlite3(?:-(?:wal|shm|journal))?|daemon-[0-9a-f]{12}\.starting', child.name))
|
||||
or child.is_socket() and re.fullmatch(r'daemon-[0-9a-f]{16}\.sock', child.name))
|
||||
for child in path.iterdir())):
|
||||
raise ValueError('Foreign or linked semantic retrieval path: data')
|
||||
@@ -150,6 +150,8 @@ def sync(root, home, clients, enabled, dry_run, update, summary=False):
|
||||
save_state(state_path, state, False)
|
||||
python = ensure_runtime(target, False)
|
||||
ensure_models(python, target)
|
||||
# Sandboxed clients may write only to data/; it cannot be created from inside the sandbox.
|
||||
(target / 'data').mkdir(exist_ok=True)
|
||||
state['clients'] = list(dict.fromkeys(state['clients']))
|
||||
save_state(state_path, state, False)
|
||||
for client in clients:
|
||||
|
||||
@@ -8,7 +8,7 @@ New-Item $work -ItemType Directory -Force | Out-Null
|
||||
function New-RepoCopy {
|
||||
$copy = Join-Path $work ("case-" + [Guid]::NewGuid())
|
||||
New-Item $copy -ItemType Directory -Force | Out-Null
|
||||
Get-ChildItem $root -Force | Where-Object { $_.Name -notin @('.git','generated') } | ForEach-Object {
|
||||
Get-ChildItem $root -Force | Where-Object { $_.Name -ne '.git' -and $_.Name -ne 'generated' -and $_.Name -notlike 'generated.*' } | ForEach-Object {
|
||||
Copy-Item $_.FullName (Join-Path $copy $_.Name) -Recurse -Force
|
||||
}
|
||||
return $copy
|
||||
@@ -20,12 +20,28 @@ function Test-BuildFails([string]$Label, [scriptblock]$Mutate) {
|
||||
$failed = $false
|
||||
try { & (Join-Path $copy 'scripts/commands/build.ps1') 2>&1 | Out-Null }
|
||||
catch { $failed = $true }
|
||||
$leftovers = @(Get-ChildItem $copy -Force -Filter 'generated*')
|
||||
Remove-Item $copy -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if (-not $failed) { throw "Expected build to fail for case '$Label' but it succeeded." }
|
||||
if ($leftovers.Count) { throw "Failed build for case '$Label' left output behind: $($leftovers.Name -join ', ')" }
|
||||
}
|
||||
|
||||
$baseline = New-RepoCopy
|
||||
& (Join-Path $baseline 'scripts/commands/build.ps1') -Summary | Out-Null
|
||||
$baselineGenerated = Join-Path $baseline 'generated'
|
||||
$expectedFiles = @(Get-ChildItem $baselineGenerated -File -Recurse | ForEach-Object { $_.FullName.Substring($baselineGenerated.Length) } | Sort-Object)
|
||||
$builds = @(1, 2 | ForEach-Object {
|
||||
$build = Start-Process (Get-Process -Id $PID).Path -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"' + (Join-Path $baseline 'scripts/commands/build.ps1') + '"'), '-Summary') -NoNewWindow -PassThru -RedirectStandardOutput (Join-Path $work "parallel-$_.out") -RedirectStandardError (Join-Path $work "parallel-$_.err")
|
||||
$null = $build.Handle # Windows PowerShell 5.1 reports ExitCode only when the handle was opened.
|
||||
$build
|
||||
})
|
||||
foreach ($build in $builds) { $build.WaitForExit() }
|
||||
foreach ($index in 0, 1) {
|
||||
if ($builds[$index].ExitCode -ne 0) { throw "Parallel build $($index + 1) failed: $(Get-Content (Join-Path $work "parallel-$($index + 1).err") -Raw)" }
|
||||
}
|
||||
$actualFiles = @(Get-ChildItem $baselineGenerated -File -Recurse | ForEach-Object { $_.FullName.Substring($baselineGenerated.Length) } | Sort-Object)
|
||||
if (Compare-Object $expectedFiles $actualFiles) { throw 'generated/ is incomplete after two parallel builds.' }
|
||||
if (@(Get-ChildItem $baseline -Force | Where-Object Name -like 'generated.*').Count) { throw 'Parallel builds left a build directory, retired tree, or lock behind.' }
|
||||
|
||||
Test-BuildFails 'invalid Claude settings.json' {
|
||||
param($copy)
|
||||
@@ -64,5 +80,5 @@ Test-BuildFails 'unknown placeholder beside an allowed installer placeholder' {
|
||||
}
|
||||
|
||||
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($Summary) { Write-Output 'Tests: PASS | build validation' } else { Write-Output 'PASS build validation: invalid JSON, duplicate agent, unknown Claude tool name, duplicate plugin, and leftover placeholders are all rejected' }
|
||||
if ($Summary) { Write-Output 'Tests: PASS | build validation' } else { Write-Output 'PASS build validation: invalid JSON, duplicate agent, unknown Claude tool name, duplicate plugin, and leftover placeholders are all rejected without leaving output; parallel builds swap generated/ safely' }
|
||||
exit 0
|
||||
@@ -16,7 +16,7 @@ trap cleanup EXIT
|
||||
new_repo_copy() {
|
||||
local copy
|
||||
copy=$(mktemp -d "$work/case-XXXXXX")
|
||||
(cd "$root" && tar -cf - --exclude='.git' --exclude='generated' .) | (cd "$copy" && tar -xf -)
|
||||
(cd "$root" && tar -cf - --exclude='.git' --exclude='generated' --exclude='generated.*' .) | (cd "$copy" && tar -xf -)
|
||||
printf '%s' "$copy"
|
||||
}
|
||||
|
||||
@@ -28,11 +28,22 @@ test_build_fails() {
|
||||
printf 'Expected build to fail for case "%s" but it succeeded.\n' "$label" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$(find "$copy" -maxdepth 1 -name 'generated*' -print -quit)" ]; then
|
||||
printf 'Failed build for case "%s" left output behind.\n' "$label" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf -- "$copy"
|
||||
}
|
||||
|
||||
baseline=$(new_repo_copy)
|
||||
bash "$baseline/scripts/commands/build.sh" --summary >/dev/null
|
||||
expected_files=$(cd "$baseline/generated" && find . -type f | sort)
|
||||
bash "$baseline/scripts/commands/build.sh" --summary > "$work/parallel-1.out" 2>&1 & first_build=$!
|
||||
bash "$baseline/scripts/commands/build.sh" --summary > "$work/parallel-2.out" 2>&1 & second_build=$!
|
||||
wait "$first_build" || { printf 'Parallel build 1 failed:\n%s\n' "$(cat "$work/parallel-1.out")" >&2; exit 1; }
|
||||
wait "$second_build" || { printf 'Parallel build 2 failed:\n%s\n' "$(cat "$work/parallel-2.out")" >&2; exit 1; }
|
||||
[ "$(cd "$baseline/generated" && find . -type f | sort)" = "$expected_files" ] || { printf 'generated/ is incomplete after two parallel builds.\n' >&2; exit 1; }
|
||||
[ -z "$(find "$baseline" -maxdepth 1 -name 'generated.*' -print -quit)" ] || { printf 'Parallel builds left a build directory, retired tree, or lock behind.\n' >&2; exit 1; }
|
||||
|
||||
mutate_invalid_json() {
|
||||
printf '} this is not json {' >> "$1/adapters/claude/config/settings.json"
|
||||
@@ -65,4 +76,4 @@ mutate_mixed_placeholders() {
|
||||
}
|
||||
test_build_fails 'unknown placeholder beside an allowed installer placeholder' mutate_mixed_placeholders
|
||||
|
||||
if [ "$summary" = true ]; then printf 'Tests: PASS | build validation\n'; else printf 'PASS build validation: invalid JSON, duplicate agent, unknown Claude tool name, duplicate plugin, and leftover placeholders are all rejected\n'; fi
|
||||
if [ "$summary" = true ]; then printf 'Tests: PASS | build validation\n'; else printf 'PASS build validation: invalid JSON, duplicate agent, unknown Claude tool name, duplicate plugin, and leftover placeholders are all rejected without leaving output; parallel builds swap generated/ safely\n'; fi
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Check that generated agent limits and permissions take effect, not only that the clients accept them.
|
||||
|
||||
Static checks (always, when the CLI is installed): the generated Codex package keeps the V1 multi-agent
|
||||
implementation that honors agents.max_depth, and Codex reads the configured limits back.
|
||||
Live checks (only with AI_CONFIG_LIVE_TESTS=1, they start short Haiku sessions): Claude, with only the generated
|
||||
settings env applied, withholds the Agent tool from subagents and refuses an over-limit burst of parallel subagents;
|
||||
with only the generated deny rules applied, blocks a destructive git command in every shell tool.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
SHELL = 'powershell' if os.name == 'nt' else 'bash'
|
||||
LIVE = os.environ.get('AI_CONFIG_LIVE_TESTS') == '1'
|
||||
TIMEOUT = 600
|
||||
|
||||
|
||||
def clean_env(**values):
|
||||
env = {key: value for key, value in os.environ.items() if not key.startswith(('BUN_INSPECT', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX'))}
|
||||
env.update(values)
|
||||
return env
|
||||
|
||||
|
||||
def stop(process):
|
||||
if os.name == 'nt':
|
||||
subprocess.run(['taskkill', '/T', '/F', '/PID', str(process.pid)], capture_output=True, timeout=30)
|
||||
else:
|
||||
process.kill()
|
||||
process.wait(timeout=30)
|
||||
|
||||
|
||||
def stream_events(command, cwd, env):
|
||||
"""Collect JSON lines until the process exits or TIMEOUT passes."""
|
||||
process = subprocess.Popen(command, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
text=True, encoding='utf-8', errors='replace')
|
||||
lines = queue.Queue()
|
||||
threading.Thread(target=lambda: ([lines.put(line) for line in process.stdout], lines.put(None)), daemon=True).start()
|
||||
events, deadline = [], time.monotonic() + TIMEOUT
|
||||
try:
|
||||
while True:
|
||||
line = lines.get(timeout=max(1, deadline - time.monotonic()))
|
||||
if line is None:
|
||||
return events
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except ValueError:
|
||||
pass
|
||||
except queue.Empty:
|
||||
raise AssertionError(f'client did not finish within {TIMEOUT}s') from None
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
stop(process)
|
||||
|
||||
|
||||
class CodexAgentLimitTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('codex'), 'Codex CLI unavailable')
|
||||
def test_generated_limits_use_the_implementation_that_honors_them(self):
|
||||
generated = (ROOT / f'generated/codex-{SHELL}/config.toml').read_text(encoding='utf-8-sig')
|
||||
config = tomllib.loads(generated.replace('__AI_CONFIG_ROOT__', '/x').replace('__HOOK_COMMAND__', 'x')
|
||||
.replace('__POWERSHELL_HOOK_COMMAND__', 'x').replace('__POWERSHELL_COMMAND__', 'x'))
|
||||
self.assertEqual(config['agents']['max_depth'], 1)
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
(Path(home) / 'config.toml').write_text(generated.replace('__AI_CONFIG_ROOT__', Path(home).as_posix()), encoding='utf-8')
|
||||
listing = subprocess.run([shutil.which('codex'), 'features', 'list'], env=clean_env(CODEX_HOME=home),
|
||||
capture_output=True, text=True, timeout=120).stdout
|
||||
states = {line.split()[0]: line.split()[-1] for line in listing.splitlines() if len(line.split()) >= 2}
|
||||
# Codex documents agents.max_depth as a limit of the V1 multi-agent implementation; V2 ignores it.
|
||||
self.assertEqual(states.get('multi_agent'), 'true', listing)
|
||||
self.assertEqual(states.get('multi_agent_v2'), 'false', 'multi_agent_v2 is enabled, so agents.max_depth has no effect')
|
||||
|
||||
|
||||
class ClaudeAgentLimitTests(unittest.TestCase):
|
||||
"""Live checks with forced test agents, so the result does not depend on how the model paraphrases a task."""
|
||||
|
||||
AGENTS = {
|
||||
'spawner': {'description': 'Test agent that starts another agent.', 'tools': ['Agent'],
|
||||
'prompt': 'Your first action: call the Agent tool with subagent_type "general-purpose", description "nested", '
|
||||
'prompt "Reply OK", and run_in_background false. Then output the exact tool result text verbatim.'},
|
||||
'sleeper': {'description': 'Test agent that replies once.', 'tools': [], 'prompt': 'Reply with the word OK. Do not use tools.'},
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
if not (LIVE and shutil.which('claude')):
|
||||
self.skipTest('set AI_CONFIG_LIVE_TESTS=1 with the Claude CLI installed')
|
||||
settings = json.loads((ROOT / f'generated/claude-{SHELL}/settings.json').read_text(encoding='utf-8-sig'))
|
||||
self.env = {key: value for key, value in settings['env'].items() if not value.startswith('__')}
|
||||
|
||||
def run_claude(self, prompt, env):
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as work:
|
||||
subprocess.run(['git', 'init', '-q'], cwd=work, check=True, timeout=60)
|
||||
events = stream_events([shutil.which('claude'), '-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', 'haiku',
|
||||
'--setting-sources', '', '--strict-mcp-config', '--settings', json.dumps({'env': env}),
|
||||
'--agents', json.dumps(self.AGENTS), '--permission-mode', 'auto', '--allowedTools', 'Agent'], work, clean_env())
|
||||
return json.dumps(events)
|
||||
|
||||
def test_spawn_depth_removes_the_agent_tool_from_subagents(self):
|
||||
self.assertEqual(self.env.get('CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH'), '1')
|
||||
prompt = ('Call the Agent tool exactly once with subagent_type "spawner", description "spawn test", prompt "go", and '
|
||||
'run_in_background false. Wait for its result, then quote it verbatim.')
|
||||
limited = self.run_claude(prompt, self.env)
|
||||
self.assertTrue('matched no tools in this session [Agent]' in limited or 'Subagent nesting limit reached' in limited,
|
||||
'a subagent still had the Agent tool')
|
||||
# Control: without the limit the same agent reaches its nested agent, so the check above can fail.
|
||||
unlimited = self.run_claude(prompt, {key: value for key, value in self.env.items() if key != 'CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH'})
|
||||
self.assertNotIn('matched no tools in this session [Agent]', unlimited)
|
||||
|
||||
def test_concurrency_limit_refuses_the_extra_parallel_subagent(self):
|
||||
limit = int(self.env['CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS'])
|
||||
prompt = (f'In ONE single message, call the Agent tool {limit + 1} times in parallel with subagent_type "sleeper" and prompt "go". '
|
||||
'Then quote every tool result and error verbatim.')
|
||||
self.assertIn(f'You can run {limit} subagents at once', self.run_claude(prompt, self.env))
|
||||
|
||||
|
||||
class ClaudePermissionTests(unittest.TestCase):
|
||||
def test_deny_rules_block_destructive_git_in_every_shell_tool(self):
|
||||
if not (LIVE and shutil.which('claude')):
|
||||
self.skipTest('set AI_CONFIG_LIVE_TESTS=1 with the Claude CLI installed')
|
||||
deny = json.loads((ROOT / f'generated/claude-{SHELL}/settings.json').read_text(encoding='utf-8-sig'))['permissions']['deny']
|
||||
for tool in ('Bash', 'PowerShell') if os.name == 'nt' else ('Bash',):
|
||||
with self.subTest(tool), tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as work:
|
||||
for command in (['init', '-q'], ['add', '-A'], ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'c', '--allow-empty']):
|
||||
subprocess.run(['git', *command], cwd=work, check=True, timeout=60)
|
||||
Path(work, 'tracked.txt').write_text('v1')
|
||||
subprocess.run(['git', 'add', '-A'], cwd=work, check=True, timeout=60)
|
||||
subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'v1'], cwd=work, check=True, timeout=60)
|
||||
Path(work, 'tracked.txt').write_text('local edit')
|
||||
# bypassPermissions isolates the deny rules: nothing else may stop the command.
|
||||
events = stream_events([shutil.which('claude'), '-p', f'Use the {tool} tool to run exactly this command and nothing else: git reset --hard',
|
||||
'--output-format', 'stream-json', '--verbose', '--model', 'haiku', '--setting-sources', '', '--strict-mcp-config',
|
||||
'--settings', json.dumps({'permissions': {'deny': deny}}), '--permission-mode', 'bypassPermissions'], work, clean_env())
|
||||
attempts = [block for event in events if event.get('type') == 'assistant' for block in event.get('message', {}).get('content', [])
|
||||
if isinstance(block, dict) and block.get('type') == 'tool_use' and block.get('name') == tool]
|
||||
self.assertTrue(attempts, f'the model never tried the {tool} tool, so the deny rule was not exercised')
|
||||
self.assertEqual(Path(work, 'tracked.txt').read_text(), 'local edit', f'git reset --hard ran through the {tool} tool')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -122,6 +122,18 @@ try {
|
||||
if (@(Get-ChildItem -LiteralPath $destination -Filter '.ai-config-write-*' -File -Recurse).Count) { throw 'Atomic writes left a temporary file.' }
|
||||
if (Test-Path -LiteralPath (Join-Path $destination '.ai-config.lock')) { throw 'Sync must release its destination lock.' }
|
||||
|
||||
$searchSkill = Join-Path $source 'skills/semantic-search/SKILL.md'
|
||||
New-Item (Split-Path $searchSkill -Parent) -ItemType Directory -Force | Out-Null
|
||||
Set-Content $searchSkill 'search skill' -NoNewline
|
||||
$installedSearchSkill = Join-Path $destination 'skills/semantic-search/SKILL.md'
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'search-off' -SemanticRetrievalEnabled $false
|
||||
if (Test-Path -LiteralPath $installedSearchSkill) { throw 'The semantic-search skill must not be installed while retrieval is disabled.' }
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'search-on' -SemanticRetrievalEnabled $true
|
||||
if (-not (Test-Path -LiteralPath $installedSearchSkill)) { throw 'The semantic-search skill must be installed while retrieval is enabled.' }
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'search-off-again' -SemanticRetrievalEnabled $false
|
||||
if (Test-Path -LiteralPath $installedSearchSkill) { throw 'Disabling retrieval must remove the setup-owned semantic-search skill.' }
|
||||
Remove-Item -LiteralPath (Split-Path $searchSkill -Parent) -Recurse -Force
|
||||
|
||||
$lockPath = Join-Path $destination '.ai-config.lock'
|
||||
[System.IO.File]::WriteAllText($lockPath, "bash other-host 1`n")
|
||||
$manifestBefore = [System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination))
|
||||
@@ -138,7 +150,47 @@ try {
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'stale-lock'
|
||||
if (Test-Path -LiteralPath $lockPath) { throw 'A stale lock from an exited process must be replaced and released.' }
|
||||
|
||||
if ($Summary) { Write-Output 'Tests: PASS | managed install' } else { Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration' }
|
||||
if (Test-Path -LiteralPath (Join-Path $destination '.ai-config-transaction.json')) { throw 'A completed sync must delete its transaction journal.' }
|
||||
if ([System.IO.File]::ReadAllText((Get-ManagedManifestPath $destination)).Contains('.ai-config-transaction')) { throw 'The transaction journal must never be listed in the manifest.' }
|
||||
|
||||
# Simulate a run with stamp tx2 interrupted after it updated keep.md, removed gone.md, and created new.md and edited.md.
|
||||
$txSource = Join-Path $work 'tx-source'
|
||||
$txDestination = Join-Path $work 'tx-destination'
|
||||
New-Item $txSource -ItemType Directory -Force | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $txSource 'keep.md'), 'v1')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txSource 'gone.md'), 'G')
|
||||
$null = Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx1'
|
||||
New-Item (Join-Path $txDestination 'backups/tx2') -ItemType Directory -Force | Out-Null
|
||||
Copy-Item (Join-Path $txDestination 'keep.md') (Join-Path $txDestination 'backups/tx2/keep.md')
|
||||
Move-Item (Join-Path $txDestination 'gone.md') (Join-Path $txDestination 'backups/tx2/gone.md')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txDestination 'keep.md'), 'v2')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txDestination 'new.md'), 'N')
|
||||
[System.IO.File]::WriteAllText((Join-Path $txDestination 'edited.md'), 'edited by the user')
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
$journalPath = Join-Path $txDestination '.ai-config-transaction.json'
|
||||
$journal = [ordered]@{ stamp = 'tx2'; files = @(
|
||||
[ordered]@{ path = 'keep.md'; action = 'update'; backup = 'tx2/keep.md'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('v2')) },
|
||||
[ordered]@{ path = 'gone.md'; action = 'remove'; backup = 'tx2/gone.md' },
|
||||
[ordered]@{ path = 'new.md'; action = 'create'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('N')) },
|
||||
[ordered]@{ path = 'edited.md'; action = 'create'; sha256 = (Get-Sha256HashOfBytes $utf8.GetBytes('E')) },
|
||||
[ordered]@{ path = 'unreached.md'; action = 'update'; backup = 'tx2/unreached.md'; sha256 = ('0' * 64) }
|
||||
) }
|
||||
[System.IO.File]::WriteAllText($journalPath, (ConvertTo-Json -InputObject $journal -Depth 4))
|
||||
$outTxDry = @(Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx-dry' -DryRun)
|
||||
if (@($outTxDry | Where-Object { $_ -eq "DRYRUN ROLLBACK $txDestination tx2" }).Count -ne 1) { throw "Dry run must report the pending rollback: $($outTxDry -join '; ')" }
|
||||
if (-not (Test-Path -LiteralPath $journalPath) -or [System.IO.File]::ReadAllText((Join-Path $txDestination 'keep.md')) -ne 'v2') { throw 'Dry run must not roll back.' }
|
||||
$outTx = @(Sync-ManagedDestination -Source $txSource -Destination $txDestination -Stamp 'tx3')
|
||||
if (@($outTx | Where-Object { $_ -eq "ROLLBACK $txDestination tx2" }).Count -ne 1) { throw "Expected a ROLLBACK line: $($outTx -join '; ')" }
|
||||
if (@($outTx | Where-Object { $_ -match '^WARN .*edited\.md' }).Count -ne 1) { throw "A created file changed since the interruption must be reported: $($outTx -join '; ')" }
|
||||
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'keep.md')) -ne 'v1') { throw 'Rollback must restore an updated file from its backup.' }
|
||||
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'gone.md')) -ne 'G') { throw 'Rollback must restore a removed file from its backup.' }
|
||||
if (Test-Path -LiteralPath (Join-Path $txDestination 'new.md')) { throw 'Rollback must delete a file the interrupted run created.' }
|
||||
if ([System.IO.File]::ReadAllText((Join-Path $txDestination 'edited.md')) -ne 'edited by the user') { throw 'Rollback must keep a created file that changed since.' }
|
||||
if (Test-Path -LiteralPath (Join-Path $txDestination 'unreached.md')) { throw 'Rollback must skip entries the interrupted run never reached.' }
|
||||
if (Test-Path -LiteralPath $journalPath) { throw 'Rollback must delete the journal.' }
|
||||
if (@($outTx | Where-Object { $_ -notlike 'UNCHANGED*' -and $_ -notlike 'SOURCE *' -and $_ -notlike 'ROLLBACK *' -and $_ -notlike 'WARN *' }).Count -ne 0) { throw "The sync after a rollback must find the prior state: $($outTx -join '; ')" }
|
||||
|
||||
if ($Summary) { Write-Output 'Tests: PASS | managed install' } else { Write-Output 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration, interrupted-run rollback' }
|
||||
} finally {
|
||||
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -137,6 +137,15 @@ sync_managed_destination "$source_dir" "$destination" concurrency "" "" "" false
|
||||
[ "$(cat "$destination/concurrency.txt")" = 7 ] || { printf 'Standalone concurrency placeholder must be resolved.\n' >&2; exit 1; }
|
||||
[ -z "$(find "$destination" -name '.ai-config-write.*' -print -quit)" ] || { printf 'Atomic writes left a temporary file.\n' >&2; exit 1; }
|
||||
[ ! -e "$destination/.ai-config.lock" ] || { printf 'Sync must release its destination lock.\n' >&2; exit 1; }
|
||||
mkdir -p "$source_dir/skills/semantic-search"
|
||||
printf 'search skill' > "$source_dir/skills/semantic-search/SKILL.md"
|
||||
sync_managed_destination "$source_dir" "$destination" search-off "" "" "" false false true 5 true false > /dev/null
|
||||
[ ! -e "$destination/skills/semantic-search/SKILL.md" ] || { printf 'The semantic-search skill must not be installed while retrieval is disabled.\n' >&2; exit 1; }
|
||||
sync_managed_destination "$source_dir" "$destination" search-on "" "" "" false false true 5 true true > /dev/null
|
||||
[ -f "$destination/skills/semantic-search/SKILL.md" ] || { printf 'The semantic-search skill must be installed while retrieval is enabled.\n' >&2; exit 1; }
|
||||
sync_managed_destination "$source_dir" "$destination" search-off-again "" "" "" false false true 5 true false > /dev/null
|
||||
[ ! -e "$destination/skills/semantic-search/SKILL.md" ] || { printf 'Disabling retrieval must remove the setup-owned semantic-search skill.\n' >&2; exit 1; }
|
||||
rm -r -- "$source_dir/skills/semantic-search"
|
||||
printf 'powershell other-host 1\n' > "$destination/.ai-config.lock"
|
||||
manifest_before=$(cat "$destination/.ai-config-manifest.tsv")
|
||||
if sync_managed_destination "$source_dir" "$destination" locked "" "" "" false > /dev/null 2>&1; then printf 'A held destination lock must block a parallel sync.\n' >&2; exit 1; fi
|
||||
@@ -146,5 +155,44 @@ wait "$exited_pid"
|
||||
printf 'bash %s %s\n' "$(uname -n)" "$exited_pid" > "$destination/.ai-config.lock"
|
||||
sync_managed_destination "$source_dir" "$destination" stale-lock "" "" "" false > /dev/null
|
||||
[ ! -e "$destination/.ai-config.lock" ] || { printf 'A stale lock from an exited process must be replaced and released.\n' >&2; exit 1; }
|
||||
[ ! -e "$destination/.ai-config-transaction.json" ] || { printf 'A completed sync must delete its transaction journal.\n' >&2; exit 1; }
|
||||
! grep -q 'ai-config-transaction' "$destination/.ai-config-manifest.tsv" || { printf 'The transaction journal must never be listed in the manifest.\n' >&2; exit 1; }
|
||||
|
||||
if [ "$summary" = true ]; then printf 'Tests: PASS | managed install\n'; else printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration\n'; fi
|
||||
# Simulate a run with stamp tx2 interrupted after it updated keep.md, removed gone.md, and created new.md and edited.md.
|
||||
tx_source="$work/tx-source"
|
||||
tx_destination="$work/tx-destination"
|
||||
mkdir -p "$tx_source"
|
||||
printf 'v1' > "$tx_source/keep.md"
|
||||
printf 'G' > "$tx_source/gone.md"
|
||||
sync_managed_destination "$tx_source" "$tx_destination" tx1 "" "" "" false > /dev/null
|
||||
mkdir -p "$tx_destination/backups/tx2"
|
||||
cp "$tx_destination/keep.md" "$tx_destination/backups/tx2/keep.md"
|
||||
mv "$tx_destination/gone.md" "$tx_destination/backups/tx2/gone.md"
|
||||
printf 'v2' > "$tx_destination/keep.md"
|
||||
printf 'N' > "$tx_destination/new.md"
|
||||
printf 'edited by the user' > "$tx_destination/edited.md"
|
||||
journal="$tx_destination/.ai-config-transaction.json"
|
||||
cat > "$journal" <<EOF
|
||||
{"stamp": "tx2", "files": [
|
||||
{"path": "keep.md", "action": "update", "backup": "tx2/keep.md", "sha256": "$(sha256_of_string v2)"},
|
||||
{"path": "gone.md", "action": "remove", "backup": "tx2/gone.md"},
|
||||
{"path": "new.md", "action": "create", "sha256": "$(sha256_of_string N)"},
|
||||
{"path": "edited.md", "action": "create", "sha256": "$(sha256_of_string E)"},
|
||||
{"path": "unreached.md", "action": "update", "backup": "tx2/unreached.md", "sha256": "$(printf '0%.0s' {1..64})"}
|
||||
]}
|
||||
EOF
|
||||
out_tx_dry=$(sync_managed_destination "$tx_source" "$tx_destination" tx-dry "" "" "" true)
|
||||
printf '%s\n' "$out_tx_dry" | grep -qx "DRYRUN ROLLBACK $tx_destination tx2" || { printf 'Dry run must report the pending rollback:\n%s\n' "$out_tx_dry" >&2; exit 1; }
|
||||
[ -f "$journal" ] && [ "$(cat "$tx_destination/keep.md")" = v2 ] || { printf 'Dry run must not roll back.\n' >&2; exit 1; }
|
||||
out_tx=$(sync_managed_destination "$tx_source" "$tx_destination" tx3 "" "" "" false)
|
||||
printf '%s\n' "$out_tx" | grep -qx "ROLLBACK $tx_destination tx2" || { printf 'Expected a ROLLBACK line:\n%s\n' "$out_tx" >&2; exit 1; }
|
||||
[ "$(printf '%s\n' "$out_tx" | grep -c '^WARN .*edited\.md')" -eq 1 ] || { printf 'A created file changed since the interruption must be reported:\n%s\n' "$out_tx" >&2; exit 1; }
|
||||
[ "$(cat "$tx_destination/keep.md")" = v1 ] || { printf 'Rollback must restore an updated file from its backup.\n' >&2; exit 1; }
|
||||
[ "$(cat "$tx_destination/gone.md")" = G ] || { printf 'Rollback must restore a removed file from its backup.\n' >&2; exit 1; }
|
||||
[ ! -e "$tx_destination/new.md" ] || { printf 'Rollback must delete a file the interrupted run created.\n' >&2; exit 1; }
|
||||
[ "$(cat "$tx_destination/edited.md")" = 'edited by the user' ] || { printf 'Rollback must keep a created file that changed since.\n' >&2; exit 1; }
|
||||
[ ! -e "$tx_destination/unreached.md" ] || { printf 'Rollback must skip entries the interrupted run never reached.\n' >&2; exit 1; }
|
||||
[ ! -e "$journal" ] || { printf 'Rollback must delete the journal.\n' >&2; exit 1; }
|
||||
[ -z "$(printf '%s\n' "$out_tx" | grep -Ev '^(UNCHANGED|SOURCE|ROLLBACK|WARN) ')" ] || { printf 'The sync after a rollback must find the prior state:\n%s\n' "$out_tx" >&2; exit 1; }
|
||||
|
||||
if [ "$summary" = true ]; then printf 'Tests: PASS | managed install\n'; else printf 'PASS managed manifest: idempotent, stale removal, modified-file protection, foreign files preserved, dry run side-effect free, skill migration, interrupted-run rollback\n'; fi
|
||||
@@ -117,6 +117,40 @@ class RetrievalServerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(MODULE.runtime_python(script, 'C:/Python/python.exe', 'nt'), runtime.resolve())
|
||||
|
||||
def test_windows_runtime_handoff_keeps_multi_word_arguments(self):
|
||||
calls = []
|
||||
with patch.object(MODULE, 'runtime_python', return_value=Path('C:/runtime/python.exe')), patch.object(MODULE.sys, 'argv', ['server.py', 'search', '--query', 'two words']), patch.object(MODULE.subprocess, 'run', side_effect=lambda command: calls.append(command) or SimpleNamespace(returncode=3)), self.assertRaises(SystemExit) as stopped:
|
||||
MODULE.use_runtime_python('nt')
|
||||
self.assertEqual(stopped.exception.code, 3)
|
||||
self.assertEqual(calls[0][2:], ['search', '--query', 'two words'])
|
||||
|
||||
def test_daemon_identity_changes_with_code_version(self):
|
||||
with TemporaryDirectory() as temporary:
|
||||
data_dir = Path(temporary)
|
||||
with patch.object(MODULE, 'code_version', return_value='aaaaaaaaaaaa'):
|
||||
old_address, old_lock = MODULE.daemon_address(data_dir), MODULE.daemon_lock_path(data_dir)
|
||||
with patch.object(MODULE, 'code_version', return_value='bbbbbbbbbbbb'):
|
||||
self.assertNotEqual(MODULE.daemon_address(data_dir), old_address)
|
||||
self.assertNotEqual(MODULE.daemon_lock_path(data_dir), old_lock)
|
||||
|
||||
def test_run_direct_runs_in_process_when_pipes_are_forbidden(self):
|
||||
def action(action, root, data_dir, model_cache, query, top_k, output):
|
||||
output.put((True, [query, top_k]))
|
||||
|
||||
with patch.object(MODULE, 'pipes_available', return_value=False), patch.object(MODULE, 'run_action', action):
|
||||
self.assertEqual(MODULE.run_direct('search', Path('.'), Path('.'), Path('.'), 'two words', 3), ['two words', 3])
|
||||
|
||||
def test_model_kwargs_use_bfloat16_only_on_supporting_gpus(self):
|
||||
bfloat16 = object()
|
||||
for available, supported, expected in [(True, True, {'dtype': bfloat16}), (True, False, {}), (False, True, {})]:
|
||||
cuda = SimpleNamespace(is_available=lambda available=available: available, is_bf16_supported=lambda supported=supported: supported)
|
||||
with patch.dict('sys.modules', torch=SimpleNamespace(cuda=cuda, bfloat16=bfloat16)):
|
||||
self.assertEqual(MODULE.model_kwargs(), expected)
|
||||
|
||||
def test_query_prompt_matches_qwen_format(self):
|
||||
self.assertTrue(MODULE.QUERY_PROMPT.startswith('Instruct: '))
|
||||
self.assertTrue(MODULE.QUERY_PROMPT.endswith('\nQuery:'))
|
||||
|
||||
def test_preferred_device_uses_cuda_with_cpu_fallback(self):
|
||||
with patch.dict('sys.modules', torch=SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: True))):
|
||||
self.assertEqual(MODULE.preferred_device(), 'cuda')
|
||||
@@ -330,7 +364,7 @@ class RetrievalServerTests(unittest.TestCase):
|
||||
|
||||
def test_shared_encoder_and_reranker_are_cached_module_singletons(self):
|
||||
with patch.object(MODULE, '_shared_encoder', None), patch.object(MODULE, '_shared_reranker', None), \
|
||||
patch.object(MODULE, 'preferred_device', lambda: 'cpu'):
|
||||
patch.object(MODULE, 'preferred_device', lambda: 'cpu'), patch.object(MODULE, 'model_kwargs', dict):
|
||||
sentinel = SimpleNamespace()
|
||||
with patch.dict('sys.modules', sentence_transformers=SimpleNamespace(
|
||||
SentenceTransformer=lambda *args, **kwargs: sentinel,
|
||||
@@ -395,6 +429,9 @@ class RetrievalServerTests(unittest.TestCase):
|
||||
('service.ts', 'export function load() {\n return 1;\n}\nexport const save = () => 2;\n', ['load', 'save']),
|
||||
('service.cs', 'public class Service\n{\n public void Load()\n {\n }\n}\n', ['Service', 'Load']),
|
||||
('setup.ps1', 'function Get-Result {\n return 1\n}\n', ['Get-Result']),
|
||||
('build.sh', 'quote() {\n echo "$1"\n}\nfor shell in a b; do\n quote "$shell"\ndone\n', ['quote', '<module>']),
|
||||
('schema.sql', 'CREATE TABLE users (id int);\nCREATE OR ALTER PROCEDURE dbo.GetUser AS SELECT 1;\n', ['users', 'dbo.GetUser']),
|
||||
('Main.kt', 'fun main() {\n println(1)\n}\n', ['main']),
|
||||
('config.toml', '[server]\nport = 1\n[client]\nport = 2\n', ['server', 'client']),
|
||||
]:
|
||||
self.assertEqual([symbol for symbol, _ in MODULE.sections(source, path)], symbols)
|
||||
|
||||
+59
-13
@@ -11,6 +11,7 @@ import multiprocessing
|
||||
from multiprocessing import AuthenticationError
|
||||
from multiprocessing.connection import Client, Listener
|
||||
import os
|
||||
import queue
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -34,9 +35,13 @@ RERANK_CANDIDATES = 50
|
||||
FINAL_RESULTS = 5
|
||||
CLI_TIMEOUT_SECONDS = 300
|
||||
RRF_K = 60
|
||||
INDEX_VERSION = 2
|
||||
INDEX_VERSION = 3
|
||||
RETRIEVAL_INSTRUCTION = "Given a codebase question, retrieve relevant code and documentation that answer the question."
|
||||
TEXT_EXTENSIONS = {".c", ".cpp", ".cs", ".go", ".java", ".js", ".json", ".md", ".py", ".ps1", ".rs", ".sh", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml"}
|
||||
# Qwen3-Embedding query format from the model card: no space after "Query:".
|
||||
QUERY_PROMPT = f"Instruct: {RETRIEVAL_INSTRUCTION}\nQuery:"
|
||||
TEXT_EXTENSIONS = {".c", ".cjs", ".cpp", ".cs", ".csproj", ".css", ".go", ".gradle", ".html", ".java", ".js", ".json", ".jsx", ".kt", ".kts",
|
||||
".md", ".mjs", ".props", ".ps1", ".psm1", ".py", ".razor", ".rs", ".scss", ".sh", ".sql", ".targets", ".toml", ".ts",
|
||||
".tsx", ".txt", ".xaml", ".xml", ".yaml", ".yml"}
|
||||
DEFAULT_MAX_FILES = 2000
|
||||
MAX_FILES_ENV = "SEMANTIC_RETRIEVAL_MAX_FILES"
|
||||
|
||||
@@ -56,10 +61,14 @@ def runtime_python(script=__file__, executable=sys.executable, system=os.name):
|
||||
return None
|
||||
|
||||
|
||||
def use_runtime_python():
|
||||
def use_runtime_python(system=os.name):
|
||||
runtime = runtime_python()
|
||||
if runtime is not None:
|
||||
os.execv(str(runtime), [str(runtime), str(Path(__file__).resolve()), *sys.argv[1:]])
|
||||
command = [str(runtime), str(Path(__file__).resolve()), *sys.argv[1:]]
|
||||
if system == 'nt':
|
||||
# os.execv does not quote arguments on Windows, so a multi-word --query would be split.
|
||||
sys.exit(subprocess.run(command).returncode)
|
||||
os.execv(command[0], command)
|
||||
|
||||
|
||||
def sections(text: str, path: str):
|
||||
@@ -107,10 +116,18 @@ def sections(text: str, path: str):
|
||||
heading = re.match(r"^\s*\[\[?([^\]]+)\]\]?\s*(?:#.*)?$", line)
|
||||
if heading:
|
||||
boundaries[number] = heading[1]
|
||||
elif suffix in {".js", ".ts", ".tsx", ".cs", ".java", ".go", ".rs", ".c", ".cpp", ".ps1", ".sh"}:
|
||||
elif suffix == ".sql":
|
||||
heading = re.match(r"^\s*create\s+(?:or\s+(?:alter|replace)\s+)?(?:procedure|proc|function|view|table|trigger|index)\s+([\w.\[\]\"]+)",
|
||||
line, re.IGNORECASE)
|
||||
if heading:
|
||||
boundaries[number] = heading[1]
|
||||
elif suffix in {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".cs", ".java", ".kt", ".kts", ".go", ".rs", ".c", ".cpp", ".ps1", ".psm1", ".sh"}:
|
||||
# A closing brace in column 0 ends a top-level declaration; code after it belongs to the module again.
|
||||
if line.startswith("}"):
|
||||
boundaries.setdefault(number + 1, "<module>")
|
||||
symbol = re.match(
|
||||
r"^\s*(?:(?:export|default|public|private|protected|internal|static|abstract|async|sealed|partial|pub)\s+)*"
|
||||
r"(?:(?:class|interface|struct|enum|record|function|fn|def)\s+([\w$-]+)"
|
||||
r"(?:(?:class|interface|struct|enum|record|function|fun|fn|def)\s+([\w$-]+)"
|
||||
r"|func\s+(?:\([^)]*\)\s*)?([\w]+)"
|
||||
r"|(?:const|let|var)\s+([\w$]+)\s*=.*=>"
|
||||
r"|([\w$-]+)\s*\([^;]*\)\s*\{"
|
||||
@@ -163,6 +180,12 @@ def preferred_device():
|
||||
return "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
|
||||
def model_kwargs():
|
||||
"""Use bfloat16 on GPUs that support it; CPUs keep float32, where bfloat16 is often slower."""
|
||||
import torch
|
||||
return {"dtype": torch.bfloat16} if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else {}
|
||||
|
||||
|
||||
def read_max_files():
|
||||
value = os.environ.get(MAX_FILES_ENV)
|
||||
if value is None:
|
||||
@@ -187,8 +210,8 @@ def shared_encoder():
|
||||
if _shared_encoder is None:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
_shared_encoder = SentenceTransformer(MODEL, revision=MODEL_REVISION, truncate_dim=EMBEDDING_DIMENSIONS,
|
||||
prompts={"query": f"Instruct: {RETRIEVAL_INSTRUCTION}\nQuery: "},
|
||||
device=preferred_device())
|
||||
prompts={"query": QUERY_PROMPT}, device=preferred_device(),
|
||||
model_kwargs=model_kwargs())
|
||||
return _shared_encoder
|
||||
|
||||
|
||||
@@ -198,7 +221,8 @@ def shared_reranker():
|
||||
if _shared_reranker is None:
|
||||
from sentence_transformers import CrossEncoder
|
||||
_shared_reranker = CrossEncoder(RERANKER_MODEL, revision=RERANKER_MODEL_REVISION, prompts={"query": RETRIEVAL_INSTRUCTION},
|
||||
default_prompt_name="query", device=preferred_device())
|
||||
default_prompt_name="query", device=preferred_device(),
|
||||
model_kwargs=model_kwargs())
|
||||
return _shared_reranker
|
||||
|
||||
|
||||
@@ -222,12 +246,18 @@ def reciprocal_rank_fusion(*rankings):
|
||||
return sorted(scores, key=lambda identifier: (-scores[identifier], identifier))
|
||||
|
||||
|
||||
def code_version() -> str:
|
||||
"""Hash of this file, so a daemon started by older code is never reused after an update."""
|
||||
return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()[:12]
|
||||
|
||||
|
||||
def daemon_lock_path(data_dir: Path) -> Path:
|
||||
return data_dir / "daemon.starting"
|
||||
return data_dir / f"daemon-{code_version()}.starting"
|
||||
|
||||
|
||||
def daemon_address(data_dir: Path) -> str:
|
||||
digest = hashlib.sha256(os.path.normcase(str(data_dir.resolve())).encode()).hexdigest()[:16]
|
||||
identity = os.path.normcase(str(data_dir.resolve())) + "|" + code_version()
|
||||
digest = hashlib.sha256(identity.encode()).hexdigest()[:16]
|
||||
if os.name == "nt":
|
||||
return r"\\.\pipe\ai-config-retrieval-" + digest
|
||||
return str(data_dir / f"daemon-{digest}.sock")
|
||||
@@ -502,7 +532,7 @@ class Index:
|
||||
def cache_key(self, query: str, top_k: int):
|
||||
identity = json.dumps([
|
||||
query, top_k,
|
||||
RERANKER_MODEL, RERANKER_MODEL_REVISION, RETRIEVAL_INSTRUCTION,
|
||||
RERANKER_MODEL, RERANKER_MODEL_REVISION, RETRIEVAL_INSTRUCTION, QUERY_PROMPT,
|
||||
DENSE_CANDIDATES, LEXICAL_CANDIDATES, RERANK_CANDIDATES, FINAL_RESULTS, RRF_K,
|
||||
], ensure_ascii=False, separators=(",", ":"))
|
||||
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
||||
@@ -544,7 +574,23 @@ class Index:
|
||||
return result
|
||||
|
||||
|
||||
def pipes_available() -> bool:
|
||||
"""Sandboxes such as Codex on Windows forbid the pipes that the daemon and worker process need."""
|
||||
try:
|
||||
first, second = multiprocessing.Pipe()
|
||||
except PermissionError:
|
||||
return False
|
||||
first.close()
|
||||
second.close()
|
||||
return True
|
||||
|
||||
|
||||
def run_direct(action: str, root: Path, data_dir: Path, model_cache: Path, query: str | None, top_k: int):
|
||||
if not pipes_available():
|
||||
# ponytail: in-process fallback has no worker timeout; the client's own command timeout still applies.
|
||||
output = queue.Queue(1)
|
||||
run_action(action, root, data_dir, model_cache, query, top_k, output)
|
||||
return output.get()[1]
|
||||
output = multiprocessing.Queue(1)
|
||||
worker = multiprocessing.Process(target=run_action, args=(action, root, data_dir, model_cache, query, top_k, output))
|
||||
worker.start()
|
||||
@@ -585,7 +631,7 @@ def main():
|
||||
parser.error("search requires --query")
|
||||
root = args.root.resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = try_daemon(root, data_dir, model_cache, args.action, args.query, args.top_k)
|
||||
result = try_daemon(root, data_dir, model_cache, args.action, args.query, args.top_k) if pipes_available() else None
|
||||
if result is None:
|
||||
result = run_direct(args.action, root, data_dir, model_cache, args.query, args.top_k)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
@@ -6,7 +6,7 @@ Preserve security, data, compatibility, and unrelated user work. Ask only when b
|
||||
|
||||
Use one applicable workflow per task. Treat skills and plugins as guidance subordinate to these rules. Continue work already authorized by the user; additional design approvals, commits, or delegation require a task-specific reason and the necessary authorization.
|
||||
|
||||
Start repository discovery with direct reads and `rg` for known paths, symbols, exact strings, localized changes, configuration edits, and routine checks. Use the installed semantic retrieval CLI only when terminology is unknown, the question spans multiple components, or direct search did not locate the relevant flow: `python ~/.my-ai-configuration/semantic-retrieval/server.py search --root . --data-dir ~/.my-ai-configuration/semantic-retrieval/data --model-cache ~/.my-ai-configuration/semantic-retrieval/model-cache --query "..." --top-k 5`. Each uncached search uses Qwen3-Embedding-0.6B for candidate retrieval and Qwen3-Reranker-0.6B for final ranking; reuse the cache for repeated unchanged queries and verify results against current files. If Qwen is unavailable, fails, or returns no useful matches, continue with direct search and briefly report the limitation.
|
||||
Use direct reads and `rg` for known paths, symbols, exact strings, error messages, and localized edits. For concept questions where the identifiers are unknown or the flow spans components, use the `semantic-search` skill first when it is installed, verify its hits against the current files, and fall back to `rg` with a brief note if it fails.
|
||||
|
||||
Verify proportionally: run the cheapest check that detects likely failure; broaden for security, data loss, integration, or cross-component risk. Never claim unrun checks passed. Report failures and material gaps.
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: semantic-search
|
||||
description: Use when you must find where a behavior, concept, or rule lives in a repository and do not know its identifiers or exact strings, or when a first rg search returned nothing useful. Returns ranked code and documentation chunks from the local Qwen index.
|
||||
---
|
||||
|
||||
# Semantic Search
|
||||
|
||||
Search the current Git repository by meaning with the locally installed Qwen3 embedding and reranking models.
|
||||
|
||||
## When to use
|
||||
|
||||
- Concept questions: "where are expired trials blocked", "how is the cache invalidated", "which code retries failed uploads".
|
||||
- Unfamiliar repositories where names and conventions are unknown.
|
||||
- After an `rg` search for guessed terms returned nothing relevant or too many unrelated files.
|
||||
|
||||
Use `rg` or direct reads instead for known file paths, symbol names, exact strings, and error messages.
|
||||
|
||||
## Run
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
python "$HOME/.my-ai-configuration/semantic-retrieval/server.py" search --root . --data-dir "$HOME/.my-ai-configuration/semantic-retrieval/data" --model-cache "$HOME/.my-ai-configuration/semantic-retrieval/model-cache" --query "<question in natural language>" --top-k 5
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
python3 "$HOME/.my-ai-configuration/semantic-retrieval/server.py" search --root . --data-dir "$HOME/.my-ai-configuration/semantic-retrieval/data" --model-cache "$HOME/.my-ai-configuration/semantic-retrieval/model-cache" --query "<question in natural language>" --top-k 5
|
||||
```
|
||||
|
||||
The output is a JSON list of `{path, symbol, text, score}`, best match first.
|
||||
|
||||
## Use the results
|
||||
|
||||
1. Phrase the query as a full question about behavior, not as a keyword list.
|
||||
2. Open the top hits and verify them against the current files before acting; the index can lag behind uncommitted edits by one refresh.
|
||||
3. Follow the verified hit with `rg` for its symbols to find callers and tests.
|
||||
|
||||
## Cost and limits
|
||||
|
||||
- The first search in a repository builds its index, which can take minutes; later searches refresh only changed files.
|
||||
- Outside a sandbox a background daemon keeps the models loaded, so warm searches take seconds. Inside the Codex sandbox every search loads the models again and takes about half a minute; batch related questions into one precise query.
|
||||
- Only Git-tracked and unignored files up to 512 KB are indexed, by default at most 2000 files per repository.
|
||||
- If the command fails or the setup is missing, continue with `rg` and state the limitation briefly.
|
||||
Reference in new issue
Block a user