Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
381f99ea54 | ||
|
|
8ab180c73a | ||
|
|
83401a4732 | ||
|
|
4f6ac68e0c | ||
|
|
f07aaf8f63 | ||
|
|
1b64c54fda | ||
|
|
13594fb401 | ||
|
|
7572ec40d9 | ||
|
|
79d981e78b | ||
|
|
b67eb844bc | ||
|
|
aaf3a183f4 | ||
|
|
27f1c1ec79 | ||
|
|
bfbe001248 | ||
|
|
151306a6cc | ||
|
|
0079fe2b92 | ||
|
|
5c23f21586 | ||
|
|
bdf98d5b71 | ||
|
|
4fa8708ba7 | ||
|
|
be87628d67 | ||
|
|
8ae8e7a173 | ||
|
|
ea2c80817b | ||
|
|
3fad860256 | ||
|
|
2c3e63ed29 | ||
|
|
1d77feeb71 | ||
|
|
5ef3bc5525 |
No files matched your search
+2
-2
@@ -7,7 +7,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f 'C:\\Users\\j.lechner\\.agents\\skills\\impeccable\\scripts\\impeccable' ] || 'C:\\Users\\j.lechner\\.agents\\skills\\impeccable\\scripts\\impeccable' hook",
|
||||
"commandWindows": "if exist \"C:\\\\Users\\\\j.lechner\\\\.agents\\\\skills\\\\impeccable\\\\scripts\\\\impeccable.cmd\" (\"C:\\\\Users\\\\j.lechner\\\\.agents\\\\skills\\\\impeccable\\\\scripts\\\\impeccable.cmd\" hook & exit /b)",
|
||||
"commandWindows": "powershell -NoProfile -NonInteractive -EncodedCommand JABQAHIAbwBnAHIAZQBzAHMAUAByAGUAZgBlAHIAZQBuAGMAZQAgAD0AIAAnAFMAaQBsAGUAbgB0AGwAeQBDAG8AbgB0AGkAbgB1AGUAJwA7ACAAaQBmACAAKABUAGUAcwB0AC0AUABhAHQAaAAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJwBDADoALwBVAHMAZQByAHMALwBqAC4AbABlAGMAaABuAGUAcgAvAC4AYQBnAGUAbgB0AHMALwBzAGsAaQBsAGwAcwAvAGkAbQBwAGUAYwBjAGEAYgBsAGUALwBzAGMAcgBpAHAAdABzAC8AaQBtAHAAZQBjAGMAYQBiAGwAZQAuAGMAbQBkACcAKQAgAHsAIAAmACAAJwBDADoALwBVAHMAZQByAHMALwBqAC4AbABlAGMAaABuAGUAcgAvAC4AYQBnAGUAbgB0AHMALwBzAGsAaQBsAGwAcwAvAGkAbQBwAGUAYwBjAGEAYgBsAGUALwBzAGMAcgBpAHAAdABzAC8AaQBtAHAAZQBjAGMAYQBiAGwAZQAuAGMAbQBkACcAIABoAG8AbwBrADsAIABlAHgAaQB0ACAAJABMAEEAUwBUAEUAWABJAFQAQwBPAEQARQAgAH0A",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f 'C:\\Users\\j.lechner\\.agents\\skills\\impeccable\\scripts\\impeccable' ] || 'C:\\Users\\j.lechner\\.agents\\skills\\impeccable\\scripts\\impeccable' hook",
|
||||
"commandWindows": "if exist \"C:\\\\Users\\\\j.lechner\\\\.agents\\\\skills\\\\impeccable\\\\scripts\\\\impeccable.cmd\" (\"C:\\\\Users\\\\j.lechner\\\\.agents\\\\skills\\\\impeccable\\\\scripts\\\\impeccable.cmd\" hook & exit /b)",
|
||||
"commandWindows": "powershell -NoProfile -NonInteractive -EncodedCommand JABQAHIAbwBnAHIAZQBzAHMAUAByAGUAZgBlAHIAZQBuAGMAZQAgAD0AIAAnAFMAaQBsAGUAbgB0AGwAeQBDAG8AbgB0AGkAbgB1AGUAJwA7ACAAaQBmACAAKABUAGUAcwB0AC0AUABhAHQAaAAgAC0ATABpAHQAZQByAGEAbABQAGEAdABoACAAJwBDADoALwBVAHMAZQByAHMALwBqAC4AbABlAGMAaABuAGUAcgAvAC4AYQBnAGUAbgB0AHMALwBzAGsAaQBsAGwAcwAvAGkAbQBwAGUAYwBjAGEAYgBsAGUALwBzAGMAcgBpAHAAdABzAC8AaQBtAHAAZQBjAGMAYQBiAGwAZQAuAGMAbQBkACcAKQAgAHsAIAAmACAAJwBDADoALwBVAHMAZQByAHMALwBqAC4AbABlAGMAaABuAGUAcgAvAC4AYQBnAGUAbgB0AHMALwBzAGsAaQBsAGwAcwAvAGkAbQBwAGUAYwBjAGEAYgBsAGUALwBzAGMAcgBpAHAAdABzAC8AaQBtAHAAZQBjAGMAYQBiAGwAZQAuAGMAbQBkACcAIABoAG8AbwBrADsAIABlAHgAaQB0ACAAJABMAEEAUwBUAEUAWABJAFQAQwBPAEQARQAgAH0A",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Design deep pass"
|
||||
}
|
||||
|
||||
+54
-50
@@ -12,20 +12,47 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Install current Codex CLI for schema validation
|
||||
run: npm install --global @openai/codex@latest
|
||||
- name: Check Linux tooling
|
||||
run: |
|
||||
command -v pwsh >/dev/null || { echo 'pwsh is required on the Linux runner'; exit 1; }
|
||||
- name: Bash syntax
|
||||
run: find scripts shared/hooks -name '*.sh' -print0 | xargs -0 -n1 bash -n
|
||||
- name: Capability checks
|
||||
run: ./scripts/check-capabilities.sh
|
||||
- name: Build
|
||||
run: REQUIRE_CODEX_SCHEMA=true ./scripts/build.sh --summary
|
||||
run: ./scripts/build.sh --summary
|
||||
- name: Prompt budget
|
||||
run: ./scripts/prompt-budget.sh --summary
|
||||
- name: Repository context cache
|
||||
run: ./scripts/repository-context.sh --summary
|
||||
- name: Doctor
|
||||
run: ./scripts/doctor.sh --summary
|
||||
- name: Platform package tests
|
||||
run: ./scripts/test-platform-packages.sh --summary
|
||||
- name: PowerShell syntax
|
||||
run: |
|
||||
find scripts shared/hooks -name '*.ps1' -print0 | xargs -0 -n1 pwsh -NoProfile -Command '$tokens=$null; $errors=$null; [System.Management.Automation.Language.Parser]::ParseFile($args[0],[ref]$tokens,[ref]$errors) | Out-Null; if ($errors.Count) { $errors | % { Write-Error $_ }; exit 1 }'
|
||||
- name: PowerShell build and validation
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:REQUIRE_CODEX_SCHEMA = 'true'
|
||||
./scripts/check-capabilities.ps1
|
||||
./scripts/build.ps1 -Summary
|
||||
./scripts/doctor.ps1 -Summary
|
||||
./scripts/test-shell-packages.ps1 -Summary
|
||||
./scripts/test-managed-install.ps1 -Summary
|
||||
./scripts/test-build-validation.ps1 -Summary
|
||||
./scripts/test-install-guards.ps1 -Summary
|
||||
./scripts/test-plugin-selection.ps1 -Summary
|
||||
./scripts/test-quick-update.ps1 -Summary
|
||||
python ./scripts/test-install-options.py
|
||||
python ./scripts/test-quick-update.py
|
||||
python ./scripts/test-managed-extensions.py
|
||||
python ./scripts/test-session-state.py
|
||||
python ./scripts/test-telemetry.py
|
||||
python ./scripts/test-semantic-retrieval.py
|
||||
./scripts/install.ps1 -Summary -DryRun -Client Both -Shell PowerShell
|
||||
./scripts/update.ps1 -Summary -DryRun -Client Both -Shell PowerShell
|
||||
- name: Shell package tests
|
||||
run: ./scripts/test-shell-packages.sh --summary
|
||||
- name: Managed manifest tests
|
||||
run: ./scripts/test-managed-install.sh --summary
|
||||
- name: Build validation tests
|
||||
@@ -34,6 +61,16 @@ jobs:
|
||||
run: ./scripts/test-install-guards.sh --summary
|
||||
- name: Plugin selection tests
|
||||
run: ./scripts/test-plugin-selection.sh --summary
|
||||
- name: Quick-update and install-option tests
|
||||
run: |
|
||||
./scripts/test-quick-update.sh --summary
|
||||
python3 scripts/test-install-options.py
|
||||
python3 scripts/test-telemetry.py
|
||||
python3 scripts/test-semantic-retrieval.py
|
||||
- name: Managed extension and session-state tests
|
||||
run: |
|
||||
python3 scripts/test-managed-extensions.py
|
||||
python3 scripts/test-session-state.py
|
||||
- name: Install dry run
|
||||
run: |
|
||||
export HOME="$RUNNER_TEMP/isolated-home"
|
||||
@@ -45,48 +82,15 @@ jobs:
|
||||
mkdir -p "$HOME"
|
||||
./scripts/update.sh --summary --dry-run --client both --platform linux
|
||||
|
||||
# windows:
|
||||
# runs-on: windows-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
# - name: Install current Codex CLI for schema validation
|
||||
# shell: powershell
|
||||
# run: npm install --global @openai/codex@latest
|
||||
# - name: PowerShell syntax
|
||||
# shell: powershell
|
||||
# run: |
|
||||
# $errors = @()
|
||||
# Get-ChildItem scripts,shared/hooks -Recurse -Filter *.ps1 | ForEach-Object {
|
||||
# $tokens = $null
|
||||
# $parseErrors = $null
|
||||
# [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$tokens, [ref]$parseErrors)
|
||||
# $errors += $parseErrors
|
||||
# }
|
||||
# if ($errors.Count) { $errors | ForEach-Object { Write-Error $_ }; exit 1 }
|
||||
# - name: Build
|
||||
# shell: powershell
|
||||
# run: $env:REQUIRE_CODEX_SCHEMA = 'true'; .\scripts\build.ps1
|
||||
# - name: Doctor
|
||||
# shell: powershell
|
||||
# run: .\scripts\doctor.ps1
|
||||
# - name: Platform package tests
|
||||
# shell: powershell
|
||||
# run: .\scripts\test-platform-packages.ps1
|
||||
# - name: Managed manifest tests
|
||||
# shell: powershell
|
||||
# run: .\scripts\test-managed-install.ps1
|
||||
# - name: Build validation tests
|
||||
# shell: powershell
|
||||
# run: .\scripts\test-build-validation.ps1
|
||||
# - name: Install guard tests
|
||||
# shell: powershell
|
||||
# run: .\scripts\test-install-guards.ps1
|
||||
# - name: Plugin selection tests
|
||||
# shell: powershell
|
||||
# run: .\scripts\test-plugin-selection.ps1
|
||||
# - name: Install dry run
|
||||
# shell: powershell
|
||||
# run: .\scripts\install.ps1 -DryRun -Client Both -Platform Windows
|
||||
# - name: Update dry run
|
||||
# shell: powershell
|
||||
# run: .\scripts\update.ps1 -DryRun -Client Both -Platform Windows
|
||||
compatibility-canary:
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- name: Latest external clients
|
||||
run: npm install --global @openai/codex@latest
|
||||
- name: Latest-client schema and dry runs
|
||||
run: |
|
||||
REQUIRE_CODEX_SCHEMA=true ./scripts/build.sh --summary
|
||||
HOME="$RUNNER_TEMP/canary-home" ./scripts/install.sh --summary --dry-run --client both --platform linux
|
||||
HOME="$RUNNER_TEMP/canary-home" ./scripts/update.sh --summary --dry-run --client both --platform linux
|
||||
@@ -11,15 +11,32 @@
|
||||
It keeps rules, skills, agents, hooks, and orchestration guidance in shared source files. Client adapters turn those files
|
||||
into shell-specific packages that can be inspected before they are installed.
|
||||
|
||||
## Capability check
|
||||
|
||||
Check the selected shell's minimum version and required Python and `jq` tools before setup. The command exits with an error when a requirement is missing.
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
.\scripts\check-capabilities.ps1
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
./scripts/check-capabilities.sh
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
From an existing checkout, with the [requirements](#requirements) installed, run pull, build, then install.
|
||||
With the [requirements](#requirements) installed, clone the repository, build it, then install it.
|
||||
Stop if any command fails. Installation asks for the shell, client, CLI update, Flashbang, and extension choices.
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
git pull --ff-only
|
||||
git clone https://git.lechner-systems.at/fraujulian/MyAiConfiguration.git
|
||||
cd MyAiConfiguration
|
||||
.\scripts\build.ps1 -Summary
|
||||
.\scripts\install.ps1 -Summary
|
||||
```
|
||||
@@ -27,7 +44,8 @@ git pull --ff-only
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
git pull --ff-only
|
||||
git clone https://git.lechner-systems.at/fraujulian/MyAiConfiguration.git
|
||||
cd MyAiConfiguration
|
||||
./scripts/build.sh --summary
|
||||
./scripts/install.sh --summary
|
||||
```
|
||||
@@ -92,9 +110,11 @@ Install and update also rebuild internally; the explicit build above lets you ch
|
||||
- Finish notifications, Claude session-state hooks, optional safety utilities, and compact status displays.
|
||||
- Separate install and update scripts: install refuses to run against an already-installed destination, update refuses
|
||||
to run against one that is not installed yet.
|
||||
- User-level plugin installation for Ponytail, i-have-adhd, Superpowers, Context7, Caveman, Humanizer, Impeccable, and
|
||||
- User-level plugin installation for Ponytail, i-have-adhd, Superpowers, Context7, Caveman, QMD, Humanizer, Impeccable, and
|
||||
Anthropic Frontend Design, with an interactive extension toggle during both install and update. Only extensions
|
||||
installed and recorded by this setup can be removed through deselection.
|
||||
integrations installed and recorded by this setup can be removed through deselection.
|
||||
- Optional local semantic retrieval for both clients using Qwen3-Embedding-0.6B, disabled by default and removable
|
||||
through the install/update choice.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -125,10 +145,9 @@ generated/
|
||||
- PowerShell 5.1 or newer; PowerShell 7 is supported as well.
|
||||
- Bash 4.3 or newer, with standard utilities such as `awk`, `sed`, and `sha256sum`.
|
||||
- Python 3.11 or newer for configuration validation and task-state hooks/helpers (`python` for PowerShell scripts, `python3` for Bash scripts).
|
||||
- Git for marketplace installation and branch display. External Codex skills are downloaded from their manifest sources.
|
||||
- `jq` for Claude's Bash status line; without it, that status line produces no output.
|
||||
- The Codex and Claude Code CLIs for the clients that should be installed or diagnosed.
|
||||
- Network access for plugin installation and updates.
|
||||
- Python `venv` and `pip` when the optional semantic retrieval layer is enabled.
|
||||
- Node.js 22 or newer and `npm` when QMD is selected.
|
||||
|
||||
The Bash scripts do not require PowerShell. The PowerShell scripts remain compatible with PowerShell 5.1;
|
||||
PowerShell 7 can also run them.
|
||||
@@ -192,13 +211,14 @@ and replaced, with a warning. Untracked files outside package target paths are l
|
||||
|
||||
Previously managed files no longer shipped are backed up and removed if unchanged. If locally modified, they are
|
||||
backed up and retained with a warning. Backups are stored under `backups/<timestamp>/` in each destination.
|
||||
Local Codex `plugins` and `marketplaces` tables are preserved when syncing `config.toml`; other local settings are
|
||||
not generally merged. Dry runs still rebuild `generated/`, but do not modify installation destinations.
|
||||
Local Codex `model`, `reasoning_effort`, `plugins`, and `marketplaces` settings are preserved when syncing `config.toml`;
|
||||
other local settings are not generally merged. 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
|
||||
Codex skills; unchecked extensions are skipped on installation. Normal runs also ask whether to update
|
||||
the selected client CLIs and enable Flashbang. These choices are saved for Quickupdate. Dry runs preview changes
|
||||
without updating CLIs or extensions. See [Plugins](docs/plugins.md) for client-specific behavior.
|
||||
the selected client CLIs, enable Flashbang, and enable Qwen3 semantic retrieval. These choices are saved for Quickupdate.
|
||||
Disabling retrieval on a later update removes its setup-owned runtime, index, and MCP registrations. Dry runs preview
|
||||
changes without updating CLIs, extensions, or retrieval. See [Plugins](docs/plugins.md) for client-specific behavior.
|
||||
|
||||
## Update
|
||||
|
||||
@@ -267,8 +287,9 @@ and uncertainty. A small change may need only focused inspection; builds, tests,
|
||||
automatic. Explicit user and project requirements still apply.
|
||||
|
||||
Both status displays include model, effort, repository, branch, context-window size, context used, and tokens used.
|
||||
Claude uses two colored lines, with context usage turning yellow at 60% and red at 85%. Codex uses its native status
|
||||
items and rendering. See [Configuration](docs/configuration.md) for details and limitations.
|
||||
Codex also shows its active permission profile and approval mode. Claude uses two colored lines, with context usage
|
||||
turning yellow at 60% and red at 85%; its status-line input does not expose the active permission mode. See
|
||||
[Configuration](docs/configuration.md) for details and limitations.
|
||||
|
||||
## Extending the Configuration
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
role tools
|
||||
architect Read,Search,WebFetch
|
||||
researcher Read,Search,WebFetch
|
||||
reviewer Read,Diff,Search
|
||||
verifier Read,Shell
|
||||
implementer Read,Write,Shell,Tests
|
||||
|
@@ -3,7 +3,7 @@ approvals_reviewer = "auto_review"
|
||||
sandbox_mode = "workspace-write"
|
||||
|
||||
[tui]
|
||||
status_line = ["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"]
|
||||
status_line = ["model", "reasoning", "permissions", "approval-mode", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"]
|
||||
|
||||
[agents]
|
||||
enabled = true
|
||||
|
||||
@@ -4,6 +4,7 @@ i-have-adhd ayghri/i-have-adhd i-have-adhd@i-have-adhd ayghri/i-have-adhd i-have
|
||||
Superpowers - superpowers@claude-plugins-official openai-curated-remote superpowers@openai-curated-remote plugin - -
|
||||
Context7 upstash/context7 context7@context7-marketplace upstash/context7 context7@context7-marketplace plugin - -
|
||||
Caveman JuliusBrussee/caveman caveman@caveman thinkhome-org/caveman-codex caveman@thinkhome-caveman plugin - -
|
||||
QMD tobi/qmd qmd@qmd - - qmd @tobilu/qmd qmd
|
||||
Humanizer blader/humanizer humanizer@humanizer - - skill blader/humanizer humanizer
|
||||
Impeccable pbakaus/impeccable impeccable@skill pbakaus/impeccable impeccable skill pbakaus/impeccable impeccable
|
||||
Impeccable pbakaus/impeccable impeccable@impeccable pbakaus/impeccable impeccable skill pbakaus/impeccable impeccable
|
||||
Anthropic Frontend Design anthropics/claude-plugins-official frontend-design@claude-plugins-official - - skill anthropics/skills frontend-design
|
||||
|
@@ -1 +1 @@
|
||||
{"Claude permanent context":5030,"Codex permanent context":4828}
|
||||
{"Claude permanent context":5030,"Codex permanent context":4828,"Claude permanent context absolute limit":6500,"Codex permanent context absolute limit":6500}
|
||||
@@ -1,2 +1,2 @@
|
||||
Claude 5030
|
||||
Codex 4828
|
||||
Claude 5030 6500
|
||||
Codex 4828 6500
|
||||
|
@@ -16,7 +16,7 @@ Codex sets `approval_policy = "on-request"`, `approvals_reviewer = "auto_review"
|
||||
|
||||
## Status displays
|
||||
|
||||
Codex's native `tui.status_line` contains `model`, `reasoning`, `project-name`, `git-branch`, `context-window-size`, `context-used`, and `used-tokens`, in that order. Rendering and colors are controlled by Codex; the adapter does not define custom colors per field.
|
||||
Codex's native `tui.status_line` contains `model`, `reasoning`, `permissions`, `approval-mode`, `project-name`, `git-branch`, `context-window-size`, `context-used`, and `used-tokens`, in that order. `permissions` reports the active permission profile or sandbox summary, and `approval-mode` reports the active command approval mode. Rendering and colors are controlled by Codex; the adapter does not define custom colors per field.
|
||||
|
||||
Claude runs `shared/statusline/statusline.ps1` for PowerShell or `statusline.sh` for Bash. Its two-line display is:
|
||||
|
||||
@@ -29,10 +29,12 @@ Model and context-window size are cyan, effort and cumulative tokens magenta, re
|
||||
|
||||
`Ctx` is the context-window capacity; `Used` is the reported percentage occupied. `Tokens` adds the session's reported total input and output tokens, not just the current context. Counts use `k` and `M` suffixes. Missing model, effort, context size, or percentage is shown as `-`; missing token totals start at zero. Repository names fall back to the Git root or current directory; unavailable branches show `-`.
|
||||
|
||||
Claude Code reruns its status-line command when the permission mode changes, but does not pass that mode in the status-line JSON. Its line therefore does not show a configured default as though it were the active mode.
|
||||
|
||||
## Verification and installation
|
||||
|
||||
The agent chooses the smallest sufficient checks based on behavior, affected callers, risk, reversibility, and uncertainty. Inspection can suffice for a small change. Full builds, broad test suites, and independent reviews are not automatic; explicit user and project requirements remain binding.
|
||||
|
||||
Delegation and long-running state use the shared orchestration rule, loaded on demand as a Codex rule or Claude skill. The coordinator owns decisions and routes concise evidence-based handoffs. Review and execution verification have separate responsibilities; see [Agents](agents.md).
|
||||
|
||||
Rebuild packages after changes to shared definitions or adapters. Generated defaults reach a user's configuration only through installation or update. Updates back up replaced files and preserve Codex's local `plugins` and `marketplaces` tables; other local settings are not generally merged. See [the README](../README.md) for overwrite and backup behavior.
|
||||
Rebuild packages after changes to shared definitions or adapters. Generated defaults reach a user's configuration only through installation or update. Updates back up replaced files and preserve Codex's local `model`, `reasoning_effort`, `plugins`, and `marketplaces` settings; other local settings are not generally merged. See [the README](../README.md) for overwrite and backup behavior.
|
||||
+5
-3
@@ -1,8 +1,8 @@
|
||||
# Plugins
|
||||
|
||||
The user-level plugin manifest is `adapters/plugins.tsv`. It maps each logical plugin to the marketplace selector understood by Claude Code and Codex.
|
||||
The user-level plugin manifest is `adapters/plugins.tsv`. It maps each logical extension to the client-specific plugin, skill, or MCP installation method.
|
||||
|
||||
The current baseline includes Ponytail, i-have-adhd, Superpowers, Context7, Caveman, Humanizer, Impeccable, and Anthropic Frontend Design. Both scripts operate at user scope and show a plugin selector unless running a dry run. Selections are collected before configuration changes. Both rebuild generated packages first.
|
||||
The current baseline includes Ponytail, i-have-adhd, Superpowers, Context7, Caveman, QMD, Humanizer, Impeccable, and Anthropic Frontend Design. Both scripts operate at user scope and show a plugin selector unless running a dry run. Selections are collected before configuration changes. Both rebuild generated packages first.
|
||||
|
||||
`install.ps1`/`install.sh` starts every plugin checked by default; unchecking one skips it. It refuses to run against an already-installed destination (`Already installed, use the update script.`).
|
||||
|
||||
@@ -12,7 +12,9 @@ Codex's local `plugins` and `marketplaces` tables survive configuration synchron
|
||||
|
||||
Codex receives Caveman through its native plugin adapter. Humanizer, Impeccable, and Anthropic Frontend Design are downloaded as skill payloads from the GitHub sources in the manifest, without running upstream installers. All three appear in the extension selector for Codex. Ownership is recorded in `~/.my-ai-configuration/extensions.json`. Skill files are tracked by hash; modified or foreign files are retained with a warning. Existing installations from before ownership tracking are not automatically adopted.
|
||||
|
||||
Normal installation and update also ask whether to update the selected client CLIs and enable Flashbang. Quickupdate reuses the saved shell, client, CLI update, Flashbang, and extension choices without opening menus. Only successful runs save selections; dry runs do not update CLIs or extensions.
|
||||
[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 registers `qmd mcp` as its MCP server. Deselecting QMD removes the client integration owned by this setup, but leaves the global `qmd` executable installed.
|
||||
|
||||
Normal installation and update also ask whether to update the selected client CLIs, enable Flashbang, and enable the optional Qwen3-Embedding-0.6B semantic retrieval layer. Retrieval is shared by Codex and Claude through a local MCP server, uses a per-user virtual environment and index, and is disabled by default. Quickupdate reuses the saved shell, client, CLI update, Flashbang, retrieval, and extension choices without opening menus. Disabling retrieval removes its setup-owned runtime, index, and MCP registrations. Only successful runs save selections; dry runs do not update CLIs, extensions, or retrieval.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+20
-20
@@ -6,6 +6,7 @@ $shared = Join-Path $root 'shared'
|
||||
$output = Join-Path $root 'generated'
|
||||
$pluginManifest = Join-Path $root 'adapters/plugins.tsv'
|
||||
$ruleSkillManifest = Join-Path $root 'adapters/claude/rule-skills.tsv'
|
||||
$capabilityManifest = Join-Path $root 'adapters/claude/capabilities.tsv'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $pluginManifest)) { throw "Missing plugin manifest: $pluginManifest" }
|
||||
$pluginEntries = @(Import-Csv -LiteralPath $pluginManifest -Delimiter ([char]9))
|
||||
@@ -20,6 +21,7 @@ foreach ($pluginEntry in $pluginEntries) {
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ruleSkillManifest)) { throw "Missing rule-skill manifest: $ruleSkillManifest" }
|
||||
if (-not (Test-Path -LiteralPath $capabilityManifest)) { throw "Missing capability manifest: $capabilityManifest" }
|
||||
$ruleSkillEntries = @(Import-Csv -LiteralPath $ruleSkillManifest -Delimiter ([char]9) | Sort-Object skill_name)
|
||||
if ($ruleSkillEntries.Count -eq 0) { throw 'Rule-skill manifest must define at least one entry.' }
|
||||
$ruleSkillNames = @()
|
||||
@@ -31,6 +33,18 @@ foreach ($entry in $ruleSkillEntries) {
|
||||
if ($ruleSkillNames -contains $entry.skill_name) { throw "Duplicate rule-skill name in manifest: $($entry.skill_name)" }
|
||||
$ruleSkillNames += $entry.skill_name
|
||||
}
|
||||
$agentTools = @{}
|
||||
foreach ($entry in @(Import-Csv -LiteralPath $capabilityManifest -Delimiter ([char]9))) {
|
||||
if ([string]::IsNullOrWhiteSpace($entry.role) -or [string]::IsNullOrWhiteSpace($entry.tools)) { throw 'Invalid capability manifest row.' }
|
||||
$agentTools[$entry.role] = $entry.tools
|
||||
}
|
||||
$codexRuleLoadingLines = @()
|
||||
foreach ($entry in $ruleSkillEntries) {
|
||||
$rulePath = $entry.rule_file
|
||||
if (Test-Path -LiteralPath (Join-Path $shared "rules/$($entry.rule_file)") -PathType Container) { $rulePath = "$($entry.rule_file)/index.md" }
|
||||
$codexRuleLoadingLines += "- rules/$rulePath for $($entry.trigger)."
|
||||
}
|
||||
$codexRuleLoadingList = $codexRuleLoadingLines -join "`r`n"
|
||||
|
||||
Remove-Item $output -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item $output -ItemType Directory -Force | Out-Null
|
||||
@@ -102,26 +116,9 @@ Detect the languages, frameworks, tools, and change areas from the repository an
|
||||
|
||||
Load rule files when their subject applies:
|
||||
|
||||
- rules/security-auth.md for authentication, authorization, sessions, tokens, permissions, or tenant boundaries.
|
||||
- rules/security-web.md for browser, frontend, cookie, redirect, XSS, or CSRF work.
|
||||
- rules/security-api.md for HTTP APIs, request handling, serialization, or endpoints.
|
||||
- rules/security-data.md for databases, persistence, sensitive data, or multi-tenancy.
|
||||
- rules/security-files.md for files, uploads, archives, paths, processes, IPC, or deserialization.
|
||||
- rules/security-network.md for network access, URLs, TLS, proxies, or SSRF.
|
||||
- rules/security-crypto.md for cryptography, secrets, credentials, keys, or tokens.
|
||||
- rules/security-supply-chain.md for dependencies, packages, plugins, builds, deployments, or CI.
|
||||
- rules/angular/index.md for Angular work.
|
||||
- rules/typescript.md for TypeScript work.
|
||||
- rules/csharp.md for C# or .NET work.
|
||||
- rules/wpf/index.md for WPF work.
|
||||
- rules/ui-ux/index.md for UI or UX decisions.
|
||||
- rules/microsoft.md for Microsoft 365, Azure DevOps, or Teams work.
|
||||
- rules/orchestration.md for delegation, subagent work, or long-running task state.
|
||||
- rules/git.md for Git operations.
|
||||
- rules/refactoring.md for refactoring work.
|
||||
- rules/definition-of-done.md when validating completion.
|
||||
- rules/decision-rule.md when requirements are unresolved, behavior is ambiguous, or a meaningful technical choice remains.
|
||||
__CODEX_RULE_LOADING__
|
||||
'@
|
||||
$codexRuleLoading = $codexRuleLoading.Replace('__CODEX_RULE_LOADING__', $codexRuleLoadingList)
|
||||
|
||||
foreach ($shell in @('powershell','bash')) {
|
||||
New-Item (Join-Path $output "codex-$shell") -ItemType Directory -Force | Out-Null
|
||||
@@ -180,7 +177,10 @@ foreach ($shell in @('powershell','bash')) {
|
||||
if ($client -eq 'codex') {
|
||||
Set-Content (Join-Path $agentsOutput "$name.toml") "name = $(Quote-Toml $name)`r`ndescription = $(Quote-Toml $description)`r`ndeveloper_instructions = $(Quote-Toml $instructions)" -Encoding UTF8
|
||||
} else {
|
||||
Set-Content (Join-Path $agentsOutput "$name.md") "---`r`nname: $name`r`ndescription: $description`r`n---`r`n`r`n$instructions" -Encoding UTF8
|
||||
$tools = $agentTools[$name]
|
||||
$frontmatter = "---`r`nname: $name`r`ndescription: $description`r`n"
|
||||
if ($tools) { $frontmatter += "tools: $tools`r`n" }
|
||||
Set-Content (Join-Path $agentsOutput "$name.md") ($frontmatter + "---`r`n`r`n$instructions") -Encoding UTF8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-22
@@ -13,6 +13,7 @@ shared="$root/shared"
|
||||
output="$root/generated"
|
||||
plugin_manifest="$root/adapters/plugins.tsv"
|
||||
rule_skill_manifest="$root/adapters/claude/rule-skills.tsv"
|
||||
capability_manifest="$root/adapters/claude/capabilities.tsv"
|
||||
|
||||
[ -f "$plugin_manifest" ] || { printf 'Missing plugin manifest: %s\n' "$plugin_manifest" >&2; exit 1; }
|
||||
declare -A plugin_seen
|
||||
@@ -29,6 +30,7 @@ done < "$plugin_manifest"
|
||||
[ "$plugin_count" -gt 0 ] || { printf 'Plugin manifest must define at least one plugin.\n' >&2; exit 1; }
|
||||
|
||||
[ -f "$rule_skill_manifest" ] || { printf 'Missing rule-skill manifest: %s\n' "$rule_skill_manifest" >&2; exit 1; }
|
||||
[ -f "$capability_manifest" ] || { printf 'Missing capability manifest: %s\n' "$capability_manifest" >&2; exit 1; }
|
||||
declare -A rule_skill_seen
|
||||
rule_skill_files=()
|
||||
rule_skill_names=()
|
||||
@@ -45,8 +47,21 @@ while IFS=$'\t' read -r rule_file skill_name trigger; do
|
||||
rule_skill_files+=("$rule_file")
|
||||
rule_skill_names+=("$skill_name")
|
||||
rule_skill_triggers+=("$trigger")
|
||||
done < "$rule_skill_manifest"
|
||||
done < <(tail -n +2 "$rule_skill_manifest" | sort -t$'\t' -k2,2)
|
||||
[ "${#rule_skill_names[@]}" -gt 0 ] || { printf 'Rule-skill manifest must define at least one entry.\n' >&2; exit 1; }
|
||||
codex_rule_loading_list=""
|
||||
for i in "${!rule_skill_names[@]}"; do
|
||||
rule_file=${rule_skill_files[$i]}
|
||||
rule_path="$rule_file"
|
||||
[ -d "$shared/rules/$rule_file" ] && rule_path="$rule_file/index.md"
|
||||
codex_rule_loading_list="${codex_rule_loading_list}- rules/${rule_path} for ${rule_skill_triggers[$i]}."$'\n'
|
||||
done
|
||||
declare -A agent_tools
|
||||
while IFS=$'\t' read -r role tools; do
|
||||
[ "$role" != role ] || continue
|
||||
[ -n "$role" ] && [ -n "$tools" ] || { printf 'Invalid capability manifest row.\n' >&2; exit 1; }
|
||||
agent_tools[$role]="$tools"
|
||||
done < "$capability_manifest"
|
||||
|
||||
copy_directory() {
|
||||
local source=$1 destination=$2
|
||||
@@ -97,27 +112,8 @@ When programming, always load and apply `rules/security.md`. This includes imple
|
||||
Detect the languages, frameworks, tools, and change areas from the repository and the requested work. Load every applicable rule file before editing. Load all matching files when multiple technologies apply.
|
||||
|
||||
Load rule files when their subject applies:
|
||||
|
||||
- rules/security-auth.md for authentication, authorization, sessions, tokens, permissions, or tenant boundaries.
|
||||
- rules/security-web.md for browser, frontend, cookie, redirect, XSS, or CSRF work.
|
||||
- rules/security-api.md for HTTP APIs, request handling, serialization, or endpoints.
|
||||
- rules/security-data.md for databases, persistence, sensitive data, or multi-tenancy.
|
||||
- rules/security-files.md for files, uploads, archives, paths, processes, IPC, or deserialization.
|
||||
- rules/security-network.md for network access, URLs, TLS, proxies, or SSRF.
|
||||
- rules/security-crypto.md for cryptography, secrets, credentials, keys, or tokens.
|
||||
- rules/security-supply-chain.md for dependencies, packages, plugins, builds, deployments, or CI.
|
||||
- rules/angular/index.md for Angular work.
|
||||
- rules/typescript.md for TypeScript work.
|
||||
- rules/csharp.md for C# or .NET work.
|
||||
- rules/wpf/index.md for WPF work.
|
||||
- rules/ui-ux/index.md for UI or UX decisions.
|
||||
- rules/microsoft.md for Microsoft 365, Azure DevOps, or Teams work.
|
||||
- rules/orchestration.md for delegation, subagent work, or long-running task state.
|
||||
- rules/git.md for Git operations.
|
||||
- rules/refactoring.md for refactoring work.
|
||||
- rules/definition-of-done.md when validating completion.
|
||||
- rules/decision-rule.md when requirements are unresolved, behavior is ambiguous, or a meaningful technical choice remains.
|
||||
BLOCK
|
||||
codex_rule_loading="${codex_rule_loading}"$'\n\n'"${codex_rule_loading_list%$'\n'}"
|
||||
|
||||
for shell in powershell bash; do
|
||||
mkdir -p "$output/codex-$shell" "$output/claude-$shell"
|
||||
@@ -178,7 +174,8 @@ for client in codex claude; do
|
||||
if [ "$client" = codex ]; then
|
||||
printf 'name = %s\ndescription = %s\ndeveloper_instructions = %s\n' "$(quote_toml "$name")" "$(quote_toml "$description")" "$(quote_toml "$instructions")" > "$agents_output/$name.toml"
|
||||
else
|
||||
printf '%s\nname: %s\ndescription: %s\n%s\n\n%s\n' '---' "$name" "$description" '---' "$instructions" > "$agents_output/$name.md"
|
||||
tools=${agent_tools[$name]:-}
|
||||
if [ -n "$tools" ]; then printf '%s\nname: %s\ndescription: %s\ntools: %s\n%s\n\n%s\n' '---' "$name" "$description" "$tools" '---' "$instructions" > "$agents_output/$name.md"; else printf '%s\nname: %s\ndescription: %s\n%s\n\n%s\n' '---' "$name" "$description" '---' "$instructions" > "$agents_output/$name.md"; fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$failed = $false
|
||||
function Pass([string]$message) { Write-Output "PASS $message" }
|
||||
function Fail([string]$message) { $script:failed = $true; Write-Error "FAIL $message" -ErrorAction Continue }
|
||||
function Require-Command([string]$name) { if (Get-Command $name -ErrorAction SilentlyContinue) { Pass "$name available" } else { Fail "$name is required" } }
|
||||
function Version-AtLeast([string]$version, [version]$minimum) { try { return ([version]$version -ge $minimum) } catch { return $false } }
|
||||
function Check-Version([string]$name, [string]$version, [version]$minimum) { if (Version-AtLeast $version $minimum) { Pass "$name $version (minimum $minimum)" } else { Fail "$name $version is below minimum $minimum" } }
|
||||
|
||||
Check-Version 'PowerShell' $PSVersionTable.PSVersion.ToString() ([version]'5.1')
|
||||
Require-Command jq; Require-Command python
|
||||
if (Get-Command python -ErrorAction SilentlyContinue) {
|
||||
$pythonVersion = (& python --version 2>$null | Select-Object -First 1) -replace '^Python\s+', ''
|
||||
if ($LASTEXITCODE -eq 0) { Check-Version 'Python' $pythonVersion ([version]'3.11') } else { Fail 'Python 3.11 or newer is required' }
|
||||
}
|
||||
|
||||
if ($failed) { Write-Error 'Capability check: FAIL'; exit 1 }
|
||||
Write-Output 'Capability check: PASS'
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
failed=false
|
||||
pass() { printf 'PASS %s\n' "$1"; }
|
||||
fail() { printf 'FAIL %s\n' "$1" >&2; failed=true; }
|
||||
require_command() {
|
||||
if command -v "$1" >/dev/null 2>&1; then pass "$1 available"; else fail "$1 is required"; fi
|
||||
}
|
||||
version_at_least() {
|
||||
awk -F. -v required="$2" 'BEGIN { split(required,r) } { split($1,v); if (v[1]+0 > r[1]+0 || (v[1]+0 == r[1]+0 && v[2]+0 >= r[2]+0)) exit 0; exit 1 }' <<<"$1"
|
||||
}
|
||||
check_version() {
|
||||
local name=$1 version=$2 required=$3
|
||||
if version_at_least "$version" "$required"; then pass "$name $version (minimum $required)"; else fail "$name $version is below minimum $required"; fi
|
||||
}
|
||||
|
||||
check_version Bash "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}" 4.3
|
||||
require_command awk; require_command sed; require_command sha256sum; require_command jq
|
||||
require_command python3
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c 'import sys; raise SystemExit(sys.version_info < (3, 11))' >/dev/null 2>&1; then
|
||||
pass "Python $(python3 --version 2>&1 | awk '{print $2}') (minimum 3.11)"
|
||||
else
|
||||
fail 'Python 3.11 or newer is required'
|
||||
fi
|
||||
|
||||
if [ "$failed" = true ]; then printf 'Capability check: FAIL\n' >&2; exit 1; fi
|
||||
printf 'Capability check: PASS\n'
|
||||
+3
-1
@@ -12,6 +12,7 @@ $root = Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $PSScriptRoot 'lib/install-targets.ps1')
|
||||
. (Join-Path $PSScriptRoot 'lib/selection-state.ps1')
|
||||
. (Join-Path $PSScriptRoot 'lib/install-options.ps1')
|
||||
. (Join-Path $PSScriptRoot 'lib/semantic-retrieval.ps1')
|
||||
|
||||
$Shell = Read-InstallShell -Shell $Shell
|
||||
$shell = $Shell.ToLowerInvariant()
|
||||
@@ -43,6 +44,7 @@ foreach ($item in (Get-InstallTargets -Generated $generated -HomePath $homePath
|
||||
}
|
||||
|
||||
Sync-ConfiguredPlugins -RepositoryRoot $root -HomePath $homePath -Client $Client -DryRun:$DryRun -Summary:$Summary -Entries $plugins.Selected
|
||||
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang }
|
||||
Sync-SemanticRetrieval -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Summary:$Summary
|
||||
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang -SemanticRetrieval $options.semantic_retrieval }
|
||||
if ($Summary) { Write-Output "Install: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS install dry-run' } else { 'PASS install' })) }
|
||||
exit 0
|
||||
@@ -21,6 +21,7 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
. "$root/scripts/lib/install-targets.sh"
|
||||
. "$root/scripts/lib/selection-state.sh"
|
||||
. "$root/scripts/lib/install-options.sh"
|
||||
. "$root/scripts/lib/semantic-retrieval.sh"
|
||||
|
||||
shell=$(read_install_shell "$shell")
|
||||
client=$(read_install_client "$client")
|
||||
@@ -60,6 +61,7 @@ while IFS='|' read -r source destination; do
|
||||
done < <(get_install_targets "$generated" "$home_path" "$shell" "$client")
|
||||
|
||||
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" false selected_plugins "$summary"
|
||||
sync_semantic_retrieval "$root" "$client" "$home_path" "$semantic_retrieval" "$dry_run" false
|
||||
if [ "$dry_run" = false ]; then save_update_selection; fi
|
||||
|
||||
if [ "$summary" = true ]; then
|
||||
|
||||
@@ -15,7 +15,7 @@ function Read-InstallBoolean {
|
||||
|
||||
function Read-InstallOptions {
|
||||
param([string]$HomePath, [string]$RepositoryRoot, [string]$Client, [switch]$DryRun)
|
||||
$defaults = @{ update_agents = $false; flashbang = $true }
|
||||
$defaults = @{ update_agents = $false; flashbang = $true; semantic_retrieval = $false }
|
||||
if (Test-Path -LiteralPath (Join-Path $HomePath '.my-ai-configuration/selection.json')) {
|
||||
try { $defaults = Read-UpdateSelection -HomePath $HomePath -RepositoryRoot $RepositoryRoot -AllowLegacy }
|
||||
catch { Write-Warning 'Saved options could not be read; confirm new options below.' }
|
||||
@@ -24,6 +24,7 @@ function Read-InstallOptions {
|
||||
return @{
|
||||
update_agents = Read-InstallBoolean -Prompt "Update selected agent CLIs ($Client)?" -Default $defaults.update_agents
|
||||
flashbang = Read-InstallBoolean -Prompt 'Enable the Flashbang notification hook?' -Default $defaults.flashbang
|
||||
semantic_retrieval = Read-InstallBoolean -Prompt 'Enable Qwen3-Embedding-0.6B semantic retrieval?' -Default $defaults.semantic_retrieval
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,13 @@ read_install_boolean() {
|
||||
read_install_options() {
|
||||
update_agents=false
|
||||
flashbang=true
|
||||
semantic_retrieval=false
|
||||
local saved key value
|
||||
if [ -f "$home_path/.my-ai-configuration/selection.json" ]; then
|
||||
if saved=$(python3 "$root/scripts/lib/selection-state.py" read --home "$home_path" --manifest "$root/adapters/plugins.tsv" --format tsv --allow-legacy); then
|
||||
while IFS=$'\t' read -r key value; do
|
||||
value=${value%$'\r'}
|
||||
case "$key" in update_agents) update_agents=$value ;; flashbang) flashbang=$value ;; esac
|
||||
case "$key" in update_agents) update_agents=$value ;; flashbang) flashbang=$value ;; semantic_retrieval) semantic_retrieval=$value ;; esac
|
||||
done <<< "$saved"
|
||||
else
|
||||
printf 'WARN Saved options could not be read; confirm new options below.\n' >&2
|
||||
@@ -32,6 +33,7 @@ read_install_options() {
|
||||
[ "$dry_run" = false ] || return 0
|
||||
update_agents=$(read_install_boolean "Update selected agent CLIs ($client)?" "$update_agents") || return
|
||||
flashbang=$(read_install_boolean 'Enable the Flashbang notification hook?' "$flashbang") || return
|
||||
semantic_retrieval=$(read_install_boolean 'Enable Qwen3-Embedding-0.6B semantic retrieval?' "$semantic_retrieval") || return
|
||||
}
|
||||
|
||||
is_windows_host() {
|
||||
|
||||
@@ -137,6 +137,17 @@ def fetch_skill(entry):
|
||||
return skill, files
|
||||
|
||||
|
||||
def resolve_revision(source):
|
||||
try:
|
||||
request = urllib.request.Request(f'https://api.github.com/repos/{source}/commits/HEAD', headers={'Accept': 'application/vnd.github+json', 'User-Agent': 'MyAiConfiguration'})
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
payload = json.loads(response.read(1024 * 1024))
|
||||
revision = payload.get('sha') if isinstance(payload, dict) else None
|
||||
return revision if isinstance(revision, str) and re.fullmatch(r'[0-9a-fA-F]{7,64}', revision) else None
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
class Manager:
|
||||
def __init__(self, home, dry_run=False, update=False, summary=False):
|
||||
self.home = Path(home).absolute()
|
||||
@@ -155,7 +166,7 @@ class Manager:
|
||||
raise ValueError('Invalid extension ownership ledger.')
|
||||
identities = set()
|
||||
for resource in self.state['resources']:
|
||||
if not isinstance(resource, dict) or resource.get('client') not in ('codex', 'claude') or resource.get('kind') not in ('plugin', 'skill'):
|
||||
if not isinstance(resource, dict) or resource.get('client') not in ('codex', 'claude') or resource.get('kind') not in ('plugin', 'skill', 'qmd'):
|
||||
raise ValueError('Invalid extension ownership record.')
|
||||
if not isinstance(resource.get('name'), str) or not resource['name']:
|
||||
raise ValueError('Invalid extension ownership name.')
|
||||
@@ -166,17 +177,23 @@ class Manager:
|
||||
if resource['kind'] == 'plugin':
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]+@[A-Za-z0-9_.-]+', resource.get('selector', '')):
|
||||
raise ValueError('Invalid owned plugin selector.')
|
||||
else:
|
||||
if 'source' in resource and not isinstance(resource['source'], str):
|
||||
raise ValueError('Invalid owned plugin source.')
|
||||
elif resource['kind'] == 'skill':
|
||||
directory = resource.get('directory', '')
|
||||
if not re.fullmatch(r'\.agents/skills/[A-Za-z0-9_-]+', directory) or resource['client'] != 'codex':
|
||||
raise ValueError('Invalid owned skill directory.')
|
||||
safe_path(self.home, directory)
|
||||
if not isinstance(resource.get('files'), dict):
|
||||
raise ValueError('Invalid owned skill file inventory.')
|
||||
if 'source' in resource and not isinstance(resource['source'], str):
|
||||
raise ValueError('Invalid owned skill source.')
|
||||
for relative, value in resource['files'].items():
|
||||
if not relative.startswith(directory + '/') or not re.fullmatch(r'[a-f0-9]{64}', value):
|
||||
raise ValueError('Invalid owned skill file record.')
|
||||
safe_path(self.home, relative)
|
||||
elif resource.get('package') != '@tobilu/qmd' or resource['client'] != 'codex':
|
||||
raise ValueError('Invalid owned QMD record.')
|
||||
|
||||
def save(self):
|
||||
if not self.dry_run:
|
||||
@@ -209,7 +226,7 @@ class Manager:
|
||||
arguments = [client, 'plugin', 'uninstall', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'remove', selector]
|
||||
self.command(arguments)
|
||||
self.cache[client].pop(selector, None)
|
||||
else:
|
||||
elif record['kind'] == 'skill':
|
||||
empty_candidates = set()
|
||||
for relative, expected in list(record['files'].items()):
|
||||
path = safe_path(self.home, relative)
|
||||
@@ -239,10 +256,15 @@ class Manager:
|
||||
pass
|
||||
if record['files']:
|
||||
return
|
||||
else:
|
||||
self.command([record['client'], 'mcp', 'remove', 'qmd'] + (['--scope', 'user'] if record['client'] == 'claude' else []))
|
||||
self.state['resources'].remove(record)
|
||||
self.save()
|
||||
|
||||
def ensure(self, client, entry):
|
||||
if entry['codex_method'] == 'qmd':
|
||||
self.ensure_qmd(client, entry)
|
||||
return
|
||||
skill = client == 'codex' and entry['codex_method'] != 'plugin'
|
||||
kind = 'skill' if skill else 'plugin'
|
||||
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None)
|
||||
@@ -271,11 +293,17 @@ class Manager:
|
||||
if item is not None and not self.update:
|
||||
if client == 'claude' and item.get('enabled') is False:
|
||||
self.command([client, 'plugin', 'enable', selector])
|
||||
if record is not None and item.get('version') is not None:
|
||||
record['version'] = item['version']; self.save()
|
||||
return
|
||||
if item is not None and client == 'claude':
|
||||
self.command([client, 'plugin', 'update', selector])
|
||||
if item.get('enabled') is False:
|
||||
self.command([client, 'plugin', 'enable', selector])
|
||||
if record is not None:
|
||||
self.cache.pop(client, None)
|
||||
version = self.installed(client).get(selector, {}).get('version')
|
||||
if version is not None: record['version'] = version; self.save()
|
||||
return
|
||||
marketplace = entry[client + '_marketplace']
|
||||
if marketplace not in ('', '-', 'openai-curated-remote'):
|
||||
@@ -287,11 +315,35 @@ class Manager:
|
||||
self.command([client, 'plugin', 'marketplace', 'add', marketplace])
|
||||
arguments = [client, 'plugin', 'install', selector, '--scope', 'user'] if client == 'claude' else [client, 'plugin', 'add', selector]
|
||||
self.command(arguments)
|
||||
resolved_version = None
|
||||
if self.update:
|
||||
self.cache.pop(client, None)
|
||||
resolved_version = self.installed(client).get(selector, {}).get('version')
|
||||
if record is None:
|
||||
self.state['resources'].append(dict(client=client, name=entry['name'], kind='plugin', selector=selector))
|
||||
record = dict(client=client, name=entry['name'], kind='plugin', selector=selector, source=entry.get(client + '_marketplace', '-'))
|
||||
if resolved_version is not None: record['version'] = resolved_version
|
||||
self.state['resources'].append(record)
|
||||
self.save()
|
||||
self.cache[client][selector] = {'scope': 'user'}
|
||||
|
||||
def ensure_qmd(self, client, entry):
|
||||
package = entry['codex_source']
|
||||
if package != '@tobilu/qmd':
|
||||
raise ValueError('Invalid QMD package.')
|
||||
if not getattr(self, 'qmd_ready', False):
|
||||
self.command(['npm', 'install', '--global', package])
|
||||
self.qmd_ready = True
|
||||
if client == 'claude':
|
||||
entry = dict(entry, codex_method='plugin')
|
||||
self.ensure(client, entry)
|
||||
return
|
||||
record = next((r for r in self.state['resources'] if r['client'] == client and r['name'] == entry['name']), None)
|
||||
if record is not None:
|
||||
return
|
||||
self.command(['codex', 'mcp', 'add', 'qmd', '--', 'qmd', 'mcp'])
|
||||
self.state['resources'].append(dict(client='codex', name=entry['name'], kind='qmd', package=package))
|
||||
self.save()
|
||||
|
||||
def ensure_skill(self, entry, record):
|
||||
skill = entry['codex_skill']
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]+', skill):
|
||||
@@ -311,12 +363,16 @@ class Manager:
|
||||
record = None
|
||||
if record and record.get('complete') and not self.update and all(safe_path(self.home, name).is_file() for name in record['files']) and record['files']:
|
||||
return
|
||||
_, files = fetch_skill(entry)
|
||||
fetched = fetch_skill(entry)
|
||||
_, files = fetched[:2]
|
||||
revision = fetched[2] if len(fetched) > 2 else resolve_revision(entry['codex_source'])
|
||||
if record is None:
|
||||
record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, files={}, complete=False)
|
||||
record = dict(client='codex', name=entry['name'], kind='skill', directory=directory, source=entry['codex_source'], files={}, complete=False)
|
||||
self.state['resources'].append(record)
|
||||
self.save()
|
||||
record['complete'] = False
|
||||
if revision:
|
||||
record['resolvedRevision'] = revision
|
||||
self.save()
|
||||
for name, (content, executable) in files.items():
|
||||
relative = directory + '/' + name
|
||||
|
||||
@@ -86,6 +86,17 @@ function Sync-ManagedDestination {
|
||||
if ($relative -eq 'config.toml' -and (Test-Path -LiteralPath $target)) {
|
||||
$localConfig = [System.IO.File]::ReadAllText($target)
|
||||
$pluginTables = [regex]::Matches($localConfig, '(?m)^[ \t]*\[(?:plugins|marketplaces)(?:\.|\])[\s\S]*?(?=^[ \t]*\[|\z)')
|
||||
$rootConfig = [regex]::Split($localConfig, '(?m)^[ \t]*\[', 2)[0]
|
||||
$localSettings = [regex]::Matches($rootConfig, '(?m)^[ \t]*(?:model|reasoning_effort)[ \t]*=.*$')
|
||||
if ($localSettings.Count -gt 0) {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($newBytes).TrimEnd([char[]]"`r`n")
|
||||
foreach ($setting in $localSettings) {
|
||||
$name = ([regex]::Match($setting.Value, '^[ \t]*([^ \t=]+)')).Groups[1].Value
|
||||
$content = [regex]::Replace($content, "(?m)^[ \t]*$([regex]::Escape($name))[ \t]*=.*(?:\r?\n|$)", '')
|
||||
}
|
||||
$preservedSettings = ($localSettings | ForEach-Object { $_.Value.TrimEnd([char[]]"`r`n") }) -join "`n"
|
||||
$newBytes = [System.Text.Encoding]::UTF8.GetBytes("$content`n$preservedSettings`n")
|
||||
}
|
||||
if ($pluginTables.Count -gt 0) {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($newBytes).TrimEnd([char[]]"`r`n")
|
||||
if ($content -match '(?m)^[ \t]*\[(?:plugins|marketplaces)(?:\.|\])') {
|
||||
|
||||
+15
-1
@@ -84,7 +84,21 @@ sync_managed_destination() {
|
||||
new_hash=$(sha256_of_file "$source_file")
|
||||
fi
|
||||
if [ "$relative" = config.toml ] && [ -f "$target" ]; then
|
||||
local plugin_tables
|
||||
local plugin_tables local_settings setting_name
|
||||
local_settings=$(awk '
|
||||
/^[ \t]*\[/ { exit }
|
||||
/^[ \t]*(model|reasoning_effort)[ \t]*=/ { print }
|
||||
' "$target")
|
||||
if [ -n "$local_settings" ]; then
|
||||
while IFS= read -r setting; do
|
||||
setting_name=${setting%%=*}
|
||||
setting_name=${setting_name//[[:space:]]/}
|
||||
content=$(printf '%s\n' "$content" | sed "/^[[:space:]]*$setting_name[[:space:]]*=/d")
|
||||
done <<< "$local_settings"
|
||||
content="$content"$'\n'"$local_settings"$'\n'
|
||||
needs_sub=true
|
||||
new_hash=$(sha256_of_string "$content")
|
||||
fi
|
||||
plugin_tables=$(awk '
|
||||
/^[ \t]*\[/ { preserve = ($0 ~ /^[ \t]*\[(plugins|marketplaces)(\.|\])/) }
|
||||
preserve { print }
|
||||
|
||||
@@ -97,7 +97,9 @@ function Select-ConfiguredPlugins {
|
||||
$checked = @($toggleable | ForEach-Object {
|
||||
if ($Mode -eq 'Install') { $true; return }
|
||||
$selector = if ($referenceClient -eq 'Claude') { $_.claude_plugin } else { $_.codex_plugin }
|
||||
if ($referenceClient -eq 'Codex' -and $_.codex_method -ne 'plugin') {
|
||||
if ($referenceClient -eq 'Codex' -and $_.codex_method -eq 'qmd') {
|
||||
$null -ne (Get-Command qmd -ErrorAction SilentlyContinue)
|
||||
} elseif ($referenceClient -eq 'Codex' -and $_.codex_method -ne 'plugin') {
|
||||
(Test-Path -LiteralPath (Join-Path $HomePath ('.agents/skills/' + $_.codex_skill))) -or (Test-Path -LiteralPath (Join-Path $HomePath ('.codex/skills/' + $_.codex_skill)))
|
||||
} else { [bool](@($installed | Where-Object { $_.$idField -eq $selector }).Count) }
|
||||
})
|
||||
|
||||
@@ -109,7 +109,9 @@ select_configured_plugins() {
|
||||
continue
|
||||
fi
|
||||
if [ "$reference_client" = claude ]; then selector=$(plugin_field "$root" "$name" claude_plugin); else selector=$(plugin_field "$root" "$name" codex_plugin); fi
|
||||
if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
|
||||
if [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" = qmd ]; then
|
||||
if command -v qmd >/dev/null 2>&1; then checked+=(true); else checked+=(false); fi
|
||||
elif [ "$reference_client" = codex ] && [ "$(plugin_field "$root" "$name" codex_method)" != plugin ]; then
|
||||
local skill_name
|
||||
skill_name=$(plugin_field "$root" "$name" codex_skill)
|
||||
if [ -d "$HOME/.agents/skills/$skill_name" ] || [ -d "$HOME/.codex/skills/$skill_name" ]; then checked+=(true); else checked+=(false); fi
|
||||
|
||||
@@ -9,8 +9,8 @@ function Read-UpdateSelection {
|
||||
|
||||
function Save-UpdateSelection {
|
||||
param([string]$HomePath, [string]$RepositoryRoot, [string]$Shell, [string]$Client, [hashtable]$Plugins,
|
||||
[Parameter(Mandatory=$true)][bool]$UpdateAgents, [Parameter(Mandatory=$true)][bool]$Flashbang)
|
||||
$arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'write', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--shell', $Shell.ToLowerInvariant(), '--client', $Client.ToLowerInvariant(), '--update-agents', $UpdateAgents.ToString().ToLowerInvariant(), '--flashbang', $Flashbang.ToString().ToLowerInvariant(), '--selected')
|
||||
[Parameter(Mandatory=$true)][bool]$UpdateAgents, [Parameter(Mandatory=$true)][bool]$Flashbang, [bool]$SemanticRetrieval = $false)
|
||||
$arguments = @((Join-Path $PSScriptRoot 'selection-state.py'), 'write', '--home', $HomePath, '--manifest', (Join-Path $RepositoryRoot 'adapters/plugins.tsv'), '--shell', $Shell.ToLowerInvariant(), '--client', $Client.ToLowerInvariant(), '--update-agents', $UpdateAgents.ToString().ToLowerInvariant(), '--flashbang', $Flashbang.ToString().ToLowerInvariant(), '--semantic-retrieval', $SemanticRetrieval.ToString().ToLowerInvariant(), '--selected')
|
||||
$arguments += @($Plugins.Selected | ForEach-Object { $_.name })
|
||||
$arguments += '--deselected'
|
||||
$arguments += @($Plugins.Deselected | ForEach-Object { $_.name })
|
||||
|
||||
@@ -18,7 +18,9 @@ def validate(state, allow_legacy=False):
|
||||
raise ValueError('Unsupported saved selection.')
|
||||
if state.get('shell') not in ('powershell', 'bash') or state.get('client') not in ('codex', 'claude', 'both'):
|
||||
raise ValueError('Invalid saved shell or client.')
|
||||
if any(type(state.get(key)) is not bool for key in ('update_agents', 'flashbang')):
|
||||
if 'semantic_retrieval' not in state:
|
||||
state['semantic_retrieval'] = False
|
||||
if any(type(state.get(key)) is not bool for key in ('update_agents', 'flashbang', 'semantic_retrieval')):
|
||||
raise ValueError('Invalid saved install options. Run update once without Quick.')
|
||||
for key in ('selected', 'deselected'):
|
||||
values = state.get(key)
|
||||
@@ -41,6 +43,7 @@ def main():
|
||||
parser.add_argument('--allow-legacy', action='store_true')
|
||||
parser.add_argument('--update-agents', choices=('true', 'false'))
|
||||
parser.add_argument('--flashbang', choices=('true', 'false'))
|
||||
parser.add_argument('--semantic-retrieval', choices=('true', 'false'))
|
||||
parser.add_argument('--client')
|
||||
parser.add_argument('--selected', nargs='*', default=[])
|
||||
parser.add_argument('--deselected', nargs='*', default=[])
|
||||
@@ -59,7 +62,7 @@ def main():
|
||||
else:
|
||||
print('shell\t' + state['shell'])
|
||||
print('client\t' + state['client'])
|
||||
for key in ('update_agents', 'flashbang'):
|
||||
for key in ('update_agents', 'flashbang', 'semantic_retrieval'):
|
||||
print(key + '\t' + str(state[key]).lower())
|
||||
for key in ('selected', 'deselected'):
|
||||
for name in state[key]:
|
||||
@@ -68,6 +71,7 @@ def main():
|
||||
state = validate(dict(version=2, shell=args.shell, client=args.client,
|
||||
update_agents=None if args.update_agents is None else args.update_agents == 'true',
|
||||
flashbang=None if args.flashbang is None else args.flashbang == 'true',
|
||||
semantic_retrieval=False if args.semantic_retrieval is None else args.semantic_retrieval == 'true',
|
||||
selected=args.selected, deselected=args.deselected))
|
||||
if (set(state['selected']) | set(state['deselected'])) - names:
|
||||
raise ValueError('Unknown plugin in selection.')
|
||||
|
||||
@@ -12,6 +12,7 @@ read_update_selection() {
|
||||
client) [ -n "$client" ] || client=$value ;;
|
||||
update_agents) update_agents=$value ;;
|
||||
flashbang) flashbang=$value ;;
|
||||
semantic_retrieval) semantic_retrieval=$value ;;
|
||||
selected) selected_plugins+=("$value") ;;
|
||||
deselected) deselected_plugins+=("$value") ;;
|
||||
esac
|
||||
@@ -20,6 +21,6 @@ read_update_selection() {
|
||||
|
||||
save_update_selection() {
|
||||
python3 "$root/scripts/lib/selection-state.py" write --home "$home_path" --manifest "$root/adapters/plugins.tsv" \
|
||||
--shell "$shell" --client "$client" --update-agents "$update_agents" --flashbang "$flashbang" \
|
||||
--shell "$shell" --client "$client" --update-agents "$update_agents" --flashbang "$flashbang" --semantic-retrieval "${semantic_retrieval:-false}" \
|
||||
--selected "${selected_plugins[@]}" --deselected "${deselected_plugins[@]}"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
function Sync-SemanticRetrieval {
|
||||
param([Parameter(Mandatory=$true)][string]$RepositoryRoot,[Parameter(Mandatory=$true)][string]$HomePath,[ValidateSet('Codex','Claude','Both')][Parameter(Mandatory=$true)][string]$Client,[Parameter(Mandatory=$true)][bool]$Enabled,[switch]$DryRun,[switch]$Update,[switch]$Summary)
|
||||
$statePath = Join-Path $HomePath '.my-ai-configuration/semantic-retrieval.json'
|
||||
if (-not $Enabled -and -not (Test-Path -LiteralPath $statePath)) { return }
|
||||
$arguments = @((Join-Path $PSScriptRoot 'semantic-retrieval.py'), 'sync', '--root', $RepositoryRoot, '--home', $HomePath, '--client', $Client.ToLowerInvariant(), '--enabled', $Enabled.ToString().ToLowerInvariant())
|
||||
if ($DryRun) { $arguments += '--dry-run' }; if ($Update) { $arguments += '--update' }
|
||||
& python @arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Semantic retrieval reconciliation failed.' }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
NAME = 'my-ai-qwen3-retrieval'
|
||||
FILES = ('server.py', 'requirements.txt')
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def command(client, arguments, home, dry_run):
|
||||
if dry_run:
|
||||
print('DRYRUN ' + ' '.join(arguments))
|
||||
return True
|
||||
executable = shutil.which(client)
|
||||
if executable is None:
|
||||
raise ValueError(f'{client} CLI is required for semantic retrieval.')
|
||||
result = subprocess.run([executable, *arguments[1:]], stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
timeout=180, env=dict(os.environ, HOME=str(home), USERPROFILE=str(home), CODEX_HOME=str(home / '.codex'), CLAUDE_CONFIG_DIR=str(home / '.claude')))
|
||||
if result.returncode:
|
||||
raise ValueError(f'Semantic retrieval command failed: {client} {" ".join(arguments[1:])}\n{result.stdout}{result.stderr}')
|
||||
return True
|
||||
|
||||
|
||||
def python_path(target):
|
||||
return target / ('Scripts/python.exe' if os.name == 'nt' else 'bin/python')
|
||||
|
||||
|
||||
def ensure_runtime(target, dry_run):
|
||||
if dry_run:
|
||||
return python_path(target / '.venv')
|
||||
venv = target / '.venv'
|
||||
if not venv.exists():
|
||||
subprocess.run([sys.executable, '-m', 'venv', str(venv)], check=True, timeout=180)
|
||||
python = python_path(venv)
|
||||
if not python.is_file():
|
||||
raise ValueError('Semantic retrieval virtual environment is incomplete.')
|
||||
subprocess.run([str(python), '-m', 'pip', 'install', '--disable-pip-version-check', '-r', str(target / 'requirements.txt')],
|
||||
check=True, timeout=900)
|
||||
return python
|
||||
|
||||
|
||||
def load_state(path):
|
||||
if not path.exists():
|
||||
return {'version': 1, 'clients': [], 'files': {}}
|
||||
state = json.loads(path.read_text(encoding='utf-8'))
|
||||
if state.get('version') != 1 or not isinstance(state.get('clients'), list) or not isinstance(state.get('files'), dict):
|
||||
raise ValueError('Invalid semantic retrieval ownership state.')
|
||||
if any(client not in ('codex', 'claude') for client in state['clients']):
|
||||
raise ValueError('Invalid semantic retrieval client state.')
|
||||
if any(name not in FILES or not isinstance(value, str) or len(value) != 64 or any(character not in '0123456789abcdef' for character in value) for name, value in state['files'].items()):
|
||||
raise ValueError('Invalid semantic retrieval file state.')
|
||||
return state
|
||||
|
||||
|
||||
def save_state(path, state, dry_run):
|
||||
if not dry_run:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(state, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
def sync(root, home, clients, enabled, dry_run, update):
|
||||
base = home / '.my-ai-configuration'
|
||||
target = base / 'semantic-retrieval'
|
||||
state_path = base / 'semantic-retrieval.json'
|
||||
state = load_state(state_path)
|
||||
clients = list(dict.fromkeys(clients))
|
||||
if base.exists() and base.is_symlink():
|
||||
raise ValueError('Semantic retrieval state directory must not be a symbolic link.')
|
||||
if not enabled:
|
||||
owned = [client for client in state['clients'] if client in clients]
|
||||
if dry_run:
|
||||
print('DRYRUN disable semantic retrieval: ' + (', '.join(owned) if owned else 'nothing owned'))
|
||||
return
|
||||
for client in owned:
|
||||
command(client, [client, 'mcp', 'remove', NAME] + (['--scope', 'user'] if client == 'claude' else []), home, False)
|
||||
state['clients'] = [client for client in state['clients'] if client not in clients]
|
||||
if state['clients']:
|
||||
save_state(state_path, state, False)
|
||||
return
|
||||
if target.is_symlink():
|
||||
raise ValueError('Semantic retrieval directory must not be a symbolic link.')
|
||||
if target.exists() and target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
state_path.unlink(missing_ok=True)
|
||||
return
|
||||
|
||||
if target.exists() and not state['files']:
|
||||
raise ValueError('Semantic retrieval directory exists without setup ownership; refusing to overwrite it.')
|
||||
if dry_run:
|
||||
print('DRYRUN enable semantic retrieval: Qwen3-Embedding-0.6B')
|
||||
return
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
source = root / 'shared' / 'retrieval'
|
||||
for name in FILES:
|
||||
shutil.copy2(source / name, target / name)
|
||||
state['files'][name] = digest(target / name)
|
||||
python = ensure_runtime(target, False)
|
||||
existing_clients = set(state['clients'])
|
||||
state['clients'] = list(dict.fromkeys(state['clients']))
|
||||
save_state(state_path, state, False)
|
||||
arguments = ['--', str(python), str(target / 'server.py'), '--root', '.', '--data-dir', str(target / 'data'), '--model-cache', str(target / 'model-cache')]
|
||||
for client in clients:
|
||||
command_args = [client, 'mcp', 'add']
|
||||
if client == 'claude':
|
||||
command_args += [NAME, '--scope', 'user', '-e', f'HF_HOME={target / "model-cache"}']
|
||||
else:
|
||||
command_args += ['--env', f'HF_HOME={target / "model-cache"}']
|
||||
command_args += [NAME]
|
||||
command_args += arguments
|
||||
if update and client in existing_clients:
|
||||
command(client, [client, 'mcp', 'remove', NAME] + (['--scope', 'user'] if client == 'claude' else []), home, False)
|
||||
command(client, command_args, home, False)
|
||||
if client not in state['clients']:
|
||||
state['clients'].append(client)
|
||||
save_state(state_path, state, False)
|
||||
save_state(state_path, state, False)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('action', choices=('sync',))
|
||||
parser.add_argument('--root', required=True, type=Path)
|
||||
parser.add_argument('--home', required=True, type=Path)
|
||||
parser.add_argument('--client', choices=('codex', 'claude', 'both'), required=True)
|
||||
parser.add_argument('--enabled', choices=('true', 'false'), required=True)
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--update', action='store_true')
|
||||
args = parser.parse_args()
|
||||
clients = ('codex', 'claude') if args.client == 'both' else (args.client,)
|
||||
sync(args.root.resolve(), args.home.resolve(), clients, args.enabled == 'true', args.dry_run, args.update)
|
||||
print('SEMANTIC RETRIEVAL: reconciliation complete' + (' (dry run)' if args.dry_run else ''))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
|
||||
print(f'Semantic retrieval: {error}', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,8 @@
|
||||
sync_semantic_retrieval() {
|
||||
local root=$1 client=$2 home_path=$3 enabled=$4 dry_run=$5 update=$6
|
||||
[ "$enabled" = true ] || [ -f "$home_path/.my-ai-configuration/semantic-retrieval.json" ] || return 0
|
||||
local arguments=("$root/scripts/lib/semantic-retrieval.py" sync --root "$root" --home "$home_path" --client "$client" --enabled "$enabled")
|
||||
[ "$dry_run" != true ] || arguments+=(--dry-run)
|
||||
[ "$update" != true ] || arguments+=(--update)
|
||||
python3 "${arguments[@]}"
|
||||
}
|
||||
@@ -19,7 +19,16 @@ $baseline = Join-Path $root 'adapters/prompt-budget-baseline.json'
|
||||
$old = if (Test-Path $baseline) { Get-Content $baseline -Raw | ConvertFrom-Json } else { $null }
|
||||
$rows = @([pscustomobject]@{Name='Claude permanent context';Bytes=$claude;Tokens=(Tokens $claude)},[pscustomobject]@{Name='Codex permanent context';Bytes=$codex;Tokens=(Tokens $codex)},[pscustomobject]@{Name='Claude skill files (lazy)';Bytes=$skills;Tokens=(Tokens $skills)},[pscustomobject]@{Name='Rule trigger metadata';Bytes=$triggers;Tokens=(Tokens $triggers)},[pscustomobject]@{Name='Largest lazy rule';Bytes=$lazy;Tokens=(Tokens $lazy)})
|
||||
$fail = $false
|
||||
if ($old) { foreach ($row in $rows | Select-Object -First 2) { $previous = [double]$old.($row.Name); if ($previous -gt 0 -and $row.Bytes -gt $previous * 1.15) { $fail = $true } } }
|
||||
if ($Summary) { Write-Output 'Prompt Budget'; $rows | ForEach-Object { Write-Output ('{0,-28} {1,8} bytes {2,6} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } } else { $rows | ForEach-Object { Write-Output ('{0}: {1} bytes / {2} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } }
|
||||
if ($fail) { Write-Error 'Prompt budget: permanent context increased by more than 15 percent.'; exit 1 }
|
||||
$baselineValues = @{}
|
||||
if ($old) {
|
||||
foreach ($row in $rows | Select-Object -First 2) {
|
||||
$previous = [double]$old.($row.Name)
|
||||
$absolute = [double]$old.(($row.Name + ' absolute limit'))
|
||||
$baselineValues[$row.Name] = @($previous, $absolute)
|
||||
if ($previous -gt 0 -and $row.Bytes -gt $previous * 1.15) { $fail = $true; Write-Error "Prompt budget relative limit failed: $($row.Name) $($row.Bytes) bytes (baseline $previous, limit 15%)." }
|
||||
if ($absolute -gt 0 -and $row.Bytes -gt $absolute) { $fail = $true; Write-Error "Prompt budget absolute limit failed: $($row.Name) $($row.Bytes) bytes (absolute limit $absolute)." }
|
||||
}
|
||||
}
|
||||
if ($Summary) { Write-Output 'Prompt Budget'; foreach ($row in $rows | Select-Object -First 2) { $limits = $baselineValues[$row.Name]; $base = if($limits){$limits[0]}else{'-'}; $absolute = if($limits){$limits[1]}else{'-'}; Write-Output ('{0,-28} {1,8} bytes baseline {2} relative 115% absolute {3}' -f $row.Name,$row.Bytes,$base,$absolute) }; $rows | Select-Object -Skip 2 | ForEach-Object { Write-Output ('{0,-28} {1,8} bytes {2,6} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } } else { $rows | ForEach-Object { Write-Output ('{0}: {1} bytes / {2} tokens' -f $_.Name,$_.Bytes,$_.Tokens) } }
|
||||
if ($fail) { exit 1 }
|
||||
exit 0
|
||||
@@ -9,7 +9,17 @@ bytes() { wc -c < "$1" | tr -d ' '; }
|
||||
tree_bytes() { find "$1" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}'; }
|
||||
tokens() { awk -v b="$1" 'BEGIN { printf "%d", (b + 3) / 4 }'; }
|
||||
claude=$(bytes "$generated/claude-bash/CLAUDE.md"); codex=$(bytes "$generated/codex-bash/AGENTS.md"); skills=$(tree_bytes "$generated/claude-bash/skills"); triggers=$(bytes "$root/adapters/claude/rule-skills.tsv"); lazy=$(find "$root/shared/rules" -type f -printf '%s %p\n' | sort -nr | awk 'NR==1 {print $1+0}'); fail=false
|
||||
if [ -f "$root/adapters/prompt-budget-baseline.tsv" ]; then while IFS=$'\t' read -r name old; do current=$([ "$name" = Claude ] && printf '%s' "$claude" || printf '%s' "$codex"); awk -v n="$current" -v o="$old" 'BEGIN { exit !(o > 0 && n > o * 1.15) }' && fail=true; done < "$root/adapters/prompt-budget-baseline.tsv"; fi
|
||||
if [ "$summary" = true ]; then printf 'Prompt Budget\nClaude permanent context %8s bytes %6s tokens\nCodex permanent context %8s bytes %6s tokens\nClaude skill files (lazy) %8s bytes %6s tokens\nRule trigger metadata %8s bytes %6s tokens\nLargest lazy rule %8s bytes %6s tokens\n' "$claude" "$(tokens "$claude")" "$codex" "$(tokens "$codex")" "$skills" "$(tokens "$skills")" "$triggers" "$(tokens "$triggers")" "$lazy" "$(tokens "$lazy")"; else printf 'Prompt budget: Claude %s bytes, Codex %s bytes, skills %s bytes, triggers %s bytes, largest lazy rule %s bytes\n' "$claude" "$codex" "$skills" "$triggers" "$lazy"; fi
|
||||
claude_baseline=0; codex_baseline=0; claude_absolute=0; codex_absolute=0
|
||||
if [ -f "$root/adapters/prompt-budget-baseline.tsv" ]; then
|
||||
while IFS=$'\t' read -r name old absolute; do
|
||||
[ "$name" = Claude ] || [ "$name" = Codex ] || continue
|
||||
current=$([ "$name" = Claude ] && printf '%s' "$claude" || printf '%s' "$codex")
|
||||
[ "$name" = Claude ] && claude_baseline=$old && claude_absolute=$absolute
|
||||
[ "$name" = Codex ] && codex_baseline=$old && codex_absolute=$absolute
|
||||
if awk -v n="$current" -v o="$old" 'BEGIN { exit !(o > 0 && n > o * 1.15) }'; then printf 'Prompt budget relative limit failed: %s %s bytes (baseline %s, limit 15%%).\n' "$name" "$current" "$old" >&2; fail=true; fi
|
||||
if awk -v n="$current" -v a="$absolute" 'BEGIN { exit !(a > 0 && n > a) }'; then printf 'Prompt budget absolute limit failed: %s %s bytes (absolute limit %s).\n' "$name" "$current" "$absolute" >&2; fail=true; fi
|
||||
done < "$root/adapters/prompt-budget-baseline.tsv"
|
||||
fi
|
||||
if [ "$summary" = true ]; then printf 'Prompt Budget\nClaude permanent context %8s bytes baseline %s relative 115%% absolute %s\nCodex permanent context %8s bytes baseline %s relative 115%% absolute %s\nClaude skill files (lazy) %8s bytes %6s tokens\nRule trigger metadata %8s bytes %6s tokens\nLargest lazy rule %8s bytes %6s tokens\n' "$claude" "$claude_baseline" "$claude_absolute" "$codex" "$codex_baseline" "$codex_absolute" "$skills" "$(tokens "$skills")" "$triggers" "$(tokens "$triggers")" "$lazy" "$(tokens "$lazy")"; else printf 'Prompt budget: Claude %s bytes, Codex %s bytes, skills %s bytes, triggers %s bytes, largest lazy rule %s bytes\n' "$claude" "$codex" "$skills" "$triggers" "$lazy"; fi
|
||||
$fail && { printf 'Prompt budget: permanent context increased by more than 15 percent.\n' >&2; exit 1; }
|
||||
exit 0
|
||||
@@ -6,7 +6,7 @@ $cacheDir = Join-Path $root '.ai-session'
|
||||
$cachePath = Join-Path $cacheDir 'repository-context.json'
|
||||
$safeRoot = $root.Replace('\','/')
|
||||
$head = (& git -c "safe.directory=$safeRoot" -C $root rev-parse HEAD 2>$null).Trim()
|
||||
$inputs = @('AI-Instructions.md','adapters/plugins.tsv','adapters/claude/rule-skills.tsv','adapters/codex/config/config.toml','adapters/claude/config/settings.json')
|
||||
$inputs = @('AI-Instructions.md','adapters/plugins.tsv','adapters/claude/rule-skills.tsv','adapters/claude/capabilities.tsv','adapters/prompt-budget-baseline.json','adapters/prompt-budget-baseline.tsv','adapters/codex/config/config.toml','adapters/claude/config/settings.json')
|
||||
$inputs += @(Get-ChildItem $root -File -Recurse | Where-Object { $_.FullName -notmatch '\\(generated|\.git|node_modules|bin|obj|vendor|\.ai-session)\\' -and $_.Extension -in @('.sln','.csproj','.fsproj','.ts','.tsx','.json','.toml','.yaml','.yml','.cs','.fs','.py','.sh','.ps1') } | ForEach-Object { $_.FullName.Substring($root.Length + 1) } | Sort-Object)
|
||||
$keyText = $head + "`n" + (($inputs | Sort-Object -Unique | ForEach-Object { $_ + ':' + (Get-FileHash (Join-Path $root $_) -Algorithm SHA256).Hash }) -join "`n")
|
||||
$key = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($keyText)).TrimEnd('=')
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
[CmdletBinding()]
|
||||
param([ValidateSet('record','summary','rotate')][string]$Action='record',[string]$Event,[string]$Agent,[string]$Role,[int]$DurationMs,[int]$ToolCalls,[string]$CommandClass,[int64]$Bytes,[int]$Count)
|
||||
param([ValidateSet('record','summary','rotate')][string]$Action='record',[string]$Event,[string]$Agent,[string]$Role,[Nullable[int]]$DurationMs,[Nullable[int]]$ToolCalls,[string]$CommandClass,[Nullable[int64]]$Bytes,[Nullable[int]]$Count,[Nullable[int]]$HandoffBytes,[Nullable[int]]$FilesRead,[Nullable[int]]$FilesChanged,[string]$Outcome,[Nullable[int]]$InputTokens,[Nullable[int]]$CachedTokens,[Nullable[int]]$OutputTokens,[Nullable[int]]$ReasoningTokens)
|
||||
$ErrorActionPreference = 'Stop'; if ($env:AI_CONFIG_TELEMETRY -eq '0') { exit 0 }
|
||||
$root = Split-Path $PSScriptRoot -Parent; $dir = Join-Path $root '.ai-session'; $path = Join-Path $dir 'telemetry.jsonl'; New-Item $dir -ItemType Directory -Force | Out-Null
|
||||
if ($Action -eq 'rotate') { if (Test-Path $path) { Move-Item $path ($path + '.1') -Force }; exit 0 }
|
||||
if ($Action -eq 'summary') { if (-not (Test-Path $path)) { Write-Output 'Telemetry: no events'; exit 0 }; $events=@(Get-Content $path | ForEach-Object { try { $_ | ConvertFrom-Json } catch {} }); $agents=@($events | Where-Object event -eq 'subagent-stop'); Write-Output ('Telemetry: {0} events | {1} subagents' -f $events.Count,$agents.Count); exit 0 }
|
||||
if ([string]::IsNullOrWhiteSpace($Event)) { throw 'Event is required.' }
|
||||
$record=[ordered]@{event=$Event;timestamp=(Get-Date).ToUniversalTime().ToString('o')}; foreach($pair in @(@('agent',$Agent),@('role',$Role),@('durationMs',$DurationMs),@('toolCalls',$ToolCalls),@('commandClass',$CommandClass),@('bytes',$Bytes),@('count',$Count))){if($pair[1]){$record[$pair[0]]=$pair[1]}}; ($record|ConvertTo-Json -Compress)|Add-Content $path; if((Get-Item $path).Length -gt 1048576){Move-Item $path ($path+'.1') -Force}
|
||||
try {
|
||||
$root = Split-Path $PSScriptRoot -Parent; $dir = Join-Path $root '.ai-session'; $path = Join-Path $dir 'telemetry.jsonl'; New-Item $dir -ItemType Directory -Force | Out-Null
|
||||
if ($Action -eq 'rotate') { if (Test-Path $path) { Move-Item $path ($path + '.1') -Force }; exit 0 }
|
||||
if ($Action -eq 'summary') { if (-not (Test-Path $path)) { Write-Output 'Telemetry: no events'; exit 0 }; $events=@(Get-Content $path | ForEach-Object { try { $_ | ConvertFrom-Json } catch {} }); Write-Output ('Telemetry: {0} events' -f $events.Count); foreach($group in @($events | Where-Object role | Group-Object role | Sort-Object Name)){ $inputs=@($group.Group|Where-Object tokenUsage|ForEach-Object {$_.tokenUsage.input}|Where-Object {$_ -is [int] -or $_ -is [long]}|Sort-Object); $median=if($inputs.Count){$inputs[[int][Math]::Floor($inputs.Count/2)]}else{'-'}; Write-Output ('{0}: runs {1} findings {2} median input {3}' -f $group.Name,$group.Count,@($group.Group|Where-Object outcome -eq findings).Count,$median)}; exit 0 }
|
||||
if ([string]::IsNullOrWhiteSpace($Event)) { throw 'Event is required.' }
|
||||
$record=[ordered]@{event=$Event;timestamp=(Get-Date).ToUniversalTime().ToString('o')}; foreach($pair in @(@('agent',$Agent),@('role',$Role),@('durationMs',$DurationMs),@('toolCalls',$ToolCalls),@('commandClass',$CommandClass),@('bytes',$Bytes),@('count',$Count),@('handoffBytes',$HandoffBytes),@('filesRead',$FilesRead),@('filesChanged',$FilesChanged),@('outcome',$Outcome))){if($null -ne $pair[1] -and "$($pair[1])" -ne ''){$record[$pair[0]]=$pair[1]}}; $tokens=[ordered]@{}; foreach($pair in @(@('input',$InputTokens),@('cached',$CachedTokens),@('output',$OutputTokens),@('reasoning',$ReasoningTokens))){if($null -ne $pair[1]){$tokens[$pair[0]]=$pair[1]}}; if($tokens.Count){$record.tokenUsage=$tokens}; ($record|ConvertTo-Json -Compress)|Add-Content $path; if((Get-Item $path).Length -gt 1048576){Move-Item $path ($path+'.1') -Force}
|
||||
} catch { exit 0 }
|
||||
+34
-4
@@ -2,9 +2,39 @@
|
||||
set -euo pipefail
|
||||
[ "${AI_CONFIG_TELEMETRY:-1}" = 0 ] && exit 0
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd); dir="$root/.ai-session"; path="$dir/telemetry.jsonl"; mkdir -p "$dir"
|
||||
case "${1:-record}" in
|
||||
action=${1:-record}; shift || true
|
||||
case "$action" in
|
||||
rotate) [ -f "$path" ] && mv -f "$path" "$path.1"; exit 0;;
|
||||
summary) [ -f "$path" ] || { printf 'Telemetry: no events\n'; exit 0; }; count=$(wc -l < "$path" | tr -d ' '); agents=$(grep -c '"event":"subagent-stop"' "$path" || true); printf 'Telemetry: %s events | %s subagents\n' "$count" "$agents"; exit 0;;
|
||||
record) event=${2:?event required};; *) printf 'Unknown action: %s\n' "$1" >&2; exit 1;;
|
||||
summary) [ -f "$path" ] || { printf 'Telemetry: no events\n'; exit 0; }; python3 - "$path" <<'PY' || exit 0
|
||||
import json,sys,statistics
|
||||
rows=[]
|
||||
for line in open(sys.argv[1],encoding='utf-8',errors='replace'):
|
||||
try: rows.append(json.loads(line))
|
||||
except Exception: pass
|
||||
print(f'Telemetry: {len(rows)} events')
|
||||
for role in sorted({r.get('role') for r in rows if r.get('role')}):
|
||||
subset=[r for r in rows if r.get('role')==role]
|
||||
inputs=[r['tokenUsage']['input'] for r in subset if isinstance(r.get('tokenUsage'),dict) and isinstance(r['tokenUsage'].get('input'),(int,float))]
|
||||
print(f"{role}: runs {len(subset)} findings {sum(r.get('outcome')=='findings' for r in subset)} median input {int(statistics.median(inputs)) if inputs else '-'}")
|
||||
PY
|
||||
exit 0;;
|
||||
record) event=${1:?event required}; shift;; *) printf 'Unknown action: %s\n' "$action" >&2; exit 1;;
|
||||
esac
|
||||
printf '{"event":"%s","timestamp":"%s"}\n' "$event" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$path"; [ "$(wc -c < "$path")" -le 1048576 ] || mv -f "$path" "$path.1"
|
||||
python3 - "$path" "$event" "$@" <<'PY' || exit 0
|
||||
import json,sys,datetime
|
||||
path,event=sys.argv[1:3]; args=sys.argv[3:]
|
||||
record={'event':event,'timestamp':datetime.datetime.now(datetime.timezone.utc).isoformat()}
|
||||
keys=['role','durationMs','handoffBytes','filesRead','filesChanged','outcome','input','cached','output','reasoning']
|
||||
if any(a.startswith('--') for a in args):
|
||||
parsed={}; i=0
|
||||
while i < len(args):
|
||||
if args[i].startswith('--') and i+1 < len(args): parsed[args[i][2:]]=args[i+1]; i+=2
|
||||
else: i+=1
|
||||
args=[parsed.get(k,'') for k in keys]
|
||||
for key,value in zip(keys,args):
|
||||
if value: record[key]=int(value) if value.lstrip('-').isdigit() else value
|
||||
tokens={k:record.pop(k) for k in ('input','cached','output','reasoning') if k in record}
|
||||
if tokens: record['tokenUsage']=tokens
|
||||
with open(path,'a',encoding='utf-8') as out: out.write(json.dumps(record,separators=(',',':'))+'\n')
|
||||
PY
|
||||
if [ "$(wc -c < "$path")" -gt 1048576 ]; then mv -f "$path" "$path.1"; fi
|
||||
@@ -184,7 +184,7 @@ class InstallOptionsTests(unittest.TestCase):
|
||||
'select_configured_plugins() { :; }\nsync_configured_plugins() { :; }\n'
|
||||
'run_plugin_command() { printf "CLI-MOCK %s\\n" "$2"; }\n')
|
||||
environment = dict(os.environ, HOME=home.as_posix(), TEST_INSTALL_HOME=str(home))
|
||||
for name, quick, answers in [('install', False, 'y\nn\n'), ('update', True, '')]:
|
||||
for name, quick, answers in [('install', False, 'y\nn\nn\n'), ('update', True, '')]:
|
||||
entry = str(scripts / (name + '.' + suffix))
|
||||
command = ([executable, '-NoProfile', '-File', entry, '-Shell', 'Bash', '-Client', 'Both', '-Summary']
|
||||
if kind == 'powershell' else [executable, entry, '--shell', 'bash', '--client', 'both', '--summary'])
|
||||
|
||||
@@ -40,7 +40,7 @@ class ManagedExtensionTests(unittest.TestCase):
|
||||
|
||||
def command(self, args, summary, home=None):
|
||||
self.commands.append(args)
|
||||
if args[2] in ('add', 'install') and args[1] == 'plugin':
|
||||
if len(args) > 3 and args[2] in ('add', 'install') and args[1] == 'plugin':
|
||||
self.installed[args[0]][args[3]] = {'scope': 'user'}
|
||||
if args[2] in ('remove', 'uninstall'):
|
||||
self.installed[args[0]].pop(args[3], None)
|
||||
@@ -110,6 +110,13 @@ class ManagedExtensionTests(unittest.TestCase):
|
||||
self.assertTrue((directory / 'foreign.txt').exists())
|
||||
self.assertFalse((directory / 'references/old.md').exists())
|
||||
|
||||
def test_skill_records_resolved_revision(self):
|
||||
with patch.object(extensions, 'resolve_revision', return_value='a' * 40):
|
||||
self.manager().sync([skill()], ['codex'], {'Humanizer'})
|
||||
record = self.manager().state['resources'][0]
|
||||
self.assertEqual(record['source'], 'blader/humanizer')
|
||||
self.assertEqual(record['resolvedRevision'], 'a' * 40)
|
||||
|
||||
def test_skill_partial_failure_records_each_successful_file(self):
|
||||
original_write = extensions.atomic_write
|
||||
def write(path, content):
|
||||
@@ -165,6 +172,20 @@ class ManagedExtensionTests(unittest.TestCase):
|
||||
self.manager(dry_run=True, summary=True).sync([plugin(), skill()], ['codex'], {'Example', 'Humanizer'})
|
||||
self.assertEqual('EXTENSIONS: reconciliation complete (dry run)\n', output.getvalue())
|
||||
|
||||
def test_qmd_installs_once_and_registers_codex_mcp(self):
|
||||
entry = dict(name='QMD', codex_method='qmd', codex_source='@tobilu/qmd', codex_skill='qmd', codex_plugin='-', codex_marketplace='-', claude_plugin='qmd@qmd', claude_marketplace='tobi/qmd')
|
||||
self.manager().sync([entry], ['codex'], {'QMD'})
|
||||
self.assertEqual([['npm', 'install', '--global', '@tobilu/qmd'], ['codex', 'mcp', 'add', 'qmd', '--', 'qmd', 'mcp']], self.commands)
|
||||
self.manager().sync([], ['codex'], set())
|
||||
self.assertEqual(['codex', 'mcp', 'remove', 'qmd'], self.commands[-1])
|
||||
|
||||
def test_qmd_installs_once_for_both_clients(self):
|
||||
entry = dict(name='QMD', codex_method='qmd', codex_source='@tobilu/qmd', codex_skill='qmd', codex_plugin='-', codex_marketplace='-', claude_plugin='qmd@qmd', claude_marketplace='tobi/qmd')
|
||||
self.manager().sync([entry], ['claude', 'codex'], {'QMD'})
|
||||
self.assertEqual(1, self.commands.count(['npm', 'install', '--global', '@tobilu/qmd']))
|
||||
self.assertIn(['claude', 'plugin', 'install', 'qmd@qmd', '--scope', 'user'], self.commands)
|
||||
self.assertIn(['codex', 'mcp', 'add', 'qmd', '--', 'qmd', 'mcp'], self.commands)
|
||||
|
||||
def test_foreign_empty_directory_survives_skill_removal(self):
|
||||
self.manager().sync([skill()], ['codex'], {'Humanizer'})
|
||||
foreign = self.home / '.agents/skills/humanizer/foreign-empty'
|
||||
|
||||
@@ -54,15 +54,15 @@ try {
|
||||
[plugins."disabled@market"]
|
||||
enabled = false
|
||||
'@
|
||||
[System.IO.File]::WriteAllText((Join-Path $source 'config.toml'), "model = 'new'`n")
|
||||
[System.IO.File]::WriteAllText($configPath, "model = 'old'`n$pluginState`n[other]`nvalue = true`n")
|
||||
[System.IO.File]::WriteAllText((Join-Path $source 'config.toml'), "model = 'new'`nreasoning_effort = 'low'`n")
|
||||
[System.IO.File]::WriteAllText($configPath, "model = 'old'`nreasoning_effort = 'high'`n$pluginState`n[other]`nvalue = true`n")
|
||||
$originalConfig = [System.IO.File]::ReadAllText($configPath)
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'plugins-dry' -DryRun
|
||||
if ([System.IO.File]::ReadAllText($configPath) -ne $originalConfig) { throw 'Dry run must preserve plugin configuration.' }
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'plugins'
|
||||
$mergedConfig = [System.IO.File]::ReadAllText($configPath)
|
||||
if (-not $mergedConfig.Contains($pluginState)) { throw 'Updating config.toml must preserve local marketplace and plugin tables, including disabled plugins.' }
|
||||
if (-not $mergedConfig.Contains("model = 'new'") -or $mergedConfig.Contains('[other]')) { throw 'Managed configuration must still be updated.' }
|
||||
if (-not ($mergedConfig -replace "`r`n", "`n").Contains(($pluginState -replace "`r`n", "`n"))) { throw 'Updating config.toml must preserve local marketplace and plugin tables, including disabled plugins.' }
|
||||
if (-not $mergedConfig.Contains("model = 'old'") -or -not $mergedConfig.Contains("reasoning_effort = 'high'") -or $mergedConfig.Contains("model = 'new'") -or $mergedConfig.Contains("reasoning_effort = 'low'") -or $mergedConfig.Contains('[other]')) { throw 'Updating config.toml must preserve local model and reasoning effort while updating managed configuration.' }
|
||||
if ([System.IO.File]::ReadAllText((Join-Path $destination 'backups/plugins/config.toml')) -ne $originalConfig) { throw 'Original plugin configuration must be backed up.' }
|
||||
$null = Sync-ManagedDestination -Source $source -Destination $destination -Stamp 'plugins-repeat'
|
||||
if ([System.IO.File]::ReadAllText($configPath) -ne $mergedConfig -or (Test-Path (Join-Path $destination 'backups/plugins-repeat'))) { throw 'Preserving plugin configuration must be idempotent.' }
|
||||
|
||||
@@ -58,15 +58,17 @@ plugin_state=' [marketplaces.ponytail]
|
||||
enabled = true
|
||||
[plugins."disabled@market"]
|
||||
enabled = false'
|
||||
printf "model = 'new'\n" > "$source_dir/config.toml"
|
||||
printf "model = 'old'\n%s\n[other]\nvalue = true\n" "$plugin_state" > "$destination/config.toml"
|
||||
printf "model = 'new'\nreasoning_effort = 'low'\n" > "$source_dir/config.toml"
|
||||
printf "model = 'old'\nreasoning_effort = 'high'\n%s\n[other]\nvalue = true\n" "$plugin_state" > "$destination/config.toml"
|
||||
original_config=$(cat "$destination/config.toml")
|
||||
sync_managed_destination "$source_dir" "$destination" plugins-dry "" "" "" true > /dev/null
|
||||
[ "$(cat "$destination/config.toml")" = "$original_config" ] || { printf 'Dry run changed plugin configuration.\n' >&2; exit 1; }
|
||||
sync_managed_destination "$source_dir" "$destination" plugins "" "" "" false > /dev/null
|
||||
merged_config=$(cat "$destination/config.toml")
|
||||
[[ "$merged_config" == *"$plugin_state"* ]] || { printf 'Updating config.toml must preserve local marketplace and plugin tables, including disabled plugins.\n' >&2; exit 1; }
|
||||
[[ "$merged_config" == *"model = 'new'"* && "$merged_config" != *'[other]'* ]] || { printf 'Managed configuration must still be updated.\n' >&2; exit 1; }
|
||||
merged_config_normalized=${merged_config//$'\r'/}
|
||||
plugin_state_normalized=${plugin_state//$'\r'/}
|
||||
[[ "$merged_config_normalized" == *"$plugin_state_normalized"* ]] || { printf 'Updating config.toml must preserve local marketplace and plugin tables, including disabled plugins.\n' >&2; exit 1; }
|
||||
[[ "$merged_config" == *"model = 'old'"* && "$merged_config" == *"reasoning_effort = 'high'"* && "$merged_config" != *"model = 'new'"* && "$merged_config" != *"reasoning_effort = 'low'"* && "$merged_config" != *'[other]'* ]] || { printf 'Updating config.toml must preserve local model and reasoning effort while updating managed configuration.\n' >&2; exit 1; }
|
||||
[ "$(cat "$destination/backups/plugins/config.toml")" = "$original_config" ] || { printf 'Original plugin configuration must be backed up.\n' >&2; exit 1; }
|
||||
sync_managed_destination "$source_dir" "$destination" plugins-repeat "" "" "" false > /dev/null
|
||||
[ "$(cat "$destination/config.toml")" = "$merged_config" ] && [ ! -d "$destination/backups/plugins-repeat" ] || { printf 'Preserving plugin configuration must be idempotent.\n' >&2; exit 1; }
|
||||
|
||||
@@ -71,6 +71,14 @@ class SelectionTests(unittest.TestCase):
|
||||
self.path.write_text(json.dumps(state))
|
||||
self.assertNotEqual(self.run_state('read').returncode, 0)
|
||||
|
||||
def test_semantic_retrieval_option_round_trips(self):
|
||||
result = self.run_state('write', '--shell', 'bash', '--client', 'both', '--update-agents', 'false',
|
||||
'--flashbang', 'true', '--semantic-retrieval', 'true')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
state = json.loads(self.run_state('read').stdout)
|
||||
self.assertTrue(state['semantic_retrieval'])
|
||||
self.assertIn('semantic_retrieval\ttrue', self.run_state('read', '--format', 'tsv').stdout)
|
||||
|
||||
def test_invalid_write_preserves_last_selection(self):
|
||||
self.save('--selected', 'Superpowers')
|
||||
original = self.path.read_bytes()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SPEC = importlib.util.spec_from_file_location('semantic_retrieval', ROOT / 'scripts/lib/semantic-retrieval.py')
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class SemanticRetrievalTests(unittest.TestCase):
|
||||
def test_claude_registration_places_name_before_environment(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory) / 'home'
|
||||
calls = []
|
||||
original = MODULE.command
|
||||
original_runtime = MODULE.ensure_runtime
|
||||
MODULE.command = lambda client, arguments, home, dry_run: calls.append((client, arguments))
|
||||
MODULE.ensure_runtime = lambda target, dry_run: target / '.venv' / 'bin' / 'python'
|
||||
try:
|
||||
MODULE.sync(ROOT, home, ['claude'], True, False, False)
|
||||
finally:
|
||||
MODULE.command = original
|
||||
MODULE.ensure_runtime = original_runtime
|
||||
add = calls[0][1]
|
||||
self.assertEqual(add[:5], ['claude', 'mcp', 'add', MODULE.NAME, '--scope'])
|
||||
self.assertIn('-e', add)
|
||||
|
||||
def test_disable_removes_owned_runtime_and_registration(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
home = Path(directory) / 'home'
|
||||
target = home / '.my-ai-configuration/semantic-retrieval'
|
||||
target.mkdir(parents=True)
|
||||
for name in MODULE.FILES:
|
||||
(target / name).write_text(name)
|
||||
state_path = target.parent / 'semantic-retrieval.json'
|
||||
state_path.write_text(json.dumps({'version': 1, 'clients': ['codex'], 'files': {
|
||||
name: hashlib.sha256((target / name).read_bytes()).hexdigest() for name in MODULE.FILES}}))
|
||||
calls = []
|
||||
original = MODULE.command
|
||||
MODULE.command = lambda client, arguments, home, dry_run: calls.append((client, arguments))
|
||||
try:
|
||||
MODULE.sync(ROOT, home, ['codex'], False, False, False)
|
||||
finally:
|
||||
MODULE.command = original
|
||||
self.assertEqual(calls[0][1], ['codex', 'mcp', 'remove', MODULE.NAME])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertFalse(state_path.exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -17,6 +17,27 @@ SHELLS = [
|
||||
|
||||
|
||||
class SessionStateHookTests(unittest.TestCase):
|
||||
def test_git_identity_and_stale_verification(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workspace = Path(directory)
|
||||
env = dict(os.environ, GIT_AUTHOR_NAME='Test', GIT_AUTHOR_EMAIL='test@example.invalid', GIT_COMMITTER_NAME='Test', GIT_COMMITTER_EMAIL='test@example.invalid')
|
||||
subprocess.run(['git', '-C', str(workspace), 'init', '-q'], check=True)
|
||||
(workspace / 'file').write_text('one')
|
||||
subprocess.run(['git', '-C', str(workspace), 'add', 'file'], check=True)
|
||||
subprocess.run(['git', '-C', str(workspace), 'commit', '-qm', 'one'], check=True, env=env)
|
||||
command = [sys.executable, str(ROOT / 'shared/hooks/scripts/session-state.py'), '--workspace', str(workspace)]
|
||||
self.assertEqual(subprocess.run(command + ['--verified', 'check'], capture_output=True).returncode, 0)
|
||||
state = json.loads((workspace / '.ai-session/state.json').read_text())
|
||||
self.assertTrue(state['head'])
|
||||
self.assertEqual(state['verification'][0]['head'], state['head'])
|
||||
(workspace / 'file').write_text('two')
|
||||
subprocess.run(['git', '-C', str(workspace), 'add', 'file'], check=True)
|
||||
subprocess.run(['git', '-C', str(workspace), 'commit', '-qm', 'two'], check=True, env=env)
|
||||
self.assertEqual(subprocess.run(command, capture_output=True).returncode, 0)
|
||||
state = json.loads((workspace / '.ai-session/state.json').read_text())
|
||||
self.assertTrue(state['verificationStale'])
|
||||
self.assertTrue(state['verification'][0]['stale'])
|
||||
|
||||
def test_writer_migrates_legacy_state_and_requires_reset_for_new_goal(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workspace = Path(directory)
|
||||
|
||||
@@ -22,8 +22,16 @@ foreach ($shell in @('powershell','bash')) {
|
||||
$package = Join-Path $root "generated/$client-$shell"
|
||||
$file = if ($client -eq 'codex') { 'config.toml' } else { 'settings.json' }
|
||||
$content = Get-Content -LiteralPath (Join-Path $package $file) -Raw
|
||||
if ($client -eq 'codex') {
|
||||
foreach ($entry in @(Import-Csv (Join-Path $root 'adapters/claude/rule-skills.tsv') -Delimiter ([char]9))) {
|
||||
$rulePath = $entry.rule_file
|
||||
if (Test-Path (Join-Path $root "shared/rules/$($entry.rule_file)") -PathType Container) { $rulePath = "$($entry.rule_file)/index.md" }
|
||||
if ((Get-Content (Join-Path $package 'AGENTS.md') -Raw) -notmatch [regex]::Escape("- rules/$rulePath for $($entry.trigger).")) { throw "Codex rule loading is missing $($entry.rule_file) in $package" }
|
||||
}
|
||||
}
|
||||
if ($client -eq 'claude') { $settings = $content | ConvertFrom-Json }
|
||||
if (@(Get-ChildItem "$package/agents" -File).Count -ne $sourceAgentCount) { throw "Missing agents in $package" }
|
||||
if ($client -eq 'claude' -and (Get-Content (Join-Path $package 'agents/reviewer.md') -Raw) -notmatch '(?m)^tools: Read,Diff,Search\r?$') { throw "Reviewer capability profile is missing in $package" }
|
||||
$expectedSkillCount = if ($client -eq 'claude') { $sourceSkillCount + $ruleSkillCount } else { $sourceSkillCount }
|
||||
if (@(Get-ChildItem "$package/skills" -Filter SKILL.md -Recurse).Count -ne $expectedSkillCount) { throw "Missing skills in $package" }
|
||||
$expected = if ($shell -eq 'powershell') { '__POWERSHELL_COMMAND__ .*flashbang.ps1' } else { 'bash .*flashbang.sh' }
|
||||
@@ -33,7 +41,7 @@ foreach ($shell in @('powershell','bash')) {
|
||||
$events = @([regex]::Matches($content, '(?m)^\[\[hooks\.([^.\]]+)\]\]\s*$') | ForEach-Object { $_.Groups[1].Value })
|
||||
if ($events.Count -ne 1 -or $events[0] -ne 'Stop' -or $content -match 'flashbang-if-input' -or $content -match '(?m)^async\s*=\s*true\s*$') { throw "Codex finish hook is incorrect in $package" }
|
||||
if ($content -notmatch 'approvals_reviewer\s*=\s*"auto_review"') { throw "Codex auto review is missing in $package" }
|
||||
if ($content -notmatch 'status_line\s*=\s*\["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"\]') { throw "Codex status line is incorrect in $package" }
|
||||
if ($content -notmatch 'status_line\s*=\s*\["model", "reasoning", "permissions", "approval-mode", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"\]') { throw "Codex status line is incorrect in $package" }
|
||||
} elseif ((($settings.hooks.PSObject.Properties.Name | Sort-Object) -join ',') -ne 'PreCompact,SessionStart,Stop' -or $content -match 'flashbang-if-input' -or $settings.hooks.Stop[0].hooks[0].async) {
|
||||
throw "Claude finish hook is incorrect in $package"
|
||||
} elseif ($content -notmatch '"defaultMode"\s*:\s*"auto"') {
|
||||
|
||||
@@ -31,7 +31,17 @@ for shell in powershell bash; do
|
||||
file=config.toml
|
||||
[ "$client" != claude ] || file=settings.json
|
||||
test -f "$package/$file"
|
||||
if [ "$client" = codex ]; then
|
||||
while IFS=$'\t' read -r rule_file skill_name trigger; do
|
||||
[ "$rule_file" = rule_file ] && continue
|
||||
[ -n "$rule_file" ] || continue
|
||||
rule_path=$rule_file
|
||||
[ -d "$root/shared/rules/$rule_file" ] && rule_path="$rule_file/index.md"
|
||||
grep -Fq -- "- rules/$rule_path for $trigger." "$package/AGENTS.md"
|
||||
done < "$root/adapters/claude/rule-skills.tsv"
|
||||
fi
|
||||
test "$(find "$package/agents" -type f | wc -l)" -eq "$source_agent_count"
|
||||
if [ "$client" = claude ]; then grep -q '^tools: Read,Diff,Search$' "$package/agents/reviewer.md"; fi
|
||||
expected_skill_count=$source_skill_count
|
||||
[ "$client" != claude ] || expected_skill_count=$((source_skill_count + rule_skill_count))
|
||||
test "$(find "$package/skills" -name SKILL.md | wc -l)" -eq "$expected_skill_count"
|
||||
@@ -46,7 +56,7 @@ for shell in powershell bash; do
|
||||
! grep -q 'flashbang-if-input' "$package/$file"
|
||||
! grep -Eq '^async[[:space:]]*=[[:space:]]*true[[:space:]]*$' "$package/$file"
|
||||
grep -q 'approvals_reviewer[[:space:]]*=[[:space:]]*"auto_review"' "$package/$file"
|
||||
grep -Fq 'status_line = ["model", "reasoning", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"]' "$package/$file"
|
||||
grep -Fq 'status_line = ["model", "reasoning", "permissions", "approval-mode", "project-name", "git-branch", "context-window-size", "context-used", "used-tokens"]' "$package/$file"
|
||||
else
|
||||
python3 -c 'import json,sys; settings=json.load(open(sys.argv[1], encoding="utf-8-sig")); assert set(settings["hooks"]) == {"PreCompact", "SessionStart", "Stop"}' "$package/$file"
|
||||
! grep -q 'flashbang-if-input' "$package/$file"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class TelemetryTests(unittest.TestCase):
|
||||
def test_bash_record_disable_and_summary(self):
|
||||
bash = shutil.which('bash')
|
||||
if not bash:
|
||||
self.skipTest('Bash unavailable')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
script = root / 'scripts/telemetry.sh'
|
||||
script.parent.mkdir()
|
||||
shutil.copy(ROOT / 'scripts/telemetry.sh', script)
|
||||
env = dict(os.environ, AI_CONFIG_TELEMETRY='1')
|
||||
result = subprocess.run([bash, str(script), 'record', 'subagent-stop', '--role', 'reviewer', '--filesRead', '3', '--outcome', 'findings'], env=env, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
event = json.loads((root / '.ai-session/telemetry.jsonl').read_text().strip())
|
||||
self.assertEqual(event['role'], 'reviewer')
|
||||
self.assertEqual(event['filesRead'], 3)
|
||||
self.assertNotIn('prompt', event)
|
||||
disabled = dict(env, AI_CONFIG_TELEMETRY='0')
|
||||
subprocess.run([bash, str(script), 'record', 'ignored'], env=disabled, check=True)
|
||||
summary = subprocess.run([bash, str(script), 'summary'], env=env, capture_output=True, text=True, check=True)
|
||||
self.assertIn('reviewer: runs 1 findings 1', summary.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+3
-1
@@ -13,6 +13,7 @@ $root = Split-Path $PSScriptRoot -Parent
|
||||
. (Join-Path $PSScriptRoot 'lib/install-targets.ps1')
|
||||
. (Join-Path $PSScriptRoot 'lib/selection-state.ps1')
|
||||
. (Join-Path $PSScriptRoot 'lib/install-options.ps1')
|
||||
. (Join-Path $PSScriptRoot 'lib/semantic-retrieval.ps1')
|
||||
|
||||
$homePath = [Environment]::GetFolderPath('UserProfile')
|
||||
if ($Quick) {
|
||||
@@ -65,6 +66,7 @@ try {
|
||||
$script:PluginNonInteractive = $previousPluginNonInteractive
|
||||
$env:CI = $previousCI
|
||||
}
|
||||
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang }
|
||||
Sync-SemanticRetrieval -RepositoryRoot $root -HomePath $homePath -Client $Client -Enabled $options.semantic_retrieval -DryRun:$DryRun -Update -Summary:$Summary
|
||||
if (-not $DryRun) { Save-UpdateSelection -HomePath $homePath -RepositoryRoot $root -Shell $Shell -Client $Client -Plugins $plugins -UpdateAgents $options.update_agents -Flashbang $options.flashbang -SemanticRetrieval $options.semantic_retrieval }
|
||||
if ($Summary) { Write-Output "Update: PASS | $Client, $Shell$(if ($DryRun) { ', dry-run' })" } else { Write-Output ($(if ($DryRun) { 'PASS update dry-run' } else { 'PASS update' })) }
|
||||
exit 0
|
||||
@@ -23,6 +23,7 @@ root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
. "$root/scripts/lib/install-targets.sh"
|
||||
. "$root/scripts/lib/selection-state.sh"
|
||||
. "$root/scripts/lib/install-options.sh"
|
||||
. "$root/scripts/lib/semantic-retrieval.sh"
|
||||
|
||||
home_path=${HOME:?HOME is required}
|
||||
selected_plugins=()
|
||||
@@ -71,6 +72,7 @@ if [ "$quick" = true ]; then
|
||||
else
|
||||
sync_configured_plugins "$root" "$client" "$home_path" "$dry_run" true selected_plugins "$summary"
|
||||
fi
|
||||
sync_semantic_retrieval "$root" "$client" "$home_path" "$semantic_retrieval" "$dry_run" true
|
||||
if [ "$dry_run" = false ]; then save_update_selection; fi
|
||||
|
||||
if [ "$summary" = true ]; then
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
Provide analytical correctness review using the acceptance criteria and actual git diff. Inspect changed symbols first, then direct callers, dependencies, tests, and contracts needed to judge correctness. Expand for shared state, configuration, persistence, security, generated code, or public API effects; avoid broad repository exploration by default.
|
||||
|
||||
For each finding, report SEVERITY (blocking, important, or minor), LOCATION, IMPACT, EVIDENCE, and SCOPE (introduced, pre-existing, or uncertain). Do not report stylistic preferences or pre-existing issues unless they materially affect the requested change. Prioritize actionable correctness defects, keep unrelated cleanup out of scope, and use blocking only for security, data-loss, broken contracts, or deterministic failures.
|
||||
|
||||
Check logic, regressions, architecture, maintainability, security, and contract consistency. Do not automatically rerun the full test suite or duplicate a verifier's evidence. Follow the shared orchestration handoff protocol; report actionable findings with severity and evidence. Do not modify implementation code unless assigned.
|
||||
@@ -2,7 +2,11 @@
|
||||
param()
|
||||
$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
$stateDir = Join-Path $root '.ai-session'
|
||||
New-Item $stateDir -ItemType Directory -Force | Out-Null
|
||||
('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content (Join-Path $stateDir 'telemetry.jsonl')
|
||||
if ($env:AI_CONFIG_TELEMETRY -ne '0') {
|
||||
New-Item $stateDir -ItemType Directory -Force | Out-Null
|
||||
$path = Join-Path $stateDir 'telemetry.jsonl'
|
||||
('{"event":"compact","timestamp":"' + (Get-Date).ToUniversalTime().ToString('o') + '"}') | Add-Content $path
|
||||
if ((Get-Item $path).Length -gt 1048576) { Move-Item $path ($path + '.1') -Force }
|
||||
}
|
||||
& python (Join-Path $PSScriptRoot 'session-state.py') --hook
|
||||
exit $LASTEXITCODE
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
mkdir -p "$root/.ai-session"
|
||||
printf '{"event":"compact","timestamp":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$root/.ai-session/telemetry.jsonl"
|
||||
[ "${AI_CONFIG_TELEMETRY:-1}" = 0 ] || { mkdir -p "$root/.ai-session" && printf '{"event":"compact","timestamp":"%s"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$root/.ai-session/telemetry.jsonl"; [ "$(wc -c < "$root/.ai-session/telemetry.jsonl")" -le 1048576 ] || mv -f "$root/.ai-session/telemetry.jsonl" "$root/.ai-session/telemetry.jsonl.1"; }
|
||||
python3 "$root/hooks/scripts/session-state.py" --hook
|
||||
@@ -4,6 +4,8 @@ import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import subprocess
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@@ -26,6 +28,28 @@ def show_pointer(path):
|
||||
print('Task state is available at ' + str(path) + '; validate its workspace and goal before reuse.')
|
||||
|
||||
|
||||
def git_identity(workspace):
|
||||
try:
|
||||
def git(*args):
|
||||
return subprocess.check_output(['git', '-C', str(workspace), *args], stderr=subprocess.DEVNULL, text=True).strip()
|
||||
root = Path(git('rev-parse', '--show-toplevel')).resolve()
|
||||
try:
|
||||
branch = git('symbolic-ref', '--quiet', '--short', 'HEAD') or 'HEAD'
|
||||
except subprocess.CalledProcessError:
|
||||
branch = 'HEAD'
|
||||
head = git('rev-parse', 'HEAD')
|
||||
try:
|
||||
repository = git('config', '--get', 'remote.origin.url') or str(root)
|
||||
if '://' in repository:
|
||||
parsed = urlsplit(repository)
|
||||
repository = urlunsplit((parsed.scheme, parsed.hostname or '', parsed.path, '', ''))
|
||||
except subprocess.CalledProcessError:
|
||||
repository = str(root)
|
||||
return {'repository': repository, 'branch': branch, 'head': head}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def hook_pointer():
|
||||
try:
|
||||
raw = '' if sys.stdin.isatty() else sys.stdin.buffer.read(1048577).decode('utf-8-sig')
|
||||
@@ -47,18 +71,25 @@ def update_state(args):
|
||||
state = json.loads(path.read_text(encoding='utf-8-sig')) if path.exists() else {}
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError('Invalid task state')
|
||||
for metadata in ('repository', 'branch', 'head'):
|
||||
if metadata in state and not isinstance(state[metadata], str):
|
||||
raise ValueError('Invalid task state metadata')
|
||||
if 'verificationStale' in state and not isinstance(state['verificationStale'], bool):
|
||||
raise ValueError('Invalid task state metadata')
|
||||
workspace = str(path.parent.parent)
|
||||
if state.get('workspace', workspace) != workspace:
|
||||
raise ValueError('Task state belongs to another workspace')
|
||||
for old, new in (('changedFiles', 'changed'), ('pending', 'open'), ('importantFindings', 'decisions')):
|
||||
if old in state:
|
||||
state.setdefault(new, state.pop(old))
|
||||
for field in ('goal', 'decisions', 'changed', 'verified', 'open', 'risks'):
|
||||
for field in ('goal', 'decisions', 'changed', 'verified', 'open', 'risks', 'verification'):
|
||||
if field not in state:
|
||||
continue
|
||||
value = state[field]
|
||||
if field == 'goal':
|
||||
valid = isinstance(value, str)
|
||||
elif field == 'verification':
|
||||
valid = isinstance(value, list) and all(isinstance(item, dict) and isinstance(item.get('check'), str) and isinstance(item.get('result'), str) for item in value)
|
||||
else:
|
||||
valid = isinstance(value, list) and all(isinstance(item, str) for item in value)
|
||||
if not valid:
|
||||
@@ -68,12 +99,28 @@ def update_state(args):
|
||||
elif args.goal is not None and state.get('goal') and state['goal'] != args.goal:
|
||||
raise ValueError('A different goal requires reset')
|
||||
state['workspace'] = workspace
|
||||
previous_identity = {key: state.get(key) for key in ('repository', 'branch', 'head')}
|
||||
identity = git_identity(workspace)
|
||||
if identity:
|
||||
state.update(identity)
|
||||
if path.exists() and any(previous_identity.get(key) not in (None, identity[key]) for key in identity):
|
||||
state['verificationStale'] = True
|
||||
for item in state.get('verification', []):
|
||||
item['stale'] = True
|
||||
else:
|
||||
for key in ('repository', 'branch', 'head'):
|
||||
state.pop(key, None)
|
||||
for field in ('goal', 'decisions', 'changed', 'verified', 'open', 'risks'):
|
||||
value = getattr(args, field)
|
||||
if value is not None:
|
||||
state[field] = value
|
||||
else:
|
||||
state.setdefault(field, '' if field == 'goal' else [])
|
||||
state.setdefault('verification', [])
|
||||
if getattr(args, 'verified', None):
|
||||
state['verification'] = [dict(check=check, head=identity['head'], result='pass') for check in args.verified] if identity else state.get('verification', [])
|
||||
if identity:
|
||||
state['verificationStale'] = False
|
||||
state['updatedAt'] = datetime.now(timezone.utc).isoformat()
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
temporary = None
|
||||
@@ -99,7 +146,7 @@ def main():
|
||||
parser.add_argument('--pointer', action='store_true')
|
||||
parser.add_argument('--reset', action='store_true')
|
||||
parser.add_argument('--goal')
|
||||
for field in ('decisions', 'changed', 'verified', 'open', 'risks'):
|
||||
for field in ('decisions', 'changed', 'verified', 'open', 'risks', 'verification'):
|
||||
parser.add_argument('--' + field, nargs='*')
|
||||
args = parser.parse_args()
|
||||
if args.hook:
|
||||
@@ -111,11 +158,14 @@ def main():
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError('Invalid task state update')
|
||||
for field, value in values.items():
|
||||
if field not in ('workspace', 'goal', 'decisions', 'changed', 'verified', 'open', 'risks'):
|
||||
if field not in ('workspace', 'goal', 'decisions', 'changed', 'verified', 'open', 'risks', 'verification'):
|
||||
raise ValueError('Invalid task state field')
|
||||
if field in ('workspace', 'goal'):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError('Invalid task state value')
|
||||
elif field == 'verification':
|
||||
if not isinstance(value, list) or any(not isinstance(item, dict) or not isinstance(item.get('check'), str) or not isinstance(item.get('result'), str) for item in value):
|
||||
raise ValueError('Invalid verification list')
|
||||
elif not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
||||
raise ValueError('Invalid task state list')
|
||||
setattr(args, field, value)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
mcp>=1.9,<2
|
||||
sentence-transformers>=4.1,<5
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local MCP semantic search backed by Qwen3-Embedding-0.6B."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
|
||||
|
||||
MODEL = "Qwen/Qwen3-Embedding-0.6B"
|
||||
TEXT_EXTENSIONS = {".c", ".cpp", ".cs", ".go", ".java", ".js", ".json", ".md", ".py", ".ps1", ".rs", ".sh", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml"}
|
||||
|
||||
|
||||
def chunks(path: Path):
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for start in range(0, len(text), 1050):
|
||||
value = text[start:start + 1200].strip()
|
||||
if value:
|
||||
yield value
|
||||
|
||||
|
||||
class Index:
|
||||
def __init__(self, root: Path, data: Path):
|
||||
self.root = root.resolve()
|
||||
self.data = data.resolve()
|
||||
self.data.mkdir(parents=True, exist_ok=True)
|
||||
self.db = sqlite3.connect(self.data / "index.sqlite3")
|
||||
self.db.execute("create table if not exists chunks (path text, text text, vector blob)")
|
||||
self.db.commit()
|
||||
self.model = None
|
||||
|
||||
def files(self):
|
||||
ignored = {".git", ".venv", "generated", "node_modules", ".my-ai-configuration"}
|
||||
result = []
|
||||
for path in self.root.rglob("*"):
|
||||
if len(result) >= 2000 or not path.is_file() or path.suffix.lower() not in TEXT_EXTENSIONS or path.stat().st_size > 524288:
|
||||
continue
|
||||
if ignored.intersection(path.relative_to(self.root).parts):
|
||||
continue
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
def encoder(self):
|
||||
if self.model is None:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
self.model = SentenceTransformer(MODEL)
|
||||
return self.model
|
||||
|
||||
def rebuild(self):
|
||||
encoder = self.encoder()
|
||||
values = []
|
||||
for path in self.files():
|
||||
for value in chunks(path):
|
||||
values.append((str(path.relative_to(self.root)), value))
|
||||
self.db.execute("delete from chunks")
|
||||
if values:
|
||||
vectors = encoder.encode([value for _, value in values], normalize_embeddings=True)
|
||||
self.db.executemany("insert into chunks values (?, ?, ?)", ((path, value, vector.tobytes()) for (path, value), vector in zip(values, vectors)))
|
||||
self.db.commit()
|
||||
return len(values)
|
||||
|
||||
def search(self, query: str, top_k: int):
|
||||
if not query.strip():
|
||||
raise ValueError("query must not be empty")
|
||||
if self.db.execute("select count(*) from chunks").fetchone()[0] == 0:
|
||||
self.rebuild()
|
||||
vector = self.encoder().encode([query], normalize_embeddings=True)[0]
|
||||
rows = []
|
||||
for path, text, blob in self.db.execute("select path, text, vector from chunks"):
|
||||
import numpy as np
|
||||
score = float(np.dot(vector, np.frombuffer(blob, dtype=np.float32)))
|
||||
rows.append({"path": path, "score": round(score, 6), "text": text})
|
||||
return sorted(rows, key=lambda item: item["score"], reverse=True)[:max(1, min(top_k, 20))]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default=os.getcwd(), type=Path)
|
||||
parser.add_argument("--data-dir", required=True, type=Path)
|
||||
parser.add_argument("--model-cache", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
args.model_cache.mkdir(parents=True, exist_ok=True)
|
||||
os.environ.setdefault("HF_HOME", str(args.model_cache.resolve()))
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
index = Index(args.root, args.data_dir)
|
||||
mcp = FastMCP("my-ai-qwen3-retrieval")
|
||||
|
||||
@mcp.tool()
|
||||
def semantic_search(query: str, top_k: int = 5) -> list[dict]:
|
||||
"""Find repository passages by meaning with Qwen3-Embedding-0.6B."""
|
||||
return index.search(query, top_k)
|
||||
|
||||
@mcp.tool()
|
||||
def rebuild_index() -> str:
|
||||
"""Rebuild the local repository embedding index."""
|
||||
return json.dumps({"chunks": index.rebuild()})
|
||||
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -19,6 +19,16 @@ Project instructions may specialize global defaults, but must not weaken securit
|
||||
* Ask about business behavior, user-visible behavior, UI, UX, APIs, database models, configuration formats, or compatibility behavior only when it is missing, ambiguous, contradictory, or open to interpretation; implement explicitly specified behavior without asking again.
|
||||
* For deeper guidance on ambiguous requirements or a meaningful technical choice, consult `decision-rule.md` (Codex: read `rules/decision-rule.md`; Claude: invoke the `rules-decision-rule` skill).
|
||||
|
||||
## Semantic repository search
|
||||
|
||||
* In Codex and Claude Code, proactively use Qwen3-Embedding-0.6B through the `my-ai-qwen3-retrieval` MCP server's `semantic_search` tool whenever available and useful. Discover the tool if it is not already exposed.
|
||||
* Use semantic search early to find unfamiliar code, understand responsibilities and relationships, locate similar implementations, or investigate behavior described without exact symbols. Reuse relevant results and search again when a new question would benefit.
|
||||
* Use direct file reads for known paths and exact text search for symbols, literals, and exhaustive caller checks. Combine these with semantic search when meaning-based discovery adds value.
|
||||
* Treat retrieved passages as leads: verify them against current files in the intended workspace before relying on them. Empty results do not prove absence; stale or unrelated results require direct inspection.
|
||||
* If retrieval is unavailable, fails, or adds no value, continue with ordinary repository tools. Report failures briefly; avoid repeated failing calls or installing or enabling retrieval without authorization.
|
||||
|
||||
* When QMD is available, use its `qmd` MCP tools for questions about indexed Markdown notes, documents, meeting records, or knowledge bases. Verify retrieved results against the source document when the current content matters.
|
||||
|
||||
## Verification
|
||||
|
||||
* Choose checks independently by behavior, callers, impact, reversibility, and uncertainty. Focused inspection can suffice; builds, tests, new tests, and separate reviews are not automatic.
|
||||
|
||||
Reference in new issue
Block a user