fix(release): make ECC 2.2 ready to publish

This commit is contained in:
haelyra
2026-08-24 20:15:12 -04:00
parent 528dbea019
commit 2c5a91a1d6
15 changed files with 259 additions and 93 deletions
+31 -35
View File
@@ -24,6 +24,16 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Require the release commit to equal origin main
run: |
git fetch origin main --no-tags
RELEASE_COMMIT=$(git rev-parse HEAD)
MAIN_COMMIT=$(git rev-parse origin/main)
if [ "$RELEASE_COMMIT" != "$MAIN_COMMIT" ]; then
echo "::error::The release commit must equal origin/main exactly"
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -69,43 +79,29 @@ jobs:
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
set +e
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
echo "already_published=true" >> "$GITHUB_OUTPUT"
else
elif printf '%s\n' "$NPM_LOOKUP" | grep -q 'E404'; then
echo "already_published=false" >> "$GITHUB_OUTPUT"
else
echo "::error::npm registry lookup failed; refusing to infer that the version is unpublished"
printf '%s\n' "$NPM_LOOKUP"
exit "$NPM_STATUS"
fi
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
- name: Generate release highlights
id: highlights
env:
TAG_NAME: ${{ github.ref_name }}
run: |
TAG_VERSION="${TAG_NAME#v}"
cat > release_body.md <<EOF
## ECC ${TAG_VERSION}
### What This Release Focuses On
- Harness reliability and hook stability across Claude Code, Cursor, OpenCode, and Codex
- Stronger eval-driven workflows and quality gates
- Better operator UX for autonomous loop execution
### Notable Changes
- Session persistence and hook lifecycle fixes
- Expanded skills and command coverage for harness performance work
- Improved release-note generation and changelog hygiene
### Notes
- npm package: \`ecc-universal\`
- Claude marketplace/plugin identifier: \`ecc@ecc\`
- For migration tips and compatibility notes, see README and CHANGELOG.
EOF
- name: Use reviewed release notes
run: cp docs/releases/2.2.0/RELEASE_NOTES.md release_body.md
- name: Pack npm artifact
id: pack
run: |
npm pack --json > npm-pack.json
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const file = data[0]?.filename; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const entries = Array.isArray(data) ? data : [data]; const file = entries.find(entry => /^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(entry?.filename || ''))?.filename; if (!file) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -182,14 +178,6 @@ jobs:
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
body_path: release_body.md
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}
make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }}
- name: Publish npm package
if: needs.verify.outputs.already_published != 'true'
env:
@@ -197,3 +185,11 @@ jobs:
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
body_path: release_body.md
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}
make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }}
+31 -28
View File
@@ -48,6 +48,16 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
persist-credentials: false
- name: Require the release commit to equal origin main
run: |
git fetch origin main --no-tags
RELEASE_COMMIT=$(git rev-parse HEAD)
MAIN_COMMIT=$(git rev-parse origin/main)
if [ "$RELEASE_COMMIT" != "$MAIN_COMMIT" ]; then
echo "::error::The release commit must equal origin/main exactly"
exit 1
fi
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -93,36 +103,29 @@ jobs:
PACKAGE_NAME=$(node -p "require('./package.json').name")
PACKAGE_VERSION=$(node -p "require('./package.json').version")
NPM_DIST_TAG=$(node -p "require('./package.json').version.includes('-') ? 'next' : 'latest'")
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
set +e
NPM_LOOKUP=$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>&1)
NPM_STATUS=$?
set -e
if [ "$NPM_STATUS" -eq 0 ]; then
echo "already_published=true" >> "$GITHUB_OUTPUT"
else
elif printf '%s\n' "$NPM_LOOKUP" | grep -q 'E404'; then
echo "already_published=false" >> "$GITHUB_OUTPUT"
else
echo "::error::npm registry lookup failed; refusing to infer that the version is unpublished"
printf '%s\n' "$NPM_LOOKUP"
exit "$NPM_STATUS"
fi
echo "dist_tag=${NPM_DIST_TAG}" >> "$GITHUB_OUTPUT"
- name: Generate release highlights
env:
TAG_NAME: ${{ inputs.tag }}
run: |
TAG_VERSION="${TAG_NAME#v}"
cat > release_body.md <<EOF
## ECC ${TAG_VERSION}
### What This Release Focuses On
- Harness reliability and cross-platform compatibility
- Eval-driven quality improvements
- Better workflow and operator ergonomics
### Package Notes
- npm package: \`ecc-universal\`
- Claude marketplace/plugin identifier: \`ecc@ecc\`
EOF
- name: Use reviewed release notes
run: cp docs/releases/2.2.0/RELEASE_NOTES.md release_body.md
- name: Pack npm artifact
id: pack
run: |
npm pack --json > npm-pack.json
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const file = data[0]?.filename; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
node -e "const crypto = require('crypto'); const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); const entries = Array.isArray(data) ? data : [data]; const file = entries.find(entry => /^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(entry?.filename || ''))?.filename; if (!file) throw new Error('Unexpected packed filename'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one packed archive'); const digest = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'package_file=' + file + '\npackage_sha256=' + digest + '\n')"
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -199,6 +202,14 @@ jobs:
ECC_RELEASE_SHA256: ${{ needs.verify.outputs.package_sha256 }}
run: node -e "const crypto = require('crypto'); const fs = require('fs'); const file = process.env.ECC_RELEASE_PACKAGE; const expected = process.env.ECC_RELEASE_SHA256; if (!/^ecc-universal-[0-9A-Za-z.+-]+\.tgz$/.test(file || '')) throw new Error('Unexpected packed filename'); if (!/^[a-f0-9]{64}$/.test(expected || '')) throw new Error('Invalid packed SHA-256'); const archives = fs.readdirSync('.').filter(name => name.endsWith('.tgz')); if (archives.length !== 1 || archives[0] !== file) throw new Error('Expected exactly one downloaded archive'); const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (actual !== expected) throw new Error('Downloaded publish artifact SHA-256 mismatch')"
- name: Publish npm package
if: needs.verify.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
- name: Create GitHub Release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
@@ -207,11 +218,3 @@ jobs:
generate_release_notes: ${{ inputs.generate-notes }}
prerelease: ${{ contains(inputs.tag, '-') }}
make_latest: ${{ contains(inputs.tag, '-') && 'false' || 'true' }}
- name: Publish npm package
if: needs.verify.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
ECC_RELEASE_PACKAGE: ${{ needs.verify.outputs.package_file }}
NPM_DIST_TAG: ${{ needs.verify.outputs.dist_tag }}
run: npm publish "./${ECC_RELEASE_PACKAGE}" --access public --provenance --tag "${NPM_DIST_TAG}"
+20
View File
@@ -2,13 +2,33 @@
## Unreleased
## 2.2.0 - 2026-08-25
### Added
- Guided, manifest-driven setup across supported harnesses, with exact install-state ownership, health checks, repair, and uninstall workflows.
- Native Antigravity 2.0 installation under `.agents/`, including rules, workflows, skills, and adapted agents, plus a cross-platform installation guide.
- New workflow and operator capabilities including the Itô skill family, Nasiko integration, multi-model council review, dev-team collaboration, agent evaluation, living-docs governance, secure terminal opening, and TasteForge multimodal workflows.
- A thin Pi adapter and expanded cross-harness support, release artifact lifecycle testing, Docker-based CLI testing, and stronger Python validation.
### Changed
- Default MCP connector set reduced to a single connector (`chrome-devtools`) per the new connector policy (`docs/MCP-CONNECTOR-POLICY.md`). The six previous defaults (`github`, `context7`, `exa`, `memory`, `playwright`, `sequential-thinking`) were retired after the June 2026 audit: their jobs are covered by skills wrapping CLIs/REST APIs (`github-ops`, `documentation-lookup`, `exa-search`, e2e skills) or by harness-native features (memory, extended thinking, web search). All six remain opt-in via `mcp-configs/mcp-servers.json`.
- OpenCode home installs now use its canonical `~/.config/opencode` location, and bundled agents inherit the model selected by the user instead of pinning an Anthropic provider.
- `skill-comply` is now part of the install manifest and npm distribution, with generated Python caches excluded from both install and package surfaces.
- Release automation now verifies the tag is exactly on `origin/main`, fails closed on npm registry errors, tests the exact packed artifact across Linux, macOS, and Windows, publishes npm before creating the GitHub Release, and uses reviewed release notes.
### Fixed
- `ecc memory` writes and `--body-file` reads failed on Windows under Node 22.12-22.16 and 24.0-24.1. libuv resolved path-based `stat()`/`lstat()` through `GetFileInformationByName` without setting the volume serial, while `fstat()` reported it, so the memory vault's TOCTOU guard rejected every operation. Fixed upstream in libuv 1.51.0; the guard no longer depends on the runtime's patch level. The guard's stat calls now request `BigInt` values, so Windows file IDs past `Number.MAX_SAFE_INTEGER` can no longer collapse two distinct files into one identity.
- Selective reinstall now merges the prior ownership ledger, so later module additions do not orphan files from earlier installs and uninstall removes the complete managed surface.
- Legacy Codex sync uninstall now uses ownership evidence, preserves user files, and requires an explicit opt-in for weaker marker-only cleanup.
- Hook, plan-canvas, session, memory, observer, skill-evolution, Discord delivery, and Windows compatibility regressions fixed across the runtime.
### Release audit
- Audited the complete delta from `v2.1.0`: 108 commits across 530 files, with 40,299 insertions and 4,679 deletions on the pre-release baseline.
- The release gate installs and exercises the exact npm archive, including cumulative ownership, doctor, drift detection, repair, uninstall, and user-file preservation.
## 2.0.0 - 2026-06-09
+40
View File
@@ -0,0 +1,40 @@
# ECC 2.2.0
ECC 2.2.0 makes the universal installer a first-class, cross-harness distribution path. It adds native Antigravity 2.0 support, repairs cumulative install ownership, aligns OpenCode with its canonical configuration directory, and strengthens the exact-artifact release gate.
## Installer and harness reliability
- Antigravity installs natively to `.agents/{rules,workflows,skills,agents}`. Do not manually rename a legacy `.agent` directory. Re-run ECC 2.2.0 so the installer can apply its ownership-aware migration rules.
- Repeated selective installs retain the complete managed ownership ledger. A later module install no longer causes previously installed ECC files to survive uninstall.
- OpenCode home installs use `~/.config/opencode`, and its bundled agent definitions inherit the user's selected model provider.
- Legacy Codex sync cleanup requires ownership evidence by default and preserves untracked or modified user files.
- `skill-comply` is included in both the install graph and npm archive. Python bytecode and pytest caches remain excluded.
## New capabilities
- Guided multi-harness setup and stronger doctor, repair, status, and uninstall flows.
- Native Antigravity 2.0 documentation for Bash and PowerShell.
- Expanded Itô, Nasiko, agent-evaluation, multi-model council, dev-team, living-docs, secure terminal, Pi, and TasteForge workflows.
- Improved Plan Canvas, memory vault, continuous learning, skill evolution, hook stability, session handling, and Discord delivery.
## Release assurance
- The release workflow requires the tagged commit to equal `origin/main` exactly.
- npm registry failures stop the release instead of being treated as an unpublished version.
- The exact packed archive is hashed once and exercised on Linux, macOS, and Windows before publication.
- The verified npm archive is published before the matching GitHub Release is created. A retry verifies byte-for-byte registry integrity.
## Upgrade
Install or update the published package, then run the same ECC install command you used previously:
```bash
npm install -g ecc-universal@2.2.0
ecc install --target antigravity --profile full
```
Use `ecc doctor --target <target>` after installation. For Antigravity, start a new conversation and verify workspace skills under Settings > Customizations.
## Scope audited
The pre-release audit covered the complete delta from `v2.1.0`: 108 commits, 530 changed files, 40,299 insertions, and 4,679 deletions before the final readiness patch.
+1
View File
@@ -317,6 +317,7 @@
"skills/security-scan/",
"skills/seo/",
"skills/skill-scout/",
"skills/skill-comply/",
"skills/skill-stocktake/",
"skills/social-graph-ranker/",
"skills/springboot-patterns/",
+1 -3
View File
@@ -18,9 +18,7 @@ const PROFILES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-profiles.sche
const COMPONENTS_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-components.schema.json');
const CURATED_SKILLS_DIR = path.join(REPO_ROOT, 'skills');
// Empty by default; add only curated skills that are intentionally unshipped.
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([
'skill-comply', // meta/measurement dev-skill; ships committed .pyc artifacts and a nested .gitignore, revisit after packaging cleanup
]);
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([]);
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
+2 -2
View File
@@ -135,8 +135,8 @@ const HARNESS_CAPABILITIES = deepFreeze([
installMode: 'managed-home',
guidedReady: false,
availability: 'advanced',
destination: '~/.opencode',
scopes: [scope('home', 'opencode', '~/.opencode')],
destination: '~/.config/opencode',
scopes: [scope('home', 'opencode', '~/.config/opencode')],
hooks: hooks(
'adapter-opt-in',
false,
+6 -1
View File
@@ -80,7 +80,12 @@ function validateLegacyTarget(target) {
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
}
const IGNORED_DIRECTORY_NAMES = new Set(['node_modules', '.git', '__pycache__']);
const IGNORED_DIRECTORY_NAMES = new Set([
'node_modules',
'.git',
'__pycache__',
'.pytest_cache',
]);
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
function listFilesRecursive(dirPath) {
+59 -12
View File
@@ -64,11 +64,55 @@ function pathsMatch(left, right) {
return canonicalPath(left) === canonicalPath(right);
}
function sameFileIdentity(left, right) {
return left.dev === right.dev
&& left.ino === right.ino
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs
&& left.ctimeMs === right.ctimeMs;
}
function readRegularFileSnapshot(filePath) {
let pathStat;
try {
pathStat = fs.lstatSync(filePath);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return null;
throw error;
}
if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
throw new Error(`Refusing to read a symbolic link or non-file at ${filePath}.`);
}
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const descriptor = fs.openSync(filePath, flags);
try {
const before = fs.fstatSync(descriptor);
if (!before.isFile() || !sameFileIdentity(pathStat, before)) {
throw new Error(`Refusing to read a file that changed during open: ${filePath}.`);
}
const content = fs.readFileSync(descriptor);
const after = fs.fstatSync(descriptor);
const finalPathStat = fs.lstatSync(filePath);
if (
finalPathStat.isSymbolicLink()
|| !sameFileIdentity(before, after)
|| !sameFileIdentity(after, finalPathStat)
) {
throw new Error(`Refusing to read a file that changed during validation: ${filePath}.`);
}
return { content, stat: after };
} finally {
fs.closeSync(descriptor);
}
}
function fingerprintFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, sha256: null };
const snapshot = readRegularFileSnapshot(filePath);
if (!snapshot) return { exists: false, sha256: null };
return {
exists: true,
sha256: crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'),
sha256: crypto.createHash('sha256').update(snapshot.content).digest('hex'),
};
}
@@ -131,11 +175,11 @@ function readOwnedDestinations(plan, dependencies) {
} catch (error) {
throw new Error(`Refusing to trust managed install-state path: ${error.message}`);
}
if (!fs.existsSync(plan.installStatePath)) {
const initialFingerprint = fingerprintFile(plan.installStatePath);
if (!initialFingerprint.exists) {
return { destinations: new Set(), stateFingerprint: { exists: false, sha256: null } };
}
const readState = dependencies.readInstallState || require('./install-state').readInstallState;
const initialFingerprint = fingerprintFile(plan.installStatePath);
const state = readState(plan.installStatePath);
const validatedFingerprint = fingerprintFile(plan.installStatePath);
if (
@@ -185,11 +229,12 @@ function readOwnedDestinations(plan, dependencies) {
return { destinations, stateFingerprint: validatedFingerprint };
}
function assertMergeDestination(destinationPath) {
if (!fs.existsSync(destinationPath)) return null;
function assertMergeDestination(destinationPath, existingSnapshot = null) {
const snapshot = existingSnapshot || readRegularFileSnapshot(destinationPath);
if (!snapshot) return null;
let current;
try {
current = JSON.parse(fs.readFileSync(destinationPath, 'utf8'));
current = JSON.parse(snapshot.content.toString('utf8'));
} catch (error) {
throw new Error(`Cannot merge ECC configuration into invalid JSON at ${destinationPath}: ${error.message}`);
}
@@ -218,10 +263,11 @@ function findJsonConflicts(current, patch, prefix = '') {
function classifyManagedOperation(operation, ownedDestinations) {
const destinationPath = operation.destinationPath;
if (!fs.existsSync(destinationPath)) return 'create';
const destination = readRegularFileSnapshot(destinationPath);
if (!destination) return 'create';
const canonicalDestination = canonicalPath(destinationPath);
if (operation.kind === 'merge-json') {
const current = assertMergeDestination(destinationPath);
const current = assertMergeDestination(destinationPath, destination);
if (ownedDestinations.has(canonicalDestination)) return 'managed-json-update';
const conflicts = findJsonConflicts(current, operation.mergePayload);
if (conflicts.length > 0) {
@@ -235,9 +281,7 @@ function classifyManagedOperation(operation, ownedDestinations) {
if (
operation.kind === 'copy-file'
&& typeof operation.sourcePath === 'string'
&& fs.existsSync(operation.sourcePath)
&& fs.statSync(destinationPath).isFile()
&& fs.readFileSync(operation.sourcePath).equals(fs.readFileSync(destinationPath))
&& readRegularFileSnapshot(operation.sourcePath)?.content.equals(destination.content)
) {
return 'identical';
}
@@ -295,6 +339,9 @@ function preflightManagedPlan(plan, dependencies = {}) {
if (!plan || !Array.isArray(plan.operations)) {
throw new Error('A managed install plan with operations is required.');
}
if (typeof plan.installStatePath !== 'string' || plan.installStatePath.length === 0) {
throw new Error('A managed install-state path is required before preflight.');
}
const ownership = readOwnedDestinations(plan, dependencies);
const operations = plan.operations.map(operation => {
assertSafeInstallOperation(plan, operation);
-7
View File
@@ -1,7 +0,0 @@
.venv/
__pycache__/
*.py[cod]
results/*.md
.pytest_cache/
.coverage
uv.lock
+50
View File
@@ -252,6 +252,38 @@ function findDriftCandidate(state, cursorRoot) {
return resolveManagedExistingPath(operation.destinationPath, cursorRoot).path;
}
function runTargetSmoke(options) {
const install = parseJsonOutput(
options.runCli([
'install',
'--modules', 'workflow-quality',
'--target', options.target,
'--json',
]),
`${options.target} packed install`
);
assert.strictEqual(install.summary.errorCount, 0);
const statePath = path.join(options.targetRoot, 'ecc-install-state.json');
assert.ok(fs.existsSync(statePath), `${options.target} install-state must exist`);
assert.ok(
fs.existsSync(path.join(options.targetRoot, 'skills', 'skill-comply', 'SKILL.md')),
`${options.target} must install skill-comply from the packed archive`
);
const doctor = parseJsonOutput(
options.runCli(['doctor', '--target', options.target, '--json']),
`${options.target} packed doctor`
);
assert.strictEqual(doctor.summary.errorCount, 0);
const uninstall = parseJsonOutput(
options.runCli(['uninstall', '--target', options.target, '--json']),
`${options.target} packed uninstall`
);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.ok(!fs.existsSync(statePath), `${options.target} uninstall must remove install-state`);
}
function runLifecycle(options) {
assert.ok(fs.existsSync(options.packagePath), `release package does not exist: ${options.packagePath}`);
assertDownloadedArtifact(options.packagePath, process.cwd());
@@ -450,6 +482,22 @@ function runLifecycle(options) {
assert.strictEqual(statusAfterUninstall.installStateProjection.warningCount, 0);
assert.strictEqual(statusAfterUninstall.readiness.status, 'ok');
const antigravityRoot = path.join(projectDir, '.agents');
runTargetSmoke({
runCli,
target: 'antigravity',
targetRoot: antigravityRoot,
});
assert.ok(!fs.existsSync(path.join(projectDir, '.agent')));
const opencodeRoot = path.join(homeDir, '.config', 'opencode');
runTargetSmoke({
runCli,
target: 'opencode',
targetRoot: opencodeRoot,
});
assert.ok(!fs.existsSync(path.join(homeDir, '.opencode')));
return {
packageSha256: options.expectedSha256,
platform: process.platform,
@@ -469,6 +517,8 @@ function runLifecycle(options) {
'uninstall',
'status-uninstalled',
'sentinel-preserved',
'antigravity-install-doctor-uninstall',
'opencode-install-doctor-uninstall',
],
};
} finally {
@@ -162,12 +162,12 @@ test('packed lifecycle invokes installed public bins, including setup help', ()
});
test('packed lifecycle validates canonical Antigravity and OpenCode installs', () => {
assert.match(lifecycleRunnerSource, /'--target', 'antigravity'/);
assert.match(lifecycleRunnerSource, /target:\s*'antigravity'/);
assert.match(lifecycleRunnerSource, /path\.join\(projectDir, '\.agents'\)/);
assert.match(lifecycleRunnerSource, /'--target', 'opencode'/);
assert.match(lifecycleRunnerSource, /target:\s*'opencode'/);
assert.match(lifecycleRunnerSource, /path\.join\(homeDir, '\.config', 'opencode'\)/);
assert.match(lifecycleRunnerSource, /doctor.*antigravity/s);
assert.match(lifecycleRunnerSource, /doctor.*opencode/s);
assert.match(lifecycleRunnerSource, /\['doctor', '--target', options\.target, '--json'\]/);
assert.match(lifecycleRunnerSource, /skill-comply.*SKILL\.md/);
});
test('packed lifecycle installs and verifies the opt-in Ito distribution surface', () => {
+1 -1
View File
@@ -90,7 +90,7 @@ function runTests() {
cursor: ['project', './.cursor'],
antigravity: ['project', './.agents'],
gemini: ['project', './.gemini'],
opencode: ['home', '~/.opencode'],
opencode: ['home', '~/.config/opencode'],
codebuddy: ['project', './.codebuddy'],
joycode: ['project', './.joycode'],
qwen: ['home', '~/.qwen'],
@@ -512,6 +512,15 @@ function runTests() {
const retry = applyInstallPlan(fixture.plan);
assert.deepStrictEqual(retry.skippedOperations, []);
const stateAfterRetry = readInstallState(fixture.installStatePath);
for (const originalOperation of fixture.operations) {
assert.strictEqual(
stateAfterRetry.operations.filter(operation => (
operation.destinationPath === originalOperation.destinationPath
)).length,
1,
`retry must record ${originalOperation.destinationPath} exactly once`
);
}
const retainedExtraRecords = stateAfterRetry.operations.filter(operation => (
operation.destinationPath === extraDestinationPath
));
+4
View File
@@ -55,6 +55,7 @@ function writeLegacySourceFixture(root) {
writeFile(root, path.join('rules', 'common', 'node_modules', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', '.git', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
writeFile(root, path.join('rules', 'common', '.pytest_cache', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('rules', 'common', 'stray.pyd'), 'ignored\n');
@@ -116,6 +117,7 @@ function writeManifestSourceFixture(root) {
writeFile(root, path.join('src', 'node_modules', 'ignored.js'), 'console.log("ignored");\n');
writeFile(root, path.join('src', '.git', 'ignored.js'), 'console.log("ignored");\n');
writeFile(root, path.join('src', '__pycache__', 'ignored.cpython-314.pyc'), 'ignored\n');
writeFile(root, path.join('src', '.pytest_cache', 'ignored.md'), '# Ignored\n');
writeFile(root, path.join('src', 'stray.pyc'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyo'), 'ignored\n');
writeFile(root, path.join('src', 'stray.pyd'), 'ignored\n');
@@ -201,6 +203,7 @@ function runTests() {
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('node_modules')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.git')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('__pycache__')));
assert.ok(!plan.operations.some(operation => operation.sourceRelativePath.includes('.pytest_cache')));
assert.ok(!plan.operations.some(operation => /\.(?:pyc|pyo|pyd)$/.test(operation.sourceRelativePath)));
assert.deepStrictEqual(plan.statePreview.request.legacyLanguages, ['typescript', 'missing-lang', '../bad']);
assert.strictEqual(plan.statePreview.request.legacyMode, true);
@@ -371,6 +374,7 @@ function runTests() {
assert.ok(!normalizedSources.some(source => source.includes('node_modules')));
assert.ok(!normalizedSources.some(source => source.includes('.git')));
assert.ok(!normalizedSources.some(source => source.includes('__pycache__')));
assert.ok(!normalizedSources.some(source => source.includes('.pytest_cache')));
assert.ok(!normalizedSources.some(source => /\.(?:pyc|pyo|pyd)$/.test(source)));
assert.ok(plan.operations.some(operation => (
operation.sourceRelativePath === path.join('.claude-plugin', 'plugin.json')