Compare commits

...
26 Commits
Author SHA1 Message Date
Julian lechnerandClaude Opus 5.5 b110b6d6cd docs(scripts): document provenance checks and config effect tests
CI / CI configuration security checks (push) Successful in 5s
CI / Capabilities and shell quality (push) Failing after 15s
CI / Python tests and compilation (push) Successful in 32s
CI / Build and inspect generated packages (push) Skipped
CI / Bash integration and installation guards (push) Skipped
CI / Installed client configuration smoke tests (push) Skipped
CI / PowerShell tests on Ubuntu (push) Successful in 56s
CI / Qwen retrieval integration (push) Successful in 14m43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:13 +02:00
Julian lechnerandClaude Opus 5.5 88f9833455 docs(readme): describe isolated builds and sync rollback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:13 +02:00
Julian lechnerandClaude Opus 5.5 f43a2df14b test(benchmarks): add C#/ASP.NET and TypeScript tasks
Six tasks with hidden checks: ASP.NET validation and 404 handling, C#
cancellation, and TypeScript overlap, deep merge, and debounce fixes, checked
with dotnet run and node --test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:12 +02:00
Julian lechnerandClaude Opus 5.5 97879fd6a5 feat(benchmarks): pin models and verify arm provenance
Pin one model and reasoning effort per client for both arms, disable user and
plugin skills in the Codex baseline home, and record what each arm loads in
provenance.json; stop when the arms are not comparable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:12 +02:00
Julian lechnerandClaude Opus 5.5 9ff0330537 test(config): verify agent limits and permissions take effect
Check that Codex keeps the V1 multi-agent implementation that honors
max_depth, and, with AI_CONFIG_LIVE_TESTS=1, that Claude withholds the Agent
tool from subagents, refuses subagents above the concurrency limit, and
blocks git reset --hard in every shell tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:11 +02:00
Julian lechnerandClaude Opus 5.5 dec926fee3 feat(install): roll back interrupted syncs from a journal
Plan each sync before writing, record the plan in .ai-config-transaction.json,
and roll an interrupted destination back from its backups on the next run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:10 +02:00
Julian lechnerandClaude Opus 5.5 9fc6dc8038 fix(claude): deny destructive commands in both shell tools
Claude offers Bash and PowerShell tools on Windows, but each package denied
git reset --hard, git clean, and recursive deletes only for its own shell. A
live test ran git reset --hard through Bash under the PowerShell package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:10 +02:00
Julian lechnerandClaude Opus 5.5 cceb9e4839 fix(build): build privately and swap generated/ after validation
Build into a per-run sibling directory and replace generated/ only after
validation, with a lock around the swap, so a failed or parallel build never
leaves generated/ partial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:59:10 +02:00
Julian lechnerandClaude Opus 5.5 6d4ac56901 docs(scripts): document the semantic search comparison
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:59 +02:00
Julian lechnerandClaude Opus 5.5 3f554f4351 docs(plugins): describe semantic-search and the Codex sandbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:58 +02:00
Julian lechnerandClaude Opus 5.5 2cf7a25baa docs(skills): list the semantic-search skill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:58 +02:00
Julian lechnerandClaude Opus 5.5 fa593d3b27 feat(benchmarks): accept task name patterns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:57 +02:00
Julian lechnerandClaude Opus 5.5 e1493d9758 feat(benchmarks): compare sessions with and without semantic search
Add the setup-no-search arm for Claude, generated filler modules, an optional
warm index outside the timing, and a searches count per run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:57 +02:00
Julian lechnerandClaude Opus 5.5 00a44f50b0 test(benchmarks): add concept-search tasks
Three tasks whose target code shares no vocabulary with the prompt, run in a
repository with generated distractor modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:56 +02:00
Julian lechnerandClaude Opus 5.5 a8f6f01f2f docs(rules): point concept questions to semantic-search
Replace the restrictive retrieval paragraph with a short rule: rg for known
names, the semantic-search skill first for concept questions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:56 +02:00
Julian lechnerandClaude Opus 5.5 587c27c1cd feat(install): install semantic-search only with retrieval
Skip the semantic-search skill while semantic retrieval is disabled and remove
a setup-owned copy when it is turned off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:55 +02:00
Julian lechnerandClaude Opus 5.5 14e6588d4e feat(skills): add semantic-search skill
Expose the local Qwen search as a skill with an explicit trigger for concept
questions, the command for both shells, result handling, and its limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:55 +02:00
Julian lechnerandClaude Opus 5.5 1ad5800833 feat(codex): allow writes to the retrieval data directory
Add the semantic retrieval data directory to
sandbox_workspace_write.writable_roots so Codex can build and refresh the
index without an approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:54 +02:00
Julian lechnerandClaude Opus 5.5 14b9185e86 feat(retrieval): create the data directory on install
Sandboxed clients may write only to data/ and cannot create it themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:54 +02:00
Julian lechnerandClaude Opus 5.5 9d922cd678 fix(retrieval): run in-process when pipes are forbidden
The Codex sandbox on Windows denies the pipes that the daemon and worker
process use, so searches waited 120 seconds and then failed. Detect that and
run the search in-process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:27:54 +02:00
Julian lechnerandClaude Opus 5.5 739d2f7801 perf(retrieval): use bfloat16 on supporting GPUs
Load both models in bfloat16 when a CUDA GPU supports it and keep float32 on
CPUs. Document the daemon versioning and the indexed file types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:13:07 +02:00
Julian lechnerandClaude Opus 5.5 f134c50d39 fix(retrieval): match the Qwen query prompt format
Drop the trailing space after "Query:" as in the model card and include the
prompt in the query cache key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:13:06 +02:00
Julian lechnerandClaude Opus 5.5 d60dd10c45 fix(retrieval): end brace-language symbols at top-level braces
Code after a top-level closing brace belonged to the preceding function, for
example shell loops labeled as quote_toml. Attribute it to the module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:13:05 +02:00
Julian lechnerandClaude Opus 5.5 e138c0277e feat(retrieval): index markup, style, SQL, and project files
Add HTML, CSS, SCSS, XAML, SQL, XML, project, Razor, Kotlin, Gradle, and
module JavaScript files with SQL and Kotlin symbols, and rebuild the index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:13:04 +02:00
Julian lechnerandClaude Opus 5.5 2354ce2c2c fix(retrieval): version the daemon by code hash
Derive the daemon address and start lock from a hash of server.py, so an
updated server never reuses a daemon still running older code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:13:03 +02:00
Julian lechnerandClaude Opus 5.5 4e4a10604d fix(retrieval): keep multi-word arguments on Windows
os.execv does not quote arguments on Windows, so the hand-off to the virtual
environment split every multi-word --query and search always failed. Run the
runtime with subprocess on Windows instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:13:02 +02:00
73 changed files with 1442 additions and 151 deletions

No files matched your search

+3
View File
@@ -1,4 +1,7 @@
generated/
generated.build-*/
generated.old-*/
generated.lock
.superpowers/
*.log
*.tmp
+5 -1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
@@ -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)
@@ -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.
@@ -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"
}
}
+42 -8
View File
@@ -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
+41 -4
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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:
+18 -2
View File
@@ -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
+13 -2
View File
@@ -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
+148
View File
@@ -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()
+53 -1
View File
@@ -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
}
+49 -1
View File
@@ -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
+38 -1
View File
@@ -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
View File
@@ -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))
+1 -1
View File
@@ -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.