Merge remote-tracking branch 'origin/main' into pr-3146

# Conflicts:
#	docs/es/rules/common/agents.md
#	docs/ja-JP/rules/common/agents.md
#	docs/tr/rules/common/agents.md
#	docs/zh-CN/rules/common/agents.md
#	rules/common/agents.md
This commit is contained in:
Affaan Mustafa
2026-09-18 21:47:13 -04:00
21 changed files with 3028 additions and 675 deletions
+3 -1
View File
@@ -3,7 +3,7 @@
## Agentes Disponibles
Los agentes de ECC se distribuyen con el plugin `ecc@ecc`, no en `~/.claude/agents/`.
Se invocan a través de la herramienta Agent con un `subagent_type` con scope del plugin:
Se invocan a través de la herramienta Agent con un `subagent_type` con ámbito de plugin:
Agent(subagent_type: "ecc:planner", prompt: "...")
@@ -21,6 +21,8 @@ Se invocan a través de la herramienta Agent con un `subagent_type` con scope de
| ecc:rust-reviewer | Revisión de código Rust | Proyectos Rust |
| ecc:harmonyos-app-resolver | Desarrollo de apps HarmonyOS | Proyectos HarmonyOS/ArkTS |
Para el roster completo de 68 agentes, ver `/ecc:ecc-guide`.
## Uso Inmediato de Agentes
Sin necesidad de prompt del usuario:
+1867 -653
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -2,8 +2,8 @@
## 利用可能な Agent
ECC エージェント`ecc@ecc` プラグインに同梱されており、`~/.claude/agents/` にはありません。
プラグインスコープの `subagent_type` を使用して Agent ツールから呼び出します:
ECC の Agent `ecc@ecc` プラグインに同梱されており、`~/.claude/agents/` には配置されません。
Agent ツールではプラグインスコープの `subagent_type` 呼び出します:
Agent(subagent_type: "ecc:planner", prompt: "...")
@@ -19,6 +19,8 @@ ECC エージェントは `ecc@ecc` プラグインに同梱されており、`~
| ecc:refactor-cleaner | デッドコードクリーンアップ | コードメンテナンス |
| ecc:doc-updater | ドキュメント | ドキュメント更新 |
全 68 Agent の一覧は `/ecc:ecc-guide` を参照。
## Agent の即座の使用
ユーザープロンプト不要:
+4 -2
View File
@@ -2,8 +2,8 @@
## Mevcut Agent'lar
ECC agent'ları `ecc@ecc` eklentisi ile birlikte gelir, `~/.claude/agents/` içinde değildirler.
Plugin kapsamı `subagent_type` ile Agent aracılığıyla çağrılır:
ECC agent'ları `ecc@ecc` eklentisiyle birlikte gelir, `~/.claude/agents/` dizininde bulunmaz.
Agent aracıyla eklenti kapsamlı bir `subagent_type` ile çağrılır:
Agent(subagent_type: "ecc:planner", prompt: "...")
@@ -20,6 +20,8 @@ Plugin kapsamı `subagent_type` ile Agent aracılığıyla çağrılır:
| ecc:doc-updater | Dokümantasyon | Dokümanları güncelleme |
| ecc:rust-reviewer | Rust kod incelemesi | Rust projeleri |
68 agent'ın tam listesi için `/ecc:ecc-guide` bölümüne bakın.
## Anlık Agent Kullanımı
Kullanıcı istemi gerekmez:
+4 -2
View File
@@ -2,8 +2,8 @@
## 可用智能体
ECC 智能体随 `ecc@ecc` 插件一起分发,而非位于 `~/.claude/agents/` 中。
它们通过具有插件作用域 `subagent_type` 的 Agent 工具调用:
ECC 智能体随 `ecc@ecc` 插件一起分发,不在 `~/.claude/agents/` 目录中。
它们通过 Agent 工具以插件作用域 `subagent_type` 调用:
Agent(subagent_type: "ecc:planner", prompt: "...")
@@ -20,6 +20,8 @@ ECC 智能体随 `ecc@ecc` 插件一起分发,而非位于 `~/.claude/agents/`
| ecc:doc-updater | 文档 | 更新文档 |
| ecc:rust-reviewer | Rust 代码审查 | Rust 项目 |
完整 68 个智能体的清单参见 `/ecc:ecc-guide`
## 即时智能体使用
无需用户提示:
+2
View File
@@ -21,6 +21,8 @@ They are invoked through the Agent tool with a plugin-scoped `subagent_type`:
| ecc:rust-reviewer | Rust code review | Rust projects |
| ecc:harmonyos-app-resolver | HarmonyOS app development | HarmonyOS/ArkTS projects |
For the full roster of 68 agents, see `/ecc:ecc-guide`.
## Immediate Agent Usage
No user prompt needed:
+160 -4
View File
@@ -117,16 +117,172 @@ if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then
go test ./... || fail "go test failed"
fi
# Resolve how this project runs pytest, into PYTEST_CMD as an argv array.
#
# Looking only for `pytest` on PATH meant the hook skipped every project that keeps
# its tools in a virtualenv -- which is most of them -- and reported "pytest is not
# installed" while sitting next to a .venv with pytest in it. A gate that silently
# declines to gate is worse than no gate, because the skip line reads like a pass.
#
# An array rather than one string, because a virtualenv path may contain spaces:
# a scalar command splits `/home/me/my env/bin/python` into two paths that do not
# exist, and the hook then rejects the push for a reason that has nothing to do
# with the code being pushed.
#
# Echoes the command it will run, so the reason for a skip is always visible.
PYTEST_CMD=()
# Does this command actually run pytest? Accepting `--version` is not evidence --
# plenty of programs take it and exit 0 -- so the output has to name pytest. The
# version is captured rather than piped: under `set -o pipefail` a `| grep -q` can
# report the SIGPIPE of the program it just matched.
#
# Only ever called on a command this script composed itself. Probing an arbitrary
# operator-supplied command is not safe: a wrapper that ignores `--version` and
# execs pytest runs the entire suite during the probe, and is then rejected for
# not having printed a version.
is_pytest() {
local version
version="$("$@" --version 2>&1)" || return 1
grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version"
}
# Does the repository itself ship this interpreter?
#
# A virtualenv is never committed -- it is platform-specific binaries, and every
# Python project gitignores it. One that IS tracked is the repository handing this
# hook an executable and asking it to run. The hook is installed globally, so
# cloning a hostile repository and pushing it to your own fork would be enough,
# and on a machine with no pytest on PATH this arm is the only thing that would
# run at all. A developer's own venv is untracked, so nothing legitimate is lost.
#
# The path is resolved through symlinks before git is asked, because `git ls-files`
# reports paths as indexed and does not follow links. A repository that commits
# `.venv` as a symlink to `.` next to a tracked `bin/python` would otherwise be
# queried for `.venv/bin/python`, a path git has never heard of, and the answer
# would be "untracked". Measured: that shape ran the planted binary twice.
repo_ships_interpreter() {
local bindir real top
bindir="$(cd -P -- "$1" 2>/dev/null && pwd -P)" || return 1
[[ -n "$bindir" ]] || return 1
real="$bindir/python"
top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
top="$(cd -P -- "$top" 2>/dev/null && pwd -P)" || return 1
[[ -n "$top" && "$real" == "$top/"* ]] || return 1
# `:(icase)` because git matches index pathspecs case-sensitively even where
# core.ignorecase is set, while the filesystem underneath does not. On macOS's
# APFS -- the platform this hook most often runs on -- a committed
# `.venv/bin/Python` is what `$venv/bin/python` opens and executes, but a
# case-sensitive query for the lowercase name finds nothing in the index and the
# guard waves it through. Measured: that spelling ran the planted binary twice.
git ls-files --error-unmatch -- ":(icase)${real#"$top"/}" >/dev/null 2>&1
}
# `-I` isolates the probe: without it Python puts the working directory first on
# sys.path, so a repository that commits a `pytest.py` in its root gets that file
# imported -- and executed -- by a check whose only job is to answer whether pytest
# exists. Measured: a committed pytest.py ran during the probe. Isolation does not
# hide a real pytest, which lives in the interpreter's own site-packages.
resolve_pytest() {
# `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is
# still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave
# alike, where the first used to fall through to discovery and the second failed
# the push. Falling through is the wrong half of that pair -- an override that
# evaluated empty (a command substitution that found nothing, say) would silently
# run a different runner than the operator asked for, which is the substitution
# this resolver refuses to make anywhere else.
#
# Not `[[ -v ECC_PYTEST_CMD ]]`: that is bash 4.2, and a stock macOS `/bin/bash`
# is 3.2, where it is a syntax error rather than a false. This hook ships to
# whatever `env bash` finds.
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
# Taken as given. This is a deliberate override, and the hook cannot inspect it
# without running it -- a wrapper script may ignore `--version` and run the
# suite, so probing costs a duplicate test run and then blocks the push anyway.
# Pointing this at something that is not pytest turns the gate off, and that is
# the operator's call to make, not a misconfiguration for the hook to second
# guess. Word-split, so the command names something on PATH or an interpreter
# whose path has no spaces; a venv with spaces is found by the loop below.
read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true
[[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command.\
Point it at your test runner, or unset it to fall back to discovery."
return 0
fi
local venv
for venv in "${VIRTUAL_ENV:-}" .venv venv env; do
if [[ -n "$venv" && -x "$venv/bin/python" ]]; then
if repo_ships_interpreter "$venv/bin"; then
log "Ignoring $venv/bin/python: the repository ships it."
log " A committed virtualenv is an executable the repository controls, and"
log " this hook runs on every push in every repository."
continue
fi
if "$venv/bin/python" -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=("$venv/bin/python" -m pytest)
return 0
fi
fi
done
if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then
if uv run --no-sync python -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=(uv run --no-sync pytest)
return 0
fi
fi
if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then
if poetry run python -I -c "import pytest" >/dev/null 2>&1; then
PYTEST_CMD=(poetry run pytest)
return 0
fi
fi
# `command -v` proves only that a file of that name exists on PATH. This one the
# script composed itself, so confirming it costs a harmless `pytest --version`.
if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then
PYTEST_CMD=(pytest)
return 0
fi
PYTEST_CMD=()
return 1
}
if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
if command -v pytest >/dev/null 2>&1; then
if resolve_pytest; then
ran_any_check=1
log "Python project detected. Running: pytest -q"
pytest -q || fail "pytest failed"
log "Python project detected. Running: ${PYTEST_CMD[*]} -q"
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
# resolve_pytest deliberately does not verify the override is pytest, because
# probing it can run the operator's suite. What this gate can honestly do
# about a stale override is refuse to be quiet about it: a bypass announced
# on every push is not the silent gate this resolver exists to prevent.
log " via ECC_PYTEST_CMD -- the hook runs what you pointed it at, and does"
log " not check that it is pytest. Unset it to gate on the real suite."
fi
pytest_status=0
"${PYTEST_CMD[@]}" -q || pytest_status=$?
case "$pytest_status" in
0) ;;
# pytest reserves 5 for NO_TESTS_COLLECTED, which is not a red suite. A
# pyproject.toml that only configures ruff or black is still a Python project
# by this hook's test, and blocking those pushes would make the gate something
# people switch off. Never silent, though: a bad rootdir, testpaths or a
# conftest that fails to import also collects nothing, and swallowing that is
# the same skip-reads-like-a-pass hole this resolver exists to close.
5)
log "pytest collected no tests (exit 5). Not gating this push."
log " If this repository is supposed to have tests, that is the bug:"
log " check rootdir, testpaths, and conftest.py import errors."
;;
# The code is in the message because 1 (tests failed) and 4 (usage error)
# need different responses, and "pytest failed" alone cannot tell them apart.
*) fail "pytest failed (exit $pytest_status)" ;;
esac
else
log "Python project detected but pytest is not installed. Skipping."
log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv,"
log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it."
fi
fi
if [[ "$ran_any_check" -eq 0 ]]; then
log "No supported checks found in this repository. Skipping."
else
+20
View File
@@ -1101,6 +1101,21 @@ function isReadOnlyGitIntrospection(command) {
// --- Gate messages ---
/**
* Batch-consistency warning (#3136). A first-touch denial marks the file
* checked so the retry passes; a parallel batch of edits to one
* not-yet-touched file therefore partially applies (first call denied,
* siblings allowed). Hooks see calls one at a time and cannot lock a
* batch, so the denial must say this out loud: name the file and tell
* the agent that siblings may already have been applied.
*/
function batchSiblingWarning(safePath) {
return (
`If this call was sent in a parallel batch, other edits to ${safePath} from that batch ` +
'may already have been applied. Re-read the file before building on them.'
);
}
function editGateMsg(filePath) {
const safe = sanitizePath(filePath);
return [
@@ -1113,6 +1128,8 @@ function editGateMsg(filePath) {
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
"4. Quote the user's current instruction verbatim",
'',
batchSiblingWarning(safe),
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
@@ -1129,6 +1146,8 @@ function writeGateMsg(filePath) {
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
"4. Quote the user's current instruction verbatim",
'',
batchSiblingWarning(safe),
'',
'Present the facts, then retry the same operation.'
].join('\n');
}
@@ -1143,6 +1162,7 @@ function condensedGateMsg(action, filePath, ordinal) {
return (
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " +
`${batchSiblingWarning(safe)} ` +
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)'
);
}
+11 -4
View File
@@ -3,16 +3,23 @@
const { extractCommandSubstitutions } = require('../lib/shell-substitution');
/**
* Recognize the deliberately narrow passive sink supported by this parser.
* Shell operators and substitutions make the payload's destination ambiguous,
* so every other form retains the original input for fail-closed checks.
* Recognize proven-passive sinks whose heredoc payload is data, not a command
* stream. `cat` and `tee` (optionally path-qualified, or wrapped in
* `command`/`builtin`/`env`) only write stdin; they do not execute the body.
* Shell operators or substitution markers make the destination ambiguous, so
* every other form retains the original input for fail-closed checks.
*
* @param {string} line
* @returns {boolean}
*/
function isProvenPassiveHeredocLine(line) {
const trimmed = line.trim();
return /^cat(?=\s|[<>])/.test(trimmed) && !/[;&|()`]/.test(trimmed);
// Fail closed on control operators / grouping / command substitutions.
if (/[;&|()`]/.test(trimmed)) return false;
// Optional wrapper + optional path prefix + cat|tee, then args or redirect.
return /^(?:(?:command|builtin|env)\s+)?(?:(?:\.\/|\/(?:[\w.+-]+\/)*)?(?:cat|tee))(?=\s|[<>])/.test(
trimmed
);
}
/**
+7
View File
@@ -132,6 +132,13 @@ function printHumanPlan(plan, dryRun) {
}
}
if (Array.isArray(plan.reconciledExcludedPaths) && plan.reconciledExcludedPaths.length > 0) {
console.log('\nReconciled excluded paths:');
for (const removedPath of plan.reconciledExcludedPaths) {
console.log(`- removed ${removedPath}`);
}
}
if (!dryRun) {
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
}
+3 -1
View File
@@ -1,6 +1,7 @@
const path = require('path');
const {
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
createInstallTargetAdapter,
createRemappedOperation,
isForeignPlatformPath,
@@ -52,6 +53,7 @@ module.exports = createInstallTargetAdapter({
kind: 'home',
rootSegments: ['.claude'],
installStatePathSegments: ['ecc', 'install-state.json'],
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
nativeRootRelativePath: '.claude-plugin',
planOperations(input, adapter) {
const modules = Array.isArray(input.modules)
@@ -66,7 +68,7 @@ module.exports = createInstallTargetAdapter({
return modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, adapter.target))
.filter(p => !isForeignPlatformPath(p, adapter.target) && !adapter.excludesSourcePath(p))
.flatMap(sourceRelativePath => {
if (
module.id === 'hooks-runtime'
+2 -1
View File
@@ -1,4 +1,4 @@
const { createInstallTargetAdapter } = require('./helpers');
const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'codex-home',
@@ -7,4 +7,5 @@ module.exports = createInstallTargetAdapter({
rootSegments: ['.codex'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.codex',
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
});
+23 -2
View File
@@ -24,6 +24,14 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.adal': 'adal',
});
// Source paths that home installs must never copy into a harness home
// directory. `.agents` is ECC's repo-local skills/plugins staging area:
// project targets such as kimi and antigravity consume it, but neither
// Claude Code nor Codex reads a `.agents` directory under ~/.claude or
// ~/.codex, so copying it there produces unread files that doctor flags as
// drift and repair keeps restoring.
const HOME_INSTALL_EXCLUDED_SOURCE_PATHS = Object.freeze(['.agents']);
function normalizeRelativePath(relativePath) {
return String(relativePath || '')
.replace(/\\/g, '/')
@@ -43,6 +51,14 @@ function isForeignPlatformPath(sourceRelativePath, adapterTarget) {
return false;
}
function isExcludedSourcePath(sourceRelativePath, excludedSourcePaths = []) {
const normalizedPath = normalizeRelativePath(sourceRelativePath);
return excludedSourcePaths.some(excluded => {
const prefix = normalizeRelativePath(excluded);
return prefix !== '' && (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
});
}
function resolveBaseRoot(scope, input = {}) {
if (scope === 'home') {
return input.homeDir || os.homedir();
@@ -351,6 +367,9 @@ function createInstallTargetAdapter(config) {
strategy: adapter.determineStrategy(normalizedSourcePath),
});
},
excludesSourcePath(sourceRelativePath) {
return isExcludedSourcePath(sourceRelativePath, config.excludedSourcePaths);
},
planOperations(input = {}) {
if (typeof config.planOperations === 'function') {
return config.planOperations(input, adapter);
@@ -360,7 +379,7 @@ function createInstallTargetAdapter(config) {
return input.modules.flatMap(module => {
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, config.target))
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
@@ -372,7 +391,7 @@ function createInstallTargetAdapter(config) {
const module = input.module || {};
const paths = Array.isArray(module.paths) ? module.paths : [];
return paths
.filter(p => !isForeignPlatformPath(p, config.target))
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
.map(sourceRelativePath => adapter.createScaffoldOperation(
module.id,
sourceRelativePath,
@@ -399,6 +418,8 @@ function createInstallTargetAdapter(config) {
}
module.exports = {
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
isExcludedSourcePath,
buildValidationIssue,
createFlatFileOperations,
createFlatRuleOperations,
+23 -2
View File
@@ -34,6 +34,10 @@ const {
preserveUnwrittenFiles,
} = require('./ownership-guard');
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
const {
completeExcludedPathsReconciliation,
prepareExcludedPathsReconciliation,
} = require('./excluded-paths-reconciliation');
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
const { adaptAntigravityAgent } = require('./antigravity-agent');
@@ -449,9 +453,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
const migration = prepareHookConsentMigration(
const migration = prepareExcludedPathsReconciliation(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
prepareHookConsentMigration(
plan,
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
)
);
const appliedPlan = {
...plan,
@@ -666,17 +673,31 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
];
}
let excludedPathsRemoved = [];
let excludedPathsWarnings = [];
try {
const excludedReconciliation = completeExcludedPathsReconciliation(migration, appliedPlan);
excludedPathsRemoved = excludedReconciliation.removedPaths;
excludedPathsWarnings = excludedReconciliation.warnings;
} catch (error) {
excludedPathsWarnings = [
`Excluded-paths reconciliation did not finish: ${error.message}. Previously managed files under excluded source paths were preserved; remove them manually or rerun the install.`,
];
}
return {
...plan,
statePreview: finalState,
plannedOperations: [...plan.operations],
operations: migration.appliedOperations,
skippedOperations: migration.skippedOperations,
reconciledExcludedPaths: excludedPathsRemoved,
warnings: [
...(Array.isArray(plan.warnings) ? plan.warnings : []),
...migration.warnings,
...antigravityMigrationWarnings,
...opencodeMigrationWarnings,
...excludedPathsWarnings,
],
applied: true,
};
@@ -0,0 +1,230 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const { getInstallTargetAdapter } = require('../install-targets/registry');
/**
* Upgrade reconciliation for excluded source paths (issue #3116).
*
* Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude
* and Codex home targets). The exclusion stops new copy operations from being
* planned, but a home install created before the exclusion still has the
* copied files on disk and the copy operations recorded in install-state, so
* doctor keeps reporting drift and repair keeps restoring files the target
* never reads.
*
* prepareExcludedPathsReconciliation runs before the new state is written: it
* reads the previous install-state and drops the recorded managed operations
* whose source path is now excluded. completeExcludedPathsReconciliation runs
* after a successful apply: it removes the files those operations recorded,
* but only when the recorded content digest still matches, and prunes the
* emptied directories. Files the state does not own, modified files,
* symlinks, and anything outside the target root are preserved with a
* warning.
*/
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function getReconcilingAdapter(plan) {
if (!plan || typeof plan.target !== 'string') {
return null;
}
let adapter;
try {
adapter = getInstallTargetAdapter(plan.target);
} catch {
return null;
}
return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null;
}
function isRecordedExcludedManagedOperation(adapter, operation) {
return Boolean(
operation
&& operation.ownership === 'managed'
&& typeof operation.destinationPath === 'string'
&& typeof operation.sourceRelativePath === 'string'
&& adapter.excludesSourcePath(operation.sourceRelativePath)
);
}
function filterStateOperations(state, shouldDrop) {
if (!state || !Array.isArray(state.operations)) {
return state;
}
return {
...state,
operations: state.operations.filter(operation => !shouldDrop(operation)),
};
}
function prepareExcludedPathsReconciliation(plan, migration) {
const adapter = getReconcilingAdapter(plan);
if (!adapter || !fs.existsSync(plan.installStatePath)) {
return { ...migration, excludedPathCandidates: [] };
}
const previousState = readInstallState(plan.installStatePath);
const candidates = ((previousState && previousState.operations) || [])
.filter(operation => isRecordedExcludedManagedOperation(adapter, operation));
if (candidates.length === 0) {
return { ...migration, excludedPathCandidates: [] };
}
const droppedDestinations = new Set(
candidates.map(operation => comparablePath(operation.destinationPath))
);
const shouldDrop = operation => Boolean(
operation
&& typeof operation.destinationPath === 'string'
&& droppedDestinations.has(comparablePath(operation.destinationPath))
&& typeof operation.sourceRelativePath === 'string'
&& adapter.excludesSourcePath(operation.sourceRelativePath)
);
return {
...migration,
bridgeState: filterStateOperations(migration.bridgeState, shouldDrop),
finalState: filterStateOperations(migration.finalState, shouldDrop),
excludedPathCandidates: candidates,
};
}
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function hashFileNoFollow(filePath) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor, { bigint: true });
if (!before.isFile()) {
throw new Error(`Refusing to read a non-file at ${filePath}`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
const unchanged = before.dev === after.dev
&& before.ino === after.ino
&& before.size === after.size
&& after.dev === finalPathStat.dev
&& after.ino === finalPathStat.ino
&& after.size === finalPathStat.size;
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
}
return crypto.createHash('sha256').update(content).digest('hex');
} finally {
fs.closeSync(descriptor);
}
}
function removeEmptyParents(startPath, targetRoot) {
let currentPath = path.dirname(startPath);
while (comparablePath(currentPath) !== comparablePath(targetRoot)) {
const safePath = assertWithinTrustedRoot(
currentPath,
targetRoot,
'reconcile excluded install paths'
);
if (!pathExists(safePath)) {
currentPath = path.dirname(safePath);
continue;
}
const stat = fs.lstatSync(safePath);
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
return;
}
fs.rmdirSync(safePath);
currentPath = path.dirname(safePath);
}
}
function completeExcludedPathsReconciliation(migration, plan) {
const candidates = (migration && migration.excludedPathCandidates) || [];
const removedPaths = [];
const warnings = [];
for (const candidate of candidates) {
if (candidate.kind !== 'copy-file') {
continue;
}
let safePath;
try {
safePath = assertWithinTrustedRoot(
candidate.destinationPath,
plan.targetRoot,
'reconcile excluded install paths'
);
} catch (error) {
warnings.push(
`Preserved previously managed file ${candidate.destinationPath}: ${error.message}`
);
continue;
}
if (!pathExists(safePath)) {
continue;
}
const stat = fs.lstatSync(safePath);
if (stat.isSymbolicLink() || !stat.isFile()) {
warnings.push(
`Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.`
);
continue;
}
if (typeof candidate.contentSha256 !== 'string') {
warnings.push(
`Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.`
);
continue;
}
let currentDigest;
try {
currentDigest = hashFileNoFollow(safePath);
} catch (error) {
warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`);
continue;
}
if (currentDigest !== candidate.contentSha256.toLowerCase()) {
warnings.push(
`Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.`
);
continue;
}
fs.unlinkSync(safePath);
removedPaths.push(safePath);
removeEmptyParents(safePath, plan.targetRoot);
}
return { removedPaths, warnings };
}
module.exports = {
completeExcludedPathsReconciliation,
prepareExcludedPathsReconciliation,
};
+20
View File
@@ -89,6 +89,26 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
2. What this specific command verifies or produces
```
## Parallel Batches and Partial Application
The first-touch gate evaluates each tool call independently. When several
edits to a file that has not been touched yet are sent in one parallel
batch, the first call is denied and the denial marks the file as checked,
so the sibling edits in that batch are applied. Nothing is rolled back:
the file can end up holding the sibling edits without the denied one.
The denial message names the file and warns that batch siblings may
already have been applied. Treat it literally:
- Send dependent edits to a not-yet-touched file sequentially, not in a
parallel batch. A definition and its first use, or an import and its
call site, must not ride in the same batch.
- After a first-touch denial, present the facts, retry the denied edit,
and re-read the file before building on anything else from the batch.
A batch-wide lock is not possible: hooks see tool calls one at a time, so
the gate cannot know which calls arrived together.
## Quick Start
### Option A: Use the ECC hook (zero install)
+6 -1
View File
@@ -70,8 +70,13 @@ class OllamaProvider(LLMProvider):
"messages": [msg.to_dict() for msg in input.messages],
"stream": False,
}
options: dict[str, Any] = {}
if input.temperature != 1.0:
payload["options"] = {"temperature": input.temperature}
options["temperature"] = input.temperature
if input.max_tokens is not None:
options["num_predict"] = input.max_tokens
if options:
payload["options"] = options
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
+168
View File
@@ -1845,6 +1845,87 @@ function runTests() {
passed++;
else failed++;
if (
test('allows #2886 migration-doc heredoc repro with DROP TABLE prose', () => {
expectAllow(
[
"cat > migration-notes.md <<'EOF'",
"This migration will DROP TABLE old_sessions once we've verified nothing reads from it anymore.",
'EOF'
].join('\n'),
'issue #2886 cat heredoc repro'
);
})
)
passed++;
else failed++;
if (
test('allows destructive SQL prose inside a tee heredoc', () => {
expectAllow(
[
"tee migration-notes.md <<'EOF'",
'This migration will DROP TABLE old_sessions after verification.',
'EOF'
].join('\n'),
'tee heredoc SQL prose'
);
})
)
passed++;
else failed++;
if (
test('allows destructive rm prose inside a path-qualified cat heredoc', () => {
expectAllow(
[
"/bin/cat > notes.md <<'EOF'",
'Cleanup steps mention rm -rf old-cache; do not run yet.',
'EOF'
].join('\n'),
'path-qualified cat heredoc prose'
);
})
)
passed++;
else failed++;
if (
test('allows destructive prose inside a command-wrapped cat heredoc', () => {
expectAllow(
[
"command cat > notes.md <<'EOF'",
'Notes: DELETE FROM sessions; truncate staging.',
'EOF'
].join('\n'),
'command-wrapped cat heredoc prose'
);
})
)
passed++;
else failed++;
if (
test('still denies real destructive commands (not heredoc prose)', () => {
expectDestructiveDeny('rm -rf /tmp/real-destructive-target', 'real rm -rf');
expectDestructiveDeny('git reset --hard', 'real git reset --hard');
expectDestructiveDeny('drop table old_sessions', 'real drop table command text');
})
)
passed++;
else failed++;
if (
test('fails closed when tee pipes heredoc payload into a shell', () => {
expectDestructiveDeny(
['tee notes.md <<EOF | bash', 'rm -rf /tmp/tee-piped-shell-target', 'EOF'].join('\n'),
'tee piped to shell'
);
})
)
passed++;
else failed++;
if (
test('denies substitutions inside literal quote characters in an unquoted heredoc', () => {
for (const payload of [
@@ -3091,6 +3172,93 @@ function runTests() {
passed++;
else failed++;
// --- Batch consistency (#3136): a parallel batch of edits to one ---
// not-yet-touched file partially applies: the first denial marks the
// file checked, so sibling edits in the same batch are allowed. Hooks
// see calls one at a time and cannot lock a batch, so the contract is
// that the denial itself names the file and warns that batch siblings
// may already have been applied.
clearState();
if (
test('first-touch Edit denial warns about applied batch siblings (#3136)', () => {
// Two edits to the same unchecked file, sent as a parallel batch.
// Each hook invocation is its own process, exactly as in a batch.
const editA = {
tool_name: 'Edit',
tool_input: { file_path: '/src/batch-target.js', old_string: 'a', new_string: 'b' }
};
const editB = {
tool_name: 'Edit',
tool_input: { file_path: '/src/batch-target.js', old_string: 'c', new_string: 'd' }
};
const first = parseOutput(runHook(editA).stdout);
assert.strictEqual(first.hookSpecificOutput.permissionDecision, 'deny', 'first edit of the batch is gated');
const firstReason = first.hookSpecificOutput.permissionDecisionReason;
assert.ok(firstReason.includes('/src/batch-target.js'), 'denial names the exact file');
assert.ok(
firstReason.includes('parallel batch'),
'denial warns that batch siblings may already have been applied'
);
assert.ok(
firstReason.includes('Re-read'),
'denial tells the agent to re-read the file before building on siblings'
);
// Sibling edit in the same batch: judged against post-denial state,
// so it applies. The warning above is what makes this visible.
const second = parseOutput(runHook(editB).stdout);
if (second && second.hookSpecificOutput) {
assert.notStrictEqual(second.hookSpecificOutput.permissionDecision, 'deny', 'batch sibling is not re-gated');
}
})
)
passed++;
else failed++;
clearState();
if (
test('condensed Edit denial also warns about applied batch siblings (#3136)', () => {
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 });
const result = runHook({ tool_name: 'Edit', tool_input: { file_path: '/src/batch-condensed.js' } });
const output = parseOutput(result.stdout);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
const reason = output.hookSpecificOutput.permissionDecisionReason;
assert.ok(reason.includes('parallel batch'), 'condensed denial keeps the batch-sibling warning');
assert.ok(!reason.includes('\n'), 'condensed denial stays a single line');
})
)
passed++;
else failed++;
clearState();
if (
test('first-touch Write and MultiEdit denials warn about applied batch siblings (#3136)', () => {
const writeOut = parseOutput(
runHook({ tool_name: 'Write', tool_input: { file_path: '/src/batch-new.js', content: 'x' } }).stdout
);
assert.strictEqual(writeOut.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(
writeOut.hookSpecificOutput.permissionDecisionReason.includes('parallel batch'),
'Write denial carries the batch-sibling warning'
);
const multiOut = parseOutput(
runHook({
tool_name: 'MultiEdit',
tool_input: { edits: [{ file_path: '/src/batch-multi.js', old_string: 'a', new_string: 'b' }] }
}).stdout
);
assert.strictEqual(multiOut.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(
multiOut.hookSpecificOutput.permissionDecisionReason.includes('parallel batch'),
'MultiEdit denial carries the batch-sibling warning'
);
})
)
passed++;
else failed++;
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {
+240
View File
@@ -306,6 +306,246 @@ if (
passed++;
else failed++;
function writeExecutable(filePath, body) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, body);
fs.chmodSync(filePath, 0o755);
}
// The Python arm of the hook, exercised without a real interpreter: the stubs
// record the argv they were handed, which is what the virtualenv-path regression
// is actually about.
function runHermeticPythonPrePush({
venvName = null,
venvExit = 0,
trackVenv = false,
trackedVenvBasename = 'python',
trackedSymlinkVenv = false,
pytestCmd = null,
overrideStub = false,
pathPytestVersionLine = null,
} = {}) {
const tempDir = createTempDir('codex-pre-push-py-');
const projectDir = path.join(tempDir, 'project');
const callsPath = path.join(tempDir, 'calls.txt');
fs.mkdirSync(projectDir);
fs.writeFileSync(path.join(projectDir, 'pyproject.toml'), '[project]\nname = "demo"\n');
const initialized = spawnSync('git', ['init', '--quiet'], { cwd: projectDir });
assert.strictEqual(initialized.status, 0, initialized.stderr?.toString());
// Every stub records the argv it was handed. That record is the assertion: it is
// how a test tells a preserved path from a split one, and a command that was run
// once from one the hook probed first.
const record = `printf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"`;
// A tracked venv has to live inside the repository to be trackable at all, and is
// found by directory-name discovery rather than by VIRTUAL_ENV.
const venvDir = venvName === null ? null : path.join(trackVenv ? projectDir : tempDir, venvName);
const venvPython = venvDir === null
? null
: path.join(venvDir, 'bin', trackVenv ? trackedVenvBasename : 'python');
if (venvPython !== null) {
writeExecutable(venvPython, `#!/bin/sh\n${record}\ncase " $* " in *" -c "*) exit 0 ;; esac\nexit ${venvExit}\n`);
if (trackVenv) {
// Staged, not committed: `git ls-files` reads the index, so this is enough to
// make the file repository-controlled without needing a committer identity.
const added = spawnSync('git', ['add', '-f', '--', venvPython], { cwd: projectDir });
assert.strictEqual(added.status, 0, added.stderr?.toString());
}
}
// The shape that defeats a naive `git ls-files -- .venv/bin/python` check: the
// repository commits `.venv` as a symlink to its own root plus a tracked
// `bin/python`, so git is asked about a path it has never indexed.
if (trackedSymlinkVenv) {
writeExecutable(path.join(projectDir, 'bin', 'python'), `#!/bin/sh\n${record}\nexit 0\n`);
fs.symlinkSync('.', path.join(projectDir, '.venv'));
const added = spawnSync('git', ['add', '-f', '--', 'bin/python', '.venv'], { cwd: projectDir });
assert.strictEqual(added.status, 0, added.stderr?.toString());
}
// Deliberately does NOT special-case --version: an operator's wrapper would not
// either, and the recorded calls are what prove the hook never probed it.
const overrideStubPath = overrideStub ? path.join(tempDir, 'bin', 'wrapper') : null;
if (overrideStubPath !== null) {
writeExecutable(overrideStubPath, `#!/bin/sh\n${record}\nexit 0\n`);
}
const pathBin = pathPytestVersionLine === null ? null : path.join(tempDir, 'pathbin');
if (pathBin !== null) {
writeExecutable(
path.join(pathBin, 'pytest'),
`#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' '${pathPytestVersionLine}'; exit 0; fi\n${record}\nexit 0\n`,
);
}
const override = overrideStubPath === null ? pytestCmd : toBashPath(overrideStubPath);
// Built from nothing rather than from process.env. The hook reads VIRTUAL_ENV and
// ECC_PYTEST_CMD from the ambient environment, so a developer running this suite
// inside an activated virtualenv, or with ECC_PYTEST_CMD exported, would resolve a
// pytest the fixture never created. Omitted, not blanked: now that a variable set
// to nothing is itself an override, blanking it here would make every one of these
// tests take that branch.
const env = {
PATH: pathBin === null
? process.env.PATH
: `${toBashPath(pathBin)}${path.delimiter}${process.env.PATH}`,
HOME: process.env.HOME ?? '',
ECC_SKIP_GIT_HOOKS: '0',
ECC_SKIP_PREPUSH: '0',
MSYS_NO_PATHCONV: '1',
...(venvDir === null || trackVenv ? {} : { VIRTUAL_ENV: toBashPath(venvDir) }),
...(override === null ? {} : { ECC_PYTEST_CMD: override }),
};
const result = runBash(prePushHook, {
env,
cwd: projectDir,
preservePath: false,
input: Buffer.from('refs/heads/main 1111111111111111111111111111111111111111 refs/heads/main 0000000000000000000000000000000000000000\n'),
});
const calls = fs.existsSync(callsPath)
? fs.readFileSync(callsPath, 'utf8').trim().split(/\r?\n/).filter(Boolean)
: [];
cleanup(tempDir);
return { result, calls, venvPython, overrideStubPath };
}
if (
test('pre-push runs pytest from a virtualenv whose path contains spaces', () => {
const { result, calls, venvPython } = runHermeticPythonPrePush({ venvName: 'my venv' });
const python = toBashPath(venvPython);
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [
`${python}|-I -c import pytest`,
`${python}|-m pytest -q`,
], JSON.stringify({ calls, python, stdout: result.stdout, stderr: result.stderr }, null, 2));
})
)
passed++;
else failed++;
if (
test('pre-push refuses to run a virtualenv python that the repository tracks', () => {
const { result, calls } = runHermeticPythonPrePush({ venvName: '.venv', trackVenv: true });
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
assert.match(result.stdout, /the repository ships it/);
})
)
passed++;
else failed++;
// A case-folded spelling, because macOS resolves `$venv/bin/python` to a committed
// `Python` while git matches index pathspecs case-sensitively. Skipped where the
// filesystem is case-sensitive and the two names cannot collide.
if (fs.existsSync(__filename.toUpperCase()) || fs.existsSync(__filename.toLowerCase())) {
if (
test('pre-push refuses a tracked interpreter committed under a folded case', () => {
const { result, calls } = runHermeticPythonPrePush({
venvName: '.venv',
trackVenv: true,
trackedVenvBasename: 'Python',
});
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
assert.match(result.stdout, /the repository ships it/);
})
)
passed++;
else failed++;
}
if (
test('pre-push refuses a tracked interpreter reached through a committed symlink', () => {
const { result, calls } = runHermeticPythonPrePush({ trackedSymlinkVenv: true });
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
assert.match(result.stdout, /the repository ships it/);
})
)
passed++;
else failed++;
if (
test('pre-push blocks the push when the resolved pytest fails', () => {
const { result } = runHermeticPythonPrePush({ venvName: 'venv-red', venvExit: 1 });
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /pytest failed \(exit 1\)/);
assert.doesNotMatch(result.stdout, /Verification checks passed/);
})
)
passed++;
else failed++;
if (
test('pre-push does not block when pytest collected no tests (exit 5)', () => {
const { result } = runHermeticPythonPrePush({ venvName: 'venv-empty', venvExit: 5 });
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /collected no tests \(exit 5\)/);
assert.match(result.stdout, /rootdir, testpaths, and conftest\.py/);
})
)
passed++;
else failed++;
if (
test('pre-push runs an ECC_PYTEST_CMD override exactly once, without probing it', () => {
const { result, calls, overrideStubPath } = runHermeticPythonPrePush({ overrideStub: true });
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [`${toBashPath(overrideStubPath)}|-q`], JSON.stringify(calls));
// The override is not verified to be pytest, so it must at least be loud.
assert.match(result.stdout, /via ECC_PYTEST_CMD/);
assert.match(result.stdout, /does\n?.*not check that it is pytest/s);
})
)
passed++;
else failed++;
// Both blank forms, because they used to disagree: an unquoted empty value fell
// through to discovery while whitespace failed the push. A venv is present so a
// fall-through would be visible as a pass rather than as an absence.
for (const [label, blank] of [['empty', ''], ['whitespace', ' ']]) {
if (
test(`pre-push fails closed when ECC_PYTEST_CMD is set to ${label}`, () => {
const { result, calls } = runHermeticPythonPrePush({
venvName: 'venv-blank',
pytestCmd: blank,
});
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /ECC_PYTEST_CMD is set but names no command/);
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
})
)
passed++;
else failed++;
}
if (
test('pre-push rejects a PATH pytest that does not identify itself as pytest', () => {
const { result, calls } = runHermeticPythonPrePush({
pathPytestVersionLine: 'true (GNU coreutils) 9.0',
});
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stdout, /no pytest found/);
assert.deepStrictEqual(calls, []);
})
)
passed++;
else failed++;
if (
test('pre-push accepts a PATH pytest that reports a pytest version', () => {
const { result, calls } = runHermeticPythonPrePush({ pathPytestVersionLine: 'pytest 8.0.0' });
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.strictEqual(calls.length, 1, JSON.stringify(calls));
assert.match(calls[0], /\|-q$/);
})
)
passed++;
else failed++;
if (
test('check-plugin-cache fails when the installed cache is missing manifest-referenced files', () => {
const homeDir = createTempDir('codex-plugin-cache-home-');
+177
View File
@@ -7,6 +7,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const crypto = require('crypto');
const yaml = require('js-yaml');
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
@@ -593,6 +594,182 @@ function runTests() {
}
})) passed++; else failed++;
if (test('home installs do not copy the repo .agents staging directory into Claude or Codex homes', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
try {
const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(claudeResult.code, 0, claudeResult.stderr);
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
assert.ok(
!fs.existsSync(path.join(claudeRoot, '.agents')),
'Claude home must not receive the repo .agents staging directory'
);
const claudeState = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
assert.ok(
!claudeState.operations.some(operation => (
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
)),
'Claude install-state must not record .agents copy operations'
);
const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(codexResult.code, 0, codexResult.stderr);
const codexRoot = path.join(homeDir, '.codex');
assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
assert.ok(
!fs.existsSync(path.join(codexRoot, '.agents')),
'Codex home must not receive the repo .agents staging directory'
);
const codexState = readJson(path.join(codexRoot, 'ecc-install-state.json'));
assert.ok(
!codexState.operations.some(operation => (
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
)),
'Codex install-state must not record .agents copy operations'
);
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('reconciles legacy .agents files and state operations on Claude and Codex home upgrades', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
const digest = content => crypto.createHash('sha256').update(content).digest('hex');
const legacyOperation = (destinationPath, sourceRelativePath, installedContent) => ({
kind: 'copy-file',
moduleId: 'agents-core',
sourceRelativePath,
destinationPath,
strategy: 'preserve-relative-path',
ownership: 'managed',
scaffoldOnly: false,
contentSha256: digest(installedContent),
});
const writeFile = (filePath, content) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
};
const writeLegacyState = (statePath, target, operations) => {
writeFile(statePath, `${JSON.stringify({
schemaVersion: 'ecc.install.v1',
installedAt: '2026-09-01T00:00:00.000Z',
target,
request: {
profile: 'core',
modules: [],
includeComponents: [],
excludeComponents: [],
legacyLanguages: [],
legacyMode: false,
hookConsent: target.target === 'claude' ? 'enabled' : null,
},
resolution: { selectedModules: ['agents-core'], skippedModules: [] },
source: { repoVersion: '2.2.1', repoCommit: null, manifestVersion: 1 },
operations,
}, null, 2)}\n`);
};
try {
// Claude home seeded as installed before the .agents exclusion.
const claudeRoot = path.join(homeDir, '.claude');
const claudeStatePath = path.join(claudeRoot, 'ecc', 'install-state.json');
const claudeSkillCopy = path.join(claudeRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md');
const claudeModifiedCopy = path.join(claudeRoot, '.agents', 'plugins', 'marketplace.json');
const claudeUserFile = path.join(claudeRoot, '.agents', 'user-note.txt');
writeFile(claudeSkillCopy, '# legacy skill\n');
writeFile(claudeModifiedCopy, '{"edited": true}\n');
writeFile(claudeUserFile, 'user notes\n');
writeLegacyState(claudeStatePath, {
id: 'claude-home', target: 'claude', kind: 'home',
root: claudeRoot, installStatePath: claudeStatePath,
}, [
legacyOperation(claudeSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'),
legacyOperation(claudeModifiedCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'),
]);
const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
assert.strictEqual(claudeResult.code, 0, claudeResult.stderr);
assert.ok(!fs.existsSync(claudeSkillCopy), 'Unchanged managed .agents file should be removed');
assert.ok(
claudeResult.stdout.includes(
`- removed ${path.join(fs.realpathSync(claudeRoot), '.agents', 'skills', 'legacy-skill', 'SKILL.md')}`
),
'Install output should log one line per removed path'
);
assert.strictEqual(
fs.readFileSync(claudeModifiedCopy, 'utf8'),
'{"edited": true}\n',
'Modified managed file must be preserved'
);
assert.strictEqual(
fs.readFileSync(claudeUserFile, 'utf8'),
'user notes\n',
'Files the state does not own must not be touched'
);
assert.ok(
!fs.existsSync(path.join(claudeRoot, '.agents', 'skills')),
'Emptied .agents subdirectories should be pruned'
);
const claudeState = readJson(claudeStatePath);
assert.ok(
!claudeState.operations.some(operation => (
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
)),
'Claude install-state must drop the excluded .agents operations'
);
assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
// Codex home seeded the same way; both recorded files are unchanged.
const codexRoot = path.join(homeDir, '.codex');
const codexStatePath = path.join(codexRoot, 'ecc-install-state.json');
const codexSkillCopy = path.join(codexRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md');
const codexMarketplaceCopy = path.join(codexRoot, '.agents', 'plugins', 'marketplace.json');
writeFile(codexSkillCopy, '# legacy skill\n');
writeFile(codexMarketplaceCopy, '{"original": true}\n');
writeLegacyState(codexStatePath, {
id: 'codex-home', target: 'codex', kind: 'home',
root: codexRoot, installStatePath: codexStatePath,
}, [
legacyOperation(codexSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'),
legacyOperation(codexMarketplaceCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'),
]);
const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir });
assert.strictEqual(codexResult.code, 0, codexResult.stderr);
assert.ok(
!fs.existsSync(path.join(codexRoot, '.agents')),
'Fully reconciled .agents directory should be pruned from the Codex home'
);
const codexState = readJson(codexStatePath);
assert.ok(
!codexState.operations.some(operation => (
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
)),
'Codex install-state must drop the excluded .agents operations'
);
assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md')));
assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('preserves existing top-level Claude rules and skills during managed install', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
+54
View File
@@ -1,3 +1,6 @@
import json
import urllib.request
from io import BytesIO
from types import SimpleNamespace
import pytest
@@ -5,6 +8,7 @@ import pytest
from llm.core.types import LLMInput, Message, Role, ToolDefinition
from llm.providers.claude import ClaudeProvider
from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR
from llm.providers.ollama import OllamaProvider
from llm.providers.openai import OpenAIProvider
@@ -114,6 +118,56 @@ def test_openai_provider_allows_missing_usage():
assert output.usage is None
@pytest.mark.parametrize(
("max_tokens", "temperature", "expected_options"),
[
(128, 1.0, {"num_predict": 128}),
(128, 0.2, {"temperature": 0.2, "num_predict": 128}),
(128, 0.0, {"temperature": 0.0, "num_predict": 128}),
(0, 1.0, {"num_predict": 0}),
(None, 1.0, {}),
(None, 0.2, {"temperature": 0.2}),
],
)
def test_ollama_provider_serializes_generation_options(
monkeypatch, max_tokens, temperature, expected_options
):
requests = []
def fake_urlopen(request, timeout):
requests.append((request, timeout))
return BytesIO(b'{"message": {"content": "ok"}, "done_reason": "stop"}')
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
provider = OllamaProvider(base_url="http://localhost:11434", default_model="llama3.2")
output = provider.generate(
LLMInput(
messages=[Message(role=Role.USER, content="hi")],
max_tokens=max_tokens,
temperature=temperature,
)
)
assert len(requests) == 1
request, timeout = requests[0]
expected_payload = {
"model": "llama3.2",
"messages": [{"role": "user", "content": "hi"}],
"stream": False,
}
if expected_options:
expected_payload["options"] = expected_options
assert json.loads(request.data) == expected_payload
assert request.full_url == "http://localhost:11434/api/chat"
assert request.get_method() == "POST"
assert request.get_header("Content-type") == "application/json"
assert timeout == 60
assert output.content == "ok"
assert output.model == "llama3.2"
assert output.stop_reason == "stop"
def test_claude_provider_serializes_tools_for_messages_api():
provider = ClaudeProvider(api_key="test")
client = _AnthropicClient()