diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 47bf6b83a..1e3fb4e6b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Why: the freshness registry is derived from immutable release tags, + # so shallow PR checkouts cannot verify historical official identities. + fetch-depth: 0 + persist-credentials: false - name: Install native build tools run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh @@ -82,6 +87,9 @@ jobs: - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides + - name: Verify skill freshness manifest + run: pnpm run verify:skill-bundle-manifest + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names diff --git a/.github/workflows/skill-update-roundtrip.yml b/.github/workflows/skill-update-roundtrip.yml new file mode 100644 index 000000000..6c9418e1f --- /dev/null +++ b/.github/workflows/skill-update-roundtrip.yml @@ -0,0 +1,50 @@ +name: Skill update round trip + +on: + pull_request: + paths: + - 'skills/**' + - 'resources/skills/**' + - 'config/scripts/verify-skill-update-roundtrip.mjs' + - 'src/main/skills/skill-freshness-eligibility.ts' + - 'src/shared/skill-freshness.ts' + - '.github/workflows/skill-update-roundtrip.yml' + merge_group: + push: + branches: + - main + +jobs: + roundtrip: + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + shape: [symlink, copy] + autocrlf: ['false', 'true'] + skills-cli: ['1.5.17'] + include: + - os: ubuntu-latest + shape: symlink + autocrlf: 'false' + skills-cli: latest + continue-on-error: ${{ matrix.skills-cli == 'latest' }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - name: Verify targeted update convergence and copy behavior + env: + GITHUB_TOKEN: ${{ github.token }} + SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }} + run: >- + node config/scripts/verify-skill-update-roundtrip.mjs + --cli=${{ matrix.skills-cli }} + --autocrlf=${{ matrix.autocrlf }} + --shape=${{ matrix.shape }} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 8f67e1068..98beb0c1c 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,12 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } +// Why: freshness detection needs immutable identity metadata from this exact +// app build, but never needs the skill package bytes or a runtime network read. +const skillFreshnessResources = { + from: 'resources/skills', + to: 'skills' +} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -31,7 +37,11 @@ const relayExtraResource = { // do not fall through to a developer checkout's node_modules. const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() -const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] +const commonExtraResources = [ + relayExtraResource, + ...packagedRuntimeNodeModuleResources, + skillFreshnessResources +] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -79,7 +89,8 @@ module.exports = { '!tsconfig.json', // Why: feature-wall media is copied via extraResources so runtime can read // it from process.resourcesPath; exclude the source copy from app.asar. - '!resources/onboarding/feature-wall/**' + '!resources/onboarding/feature-wall/**', + '!resources/skills/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules // from out/shared/ and local hook mutators from out/main/. These paths must be diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 7ada984eb..d56714984 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -29,6 +29,7 @@ describe('electron-builder config', () => { '!native{,/**/*}', '!skills{,/**/*}', '!skill-guides{,/**/*}', + '!resources/skills/**', '!tests{,/**/*}', '!pr-evidence{,/**/*}', '!Casks{,/**/*}', @@ -39,6 +40,12 @@ describe('electron-builder config', () => { }) it('keeps runtime resources available through extraResources', () => { + for (const platform of ['mac', 'linux', 'win']) { + expect(electronBuilderConfig[platform].extraResources).toContainEqual({ + from: 'resources/skills', + to: 'skills' + }) + } expect(electronBuilderConfig.mac.extraResources).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs new file mode 100644 index 000000000..c3542d6f0 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -0,0 +1,522 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { isDeepStrictEqual } from 'node:util' + +const SCHEMA_VERSION = 1 +const SCRIPT_DIR = import.meta.dirname +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') +const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') +const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills') +const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json') +const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json') +const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json') + +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left, right) { + return left === right ? 0 : left < right ? -1 : 1 +} + +function gitObjectSha(kind, bytes) { + return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest() +} + +function normalizeText(bytes) { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +function classifyFile(bytes) { + if (bytes.includes(0)) { + return 'binary' + } + try { + normalizeText(bytes) + return 'text' + } catch { + return 'binary' + } +} + +function assertSafeRelativePath(relativePath) { + if ( + path.isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) + ) { + throw new Error(`Unsafe skill package path: ${relativePath}`) + } +} + +function describeFile(manifestPath, bytes, executable) { + const classification = classifyFile(bytes) + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null + return { + path: manifestPath, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256, + gitBlobSha: gitObjectSha('blob', bytes).toString('hex') + } +} + +function gitTreeSha(entries) { + const root = { directories: new Map(), files: [] } + for (const entry of entries) { + const parts = entry.path.split('/') + const filename = parts.pop() + let directory = root + for (const part of parts) { + let child = directory.directories.get(part) + if (!child) { + child = { directories: new Map(), files: [] } + directory.directories.set(part, child) + } + directory = child + } + directory.files.push({ filename, ...entry }) + } + + function hashDirectory(directory) { + const children = [ + ...[...directory.directories].map(([name, child]) => ({ + mode: '40000', + name, + hash: hashDirectory(child) + })), + ...directory.files.map((file) => ({ + mode: file.executable ? '100755' : '100644', + name: file.filename, + hash: Buffer.from(file.gitBlobSha, 'hex') + })) + ].sort((left, right) => { + const leftName = left.mode === '40000' ? `${left.name}/` : left.name + const rightName = right.mode === '40000' ? `${right.name}/` : right.name + return Buffer.from(leftName).compare(Buffer.from(rightName)) + }) + const body = Buffer.concat( + children.map(({ mode, name, hash }) => + Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash]) + ) + ) + return gitObjectSha('tree', body) + } + + return hashDirectory(root).toString('hex') +} + +async function collectPackageFiles(packageRoot) { + const files = [] + const caseFoldedPaths = new Map() + + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + // Why: build-time Node and packaged Electron may ship different ICU data; + // package identity order must use the same locale-independent comparison. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + const relativePath = path.relative(packageRoot, absolutePath) + assertSafeRelativePath(relativePath) + const manifestPath = relativePath.split(path.sep).join('/') + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`) + } + if (fileStat.isDirectory()) { + await visit(absolutePath) + continue + } + if (!fileStat.isFile()) { + throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`) + } + // Why: Windows observation cannot see execute bits, so an executable file in + // a shipped skill would misclassify every pristine Windows install as unrecognized. + if ((fileStat.mode & 0o111) !== 0) { + throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`) + } + files.push(describeFile(manifestPath, await readFile(absolutePath), false)) + } + } + + await visit(packageRoot) + return sortManifestFiles(files) +} + +function collectGitSkillTreeEntries(treeSha) { + const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha]) + .toString('utf8') + .split('\0') + .filter(Boolean) + const packages = new Map() + for (const line of output) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line) + if (!match) { + throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`) + } + const [, mode, type, objectSha, sourcePath] = match + const separator = sourcePath.indexOf('/') + if (separator <= 0 || separator === sourcePath.length - 1) { + throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`) + } + const name = sourcePath.slice(0, separator) + const manifestPath = sourcePath.slice(separator + 1) + const entries = packages.get(name) ?? [] + entries.push({ mode, type, objectSha, manifestPath }) + packages.set(name, entries) + } + return packages +} + +function readGitBlobs(objectShas) { + const uniqueShas = [...new Set(objectShas)] + if (uniqueShas.length === 0) { + return new Map() + } + // Why: released history spans hundreds of tags. Batch mode avoids a Git + // subprocess per historical file while remaining available on Git 2.25. + const output = execFileSync('git', ['cat-file', '--batch'], { + input: `${uniqueShas.join('\n')}\n`, + maxBuffer: 64 * 1024 * 1024 + }) + const blobs = new Map() + let offset = 0 + for (const requestedSha of uniqueShas) { + const headerEnd = output.indexOf(10, offset) + if (headerEnd < 0) { + throw new Error(`Missing git cat-file header for ${requestedSha}`) + } + const header = output.subarray(offset, headerEnd).toString('utf8') + const match = /^([a-f0-9]+) blob (\d+)$/.exec(header) + if (!match || match[1] !== requestedSha) { + throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`) + } + const size = Number(match[2]) + const contentStart = headerEnd + 1 + const contentEnd = contentStart + size + if (contentEnd >= output.length || output[contentEnd] !== 10) { + throw new Error(`Truncated git blob for ${requestedSha}`) + } + blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd))) + offset = contentEnd + 1 + } + return blobs +} + +function collectGitPackageFiles(treeSha, name, entries, blobs) { + const caseFoldedPaths = new Map() + const files = entries.map(({ mode, type, objectSha, manifestPath }) => { + if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) { + throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`) + } + assertSafeRelativePath(manifestPath) + const foldedPath = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(foldedPath) + if (collision && collision !== manifestPath) { + throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`) + } + caseFoldedPaths.set(foldedPath, manifestPath) + const bytes = blobs.get(objectSha) + if (!bytes) { + throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`) + } + return describeFile(manifestPath, bytes, mode === '100755') + }) + // Why: git ls-tree emits git byte-order, not the canonical walk order. + return sortManifestFiles(files) +} + +// Why: snapshot matching compares files by array index, so every producer — +// working-tree walk, git history, and runtime observation — must emit one +// canonical order. This mirrors the sorted depth-first filesystem walk. +function compareManifestPaths(left, right) { + const leftParts = left.split('/') + const rightParts = right.split('/') + const shared = Math.min(leftParts.length, rightParts.length) + for (let index = 0; index < shared; index += 1) { + const order = compareCodeUnits(leftParts[index], rightParts[index]) + if (order !== 0) { + return order + } + } + return leftParts.length - rightParts.length +} + +function sortManifestFiles(files) { + return [...files].sort((left, right) => compareManifestPaths(left.path, right.path)) +} + +function packageDigest(files) { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ), + 'utf8' + ) + ) +} + +function releaseTags() { + return execFileSync( + 'git', + ['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'], + { encoding: 'utf8' } + ) + .split('\n') + .filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag)) +} + +function skillsTreeShasAtRefs(refs) { + if (refs.length === 0) { + return [] + } + const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { + input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`, + encoding: 'utf8' + }) + const lines = output.trimEnd().split('\n') + if (lines.length !== refs.length) { + throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`) + } + return lines.map((line, index) => { + if (line.endsWith(' missing')) { + return null + } + const match = /^([a-f0-9]+) tree$/.exec(line) + if (!match) { + throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`) + } + return match[1] + }) +} + +function buildReleasedHistory() { + const registry = { schemaVersion: SCHEMA_VERSION, skills: {} } + const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] } + const tags = releaseTags() + const treeShas = skillsTreeShasAtRefs(tags) + const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] + const packagesByTree = new Map( + distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)]) + ) + const blobs = readGitBlobs( + [...packagesByTree.values()].flatMap((packages) => + [...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha)) + ) + ) + let previousSkillsTreeSha = null + for (const [index, tag] of tags.entries()) { + const skillsTreeSha = treeShas[index] + if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) { + continue + } + previousSkillsTreeSha = skillsTreeSha + const revisions = {} + const packages = packagesByTree.get(skillsTreeSha) + if (!packages) { + throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`) + } + for (const name of [...packages.keys()].sort(compareCodeUnits)) { + const entries = packages.get(name) + const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + continue + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshots.push({ + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + }) + registry.skills[name] = snapshots + } + revisions[name] = snapshots.at(-1).releaseRevision + } + if (Object.keys(revisions).length > 0) { + mapping.releases.push({ appVersion: tag.slice(1), skills: revisions }) + } + } + return { registry, mapping } +} + +async function buildArtifacts(appVersion) { + const { registry, mapping } = buildReleasedHistory() + const releasedSnapshotCounts = Object.fromEntries( + Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) + ) + const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareCodeUnits) + const currentSkills = [] + for (const name of skillDirectories) { + const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name)) + if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) { + throw new Error(`Skill package ${name} has no top-level SKILL.md`) + } + const digest = packageDigest(filesWithGitHashes) + const snapshots = registry.skills[name] ?? [] + const latest = snapshots.at(-1) + let snapshot = latest + if (!latest || latest.packageDigest !== digest) { + const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file) + snapshot = { + releaseRevision: (latest?.releaseRevision ?? 0) + 1, + packageDigest: digest, + gitTreeSha: gitTreeSha(filesWithGitHashes), + files + } + snapshots.push(snapshot) + registry.skills[name] = snapshots + } + currentSkills.push({ + name, + sourcePath: `skills/${name}`, + appVersion, + ...snapshot + }) + } + return { + currentManifest: { + schemaVersion: SCHEMA_VERSION, + appVersion, + skills: currentSkills + }, + snapshotRegistry: registry, + releaseMapping: mapping, + releasedSnapshotCounts + } +} + +// Why: released snapshots are the detection ground truth for existing installs, +// so a generation-logic change must not rewrite them silently. Only the one +// unreleased working-tree append per skill may change between runs. +function assertReleasedHistoryPreserved(committedRegistry, artifacts) { + if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) { + return + } + for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { + const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0 + const regenerated = artifacts.snapshotRegistry.skills[name] ?? [] + if (releasedCount < Math.max(0, committedSnapshots.length - 1)) { + throw new Error( + `Released snapshot history is incomplete for ${name}. ` + + 'Fetch all release tags before regenerating skill artifacts.' + ) + } + const protectedCount = Math.min(committedSnapshots.length, releasedCount) + for (let index = 0; index < protectedCount; index += 1) { + const committed = committedSnapshots[index] + const rebuilt = regenerated[index] + if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) { + throw new Error( + `Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` + + 'Released snapshots are append-only; a deliberate identity migration must update this check.' + ) + } + } + } +} + +async function readCommittedRegistry() { + try { + return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8')) + } catch { + return null + } +} + +function serialized(value) { + return `${JSON.stringify(value, null, 2)}\n` +} + +async function writeArtifacts(artifacts) { + await mkdir(OUTPUT_ROOT, { recursive: true }) + await Promise.all([ + writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)), + writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)), + writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping)) + ]) +} + +async function verifyArtifacts(artifacts) { + const expected = [ + [CURRENT_MANIFEST_PATH, artifacts.currentManifest], + [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], + [RELEASE_MAPPING_PATH, artifacts.releaseMapping] + ] + const stale = [] + for (const [filePath, value] of expected) { + try { + await access(filePath, constants.R_OK) + if ((await readFile(filePath, 'utf8')) !== serialized(value)) { + stale.push(filePath) + } + } catch { + stale.push(filePath) + } + } + if (stale.length > 0) { + throw new Error( + `Generated skill artifacts are stale:\n${stale + .map((filePath) => path.relative(REPO_ROOT, filePath)) + .join('\n')}\nRun pnpm generate:skill-bundle-manifest.` + ) + } +} + +async function main() { + const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')) + const artifacts = await buildArtifacts(packageJson.version) + assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts) + await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} + +export { + assertReleasedHistoryPreserved, + buildArtifacts, + buildReleasedHistory, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles, + verifyArtifacts, + writeArtifacts +} diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs new file mode 100644 index 000000000..cf4a8a100 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -0,0 +1,199 @@ +import { execFileSync } from 'node:child_process' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + assertReleasedHistoryPreserved, + classifyFile, + collectPackageFiles, + describeFile, + gitTreeSha, + normalizeText, + packageDigest, + sortManifestFiles +} from './generate-skill-bundle-manifest.mjs' + +const temporaryDirectories = [] + +async function createPackage() { + const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-')) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('skill bundle manifest generator', () => { + it('folds platform line endings for text identity', () => { + const lf = Buffer.from('first\nsecond\n') + const crlf = Buffer.from('first\r\nsecond\r\n') + + expect(classifyFile(lf)).toBe('text') + expect(normalizeText(crlf)).toEqual(lf) + }) + + it('classifies null-containing and invalid UTF-8 content as binary', () => { + expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary') + expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary') + }) + + it('uses normalized text identity but exact executable identity', () => { + const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false) + const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true) + + expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256) + expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256) + expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/) + }) + + it('orders git-history files identically to the filesystem walk', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) { + await writeFile(path.join(packageRoot, name), `${name}\n`) + } + const walked = await collectPackageFiles(packageRoot) + + // Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based + // snapshot matching requires history and observation to share one order. + const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) => + walked.find((file) => file.path === manifestPath) + ) + + expect(sortManifestFiles(gitOrdered)).toEqual(walked) + expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked)) + expect(walked.map((file) => file.path)).toEqual([ + 'Zebra.md', + 'apple.md', + 'sub/inner.txt', + 'sub.md' + ]) + }) + + it('rejects rewrites of released snapshots and allows floating-tail replacement', () => { + const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest }) + const artifacts = { + releasedSnapshotCounts: { 'orca-cli': 2 }, + snapshotRegistry: { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] } + } + } + + expect(() => + assertReleasedHistoryPreserved( + { schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] } + }, + artifacts + ) + ).not.toThrow() + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }] + } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] + } + }, + { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } } + ) + ).toThrow('Released snapshot history is incomplete for orca-cli') + expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() + }) + + it.runIf(process.platform !== 'win32')( + 'rejects executable files in shipped skill packages', + async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n') + await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n') + await chmod(path.join(packageRoot, 'run.sh'), 0o755) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Executable file is not allowed in a shipped skill: run.sh' + ) + } + ) + + it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await writeFile(path.join(packageRoot, 'Readme.md'), 'one') + await writeFile(path.join(packageRoot, 'README.md'), 'two') + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths') + }) + + it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => { + const packageRoot = await createPackage() + await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill') + await symlink('SKILL.md', path.join(packageRoot, 'linked.md')) + + await expect(collectPackageFiles(packageRoot)).rejects.toThrow( + 'Symlink is not allowed in a shipped skill' + ) + }) + + it('computes the same Git tree identity as Git', async () => { + const packageRoot = path.resolve('skills', 'orca-cli') + const files = await collectPackageFiles(packageRoot) + const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], { + encoding: 'utf8' + }) + .trim() + .split(/\s+/)[2] + + expect(gitTreeSha(files)).toBe(expected) + }) + + it('matches Git when a directory and file share a name prefix', async () => { + const packageRoot = await createPackage() + await mkdir(path.join(packageRoot, 'sub')) + await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n') + await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n') + const files = await collectPackageFiles(packageRoot) + execFileSync('git', ['init', '--quiet'], { cwd: packageRoot }) + execFileSync('git', ['add', '-A'], { cwd: packageRoot }) + const expected = execFileSync('git', ['write-tree'], { + cwd: packageRoot, + encoding: 'utf8' + }).trim() + + expect(gitTreeSha(files)).toBe(expected) + }) +}) diff --git a/config/scripts/verify-skill-update-roundtrip.mjs b/config/scripts/verify-skill-update-roundtrip.mjs new file mode 100644 index 000000000..bfb10a504 --- /dev/null +++ b/config/scripts/verify-skill-update-roundtrip.mjs @@ -0,0 +1,249 @@ +import { execFileSync } from 'node:child_process' +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs' + +function option(name) { + return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3) +} + +const cliVersion = option('cli') +const autocrlf = option('autocrlf') +const shape = option('shape') +// Why: PR branch names are untrusted workflow input. Keep them out of the +// generated shell command and pass them to Node through the environment. +const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE +const ref = option('ref') ?? process.env.SKILL_UPDATE_REF +if ( + !cliVersion || + (autocrlf !== 'true' && autocrlf !== 'false') || + (shape !== 'symlink' && shape !== 'copy') || + !source || + !ref || + !/^[^/\s]+\/[^/\s]+$/.test(source) +) { + throw new Error( + 'Usage: verify-skill-update-roundtrip.mjs --cli= --autocrlf=true|false --shape=symlink|copy --source= --ref=' + ) +} + +const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-')) +const home = path.join(sandbox, 'home') +const stateHome = path.join(home, '.state') +const fakeBin = path.join(sandbox, 'bin') +const targetName = 'orca-cli' +const controlName = 'orchestration' +const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8')) +const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8')) +const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8')) + +function currentSkill(name) { + const skill = manifest.skills.find((entry) => entry.name === name) + if (!skill) { + throw new Error(`Current manifest is missing ${name}`) + } + return skill +} + +function historicalRelease(name) { + const current = currentSkill(name) + for (const release of releaseMapping.releases.toReversed()) { + const revision = release.skills[name] + if (typeof revision !== 'number' || revision >= current.releaseRevision) { + continue + } + const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision) + if (snapshot) { + return { tag: `v${release.appVersion}`, snapshot } + } + } + throw new Error(`No historical released snapshot is available for ${name}`) +} + +async function materializePackage(name, tag, destination) { + const prefix = `skills/${name}/` + const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`]) + .toString('utf8') + .split('\0') + .filter(Boolean) + if (entries.length === 0) { + throw new Error(`${tag} does not contain ${name}`) + } + for (const entry of entries) { + const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry) + if (!match || match[2] !== 'blob') { + throw new Error(`Unsupported historical tree entry: ${entry}`) + } + const relativePath = match[4].slice(prefix.length) + const destinationPath = path.join(destination, ...relativePath.split('/')) + await mkdir(path.dirname(destinationPath), { recursive: true }) + await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]])) + if (process.platform !== 'win32' && match[1] === '100755') { + await chmod(destinationPath, 0o755) + } + } +} + +async function seedPlacement(name, tag) { + const canonical = path.join(home, '.agents', 'skills', name) + await materializePackage(name, tag, canonical) + const providerRoot = path.join(home, '.claude', 'skills') + const provider = path.join(providerRoot, name) + await mkdir(providerRoot, { recursive: true }) + await (shape === 'copy' + ? cp(canonical, provider, { recursive: true }) + : symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir')) +} + +async function installFakeAgentCommands() { + await mkdir(fakeBin, { recursive: true }) + for (const name of ['codex', 'claude']) { + const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name) + await writeFile( + executable, + process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n' + ) + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + } +} + +async function packageDigestAt(pathValue) { + return packageDigest(await collectPackageFiles(pathValue)) +} + +async function assertCurrentCanonical(name) { + const expected = currentSkill(name).packageDigest + const canonical = path.join(home, '.agents', 'skills', name) + if ((await packageDigestAt(canonical)) !== expected) { + throw new Error(`${name} canonical placement did not update to the PR content`) + } +} + +function execSkills(args) { + const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx' + const cliArgs = ['--yes', `skills@${cliVersion}`, ...args] + execFileSync( + executable, + process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs, + { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CODEX_HOME: path.join(home, '.codex'), + CLAUDE_CONFIG_DIR: path.join(home, '.claude'), + XDG_STATE_HOME: stateHome, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.autocrlf', + GIT_CONFIG_VALUE_0: autocrlf, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, + CI: '1' + }, + stdio: 'inherit' + } + ) +} + +try { + const targetHistorical = historicalRelease(targetName) + const controlHistorical = historicalRelease(controlName) + await installFakeAgentCommands() + await mkdir(path.join(home, '.codex'), { recursive: true }) + await mkdir(path.join(home, '.claude'), { recursive: true }) + await seedPlacement(targetName, targetHistorical.tag) + await seedPlacement(controlName, controlHistorical.tag) + const targetProvider = path.join(home, '.claude', 'skills', targetName) + const controlCanonical = path.join(home, '.agents', 'skills', controlName) + const controlProvider = path.join(home, '.claude', 'skills', controlName) + const targetProviderBefore = await packageDigestAt(await realpath(targetProvider)) + const controlBefore = await packageDigestAt(controlCanonical) + const controlProviderBefore = await packageDigestAt(await realpath(controlProvider)) + + const timestamp = new Date().toISOString() + const lock = { + version: 3, + skills: { + [targetName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${targetName}/SKILL.md`, + skillFolderHash: targetHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + }, + [controlName]: { + source, + sourceType: 'github', + sourceUrl: `https://github.com/${source}.git`, + ref, + skillPath: `skills/${controlName}/SKILL.md`, + skillFolderHash: controlHistorical.snapshot.gitTreeSha, + installedAt: timestamp, + updatedAt: timestamp + } + } + } + const lockPath = path.join(stateHome, 'skills', '.skill-lock.json') + await mkdir(path.dirname(lockPath), { recursive: true }) + await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`) + + // Why: this is the exact user-visible rail. A bare update would include + // unrelated vendors, while this command must leave the control skill alone. + execSkills(['update', targetName, '--global']) + await assertCurrentCanonical(targetName) + const targetProviderAfter = await packageDigestAt(await realpath(targetProvider)) + const targetProviderStat = await lstat(targetProvider) + if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) { + throw new Error(`${targetName} provider alias was replaced with an independent copy`) + } + if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) { + throw new Error(`${targetName} provider alias did not converge with the canonical update`) + } + if ( + shape === 'copy' && + targetProviderAfter !== targetProviderBefore && + targetProviderAfter !== currentSkill(targetName).packageDigest + ) { + throw new Error('Independent provider copy changed to an unexpected package identity') + } + if (shape === 'copy') { + // Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs + // retain the copy. Both prove this input topology must remain ineligible. + const outcome = targetProviderStat.isSymbolicLink() + ? 'converged to an alias' + : targetProviderAfter === targetProviderBefore + ? 'remained a historical copy' + : 'converged as a copy' + console.log(`[skill-update-roundtrip] independent copy ${outcome}`) + } + if ((await packageDigestAt(controlCanonical)) !== controlBefore) { + throw new Error('Targeted update changed the non-targeted control skill') + } + if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) { + throw new Error('Targeted update changed the non-targeted control provider placement') + } + const controlProviderStat = await lstat(controlProvider) + if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) { + throw new Error('Targeted update changed the non-targeted control topology') + } +} finally { + await rm(sandbox, { recursive: true, force: true }) +} diff --git a/notes/skill-freshness-design.md b/notes/skill-freshness-design.md index 5da3b2428..8d12f8915 100644 --- a/notes/skill-freshness-design.md +++ b/notes/skill-freshness-design.md @@ -126,6 +126,7 @@ Rules: ### C. Read-only detection (kept from Phase 1, slimmed) Kept as-is: + - Bundled `skills/` packages + current manifest + released-snapshot registry + release mapping, with the generation script and merge-queue monotonicity gate (static data + CI, not runtime machinery). @@ -135,9 +136,12 @@ Kept as-is: plugin caches and repo scopes excluded), and the launch / focus / post-install triggers. - The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests to historical-fat-install → targeted global update → stub migration. The matrix covers - copy/symlink shapes, LF/CRLF, supported lock migrations, and post-update identity. + LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy + observation that accepts only unchanged historical or exact-current bytes. Post-update + bytes, not exit status, decide. Slimmed: + - Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot), `newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly distinguish a locally modified official copy from unrelated same-named content; @@ -151,32 +155,51 @@ Slimmed: ### D. Surfacing -- **Settings rows** (read-only): name, status badge, one-line explanation. `newer-known`, - `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. -- **Name-scoped update eligibility:** the skills CLI reinstalls every placement of a selected - skill name, so eligibility is computed across all discovered placements of that name, not - per row. Offer a name only when at least one placement is `outdated` and every placement is - an exact `current` or `outdated` official snapshot in a supported global topology. One - `newer-known`, unrecognized, external, read-only, inaccessible, or otherwise unsupported - provider copy poisons the update offer for that name entirely. +- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the + existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace + Environments. Their installed pills carry safe freshness status, while their existing + Update and Re-check actions remain the per-skill path. The Skills page was + de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed — + so it is no longer a venue; the freshness surface moved off it entirely. The behavior + contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory + triggers) are unchanged; only the venue moved. +- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`, + `unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in + the modal's collapsed **Details** section (auto-expanded when a placement is blocked). +- **Name-scoped update eligibility:** eligibility is computed across all discovered placements + of a name, not per row. Offer a name only when at least one placement is `outdated` and every + placement is an exact `current` or `outdated` official snapshot in a topology the validated + rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and + provider aliases to it. Independent provider copies are informational and poison the offer: + empirical copy-mode testing produced both stale and converged provider copies in otherwise + equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One + `newer-known`, unrecognized, external, read-only, + inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the + update offer for that name entirely. - The action combines only eligible outdated Orca names into - `npx skills update --global`, opens the existing run-command terminal with that - command pre-filled, and leaves execution to the user. Never use an unscoped bulk update and - never auto-submit the command. Re-inventory after terminal exit or focus; only observed - bytes, not the skills CLI exit status, determine success. -- **One non-repeating nudge**: count only eligible outdated skill names and offer the same - targeted run-the-command action. An outdated name poisoned by another placement remains - visible in settings but never produces an unsafe nudge action. Dismissal is recorded per - (install, bundled revision), so a newly outdated official placement or genuinely newer - stub revision may prompt once more. No toggle — nothing automatic happens that would need - one. + `npx skills update --global` and opens the update modal's editable terminal with + that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and + never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only + observed bytes, not the skills CLI exit status, determine success. When the eligible set + empties and every placement is `current`, the modal shows an up-to-date state; if placements + remain outdated-but-blocked or unrecognized, it says so honestly instead. +- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the + same targeted action, which opens the update modal. The toast lingers (no auto-close) until the + user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a + still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks + the offered tuple retracts the stale toast without recording a dismissal. An outdated name + poisoned by another placement remains visible in the modal's Details but never produces an + unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only + on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision + may prompt once more. No + toggle — nothing automatic happens that would need one. ### E. Migration (fat → stub) 1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks, and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first. -2. From a separate PR, land slim read-only detection and settings/nudge UI, including the +2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the name-scoped targeted update action and the real migration-rail CI. Keep distributed skills fat. 3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout @@ -211,15 +234,17 @@ not thin, until the relevant variant passes. global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode topology changes. The historical-fat → targeted-global-update → stub CI is a release gate, not an early-warning job. Detection always re-checks bytes after the user updates, so a - failed or no-op update re-surfaces `outdated` instead of lying. Choose and document a - validated CLI-version policy before rollout; monitor and contribute upstream fixes. + failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is + 1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence + still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream + fixes before broadening eligibility. - **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing installs only when users run the npx command. Acceptable at stub-change cadence; the compiled guides (the content that matters) are exempt by construction. - **Multi-file skills.** Current shipped packages are single-file. If a future skill needs scripts/assets, either the binary serves them (`--full` / `--script`) or that skill accepts the fat-file decay model. Decide when it happens. -- **Remote hosts.** Detection ships local-host-only. Stubs make remote *content* a non-issue: +- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue: SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command surface that will handle subsequent requests. Remote stub installs can lag on trigger copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are diff --git a/package.json b/package.json index 6510dbf0d..c7e41d8ce 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -23,6 +23,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", + "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write", + "verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", "tc:node": "pnpm run typecheck:node", diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json new file mode 100644 index 000000000..fca1162fa --- /dev/null +++ b/resources/skills/current-manifest.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 1, + "appVersion": "1.4.144-rc.1", + "skills": [ + { + "name": "computer-use", + "sourcePath": "skills/computer-use", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + }, + { + "name": "linear-tickets", + "sourcePath": "skills/linear-tickets", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + }, + { + "name": "orca-cli", + "sourcePath": "skills/orca-cli", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + }, + { + "name": "orca-emulator", + "sourcePath": "skills/orca-emulator", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + }, + { + "name": "orca-emulator-android", + "sourcePath": "skills/orca-emulator-android", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + }, + { + "name": "orca-linear", + "sourcePath": "skills/orca-linear", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + }, + { + "name": "orca-per-workspace-env", + "sourcePath": "skills/orca-per-workspace-env", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + }, + { + "name": "orchestration", + "sourcePath": "skills/orchestration", + "appVersion": "1.4.144-rc.1", + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ] +} diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json new file mode 100644 index 000000000..12baa4b6e --- /dev/null +++ b/resources/skills/release-mapping.json @@ -0,0 +1,501 @@ +{ + "schemaVersion": 1, + "releases": [ + { + "appVersion": "1.0.81", + "skills": { + "orca-cli": 1 + } + }, + { + "appVersion": "1.0.86", + "skills": { + "orca-cli": 2 + } + }, + { + "appVersion": "1.0.94-rc.0", + "skills": { + "orca-cli": 3 + } + }, + { + "appVersion": "1.3.8-rc.1", + "skills": { + "orca-cli": 4 + } + }, + { + "appVersion": "1.3.12-rc.4", + "skills": { + "orca-cli": 5 + } + }, + { + "appVersion": "1.3.24-rc.2", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.33-rc.3", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "0.0.1", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.35-rc.0", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38-rc.3", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.38", + "skills": { + "mobile-fit-debug": 1, + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.39", + "skills": { + "orca-cli": 6, + "orchestration": 1 + } + }, + { + "appVersion": "1.3.46-rc.3", + "skills": { + "orca-cli": 7, + "orchestration": 2 + } + }, + { + "appVersion": "1.3.47-rc.0", + "skills": { + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.3.49-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 8, + "orchestration": 3 + } + }, + { + "appVersion": "1.4.2-rc.10", + "skills": { + "computer-use": 1, + "orca-cli": 9, + "orchestration": 4 + } + }, + { + "appVersion": "1.4.2", + "skills": { + "computer-use": 1, + "orca-cli": 10, + "orchestration": 5 + } + }, + { + "appVersion": "1.4.3-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 11, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.9-rc.1", + "skills": { + "computer-use": 1, + "orca-cli": 12, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.10-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 13, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.11-rc.0", + "skills": { + "computer-use": 1, + "orca-cli": 14, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 15, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.18-rc.3", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 6 + } + }, + { + "appVersion": "1.4.22", + "skills": { + "computer-use": 1, + "orca-cli": 16, + "orchestration": 7 + } + }, + { + "appVersion": "1.4.36-rc.2", + "skills": { + "computer-use": 1, + "orca-cli": 17, + "orchestration": 8 + } + }, + { + "appVersion": "1.4.36-rc.6", + "skills": { + "computer-use": 1, + "orca-cli": 18, + "orchestration": 9 + } + }, + { + "appVersion": "1.4.42-rc.8", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 10 + } + }, + { + "appVersion": "1.4.46-rc.0", + "skills": { + "computer-use": 2, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.8", + "skills": { + "computer-use": 3, + "orca-cli": 19, + "orchestration": 11 + } + }, + { + "appVersion": "1.4.51-rc.9", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.65", + "skills": { + "computer-use": 3, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.66-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 1, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.67-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 12 + } + }, + { + "appVersion": "1.4.68", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 20, + "orca-emulator": 1, + "orchestration": 13 + } + }, + { + "appVersion": "1.4.74-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 21, + "orca-emulator": 1, + "orchestration": 14 + } + }, + { + "appVersion": "1.4.78-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 22, + "orca-emulator": 1, + "orchestration": 15 + } + }, + { + "appVersion": "1.4.78-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 23, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 24, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.81-rc.3", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 25, + "orca-emulator": 1, + "orchestration": 16 + } + }, + { + "appVersion": "1.4.88", + "skills": { + "computer-use": 3, + "linear-tickets": 2, + "orca-cli": 26, + "orca-emulator": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.90", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 26, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.92-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 27, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.96-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 28, + "orca-emulator": 1, + "orca-linear": 1, + "orchestration": 17 + } + }, + { + "appVersion": "1.4.105-rc.2", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.111-rc.0", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 1, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.6", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 18 + } + }, + { + "appVersion": "1.4.124-rc.9", + "skills": { + "computer-use": 3, + "linear-tickets": 3, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 1, + "orca-per-workspace-env": 2, + "orchestration": 19 + } + }, + { + "appVersion": "1.4.124", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 29, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 20 + } + }, + { + "appVersion": "1.4.137-rc.1", + "skills": { + "computer-use": 3, + "linear-tickets": 4, + "orca-cli": 30, + "orca-emulator": 2, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 21 + } + }, + { + "appVersion": "1.4.138-rc.0", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 22 + } + }, + { + "appVersion": "1.4.138-rc.3", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 23 + } + }, + { + "appVersion": "1.4.138-rc.4", + "skills": { + "computer-use": 4, + "linear-tickets": 4, + "orca-cli": 31, + "orca-emulator": 3, + "orca-emulator-android": 1, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + }, + { + "appVersion": "1.4.141", + "skills": { + "computer-use": 5, + "linear-tickets": 4, + "orca-cli": 32, + "orca-emulator": 4, + "orca-emulator-android": 2, + "orca-linear": 2, + "orca-per-workspace-env": 2, + "orchestration": 24 + } + } + ] +} diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json new file mode 100644 index 000000000..58d6c7f6c --- /dev/null +++ b/resources/skills/snapshot-registry.json @@ -0,0 +1,1239 @@ +{ + "schemaVersion": 1, + "skills": { + "orca-cli": [ + { + "releaseRevision": 1, + "packageDigest": "a3831a8a5561b24f085cb3c185c23ccc368970898119d90723aa3406778aecb6", + "gitTreeSha": "aed90e508a1acac62d3f22ceba3afa5489cffb84", + "files": [ + { + "path": "SKILL.md", + "size": 5547, + "executable": false, + "classification": "text", + "exactSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "textNormalizedSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "identitySha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "e0ed74622f5f77a6339f6f2632dc6edb0b724c7f69a4d2ffecaa45bd0995865a", + "gitTreeSha": "d9d879b3040843ae434462db46939b1eac410304", + "files": [ + { + "path": "SKILL.md", + "size": 5850, + "executable": false, + "classification": "text", + "exactSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "textNormalizedSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "identitySha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "0032941edd38a56de97a56f50c7688e38bcb566735310a9321bb8a7959a06878", + "gitTreeSha": "57ab93e99a12dc947d4329ef7d1227d24bf3dd26", + "files": [ + { + "path": "SKILL.md", + "size": 8871, + "executable": false, + "classification": "text", + "exactSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "textNormalizedSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "identitySha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "d30e6b53ff35008ffe467e2557326326575ac5d37fab88019b1d8c2d6fb8eeba", + "gitTreeSha": "f43280abdf0e484966ce840600a6cae4963f9d64", + "files": [ + { + "path": "SKILL.md", + "size": 24622, + "executable": false, + "classification": "text", + "exactSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "textNormalizedSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "identitySha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "1f2ae2e3922a93e020feebfa799b993557fead2e8c83ce03b5646767ea354f19", + "gitTreeSha": "f8a840864ad01f1c3892a1bf05fb699c5f4543e5", + "files": [ + { + "path": "SKILL.md", + "size": 26576, + "executable": false, + "classification": "text", + "exactSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "textNormalizedSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "identitySha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "6e77196af8e6b9123861d7f09c0727e3f982722940eaff2ca53521d76134362f", + "gitTreeSha": "35f50f11a68d2f8f532df28d677ba1b0bc71b740", + "files": [ + { + "path": "SKILL.md", + "size": 26970, + "executable": false, + "classification": "text", + "exactSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "textNormalizedSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "identitySha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "d15b79aee7d15f1cc089467284f2454a8ad4f351c08e1e3758e8c58fdcf07b02", + "gitTreeSha": "d7788dc3cd1f1ed705981299c7b8ff9b26cdd5e6", + "files": [ + { + "path": "SKILL.md", + "size": 27011, + "executable": false, + "classification": "text", + "exactSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "textNormalizedSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "identitySha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "962904e301eef6917af25eccbfe74ab8a119503749030e44c67fd34415d85002", + "gitTreeSha": "4fde9a02a5a77e7ff1a47c04819b764378557a28", + "files": [ + { + "path": "SKILL.md", + "size": 28908, + "executable": false, + "classification": "text", + "exactSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "textNormalizedSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "identitySha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "d73f45feb6ccf88947aba3095e9ae36c2c02e9fb4dfeaba67f3f8e3af1cb6f74", + "gitTreeSha": "b5c98b55e0b10e4525d63f5a8df3bc95b62158e6", + "files": [ + { + "path": "SKILL.md", + "size": 30358, + "executable": false, + "classification": "text", + "exactSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "textNormalizedSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "identitySha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "d1adae602b254aa2de6d65d09066ca1dcd9e89685bc1ba7c72e9964beb3f6539", + "gitTreeSha": "b545ca22a27b8cfff80fd2d33c77db8b03efd7ed", + "files": [ + { + "path": "SKILL.md", + "size": 30787, + "executable": false, + "classification": "text", + "exactSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "textNormalizedSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "identitySha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "23a6bb7a6069a8c4d6506aaa688a44c8421699e3575bb8c73185561579c1f623", + "gitTreeSha": "8b7caa9655971745a8ea782f6d05d76ab0f3812c", + "files": [ + { + "path": "SKILL.md", + "size": 31985, + "executable": false, + "classification": "text", + "exactSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "textNormalizedSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "identitySha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "c882bcd1293ef6f601e7f48199f6e755692f668419f8c2098e00757d9349d557", + "gitTreeSha": "22de521546df142d7fd540dd3a329462c548e86b", + "files": [ + { + "path": "SKILL.md", + "size": 32276, + "executable": false, + "classification": "text", + "exactSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "textNormalizedSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "identitySha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "a1bfdab91a68b3441b548de158e2c6a56452a499600625c6a9a3523d325837ec", + "gitTreeSha": "c694b183230bface19ef8ec9218726f82e391f69", + "files": [ + { + "path": "SKILL.md", + "size": 32853, + "executable": false, + "classification": "text", + "exactSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "textNormalizedSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "identitySha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "afb5026bf156e8d411898ecc84c48348010129558499a18240194b0d5533028c", + "gitTreeSha": "3b9164e0fba50923c9ce3a4a298a9c8b81677291", + "files": [ + { + "path": "SKILL.md", + "size": 33310, + "executable": false, + "classification": "text", + "exactSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "textNormalizedSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "identitySha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "2c07a8155f0a0f3fdc6a038c25e0758486dc2ce19d8e0f5bb19d4722f6acbae7", + "gitTreeSha": "dc8ef30fef07f81167e948a9bfc90d14670ea10c", + "files": [ + { + "path": "SKILL.md", + "size": 11055, + "executable": false, + "classification": "text", + "exactSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "textNormalizedSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "identitySha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "30b067d09f9f9ee9c048f0b3d809c25e269fd46cc948486cdb66690205ae8c4b", + "gitTreeSha": "833e5ce46df5bf55d29fbbd3bd710aa2dfe6b5c4", + "files": [ + { + "path": "SKILL.md", + "size": 14298, + "executable": false, + "classification": "text", + "exactSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "textNormalizedSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "identitySha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f" + } + ] + }, + { + "releaseRevision": 25, + "packageDigest": "2ae59c0bea7f2a12ef0c78e3cba65895ff67b311b40b97b46223460b4d1a7acd", + "gitTreeSha": "ba4e183d53b70fd01b763a9cc19f757550a18e2e", + "files": [ + { + "path": "SKILL.md", + "size": 14320, + "executable": false, + "classification": "text", + "exactSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "textNormalizedSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "identitySha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4" + } + ] + }, + { + "releaseRevision": 26, + "packageDigest": "5d7bdc664990cd84771eceea9393b0a566aee4ad9cdbf0883910cd8d4d46d5ac", + "gitTreeSha": "6b459e105bb77cbfa67ba0086f3afcbe32e12093", + "files": [ + { + "path": "SKILL.md", + "size": 14636, + "executable": false, + "classification": "text", + "exactSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "textNormalizedSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "identitySha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce" + } + ] + }, + { + "releaseRevision": 27, + "packageDigest": "94f535ede189381ae1cf1a7cf6effee03335b2e98e9bdf57fed72dcba39036e3", + "gitTreeSha": "1b02d6a7c06465e3ba6661bef201c6db6552c9e6", + "files": [ + { + "path": "SKILL.md", + "size": 14829, + "executable": false, + "classification": "text", + "exactSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "textNormalizedSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "identitySha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835" + } + ] + }, + { + "releaseRevision": 28, + "packageDigest": "d70d2e70a9ac4ae8cf8ec5cb958524521842d558e40ea24dd882b90a846a2cdc", + "gitTreeSha": "3d734f7f385d66ab408c56e8814b9c115d53556c", + "files": [ + { + "path": "SKILL.md", + "size": 15055, + "executable": false, + "classification": "text", + "exactSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "textNormalizedSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "identitySha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995" + } + ] + }, + { + "releaseRevision": 29, + "packageDigest": "ca9d1d5e2ad2dc6814be80af642c5fb485644713e9bd7ef19acd36d4228b94c5", + "gitTreeSha": "d1175a5a5807a6783a3d896f720645b39f736dbb", + "files": [ + { + "path": "SKILL.md", + "size": 17257, + "executable": false, + "classification": "text", + "exactSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "textNormalizedSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "identitySha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e" + } + ] + }, + { + "releaseRevision": 30, + "packageDigest": "4994ac99e6563e53f85c404d52470ab05945679ecb3e0f6a1ac4dbba83d70b35", + "gitTreeSha": "91c0234c3e64dfcf45ac1515ecc77c973a7af185", + "files": [ + { + "path": "SKILL.md", + "size": 19660, + "executable": false, + "classification": "text", + "exactSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "textNormalizedSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "identitySha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368" + } + ] + }, + { + "releaseRevision": 31, + "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901", + "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c", + "files": [ + { + "path": "SKILL.md", + "size": 20862, + "executable": false, + "classification": "text", + "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14" + } + ] + }, + { + "releaseRevision": 32, + "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", + "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", + "files": [ + { + "path": "SKILL.md", + "size": 21557, + "executable": false, + "classification": "text", + "exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be", + "identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be" + } + ] + } + ], + "orchestration": [ + { + "releaseRevision": 1, + "packageDigest": "427a9ae737ae258ccf2c1f7659549da1b6b00a65c1fad8ac9c6baf4f42af8c4e", + "gitTreeSha": "13840d1ad238a462bdc34f7fca11cb4d224b514c", + "files": [ + { + "path": "SKILL.md", + "size": 12589, + "executable": false, + "classification": "text", + "exactSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "textNormalizedSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "identitySha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "122aa952ff591cddbda47767a7de738b05dbeb0c5d26065010bcb6b788ef329d", + "gitTreeSha": "2c0e80be2d5ff6760f461711179d9fd94a7454b2", + "files": [ + { + "path": "SKILL.md", + "size": 12972, + "executable": false, + "classification": "text", + "exactSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "textNormalizedSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "identitySha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "0c9848cc3d1e135f6d0105c49ec6f16a126e675125408744d9e6c940645fd427", + "gitTreeSha": "d12449bc4ba94699a9c9ec636a15ad3b6f095bfd", + "files": [ + { + "path": "SKILL.md", + "size": 12749, + "executable": false, + "classification": "text", + "exactSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "textNormalizedSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "identitySha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "fbb016bd4ec67677fea3ffa16aacc2e95d13968b33520409385bef5d1a025c16", + "gitTreeSha": "413ae24e6011f86372aa44c8611d8fe76d936f8a", + "files": [ + { + "path": "SKILL.md", + "size": 12795, + "executable": false, + "classification": "text", + "exactSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "textNormalizedSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "identitySha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "cb991258e829f2a1256bcaf4f90018046d2bc91b5fba301bd12b046e9e2e6e02", + "gitTreeSha": "820faebf9aa70e7c1792909ceb9fbf35db93795a", + "files": [ + { + "path": "SKILL.md", + "size": 15044, + "executable": false, + "classification": "text", + "exactSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "textNormalizedSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "identitySha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "0d8470040c3c15570fe2745fc159b5ff57b5a229ec4e27ea600a8ced594c0455", + "gitTreeSha": "5113d720ff1a96ed158befcb8752f3a26aadd161", + "files": [ + { + "path": "SKILL.md", + "size": 10338, + "executable": false, + "classification": "text", + "exactSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "textNormalizedSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "identitySha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "9008c30bdf8a20857642968753013bba4961a6297b02d31afe65105606b0bcfd", + "gitTreeSha": "993c9e1861ad97047e34ab82e5dbd045e888a4a5", + "files": [ + { + "path": "SKILL.md", + "size": 10348, + "executable": false, + "classification": "text", + "exactSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "textNormalizedSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "identitySha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "c2a45891a381db4376a61f2e0f170cd512883e0f817a5c28266b744e04d00006", + "gitTreeSha": "2f7404841129a80c8b9add53c761f0ff11174dae", + "files": [ + { + "path": "SKILL.md", + "size": 10973, + "executable": false, + "classification": "text", + "exactSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "textNormalizedSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "identitySha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "f51066a029f17a1d87f7ac587df9850440cde08f5bdda80cb810a100ea45a169", + "gitTreeSha": "df7b970dda7e03e87bd9fd1dfe62c9aaf8769adc", + "files": [ + { + "path": "SKILL.md", + "size": 13415, + "executable": false, + "classification": "text", + "exactSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "textNormalizedSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "identitySha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "77663355eef8ed82ff6918d9745bfecc4a50550ea86edc0f2a151f989c205a23", + "gitTreeSha": "7ff543c1701d55ffb5dffb2cd8dc5dd3fc47c700", + "files": [ + { + "path": "SKILL.md", + "size": 17357, + "executable": false, + "classification": "text", + "exactSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "textNormalizedSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "identitySha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "3318ddeba58457774a0436b6d2d1e722b8d81e2ab3c67cd8409bfbd8efe62a5f", + "gitTreeSha": "90a57c3d0135992369caa36493ca04278e02e55a", + "files": [ + { + "path": "SKILL.md", + "size": 17600, + "executable": false, + "classification": "text", + "exactSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "textNormalizedSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "identitySha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "15ec10fdb480836bedc166c54ea15c10fb1e66e7c480f19300ad1227abe2b34d", + "gitTreeSha": "189912ad39f48b0a71f1cf0ee87abfcd55f90716", + "files": [ + { + "path": "SKILL.md", + "size": 19701, + "executable": false, + "classification": "text", + "exactSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "textNormalizedSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "identitySha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "211f80c301de8afa2623dac3fde79f9ada8ec99a817f54a56dbb3cb43e4325d7", + "gitTreeSha": "a96dcd29ab8618eccc54c5c433f0b2378e50f698", + "files": [ + { + "path": "SKILL.md", + "size": 21546, + "executable": false, + "classification": "text", + "exactSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "textNormalizedSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "identitySha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "c8eddb2814dda4c4b000048e628f147ef730088f8c9628afb04529a4863a410b", + "gitTreeSha": "ad1024ebab1f464a743d1c91a7005211beed0917", + "files": [ + { + "path": "SKILL.md", + "size": 21701, + "executable": false, + "classification": "text", + "exactSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "textNormalizedSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "identitySha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b", + "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2", + "files": [ + { + "path": "SKILL.md", + "size": 22850, + "executable": false, + "classification": "text", + "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", + "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "files": [ + { + "path": "SKILL.md", + "size": 22861, + "executable": false, + "classification": "text", + "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", + "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + } + ] + } + ], + "mobile-fit-debug": [ + { + "releaseRevision": 1, + "packageDigest": "97ff3f85018bdea5e4518325423b159708eabe16cde3fbf7297525fa084d6a46", + "gitTreeSha": "9ff10aaeea448fa956cd4708b7f3b8242d178c0a", + "files": [ + { + "path": "SKILL.md", + "size": 9414, + "executable": false, + "classification": "text", + "exactSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "textNormalizedSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "identitySha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab" + } + ] + } + ], + "computer-use": [ + { + "releaseRevision": 1, + "packageDigest": "7c844c42b8e8c1a413cb8755b11a3b89fb3dc6c5a349d7aa63f269df975c1ccb", + "gitTreeSha": "ac1f6e3de0cbd9b488c5cfb1d4c5c076e17afea0", + "files": [ + { + "path": "SKILL.md", + "size": 8514, + "executable": false, + "classification": "text", + "exactSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "textNormalizedSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "identitySha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "6c882a35e50a00c49d2f87647c54d401fe2af60a95dc3d17a14bf3cf879e5547", + "gitTreeSha": "d83929742fd30aa3d4ab82ed1be4000acbb9cce0", + "files": [ + { + "path": "SKILL.md", + "size": 6368, + "executable": false, + "classification": "text", + "exactSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "textNormalizedSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "identitySha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "747b9bbe7d08d0c6fc69a47c02064de73fa09ab755244b40a6253aa810d015f7", + "gitTreeSha": "b3fb19187d9775774df2a93d59e9e0003093e4a2", + "files": [ + { + "path": "SKILL.md", + "size": 10454, + "executable": false, + "classification": "text", + "exactSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "textNormalizedSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "identitySha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc", + "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b", + "files": [ + { + "path": "SKILL.md", + "size": 10625, + "executable": false, + "classification": "text", + "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", + "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", + "files": [ + { + "path": "SKILL.md", + "size": 11241, + "executable": false, + "classification": "text", + "exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39", + "identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39" + } + ] + } + ], + "orca-emulator": [ + { + "releaseRevision": 1, + "packageDigest": "fdecfa3957c5502ec5f4933504f7112ab98c2c3b7b8902aad34db6cb1b7dc28a", + "gitTreeSha": "6b6b92ea67eec1661e45a8b37bf8014542f05417", + "files": [ + { + "path": "SKILL.md", + "size": 10715, + "executable": false, + "classification": "text", + "exactSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "textNormalizedSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "identitySha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "5a25152a06a88be0361f47cc37325e06ab840e81df0092cfea82f5e20e50978b", + "gitTreeSha": "5575f765c3dc31f8bc57032ca3716df38826eabd", + "files": [ + { + "path": "SKILL.md", + "size": 10717, + "executable": false, + "classification": "text", + "exactSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "textNormalizedSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "identitySha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412", + "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f", + "files": [ + { + "path": "SKILL.md", + "size": 10853, + "executable": false, + "classification": "text", + "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", + "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", + "files": [ + { + "path": "SKILL.md", + "size": 11527, + "executable": false, + "classification": "text", + "exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429", + "identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429" + } + ] + } + ], + "linear-tickets": [ + { + "releaseRevision": 1, + "packageDigest": "d70fb0e7640182b1b193969c96d2ff0d70821f59f71945915709ae95ddc4e350", + "gitTreeSha": "bb8c28593807f05b5007f89ccc86f9c054728882", + "files": [ + { + "path": "SKILL.md", + "size": 6271, + "executable": false, + "classification": "text", + "exactSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "textNormalizedSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "identitySha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "cc5901e9ce67829d909ab97910eb22825ca58547d215c42b665b8ff7401d5a05", + "gitTreeSha": "954d226df97ba0034002887bc9d23221667d45f0", + "files": [ + { + "path": "SKILL.md", + "size": 8985, + "executable": false, + "classification": "text", + "exactSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "textNormalizedSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "identitySha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "948919e8f2b37157c40009f865fa43fc925e56d0c30b544d0508cec823c26b15", + "gitTreeSha": "e00b39a2c9b37916e40b539e08341274bd8db2f7", + "files": [ + { + "path": "SKILL.md", + "size": 9670, + "executable": false, + "classification": "text", + "exactSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "textNormalizedSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "identitySha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + } + ], + "orca-linear": [ + { + "releaseRevision": 1, + "packageDigest": "9015e708218fe86620eee39041d3e4f9ddd4b67ff2c6d049beca9ffe3783e03d", + "gitTreeSha": "bc547faebffa3f5645b7d2aad32560d25d836b7f", + "files": [ + { + "path": "SKILL.md", + "size": 9394, + "executable": false, + "classification": "text", + "exactSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "textNormalizedSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "identitySha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + } + ], + "orca-emulator-android": [ + { + "releaseRevision": 1, + "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4", + "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6", + "files": [ + { + "path": "SKILL.md", + "size": 8189, + "executable": false, + "classification": "text", + "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", + "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", + "files": [ + { + "path": "SKILL.md", + "size": 8886, + "executable": false, + "classification": "text", + "exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332", + "identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332" + } + ] + } + ], + "orca-per-workspace-env": [ + { + "releaseRevision": 1, + "packageDigest": "dc7f471327f55b27a40cd9575ca476fea7a79c2aba74881af00ce4e468cbf714", + "gitTreeSha": "8efb9872787bf874c558393b9a651df91604650c", + "files": [ + { + "path": "SKILL.md", + "size": 37692, + "executable": false, + "classification": "text", + "exactSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "textNormalizedSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "identitySha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + } + ] + } +} diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 680163f8b..bc390b2be 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -1,13 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - discoverSkillsMock: vi.fn(), - getDefaultWslDistroMock: vi.fn(), - getWslHomeMock: vi.fn() - }) -) +const { + handleMock, + discoverSkillsMock, + inventorySkillFreshnessMock, + getDefaultWslDistroMock, + getWslHomeMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + discoverSkillsMock: vi.fn(), + inventorySkillFreshnessMock: vi.fn(), + getDefaultWslDistroMock: vi.fn(), + getWslHomeMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +24,10 @@ vi.mock('../skills/discovery', () => ({ discoverSkills: discoverSkillsMock })) +vi.mock('../skills/skill-freshness-inventory', () => ({ + inventorySkillFreshness: inventorySkillFreshnessMock +})) + vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock, getWslHome: getWslHomeMock @@ -39,6 +48,12 @@ describe('registerSkillsHandlers', () => { getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + inventorySkillFreshnessMock.mockResolvedValue({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: 1 + }) getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') Object.defineProperty(process, 'platform', { configurable: true, @@ -61,6 +76,17 @@ describe('registerSkillsHandlers', () => { return call[1] as (_event: unknown, target?: unknown) => Promise } + function getFreshnessHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find( + (entry: unknown[]) => entry[0] === 'skills:freshnessInventory' + ) + if (!call) { + throw new Error('skills:freshnessInventory handler was not registered') + } + return call[1] as (_event: unknown) => Promise + } + it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { const handler = getDiscoverHandler() @@ -136,4 +162,13 @@ describe('registerSkillsHandlers', () => { ).rejects.toThrow('Project runtime requires repair before skill discovery') expect(discoverSkillsMock).not.toHaveBeenCalled() }) + + it('keeps freshness inventory local and read-only over known repositories', async () => { + const handler = getFreshnessHandler() + + await handler(null) + + expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos }) + expect(getWslHomeMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index b81fd6957..d210123a7 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -2,7 +2,9 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import { discoverSkills } from '../skills/discovery' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { SkillFreshnessInventory } from '../../shared/skill-freshness' import { getDefaultWslDistro, getWslHome } from '../wsl' +import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' type SkillDiscoveryRuntimeTarget = | { runtime: 'host' } @@ -55,4 +57,10 @@ export function registerSkillsHandlers(store: Store): void { return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) } ) + + ipcMain.handle('skills:freshnessInventory', async (): Promise => { + // Why: the update command targets this machine's global homes. WSL and SSH + // inventories stay out until their installer rail has an equivalent proof. + return inventorySkillFreshness({ repos: store.getRepos() }) + }) } diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 68ff5e9d0..d65f30dc3 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -83,6 +83,41 @@ describe('skill discovery', () => { } }) + it('does not add runtime-owned repository paths to local scan roots', () => { + const runtimeRepo = makeRepo('/runtime/repo') + runtimeRepo.executionHostId = 'runtime:environment-1' + + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [runtimeRepo] + }) + + expect(roots.map((root) => root.path.replace(/\\/g, '/'))).not.toContain( + '/runtime/repo/.agents/skills' + ) + }) + + it('can exclude the implicit cwd without excluding explicit local repositories', () => { + const defaultRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')] + }) + const explicitRoots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current', + repos: [makeRepo('/workspace/known')], + includeCwd: false + }) + + const normalizedDefaultPaths = defaultRoots.map((root) => root.path.replace(/\\/g, '/')) + const normalizedExplicitPaths = explicitRoots.map((root) => root.path.replace(/\\/g, '/')) + expect(normalizedDefaultPaths).toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).not.toContain('/workspace/current/.agents/skills') + expect(normalizedExplicitPaths).toContain('/workspace/known/.agents/skills') + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index d84f82af4..ed74ef3ff 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -234,6 +234,7 @@ export async function discoverSkills(args: { repos?: Repo[] homeDir?: string cwd?: string + includeCwd?: boolean }): Promise { const roots = buildSkillDiscoverySources(args) const sources: SkillDiscoverySource[] = [] diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts new file mode 100644 index 000000000..f86d5c382 --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.test.ts @@ -0,0 +1,37 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill bundle artifacts', () => { + it('rejects malformed nested release entries before building provenance', async () => { + const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(resourceRoot) + const target = join(resourceRoot, 'skills') + const source = resolve('resources', 'skills') + await mkdir(target, { recursive: true }) + const [manifest, registry, releaseMapping] = await Promise.all( + ['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) => + readFile(join(source, name), 'utf8') + ) + ) + const malformedMapping = JSON.parse(releaseMapping) + malformedMapping.releases[0] = { appVersion: 'invalid' } + await Promise.all([ + writeFile(join(target, 'current-manifest.json'), manifest), + writeFile(join(target, 'snapshot-registry.json'), registry), + writeFile(join(target, 'release-mapping.json'), JSON.stringify(malformedMapping)) + ]) + + await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow( + 'Invalid skill release mapping' + ) + }) +}) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts new file mode 100644 index 000000000..e7d403442 --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -0,0 +1,164 @@ +import { app } from 'electron' +import { readFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { z, type ZodType } from 'zod' +import type { + SkillBundleManifest, + SkillKnownSnapshot, + SkillReleaseMapping, + SkillSnapshotRegistry +} from '../../shared/skill-freshness' + +export type SkillBundleArtifacts = { + manifest: SkillBundleManifest + registry: SkillSnapshotRegistry + releaseMapping: SkillReleaseMapping + knownSnapshots: Record + releasedAppVersions: Record> +} + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/) +const snapshotShape = { + releaseRevision: z.number().int().positive(), + packageDigest: sha256Schema, + gitTreeSha: z.string().regex(/^[a-f0-9]{40}$/), + files: z + .array( + z + .object({ + path: z.string().min(1), + size: z.number().int().nonnegative(), + executable: z.boolean(), + classification: z.enum(['text', 'binary']), + exactSha256: sha256Schema, + textNormalizedSha256: sha256Schema.nullable(), + identitySha256: sha256Schema + }) + .strict() + ) + .min(1) +} +const knownSnapshotSchema = z.object(snapshotShape).strict() +const manifestSchema = z + .object({ + schemaVersion: z.literal(1), + appVersion: z.string().min(1), + skills: z.array( + z + .object({ + name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/), + sourcePath: z.string().min(1), + appVersion: z.string().min(1), + ...snapshotShape + }) + .strict() + ) + }) + .strict() +const registrySchema = z + .object({ + schemaVersion: z.literal(1), + skills: z.record(z.string().min(1), z.array(knownSnapshotSchema).min(1)) + }) + .strict() +const releaseMappingSchema = z + .object({ + schemaVersion: z.literal(1), + releases: z.array( + z + .object({ + appVersion: z.string().min(1), + skills: z.record(z.string().min(1), z.number().int().positive()) + }) + .strict() + ) + }) + .strict() + +function parseArtifact(schema: ZodType, value: unknown, label: string): T { + const result = schema.safeParse(value) + if (!result.success) { + throw new Error(`Invalid ${label}: ${result.error.issues[0]?.message ?? 'schema mismatch'}`) + } + return result.data +} + +const artifactsByResourceRoot = new Map>() + +// Why: the artifacts ship with the binary and never change within a run, while +// focus-triggered rescans would otherwise re-read and re-parse them every time. +export function loadSkillBundleArtifacts( + resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources') +): Promise { + const cached = artifactsByResourceRoot.get(resourceRoot) + if (cached) { + return cached + } + const loading = readSkillBundleArtifacts(resourceRoot) + artifactsByResourceRoot.set(resourceRoot, loading) + loading.catch(() => { + artifactsByResourceRoot.delete(resourceRoot) + }) + return loading +} + +async function readSkillBundleArtifacts(resourceRoot: string): Promise { + const bundleRoot = join(resourceRoot, 'skills') + const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([ + readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse), + readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse) + ]) + const manifest: SkillBundleManifest = parseArtifact( + manifestSchema, + manifestValue, + 'skill bundle manifest' + ) + const registry: SkillSnapshotRegistry = parseArtifact( + registrySchema, + registryValue, + 'skill snapshot registry' + ) + const releaseMapping: SkillReleaseMapping = parseArtifact( + releaseMappingSchema, + releaseMappingValue, + 'skill release mapping' + ) + for (const current of manifest.skills) { + if ( + current.appVersion !== manifest.appVersion || + !registry.skills[current.name]?.some( + (snapshot) => + snapshot.releaseRevision === current.releaseRevision && + snapshot.packageDigest === current.packageDigest + ) + ) { + throw new Error(`Inconsistent current skill snapshot: ${current.name}`) + } + } + + const releasedAppVersions: Record> = {} + for (const release of releaseMapping.releases) { + for (const [name, revision] of Object.entries(release.skills)) { + if (!registry.skills[name]?.some((snapshot) => snapshot.releaseRevision === revision)) { + throw new Error(`Unknown released skill revision: ${name}@${revision}`) + } + releasedAppVersions[name] ??= {} + releasedAppVersions[name][revision] ??= release.appVersion + } + } + for (const current of manifest.skills) { + releasedAppVersions[current.name] ??= {} + releasedAppVersions[current.name][current.releaseRevision] = current.appVersion + } + + return { + manifest, + registry, + releaseMapping, + // Why: newer-known classification needs every identity packaged with this + // build, while release mapping remains the provenance record for shipped revisions. + knownSnapshots: registry.skills, + releasedAppVersions + } +} diff --git a/src/main/skills/skill-candidate-concurrency.ts b/src/main/skills/skill-candidate-concurrency.ts new file mode 100644 index 000000000..c81f1942c --- /dev/null +++ b/src/main/skills/skill-candidate-concurrency.ts @@ -0,0 +1,24 @@ +const MAX_CONCURRENT_SKILL_CANDIDATES = 4 + +export async function runSkillCandidateTasks( + tasks: readonly (() => Promise)[] +): Promise { + const results = Array.from({ length: tasks.length }) + let nextIndex = 0 + + async function worker(): Promise { + for (;;) { + const index = nextIndex + nextIndex += 1 + if (index >= tasks.length) { + return + } + results[index] = await tasks[index]() + } + } + + await Promise.all( + Array.from({ length: Math.min(MAX_CONCURRENT_SKILL_CANDIDATES, tasks.length) }, () => worker()) + ) + return results +} diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index bec34117c..ba3726f43 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os' import { basename, join } from 'node:path' import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills' import type { Repo } from '../../shared/types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' export type SkillScanRoot = Omit @@ -25,6 +26,7 @@ export function buildSkillDiscoverySources( homeDir?: string cwd?: string repos?: Repo[] + includeCwd?: boolean } = {} ): SkillScanRoot[] { const home = args.homeDir ?? homedir() @@ -62,12 +64,16 @@ export function buildSkillDiscoverySources( const projectPaths = new Set() for (const repo of args.repos ?? []) { - if (repo.connectionId) { + // Why: runtime-owned repos can have no legacy connectionId while their + // paths are meaningful only on a remote host. + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { continue } projectPaths.add(repo.path) } - projectPaths.add(cwd) + if (args.includeCwd !== false) { + projectPaths.add(cwd) + } for (const repoPath of projectPaths) { const label = `Repo ${basename(repoPath)}` diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts new file mode 100644 index 000000000..e37541975 --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { + buildTargetedSkillUpdateCommand, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('skill freshness name-scoped update eligibility', () => { + it('offers a name when at least one supported placement is outdated and all are official', () => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-claude', + rootId: 'home-claude', + topology: 'provider-alias', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it.each([ + ['newer-known', 'independent-copy'], + ['unrecognized', 'independent-copy'], + ['inaccessible', 'broken-link'], + ['current', 'external-link'], + ['current', 'read-only'], + ['current', 'repo-scope'], + ['current', 'plugin-cache'] + ] as const)('poisons a name for a %s placement in %s topology', (status, topology) => { + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { id: `poison-${status}-${topology}`, status, topology }) + ]) + ).toEqual([]) + }) + + it('still updates the canonical copy when a clean standalone duplicate exists', () => { + // Why: a duplicate no longer omits the whole name — the canonical copy converges + // and the duplicate row is flagged as maybe-not-reached rather than blocking. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli'), + placement('orca-cli', { + id: 'orca-cli-gemini', + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'current' + }) + ]) + ).toEqual(['orca-cli']) + }) + + it('does not offer a skill that exists only as a standalone copy', () => { + // Why: with no canonical or alias to anchor `--global`, the command has no + // reliable target, so a duplicate-only skill stays unoffered. + expect( + eligibleSkillUpdateNames([ + placement('orca-cli', { + rootId: 'home-gemini', + unresolvedPath: '/home/.gemini/skills/orca-cli', + resolvedPath: '/home/.gemini/skills/orca-cli', + topology: 'independent-copy', + status: 'outdated' + }) + ]) + ).toEqual([]) + }) + + it('does not offer an all-current name or let another safe name hide a poisoned one', () => { + expect( + eligibleSkillUpdateNames([ + placement('computer-use', { status: 'current' }), + placement('orchestration'), + placement('orchestration', { + id: 'orchestration-project', + status: 'unrecognized', + topology: 'repo-scope' + }) + ]) + ).toEqual([]) + }) + + it('builds only an explicit, deterministic global command', () => { + expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe( + 'npx skills update orca-cli orchestration --global' + ) + expect(buildTargetedSkillUpdateCommand([])).toBeNull() + expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull() + }) +}) diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts new file mode 100644 index 000000000..f4eb27a9f --- /dev/null +++ b/src/main/skills/skill-freshness-eligibility.ts @@ -0,0 +1,41 @@ +import { + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, + type SkillFreshnessInstallation +} from '../../shared/skill-freshness' + +export function eligibleSkillUpdateNames( + installations: readonly SkillFreshnessInstallation[] +): string[] { + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + + const eligible: string[] = [] + for (const [name, entries] of byName) { + const hasOutdated = entries.some((entry) => entry.status === 'outdated') + const everyPlacementIsOfficialAndUpdatable = entries.every( + (entry) => + (entry.status === 'current' || entry.status === 'outdated') && + // Why: the rail reliably converges the canonical copy and its symlink aliases. + // A standalone duplicate no longer blocks the whole name — the canonical copy + // still updates and the duplicate row is flagged as maybe-not-reached — while + // data-loss topologies (unrecognized/read-only/etc.) still poison via these checks. + (SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) || + entry.topology === 'independent-copy') && + Boolean(entry.resolvedPath && entry.physicalIdentity) + ) + // Why: only offer the global command when a reliably-convergent placement anchors it, + // so a skill that exists solely as a standalone copy never draws a command that could + // no-op or error against a canonical install that isn't there. + const hasReliableTarget = entries.some((entry) => + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) + ) + if (hasOutdated && everyPlacementIsOfficialAndUpdatable && hasReliableTarget) { + eligible.push(name) + } + } + return eligible.sort((left, right) => left.localeCompare(right, 'en')) +} diff --git a/src/main/skills/skill-freshness-inventory-limits.test.ts b/src/main/skills/skill-freshness-inventory-limits.test.ts new file mode 100644 index 000000000..c43d137fc --- /dev/null +++ b/src/main/skills/skill-freshness-inventory-limits.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + boundRepositorySkillRoots, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import type { SkillScanRoot } from './skill-discovery-sources' + +describe('skill freshness inventory limits', () => { + it('caps repository roots before creating candidate probes', () => { + const roots = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS + 3 }, + (_, index): SkillScanRoot => ({ + id: `repo-${index}`, + label: `Repo ${index}`, + path: `/repo-${index}/.agents/skills`, + sourceKind: 'repo', + providers: ['agent-skills'] + }) + ) + + const bounded = boundRepositorySkillRoots(roots) + + expect(bounded.scanned).toHaveLength(MAXIMUM_REPOSITORY_SKILL_ROOTS) + expect(bounded.omitted).toHaveLength(3) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts new file mode 100644 index 000000000..990fbdba1 --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -0,0 +1,363 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Repo } from '../../shared/types' +import type { + SkillBundleFileIdentity, + SkillCurrentBundleEntry, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import { + inventorySkillFreshness, + MAXIMUM_REPOSITORY_SKILL_ROOTS +} from './skill-freshness-inventory' +import { describeObservedSkillFile, skillPackageDigest } from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +function snapshot(releaseRevision: number, markdown: string): SkillKnownSnapshot { + const observed = describeObservedSkillFile('SKILL.md', Buffer.from(markdown), false) + const file: SkillBundleFileIdentity = { + path: observed.path, + size: observed.size, + executable: observed.executable, + classification: observed.classification, + exactSha256: observed.exactSha256, + textNormalizedSha256: observed.textNormalizedSha256, + identitySha256: observed.identitySha256 + } + return { + releaseRevision, + packageDigest: skillPackageDigest([file]), + gitTreeSha: releaseRevision.toString(16).padStart(40, '0'), + files: [file] + } +} + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-')) + temporaryDirectories.push(root) + const homeDir = join(root, 'home') + const resourceRoot = join(root, 'resources') + const skillResourceRoot = join(resourceRoot, 'skills') + await mkdir(skillResourceRoot, { recursive: true }) + + const oldMarkdown = '---\nname: orca-cli\ndescription: Old official guide.\n---\n\n# Old\n' + const currentMarkdown = + '---\nname: orca-cli\ndescription: Current official guide.\n---\n\n# Current\n' + const newerMarkdown = '---\nname: orca-cli\ndescription: Newer official guide.\n---\n\n# Newer\n' + const snapshots = [ + snapshot(1, oldMarkdown), + snapshot(2, currentMarkdown), + snapshot(3, newerMarkdown) + ] + const current: SkillCurrentBundleEntry = { + name: 'orca-cli', + sourcePath: 'skills/orca-cli', + appVersion: '2.0.0', + ...snapshots[1] + } + await Promise.all([ + writeFile( + join(skillResourceRoot, 'current-manifest.json'), + `${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'snapshot-registry.json'), + `${JSON.stringify({ schemaVersion: 1, skills: { 'orca-cli': snapshots } }, null, 2)}\n` + ), + writeFile( + join(skillResourceRoot, 'release-mapping.json'), + `${JSON.stringify( + { + schemaVersion: 1, + releases: [ + { appVersion: '1.0.0', skills: { 'orca-cli': 1 } }, + { appVersion: '2.0.0', skills: { 'orca-cli': 2 } }, + { appVersion: '3.0.0', skills: { 'orca-cli': 3 } } + ] + }, + null, + 2 + )}\n` + ) + ]) + + const writeSkill = async (rootPath: string, markdown: string): Promise => { + const directory = join(rootPath, 'orca-cli') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), markdown) + return directory + } + return { + root, + homeDir, + resourceRoot, + oldMarkdown, + currentMarkdown, + newerMarkdown, + writeSkill + } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('read-only skill freshness inventory', () => { + it('offers an exact older official name only when all global placements are safe', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual(['outdated']) + expect(inventory.installations[0]?.installedAppVersion).toBe('1.0.0') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + }) + + it('labels newer known and unrecognized bytes honestly without calling them modified', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown) + await test.writeSkill( + join(test.homeDir, '.claude', 'skills'), + '---\nname: orca-cli\ndescription: User copy.\n---\n' + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'newer-known', + 'unrecognized' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('retains full-file identity without projecting unused metadata', async () => { + const test = await fixture() + const lateDescription = 'Description beyond the metadata parsing budget.' + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + `${' '.repeat(256 * 1024)}\n${lateDescription}` + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'unrecognized' + }) + expect(inventory.installations[0]).not.toHaveProperty('description') + expect(inventory.installations[0]?.observedPackageDigest).toMatch(/^[a-f0-9]{64}$/) + }) + + it.runIf(process.platform !== 'win32')( + 'deduplicates a provider alias to the canonical copy', + async () => { + const test = await fixture() + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown + ) + const claudeRoot = join(test.homeDir, '.claude', 'skills') + await mkdir(claudeRoot, { recursive: true }) + await symlink(canonical, join(claudeRoot, 'orca-cli')) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toHaveLength(1) + expect(inventory.installations[0]?.providers).toEqual(['agent-skills', 'claude']) + expect(inventory.installations[0]?.topology).toBe('canonical-copy') + expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) + } + ) + + it.runIf(process.platform !== 'win32')( + 'deduplicates aliases within an unsupported topology without hiding its poison', + async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const shared = await test.writeSkill(join(test.root, 'shared'), test.currentMarkdown) + const repos = await Promise.all( + ['one', 'two'].map(async (id) => { + const repoPath = join(test.root, `repo-${id}`) + const root = join(repoPath, '.agents', 'skills') + await mkdir(root, { recursive: true }) + await symlink(shared, join(root, 'orca-cli')) + return { id, path: repoPath } as unknown as Repo + }) + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect( + inventory.installations.filter((entry) => entry.topology === 'repo-scope') + ).toHaveLength(1) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('keeps inaccessible placements visible and lets them poison the name', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations.map((entry) => entry.status)).toEqual([ + 'outdated', + 'inaccessible' + ]) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it('does not lose an inaccessible known repository placement', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repoPath = join(test.root, 'repo') + const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli') + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[], + resourceRoot: test.resourceRoot, + candidateLstat: async (path) => { + if (path === inaccessiblePath) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + } + return import('node:fs/promises').then(({ lstat }) => lstat(path)) + } + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + unresolvedPath: inaccessiblePath, + topology: 'repo-scope', + status: 'inaccessible' + }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + + it.each([ + ['repo', 'repo-scope'], + ['plugin', 'plugin-cache'] + ] as const)( + 'keeps an official %s placement informational and name-poisoning', + async (kind, topology) => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + let repos: Repo[] = [] + if (kind === 'repo') { + const repoPath = join(test.root, 'repo') + await test.writeSkill(join(repoPath, '.agents', 'skills'), test.currentMarkdown) + repos = [{ id: 'repo', path: repoPath }] as unknown as Repo[] + } else { + await test.writeSkill( + join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'skills'), + test.currentMarkdown + ) + } + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations.some((entry) => entry.topology === topology)).toBe(true) + expect(inventory.eligibleUpdateNames).toEqual([]) + } + ) + + it('accepts CRLF as the same official text identity', async () => { + const test = await fixture() + await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown.replaceAll('\n', '\r\n') + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + expect(inventory.installations[0]?.status).toBe('outdated') + }) + + it('classifies exact current bytes as current when a later snapshot reuses them', async () => { + const test = await fixture() + const resourceRoot = join(test.resourceRoot, 'skills') + const registryPath = join(resourceRoot, 'snapshot-registry.json') + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + registry.skills['orca-cli'].push(snapshot(4, test.currentMarkdown)) + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + status: 'current', + installedReleaseRevision: 2, + installedAppVersion: '2.0.0' + }) + }) + + it('withholds updates when stored repositories exceed the probe budget', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + const repos = Array.from( + { length: MAXIMUM_REPOSITORY_SKILL_ROOTS / 2 + 1 }, + (_, index) => ({ id: `repo-${index}`, path: join(test.root, `repo-${index}`) }) as Repo + ) + + const inventory = await inventorySkillFreshness({ + homeDir: test.homeDir, + repos, + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ errorCategory: 'repository-scan-limit', status: 'inaccessible' }) + ]) + ) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) +}) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts new file mode 100644 index 000000000..e5bbae31d --- /dev/null +++ b/src/main/skills/skill-freshness-inventory.ts @@ -0,0 +1,179 @@ +import { lstat } from 'node:fs/promises' +import { join } from 'node:path' +import type { Repo } from '../../shared/types' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../shared/skill-freshness' +import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources' +import { loadSkillBundleArtifacts } from './skill-bundle-artifacts' +import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' +import { runSkillCandidateTasks } from './skill-candidate-concurrency' +import { + classifyHomeSkillCandidate, + classifyUnsupportedSkillCandidate, + dedupeSkillFreshnessPlacements, + observeSkillFreshnessInstallation, + type CandidateLstat +} from './skill-freshness-placement-observation' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 + +export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): { + scanned: SkillScanRoot[] + omitted: SkillScanRoot[] +} { + return { + scanned: roots.slice(0, MAXIMUM_REPOSITORY_SKILL_ROOTS), + omitted: roots.slice(MAXIMUM_REPOSITORY_SKILL_ROOTS) + } +} + +export async function inventorySkillFreshness( + args: { + homeDir?: string + cwd?: string + repos?: Repo[] + resourceRoot?: string + candidateLstat?: CandidateLstat + } = {} +): Promise { + const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) + const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) + const discoveryArgs = { + homeDir: args.homeDir, + cwd: args.cwd, + repos: args.repos, + // Why: freshness scans known repositories explicitly; treating the app's + // launch cwd as another repo would create phantom poison placements. + includeCwd: false + } + const roots = buildSkillDiscoverySources(discoveryArgs) + const homeRoots = roots.filter((root) => root.sourceKind === 'home') + const allRepoRoots = roots.filter((root) => root.sourceKind === 'repo') + const { scanned: repoRoots, omitted: omittedRepoRoots } = boundRepositorySkillRoots(allRepoRoots) + const pluginRoots = roots.filter((root) => root.sourceKind === 'plugin') + const canonicalRootPath = homeRoots.find((root) => root.id === 'home-agents')?.path + if (!canonicalRootPath) { + throw new Error('Missing canonical agent skills root') + } + + const homeTasks = artifacts.manifest.skills.flatMap((current) => + homeRoots.map( + (root) => () => + classifyHomeSkillCandidate({ + root, + current, + artifacts, + canonicalRootPath, + candidateLstat: args.candidateLstat ?? ((path) => lstat(path)) + }) + ) + ) + // Why: each observation may retain the package byte ceiling while hashing; + // launch/focus scans must not fan out across every known placement. + const homeInstallations = (await runSkillCandidateTasks(homeTasks)).filter( + (installation): installation is SkillFreshnessInstallation => installation !== null + ) + + const candidateLstat = args.candidateLstat ?? ((path) => lstat(path)) + const repoTasks = artifacts.manifest.skills.flatMap((current) => + repoRoots.map( + (root) => () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: join(root.path, current.name), + candidateLstat + }) + ) + ) + // Why: stored repositories can grow without bound. If the probe budget is + // exhausted, one sentinel per name preserves safety without hashing more packages. + const omittedRepoTasks = + omittedRepoRoots.length === 0 + ? [] + : artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: 'repo-scan-limit', + providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))], + sourceKind: 'repo', + sourceLabel: 'Additional repositories', + unresolvedPath: omittedRepoRoots[0]?.path ?? 'repo-scan-limit', + topology: { + topology: 'repo-scope', + resolvedPath: null, + identity: null, + errorCategory: 'repository-scan-limit' + } + }) + ) + const pluginScans = await Promise.all( + pluginRoots.map(async (root) => ({ + root, + scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys())) + })) + ) + const pluginTasks = pluginScans.flatMap(({ root, scan }) => [ + ...scan.candidates.flatMap((candidate) => { + const current = currentByName.get(candidate.name) + return current + ? [ + () => + classifyUnsupportedSkillCandidate({ + root, + current, + artifacts, + unresolvedPath: candidate.path, + candidateLstat + }) + ] + : [] + }), + // Why: unreadable plugin subtrees could hide any official name. An + // incomplete scan must conservatively poison every name rather than imply absence. + ...scan.incompletePaths.flatMap((incompletePath) => + artifacts.manifest.skills.map( + (current) => () => + observeSkillFreshnessInstallation({ + current, + artifacts, + rootId: root.id, + providers: root.providers, + sourceKind: 'plugin', + sourceLabel: root.label, + unresolvedPath: join(incompletePath, current.name), + topology: { + topology: 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: 'plugin-cache-scan-incomplete' + } + }) + ) + ) + ]) + const unsupportedInstallations = ( + await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks]) + ).filter((installation): installation is SkillFreshnessInstallation => installation !== null) + const installations = dedupeSkillFreshnessPlacements([ + ...homeInstallations, + ...unsupportedInstallations + ]).sort( + (left, right) => + left.name.localeCompare(right.name, 'en') || + left.unresolvedPath.localeCompare(right.unresolvedPath, 'en') + ) + + return { + schemaVersion: 1, + installations, + eligibleUpdateNames: eligibleSkillUpdateNames(installations), + scannedAt: Date.now() + } +} diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts new file mode 100644 index 000000000..2f8d32ca6 --- /dev/null +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -0,0 +1,251 @@ +import type { Stats } from 'node:fs' +import { join } from 'node:path' +import type { + SkillCurrentBundleEntry, + SkillFreshnessInstallation, + SkillFreshnessStatus, + SkillKnownSnapshot +} from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' +import type { SkillBundleArtifacts } from './skill-bundle-artifacts' +import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity' +import { + classifyHomeSkillTopology, + classifyUnsupportedSkillTopology, + skillPlacementId, + skillTopologyPriority, + type ClassifiedSkillTopology +} from './skill-installation-topology' + +export type CandidateLstat = (path: string) => Promise + +function freshnessStatus( + snapshot: SkillKnownSnapshot | null, + current: SkillCurrentBundleEntry +): SkillFreshnessStatus { + if (!snapshot) { + return 'unrecognized' + } + if (snapshot.releaseRevision > current.releaseRevision) { + return 'newer-known' + } + return snapshot.packageDigest === current.packageDigest ? 'current' : 'outdated' +} + +function errorCategory(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback +} + +function isInaccessibleError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'EACCES' || error.code === 'EPERM') + ) +} + +function knownSnapshots( + artifacts: SkillBundleArtifacts, + current: SkillCurrentBundleEntry +): SkillKnownSnapshot[] { + const snapshots = artifacts.knownSnapshots[current.name] ?? [] + return snapshots.some((snapshot) => snapshot.packageDigest === current.packageDigest) + ? snapshots + : [...snapshots, current] +} + +export async function observeSkillFreshnessInstallation(args: { + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + rootId: string + providers: SkillFreshnessInstallation['providers'] + sourceKind: SkillFreshnessInstallation['sourceKind'] + sourceLabel: string + unresolvedPath: string + topology: ClassifiedSkillTopology +}): Promise { + const base = { + id: skillPlacementId(args.unresolvedPath, args.current.name), + name: args.current.name, + rootId: args.rootId, + providers: args.providers, + sourceKind: args.sourceKind, + sourceLabel: args.sourceLabel, + unresolvedPath: args.unresolvedPath, + resolvedPath: args.topology.resolvedPath, + physicalIdentity: args.topology.identity, + topology: args.topology.topology, + currentReleaseRevision: args.current.releaseRevision, + currentPackageDigest: args.current.packageDigest, + currentAppVersion: args.current.appVersion, + errorCategory: args.topology.errorCategory + } + if (!args.topology.resolvedPath || !args.topology.identity) { + return { + ...base, + status: 'inaccessible', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null + } + } + + try { + const observed = await observeSkillPackage(args.topology.resolvedPath) + const matchedSnapshot = matchingKnownSnapshot( + observed, + knownSnapshots(args.artifacts, args.current) + ) + // Why: a later release can reintroduce identical bytes. Exact current + // identity is still current, and cannot honestly be attributed to the later tag. + const snapshot = + observed.observedDigest === args.current.packageDigest ? args.current : matchedSnapshot + return { + ...base, + status: freshnessStatus(snapshot, args.current), + installedReleaseRevision: snapshot?.releaseRevision ?? null, + installedAppVersion: snapshot + ? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? + null) + : null, + observedPackageDigest: observed.observedDigest + } + } catch (error) { + return { + ...base, + status: isInaccessibleError(error) ? 'inaccessible' : 'unrecognized', + installedReleaseRevision: null, + installedAppVersion: null, + observedPackageDigest: null, + errorCategory: errorCategory(error, 'skill-package-read-failed') + } + } +} + +export async function classifyHomeSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + canonicalRootPath: string + candidateLstat: CandidateLstat +}): Promise { + const unresolvedPath = join(args.root.path, args.current.name) + try { + await args.candidateLstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology: { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-inaccessible') + } + }) + } + + let topology: ClassifiedSkillTopology + try { + topology = await classifyHomeSkillTopology(args.root, unresolvedPath, args.canonicalRootPath) + } catch (error) { + topology = { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'skill-candidate-topology-failed') + } + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath, + topology + }) +} + +export async function classifyUnsupportedSkillCandidate(args: { + root: SkillScanRoot + current: SkillCurrentBundleEntry + artifacts: SkillBundleArtifacts + unresolvedPath: string + candidateLstat: CandidateLstat +}): Promise { + try { + await args.candidateLstat(args.unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return null + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: { + topology: args.root.sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: errorCategory(error, 'unsupported-candidate-inaccessible') + } + }) + } + return observeSkillFreshnessInstallation({ + current: args.current, + artifacts: args.artifacts, + rootId: args.root.id, + providers: args.root.providers, + sourceKind: args.root.sourceKind, + sourceLabel: args.root.label, + unresolvedPath: args.unresolvedPath, + topology: await classifyUnsupportedSkillTopology( + args.unresolvedPath, + args.root.sourceKind === 'repo' ? 'repo' : 'plugin' + ) + }) +} + +function topologyDedupeBucket(installation: SkillFreshnessInstallation): string { + return installation.topology === 'canonical-copy' || installation.topology === 'provider-alias' + ? 'managed-global' + : installation.topology +} + +export function dedupeSkillFreshnessPlacements( + installations: readonly SkillFreshnessInstallation[] +): SkillFreshnessInstallation[] { + const deduped = new Map() + for (const installation of installations) { + const key = installation.physicalIdentity + ? `${installation.name}\0${installation.physicalIdentity}\0${topologyDedupeBucket(installation)}` + : `logical\0${installation.id}` + const existing = deduped.get(key) + if (!existing) { + deduped.set(key, installation) + continue + } + const providers = [...new Set([...existing.providers, ...installation.providers])] + if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) { + deduped.set(key, { ...installation, providers }) + } else { + existing.providers = providers + } + } + return [...deduped.values()] +} diff --git a/src/main/skills/skill-installation-topology.ts b/src/main/skills/skill-installation-topology.ts new file mode 100644 index 000000000..673fcb3d1 --- /dev/null +++ b/src/main/skills/skill-installation-topology.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, lstat, realpath, stat } from 'node:fs/promises' +import { dirname, normalize, resolve } from 'node:path' +import type { SkillInstallationTopology } from '../../shared/skill-freshness' +import type { SkillScanRoot } from './skill-discovery-sources' + +export type ClassifiedSkillTopology = { + topology: SkillInstallationTopology + resolvedPath: string | null + identity: string | null + errorCategory: string | null +} + +export function skillPlacementId(unresolvedPath: string, name: string): string { + return createHash('sha256') + .update(normalizedSkillIdentityPath(unresolvedPath)) + .update('\0') + .update(name) + .digest('hex') + .slice(0, 24) +} + +export function normalizedSkillIdentityPath(value: string): string { + const normalized = normalize(value) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +export function skillPhysicalIdentity( + resolvedPath: string, + fileStat: Awaited> +): string { + const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null + return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath) +} + +export function skillTopologyPriority(topology: SkillInstallationTopology): number { + switch (topology) { + case 'canonical-copy': + return 3 + case 'independent-copy': + return 2 + case 'provider-alias': + return 1 + case 'external-link': + case 'broken-link': + case 'read-only': + case 'repo-scope': + case 'plugin-cache': + return 0 + } +} + +async function writableDestination(path: string): Promise { + try { + await Promise.all([ + access(path, constants.R_OK | constants.W_OK), + access(dirname(path), constants.W_OK) + ]) + return true + } catch { + return false + } +} + +async function hasSymlinkedAncestor(path: string, boundary: string): Promise { + let current = resolve(path) + const stop = resolve(boundary) + for (;;) { + const entry = await lstat(current).catch(() => null) + if (!entry || entry.isSymbolicLink()) { + return true + } + const parent = dirname(current) + if (current === stop) { + return false + } + if (parent === current) { + return true + } + current = parent + } +} + +export async function classifyHomeSkillTopology( + root: SkillScanRoot, + unresolvedPath: string, + canonicalRootPath: string +): Promise { + let logicalStat: Awaited> + try { + logicalStat = await lstat(unresolvedPath) + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'missing' + } + } + throw error + } + const linked = logicalStat.isSymbolicLink() + let resolvedPath: string + let resolvedStat: Awaited> + try { + resolvedPath = await realpath(unresolvedPath) + resolvedStat = await stat(resolvedPath) + } catch { + return { + topology: 'broken-link', + resolvedPath: null, + identity: null, + errorCategory: 'dangling-link' + } + } + if (!resolvedStat.isDirectory()) { + return { + topology: 'broken-link', + resolvedPath, + identity: null, + errorCategory: 'not-directory' + } + } + + const identity = skillPhysicalIdentity(resolvedPath, resolvedStat) + const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath)) + const homeBoundary = dirname(dirname(canonicalRootPath)) + const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary) + const isCanonicalTarget = + normalizedSkillIdentityPath(dirname(resolvedPath)) === + normalizedSkillIdentityPath(canonicalRoot) + let topology: SkillInstallationTopology + if (linked) { + topology = isCanonicalTarget ? 'provider-alias' : 'external-link' + } else if (rootOrProviderParentLinked) { + topology = 'external-link' + } else { + topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy' + } + if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) { + topology = 'read-only' + } + return { topology, resolvedPath, identity, errorCategory: null } +} + +export async function classifyUnsupportedSkillTopology( + directoryPath: string, + sourceKind: 'repo' | 'plugin' +): Promise { + try { + const resolvedPath = await realpath(directoryPath) + const resolvedStat = await stat(resolvedPath) + if (!resolvedStat.isDirectory()) { + throw new Error('not-directory') + } + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath, + identity: skillPhysicalIdentity(resolvedPath, resolvedStat), + errorCategory: null + } + } catch (error) { + return { + topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache', + resolvedPath: null, + identity: null, + errorCategory: error instanceof Error ? error.message : 'read-failed' + } + } +} diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts new file mode 100644 index 000000000..648b628e8 --- /dev/null +++ b/src/main/skills/skill-package-identity.test.ts @@ -0,0 +1,92 @@ +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + describeObservedSkillFile, + matchingKnownSnapshot, + observeSkillPackage, + skillPackageDigest +} from './skill-package-identity' + +const temporaryDirectories: string[] = [] + +async function temporarySkill(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-freshness-')) + temporaryDirectories.push(root) + return root +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('skill package identity', () => { + it('matches CRLF installed text to an LF official snapshot', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'first\r\nsecond\r\n') + const observed = await observeSkillPackage(root) + const expected = describeObservedSkillFile('SKILL.md', Buffer.from('first\nsecond\n'), false) + + // Why: scans can observe several package byte budgets concurrently; only + // hashes, not raw file buffers, should survive each file's identity pass. + expect(observed.files[0]).not.toHaveProperty('bytes') + expect( + matchingKnownSnapshot(observed, [ + { + releaseRevision: 1, + packageDigest: skillPackageDigest([expected]), + gitTreeSha: 'tree', + files: [expected] + } + ])?.releaseRevision + ).toBe(1) + }) + + it('uses exact bytes for executable and binary files', async () => { + const executable = describeObservedSkillFile('run.sh', Buffer.from('#!/bin/sh\r\n'), true) + const binary = describeObservedSkillFile('asset.bin', Buffer.from([0, 13, 10]), false) + expect(executable.identitySha256).toBe(executable.exactSha256) + expect(binary.identitySha256).toBe(binary.exactSha256) + expect(binary.classification).toBe('binary') + }) + + it('orders package files by locale-independent code units', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'apple.md'), 'apple') + await writeFile(join(root, 'Zebra.md'), 'zebra') + + const observed = await observeSkillPackage(root) + + expect(observed.files.map((file) => file.path)).toEqual(['Zebra.md', 'apple.md']) + }) + + it('rejects links and bounded-observation overflows', async () => { + const root = await temporarySkill() + await writeFile(join(root, 'SKILL.md'), 'skill') + if (process.platform !== 'win32') { + await symlink(join(root, 'SKILL.md'), join(root, 'linked.md')) + await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link') + await rm(join(root, 'linked.md')) + } + await expect( + observeSkillPackage(root, { + maximumDepth: 1, + maximumEntries: 0, + maximumFiles: 1, + maximumSingleFileBytes: 10, + maximumTotalBytes: 10 + }) + ).rejects.toThrow('skill-package-entry-limit') + }) + + it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { + const root = await temporarySkill() + await mkdir(join(root, 'scripts')) + const script = join(root, 'scripts', 'run.sh') + await writeFile(script, '#!/bin/sh\n') + await chmod(script, 0o755) + const observed = await observeSkillPackage(root) + expect(observed.files[0]?.executable).toBe(true) + }) +}) diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts new file mode 100644 index 000000000..8189ee0b2 --- /dev/null +++ b/src/main/skills/skill-package-identity.ts @@ -0,0 +1,229 @@ +import { createHash } from 'node:crypto' +import type { Dirent } from 'node:fs' +import { lstat, open, opendir } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness' + +type ObservedSkillFile = SkillBundleFileIdentity + +export type ObservedSkillPackage = { + files: ObservedSkillFile[] + observedDigest: string +} + +export const SKILL_PACKAGE_OBSERVATION_LIMITS = { + maximumDepth: 16, + maximumEntries: 2_048, + maximumFiles: 512, + maximumSingleFileBytes: 4 * 1024 * 1024, + maximumTotalBytes: 32 * 1024 * 1024 +} as const + +type SkillPackageObservationLimits = { + maximumDepth: number + maximumEntries: number + maximumFiles: number + maximumSingleFileBytes: number + maximumTotalBytes: number +} + +async function readBoundedSkillFile( + path: string, + remainingTotalBytes: number, + maximumSingleFileBytes: number +): Promise { + const handle = await open(path, 'r') + try { + const before = await handle.stat() + if (before.size > maximumSingleFileBytes) { + throw new Error('skill-package-file-size-limit') + } + if (before.size > remainingTotalBytes) { + throw new Error('skill-package-total-size-limit') + } + const bytes = Buffer.alloc(before.size) + let offset = 0 + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset) + if (result.bytesRead === 0) { + throw new Error('skill-package-changed-during-read') + } + offset += result.bytesRead + } + if ((await handle.stat()).size !== before.size) { + throw new Error('skill-package-changed-during-read') + } + return bytes + } finally { + await handle.close() + } +} + +function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex') +} + +function compareCodeUnits(left: string, right: string): number { + return left === right ? 0 : left < right ? -1 : 1 +} + +function normalizedText(bytes: Buffer): Buffer { + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8') +} + +export function describeObservedSkillFile( + path: string, + bytes: Buffer, + executable: boolean +): ObservedSkillFile { + let normalized: Buffer | null = null + if (!bytes.includes(0)) { + try { + normalized = normalizedText(bytes) + } catch { + normalized = null + } + } + const classification = normalized ? 'text' : 'binary' + const exactSha256 = sha256(bytes) + const textNormalizedSha256 = normalized ? sha256(normalized) : null + return { + path, + size: bytes.length, + executable, + classification, + exactSha256, + textNormalizedSha256, + identitySha256: + textNormalizedSha256 !== null && !executable ? textNormalizedSha256 : exactSha256 + } +} + +export function skillPackageDigest(files: readonly SkillBundleFileIdentity[]): string { + return sha256( + Buffer.from( + JSON.stringify( + files.map((file) => ({ + path: file.path, + executable: file.executable, + classification: file.classification, + identitySha256: file.identitySha256 + })) + ) + ) + ) +} + +function matchesFileIdentity( + actual: ObservedSkillFile, + expected: SkillBundleFileIdentity +): boolean { + if ( + actual.path !== expected.path || + actual.executable !== expected.executable || + actual.classification !== expected.classification + ) { + return false + } + return expected.classification === 'text' && !expected.executable + ? actual.textNormalizedSha256 === expected.textNormalizedSha256 + : actual.exactSha256 === expected.exactSha256 +} + +export async function observeSkillPackage( + packageRoot: string, + limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS +): Promise { + const files: ObservedSkillFile[] = [] + const caseFoldedPaths = new Map() + let entryCount = 0 + let totalBytes = 0 + + async function visit(directory: string, depth: number): Promise { + const directoryHandle = await opendir(directory) + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await directoryHandle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > limits.maximumEntries) { + throw new Error('skill-package-entry-limit') + } + entries.push(entry) + } + } finally { + await directoryHandle.close().catch(() => undefined) + } + // Why: runtime Electron and the build's Node may carry different ICU data; + // identity order must match the generator without locale-sensitive collation. + entries.sort((left, right) => compareCodeUnits(left.name, right.name)) + for (const entry of entries) { + const absolutePath = join(directory, entry.name) + const relativePath = relative(packageRoot, absolutePath) + if ( + isAbsolute(relativePath) || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) + ) { + throw new Error('skill-path-escape') + } + const manifestPath = relativePath.split(sep).join('/') + const folded = manifestPath.toLocaleLowerCase('en-US') + const collision = caseFoldedPaths.get(folded) + if (collision && collision !== manifestPath) { + throw new Error('skill-case-collision') + } + caseFoldedPaths.set(folded, manifestPath) + const fileStat = await lstat(absolutePath) + if (fileStat.isSymbolicLink()) { + throw new Error('skill-package-link') + } + if (fileStat.isDirectory()) { + if (depth >= limits.maximumDepth) { + throw new Error('skill-package-depth-limit') + } + await visit(absolutePath, depth + 1) + } else if (fileStat.isFile()) { + if (files.length >= limits.maximumFiles) { + throw new Error('skill-package-file-count-limit') + } + const bytes = await readBoundedSkillFile( + absolutePath, + limits.maximumTotalBytes - totalBytes, + limits.maximumSingleFileBytes + ) + totalBytes += bytes.length + files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0)) + } else { + throw new Error('skill-package-special-file') + } + } + } + + await visit(packageRoot, 0) + return { files, observedDigest: skillPackageDigest(files) } +} + +export function matchingKnownSnapshot( + observed: ObservedSkillPackage, + snapshots: readonly SkillKnownSnapshot[] +): SkillKnownSnapshot | null { + for (const snapshot of snapshots.toReversed()) { + if (snapshot.files.length !== observed.files.length) { + continue + } + if ( + snapshot.files.every((expected, index) => { + const actual = observed.files[index] + return Boolean(actual && matchesFileIdentity(actual, expected)) + }) + ) { + return snapshot + } + } + return null +} diff --git a/src/main/skills/skill-plugin-cache-scan.test.ts b/src/main/skills/skill-plugin-cache-scan.test.ts new file mode 100644 index 000000000..46216ce6c --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.test.ts @@ -0,0 +1,40 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('plugin skill candidate scan', () => { + it('stops at the package candidate budget and marks the scan incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-')) + temporaryDirectories.push(root) + await Promise.all( + ['one', 'two'].map((vendor) => mkdir(join(root, vendor, 'orca-cli'), { recursive: true })) + ) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1) + + expect(result.candidates).toHaveLength(1) + expect(result.incompletePaths).toEqual([root]) + }) + + it('marks depth-truncated subtrees incomplete so hidden skills poison eligibility', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-')) + temporaryDirectories.push(root) + const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`) + const hiddenSkill = join(root, ...segments, 'orca-cli') + await mkdir(hiddenSkill, { recursive: true }) + + const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli'])) + + expect(result.candidates).toEqual([]) + expect(result.incompletePaths).toHaveLength(1) + expect(hiddenSkill.startsWith(result.incompletePaths[0] ?? '')).toBe(true) + }) +}) diff --git a/src/main/skills/skill-plugin-cache-scan.ts b/src/main/skills/skill-plugin-cache-scan.ts new file mode 100644 index 000000000..7162cedbc --- /dev/null +++ b/src/main/skills/skill-plugin-cache-scan.ts @@ -0,0 +1,142 @@ +import type { Dirent } from 'node:fs' +import { opendir, realpath, stat } from 'node:fs/promises' +import { join } from 'node:path' + +const MAXIMUM_PLUGIN_SCAN_DEPTH = 9 +const MAXIMUM_PLUGIN_SCAN_ENTRIES = 4_096 +export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64 +const MAXIMUM_PLUGIN_INCOMPLETE_PATHS = 16 + +export type KnownPluginSkillCandidate = { + name: string + path: string +} + +export type KnownPluginSkillScan = { + candidates: KnownPluginSkillCandidate[] + incompletePaths: string[] +} + +function errorCode(error: unknown): string | null { + return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null +} + +export async function scanKnownPluginSkillCandidates( + rootPath: string, + knownNames: ReadonlySet, + maximumCandidates = MAXIMUM_PLUGIN_SKILL_CANDIDATES +): Promise { + const candidates: KnownPluginSkillCandidate[] = [] + const incompletePaths = new Set() + const visited = new Set() + let entryCount = 0 + let limitReached = false + + function recordIncomplete(path: string): void { + if (incompletePaths.has(path)) { + return + } + if (incompletePaths.size >= MAXIMUM_PLUGIN_INCOMPLETE_PATHS) { + // Why: each incomplete path expands to one conservative row per official + // skill. Collapse a hostile cache into one poison sentinel before IPC/render fanout. + incompletePaths.clear() + incompletePaths.add(rootPath) + limitReached = true + return + } + incompletePaths.add(path) + } + + async function visit(directory: string, depth: number): Promise { + if (limitReached) { + return + } + if (depth > MAXIMUM_PLUGIN_SCAN_DEPTH) { + recordIncomplete(directory) + return + } + let resolved: string + try { + resolved = await realpath(directory) + } catch (error) { + if (errorCode(error) !== 'ENOENT') { + recordIncomplete(directory) + } + return + } + if (visited.has(resolved)) { + return + } + visited.add(resolved) + + let handle: Awaited> + try { + handle = await opendir(directory) + } catch { + recordIncomplete(directory) + return + } + const entries: Dirent[] = [] + try { + for (;;) { + const entry = await handle.read() + if (!entry) { + break + } + entryCount += 1 + if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) { + limitReached = true + recordIncomplete(rootPath) + break + } + entries.push(entry) + } + } catch { + recordIncomplete(directory) + } finally { + await handle.close().catch(() => undefined) + } + + entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1)) + for (const entry of entries) { + if (limitReached) { + return + } + const entryPath = join(directory, entry.name) + let directoryEntry = entry.isDirectory() + if (entry.isSymbolicLink()) { + try { + directoryEntry = (await stat(entryPath)).isDirectory() + } catch { + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + } + continue + } + } + if (!directoryEntry) { + continue + } + if (knownNames.has(entry.name)) { + if (candidates.length >= maximumCandidates) { + limitReached = true + recordIncomplete(rootPath) + return + } + candidates.push({ name: entry.name, path: entryPath }) + continue + } + await visit(entryPath, depth + 1) + } + } + + await visit(rootPath, 0) + return { candidates, incompletePaths: [...incompletePaths] } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a8793c5f8..a3db4bbbc 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -321,6 +321,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-c import type { SourceControlAiSettings } from '../shared/source-control-ai-types' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { CrashReportBreadcrumbData, CrashReportCopyDiagnosticsArgs, @@ -2255,6 +2256,7 @@ export type PreloadApi = { } skills: { discover: (target?: SkillDiscoveryTarget) => Promise + freshnessInventory: () => Promise } pet: { import: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index d1a6dfb5c..4aa685d7b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -72,6 +72,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessInventory } from '../shared/skill-freshness' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -2216,7 +2217,9 @@ const api = { skills: { discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target) + ipcRenderer.invoke('skills:discover', target), + freshnessInventory: (): Promise => + ipcRenderer.invoke('skills:freshnessInventory') }, pet: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9fecbe8fd..ab19d2e24 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -59,6 +59,8 @@ import RightSidebar from './components/right-sidebar' import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' +import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge' +import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -2828,10 +2830,20 @@ function App(): React.JSX.Element { > + {/* Why: the dialog hosts a live terminal pane, which requires the + link-routing preference context; mounting outside crashes it. */} + + + + {/* Why: rendered last so it sits after all -webkit-app-region:drag elements in DOM order. Electron's hit-test for drag regions is DOM-order-based and diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index d7f4da5b6..4e986a268 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react' import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react' import { toast } from 'sonner' import { IntegrationStatusPill } from '../integration-status-pill' +import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' @@ -47,6 +48,10 @@ type AgentSkillSetupPanelProps = { openingHint?: ReactNode footer?: ReactNode onRecheck: () => void | Promise + // Why: when set, the installed pill reflects skill freshness and Re-check also + // refreshes the freshness inventory. Callers omit it for non-local runtimes, + // which the local-host-only freshness scan cannot vouch for. + freshnessSkillName?: string } export function AgentSkillSetupPanel({ @@ -79,7 +84,8 @@ export function AgentSkillSetupPanel({ actionHint, openingHint, footer, - onRecheck + onRecheck, + freshnessSkillName }: AgentSkillSetupPanelProps): React.JSX.Element { const [terminalOpen, setTerminalOpen] = useState(false) const [terminalCommand, setTerminalCommand] = useState(null) @@ -211,7 +217,12 @@ export function AgentSkillSetupPanel({ variant="ghost" size="sm" className="gap-1.5" - onClick={() => void onRecheck()} + onClick={() => { + void onRecheck() + if (freshnessSkillName) { + notifyInstalledAgentSkillsChanged() + } + }} disabled={loading} > @@ -264,12 +275,16 @@ export function AgentSkillSetupPanel({ )} ) : installed ? ( - - {translate( - 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', - 'Installed' - )} - + freshnessSkillName ? ( + + ) : ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', + 'Installed' + )} + + ) ) : ( {translate( diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 203803320..eae2ce2a7 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -390,6 +390,7 @@ export function CliSection({ })) }} onRecheck={refreshCliSkill} + freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined} /> ) : null} diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 270e1785a..0ffc0a2b5 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshComputerUseSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME + } /> ) } diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 85b27f12e..f1a34a039 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshSkill} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME + } />
diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index b757261bb..d597895d5 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -7,6 +7,7 @@ import { ORCHESTRATION_SKILL_INSTALL_COMMAND, ORCHESTRATION_SKILL_UPDATE_COMMAND } from '@/lib/orchestration-install-command' +import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' @@ -75,6 +76,9 @@ export function OrchestrationSetupCard(props: { : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={skill.refresh} + freshnessSkillName={ + activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME + } /> ) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2e0d2baa0..0d4c2d5d5 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -100,6 +100,7 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance' import { translate } from '@/i18n/i18n' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' @@ -207,11 +208,15 @@ function getSettingsNavGroupDefinitionsForSearch( function getSkillNavInstallStatus(skill: { installed: boolean loading: boolean + updateAvailable?: boolean }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' } - return skill.installed ? 'installed' : 'install' + if (!skill.installed) { + return 'install' + } + return skill.updateAvailable ? 'update-available' : 'installed' } function hasReadyVoiceModel( @@ -346,6 +351,10 @@ function Settings(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + // Why: mirror the setup cards — freshness only speaks for the validated global + // rail, which doesn't run under WSL, so the nav pill stays presence-only there. + const { inventory: skillFreshnessInventory } = useSkillFreshness() + const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl' const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) // Why: the Terminal settings section shares one search index with the // sidebar. We trim platform-only entries on other platforms so search never @@ -724,13 +733,16 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill + const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames const capabilityInstallStatusBySectionId = useMemo(() => { + const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : []) const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ installed: orchestrationSkillInstalled, - loading: orchestrationSkillLoading + loading: orchestrationSkillLoading, + updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME) }) ] ]) @@ -739,7 +751,8 @@ function Settings(): React.JSX.Element { 'computer-use', getSkillNavInstallStatus({ installed: computerUseSkillInstalled, - loading: computerUseSkillLoading + loading: computerUseSkillLoading, + updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME) }) ) if (settings) { @@ -757,11 +770,13 @@ function Settings(): React.JSX.Element { }, [ computerUseSkillInstalled, computerUseSkillLoading, + eligibleUpdateSkillNames, modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, settings, showDesktopOnlySettings, + skillFreshnessApplies, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 7bbcea653..8029eabda 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -155,6 +155,11 @@ export function SettingsSidebar({ ) case 'installed': return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') + case 'update-available': + return translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + ) case 'checking': return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...') } @@ -164,9 +169,11 @@ export function SettingsSidebar({ 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', status === 'installed' ? 'border-status-success-border bg-status-success-background text-status-success' - : status === 'install' - ? 'border-foreground/15 bg-foreground/10 text-foreground' - : 'border-border/50 bg-muted/30 text-muted-foreground' + : status === 'update-available' + ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + : status === 'install' + ? 'border-foreground/15 bg-foreground/10 text-foreground' + : 'border-border/50 bg-muted/30 text-muted-foreground' ) return ( diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx new file mode 100644 index 000000000..ca0090a6c --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessNudge } from './SkillFreshnessNudge' + +const mocks = vi.hoisted(() => ({ + dismissed: [] as string[], + updateSettings: vi.fn(), + toastInfo: vi.fn(), + toastDismiss: vi.fn(), + requestDialog: vi.fn(), + settingsLoaded: true, + inventory: null as SkillFreshnessInventory | null, + error: null as string | null +})) + +function placement( + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: 'orca-cli', + name: 'orca-cli', + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: '/home/.agents/skills/orca-cli', + resolvedPath: '/home/.agents/skills/orca-cli', + physicalIdentity: 'physical-orca-cli', + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement()], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: mocks.error, + refresh: vi.fn() + }) +})) + +vi.mock('sonner', () => ({ + toast: { info: mocks.toastInfo, dismiss: mocks.toastDismiss } +})) + +vi.mock('./skill-freshness-update-dialog', () => ({ + requestSkillFreshnessUpdateDialog: mocks.requestDialog +})) + +vi.mock('@/store', () => { + const state = () => ({ + settings: mocks.settingsLoaded ? { dismissedSkillFreshnessNudges: mocks.dismissed } : null, + updateSettings: mocks.updateSettings + }) + const useAppStore = (selector: (value: ReturnType) => unknown) => selector(state()) + useAppStore.getState = state + return { useAppStore } +}) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderNudge(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerenderNudge(): Promise { + await act(async () => { + root?.render() + }) +} + +const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', '2'].join('\0') + +describe('SkillFreshnessNudge', () => { + beforeEach(() => { + mocks.dismissed = [] + mocks.settingsLoaded = true + mocks.inventory = eligibleInventory() + mocks.error = null + mocks.updateSettings.mockReset() + mocks.updateSettings.mockResolvedValue(undefined) + mocks.toastInfo.mockReset() + mocks.toastInfo.mockReturnValue('freshness-toast') + mocks.toastDismiss.mockReset() + mocks.requestDialog.mockReset() + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('lingers without an auto-close timer or auto-close dismissal write', async () => { + await renderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + const options = mocks.toastInfo.mock.calls[0]?.[1] + expect(options.duration).toBe(Number.POSITIVE_INFINITY) + expect(options.onAutoClose).toBeUndefined() + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('opens the update dialog on action click without persisting a dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.action.onClick() + options.onDismiss() + + expect(mocks.requestDialog).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a resolved nudge without recording a dismissal', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = { + schemaVersion: 1, + installations: [placement({ status: 'current', observedPackageDigest: 'current' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerenderNudge() + // Sonner invokes onDismiss for programmatic dismissals on its next render. + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('retracts a stale nudge when re-inventory fails', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = null + mocks.error = 'scan failed' + await rerenderNudge() + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + + it('persists the exact placement/revision key once on explicit dismissal', async () => { + await renderNudge() + + const options = mocks.toastInfo.mock.calls[0]?.[1] + options.onDismiss() + options.onDismiss() + + expect(mocks.updateSettings).toHaveBeenCalledTimes(1) + expect(mocks.updateSettings).toHaveBeenCalledWith({ + dismissedSkillFreshnessNudges: [DISMISSAL_KEY] + }) + }) + + it('does not repeat the same nudge within a session', async () => { + await renderNudge() + // A fresh inventory object with identical content re-runs the effect. + mocks.inventory = eligibleInventory() + await rerenderNudge() + + expect(mocks.toastInfo).toHaveBeenCalledTimes(1) + }) + + it('does not repeat a nudge for an already dismissed exact tuple', async () => { + mocks.dismissed = [DISMISSAL_KEY] + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('does not nudge for a poisoned name with no eligible update', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) + + it('waits for persisted settings before deciding whether to nudge', async () => { + mocks.settingsLoaded = false + + await renderNudge() + + expect(mocks.toastInfo).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx new file mode 100644 index 000000000..135d86065 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from 'lucide-react' +import { toast } from 'sonner' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog' + +const MAX_DISMISSED_FRESHNESS_NUDGES = 512 +const NO_DISMISSED_FRESHNESS_NUDGES: string[] = [] + +type ActiveFreshnessNudge = { + id: string | number + fingerprint: string + persistDismissal: boolean +} + +function candidateKey(args: { + physicalIdentity: string + name: string + currentReleaseRevision: number +}): string { + return [args.physicalIdentity, args.name, args.currentReleaseRevision].join('\0') +} + +export function SkillFreshnessNudge(): null { + const state = useSkillFreshness() + const settingsLoaded = useAppStore((store) => store.settings !== null) + const dismissed = useAppStore( + (store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES + ) + const updateSettings = useAppStore((store) => store.updateSettings) + const shownFingerprints = useRef(new Set()) + const persistedFingerprints = useRef(new Set()) + const activeNudgeRef = useRef(null) + + useEffect(() => { + const inventory = state.inventory + if (!settingsLoaded) { + return + } + if (!inventory) { + const active = activeNudgeRef.current + if (state.error && active) { + // Why: a failed re-check cannot keep advertising authority derived from + // old bytes; retract without turning the scan failure into a dismissal. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const eligibleNames = new Set(inventory.eligibleUpdateNames) + const candidates = inventory.installations.flatMap((installation) => + installation.status === 'outdated' && + eligibleNames.has(installation.name) && + installation.physicalIdentity + ? [ + { + key: candidateKey({ + physicalIdentity: installation.physicalIdentity, + name: installation.name, + currentReleaseRevision: installation.currentReleaseRevision + }), + name: installation.name + } + ] + : [] + ) + const dismissedKeys = new Set(dismissed) + const unseen = candidates.filter((candidate) => !dismissedKeys.has(candidate.key)) + if (unseen.length === 0) { + const active = activeNudgeRef.current + if (active) { + // Why: a resolved/replaced nudge is stale presentation, not an explicit + // user dismissal, so retract it without persisting its tuple keys. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + return + } + const fingerprint = unseen + .map((candidate) => candidate.key) + .sort((left, right) => left.localeCompare(right, 'en')) + .join('\n') + const active = activeNudgeRef.current + if (active?.fingerprint === fingerprint) { + return + } + if (active) { + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } + if (shownFingerprints.current.has(fingerprint)) { + return + } + shownFingerprints.current.add(fingerprint) + + const persistDismissal = (): void => { + if (persistedFingerprints.current.has(fingerprint)) { + return + } + persistedFingerprints.current.add(fingerprint) + const current = useAppStore.getState().settings?.dismissedSkillFreshnessNudges ?? [] + const next = [...new Set([...current, ...unseen.map((candidate) => candidate.key)])].slice( + -MAX_DISMISSED_FRESHNESS_NUDGES + ) + void updateSettings({ dismissedSkillFreshnessNudges: next }).catch(() => { + persistedFingerprints.current.delete(fingerprint) + }) + } + const names = new Set(unseen.map((candidate) => candidate.name)) + // Why: name the outdated skills so the nudge is actionable without opening + // the modal; the sentence is translatable but the identifiers interpolate as-is. + const outdatedNames = [...names] + .sort((left, right) => left.localeCompare(right, 'en')) + .join(', ') + const nextActive: ActiveFreshnessNudge = { + id: '', + fingerprint, + persistDismissal: true + } + nextActive.id = toast.info( + names.size === 1 + ? translate( + 'auto.components.skills.SkillFreshnessNudge.titleOne', + 'An installed Orca skill is out of date' + ) + : translate( + 'auto.components.skills.SkillFreshnessNudge.titleMany', + '{{value0}} installed Orca skills are out of date', + { value0: names.size } + ), + { + description: translate( + 'auto.components.skills.SkillFreshnessNudge.description', + 'Update {{value0}} so agents follow the current instructions for this version of Orca.', + { value0: outdatedNames } + ), + // Why: the nudge lingers until the user acts. Ignoring it (app quit) + // records nothing, so a still-outdated skill may prompt once next launch. + duration: Number.POSITIVE_INFINITY, + // Why: only an explicit dismissal (the close button) records the keys; + // opening the review dialog is engagement, not a decision to hide it. + onDismiss: () => { + if (nextActive.persistDismissal) { + persistDismissal() + } + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + }, + action: { + label: ( + + + {names.size === 1 + ? translate('auto.components.skills.SkillFreshnessNudge.updateOne', 'Update skill') + : translate( + 'auto.components.skills.SkillFreshnessNudge.updateMany', + 'Update skills' + )} + + ), + onClick: () => { + // Sonner closes action toasts without onDismiss; clear ownership so + // a later inventory cannot treat the already-closed toast as active. + nextActive.persistDismissal = false + if (activeNudgeRef.current === nextActive) { + activeNudgeRef.current = null + } + requestSkillFreshnessUpdateDialog() + } + } + } + ) + activeNudgeRef.current = nextActive + }, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings]) + + return null +} diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx new file mode 100644 index 000000000..785b67aaf --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: false, + error: null, + refresh: vi.fn() + }) +})) + +function inventory( + entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[], + eligibleUpdateNames: string[] +): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: entries.map((entry, index) => ({ + id: `${entry.name}-${index}`, + name: entry.name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${entry.name}`, + resolvedPath: `/home/.agents/skills/${entry.name}`, + physicalIdentity: `physical-${entry.name}-${index}`, + topology: 'canonical-copy', + status: entry.status, + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null + })), + eligibleUpdateNames, + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderPill(skillName: string): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) + return container +} + +describe('SkillFreshnessStatusPill', () => { + beforeEach(() => { + mocks.inventory = null + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('shows Update available for an eligible outdated skill', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli']) + + expect((await renderPill('orca-cli')).textContent).toBe('Update available') + }) + + it('shows Up to date when every placement is current', async () => { + mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], []) + + expect((await renderPill('orca-cli')).textContent).toBe('Up to date') + }) + + it('falls back to Installed for a blocked outdated placement', async () => { + mocks.inventory = inventory( + [ + { name: 'orca-cli', status: 'outdated' }, + { name: 'orca-cli', status: 'unrecognized' } + ], + [] + ) + + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) + + it('falls back to Installed before the inventory loads', async () => { + expect((await renderPill('orca-cli')).textContent).toBe('Installed') + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx new file mode 100644 index 000000000..3c8586865 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -0,0 +1,40 @@ +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { translate } from '@/i18n/i18n' +import { IntegrationStatusPill } from '@/components/integration-status-pill' + +// Why: the setup rails' Installed pill is presence-only; when freshness knows a +// safe update exists (or that every copy is current) the pill should say so. +// Falls back to plain Installed for blocked/unrecognized copies so an unsafe +// placement is never advertised as updatable here. +export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { + const { inventory } = useSkillFreshness() + if (inventory?.eligibleUpdateNames.includes(skillName)) { + return ( + + {translate( + 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', + 'Update available' + )} + + ) + } + const placements = inventory?.installations.filter( + (installation) => installation.name === skillName + ) + if ( + placements && + placements.length > 0 && + placements.every((installation) => installation.status === 'current') + ) { + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} + + ) + } + return ( + + {translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')} + + ) +} diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx new file mode 100644 index 000000000..a810ae06c --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -0,0 +1,371 @@ +// @vitest-environment happy-dom + +import { act, useState, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory +} from '../../../../shared/skill-freshness' +import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog' +import { + consumeSkillFreshnessUpdateDialogRequest, + requestSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +const mocks = vi.hoisted(() => ({ + inventory: null as SkillFreshnessInventory | null, + loading: false, + error: null as string | null, + refresh: vi.fn(), + terminalProps: [] as { + command: string + description: string + onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void + onTerminalExit?: () => void + }[], + notifyChanged: vi.fn() +})) + +vi.mock('@/hooks/useSkillFreshness', () => ({ + useSkillFreshness: () => ({ + inventory: mocks.inventory, + loading: mocks.loading, + error: mocks.error, + refresh: mocks.refresh + }) +})) + +vi.mock('@/hooks/useInstalledAgentSkills', () => ({ + notifyInstalledAgentSkillsChanged: mocks.notifyChanged +})) + +vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ + OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + mocks.terminalProps.push(props) + return
{props.command}
+ } +})) + +// Radix Dialog/Collapsible internals (portal, focus-scope) are exercised in +// Electron QA; here the content logic is what matters, so use plain wrappers. +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

+})) + +vi.mock('@/components/ui/collapsible', () => ({ + Collapsible: ({ + children, + defaultOpen = false + }: { + children?: ReactNode + defaultOpen?: boolean + }) => { + // Model Radix's uncontrolled default: changing defaultOpen only matters + // when the keyed disclosure remounts. + const [open] = useState(defaultOpen) + return
{children}
+ }, + CollapsibleTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children?: ReactNode }) =>
{children}
+})) + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +function eligibleInventory(): SkillFreshnessInventory { + return { + schemaVersion: 1, + installations: [placement('orca-cli')], + eligibleUpdateNames: ['orca-cli'], + scannedAt: 1 + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function renderDialog(): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +async function rerender(): Promise { + await act(async () => { + root?.render() + }) +} + +async function openViaRequest(): Promise { + await act(async () => { + requestSkillFreshnessUpdateDialog() + }) +} + +async function clickButton(label: string): Promise { + const button = Array.from(container?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === label + ) + expect(button).toBeDefined() + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) +} + +describe('SkillFreshnessUpdateDialog', () => { + beforeEach(() => { + consumeSkillFreshnessUpdateDialogRequest() + mocks.inventory = eligibleInventory() + mocks.loading = false + mocks.error = null + mocks.refresh.mockReset() + mocks.notifyChanged.mockReset() + mocks.terminalProps.length = 0 + }) + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('stays closed until an open request arrives', async () => { + await renderDialog() + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('shows the eligible summary and the exact pre-filled draft command', async () => { + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Update skills') + expect(container?.textContent).toContain('1 skill can be updated safely') + expect(mocks.terminalProps.at(-1)).toMatchObject({ + command: 'npx skills update orca-cli --global', + description: 'Review the pre-filled command, then press Enter to run it.' + }) + }) + + it('resolves a request made before inventory loads once a safe command arrives', async () => { + mocks.inventory = null + await openViaRequest() + await renderDialog() + + expect(container?.querySelector('[data-dialog-open]')).not.toBeNull() + expect(mocks.terminalProps).toEqual([]) + + mocks.inventory = eligibleInventory() + await rerender() + + expect(mocks.terminalProps.at(-1)?.command).toBe('npx skills update orca-cli --global') + }) + + it('shows the up-to-date state once every installation is current', async () => { + await renderDialog() + await openViaRequest() + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('auto-expands Details when a rescan changes the result to blocked', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-collapsible-open="false"]')).not.toBeNull() + + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 2 + } + await rerender() + + expect(container?.querySelector('[data-collapsible-open="true"]')).not.toBeNull() + }) + + it('does not replace or tear down a submitted command during a rescan', async () => { + await renderDialog() + await openViaRequest() + + await act(async () => { + mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' }) + }) + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli', { status: 'current', observedPackageDigest: 'current' }) + ], + eligibleUpdateNames: [], + scannedAt: 2 + } + mocks.loading = false + await rerender() + + expect(container?.textContent).toContain('All installed Orca skills are up to date.') + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + }) + + it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull() + + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.textContent).toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('shows a failed scan as an error instead of indefinite progress', async () => { + mocks.inventory = null + mocks.error = 'Could not inspect installed skills.' + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Could not inspect installed skills.') + expect(container?.textContent).not.toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => { + await renderDialog() + await openViaRequest() + const firstTerminal = mocks.terminalProps.at(-1) + + await act(async () => { + firstTerminal?.onTerminalExit?.() + }) + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + + mocks.inventory = { ...eligibleInventory(), scannedAt: 2 } + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + expect(mocks.terminalProps.at(-1)).not.toBe(firstTerminal) + }) + + it('explains a poisoned sibling in Details without offering a command', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [ + placement('orca-cli'), + placement('orca-cli', { + id: 'repo-copy', + rootId: 'repo', + sourceKind: 'repo', + topology: 'repo-scope', + status: 'unrecognized', + unresolvedPath: '/repo/.agents/skills/orca-cli' + }) + ], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'it may be modified, or a different skill with the same name' + ) + expect(container?.textContent).toContain('Unrecognized') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('explains a self-blocked read-only placement without blaming a sibling', async () => { + mocks.inventory = { + schemaVersion: 1, + installations: [placement('orca-cli', { topology: 'read-only' })], + eligibleUpdateNames: [], + scannedAt: 1 + } + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain( + 'read-only location, so Orca left it out of the update' + ) + expect(container?.textContent).toContain('Read only') + }) + + it('re-inventories installed skills when the dialog closes', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Close') + + expect(mocks.notifyChanged).toHaveBeenCalledTimes(1) + expect(container?.querySelector('[data-dialog-open]')).toBeNull() + }) + + it('forces a re-check from the Re-check affordance', async () => { + await renderDialog() + await openViaRequest() + + await clickButton('Re-check') + + expect(mocks.refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx new file mode 100644 index 000000000..0a0e342c4 --- /dev/null +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -0,0 +1,310 @@ +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type KeyboardEvent +} from 'react' +import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, RefreshCw } from 'lucide-react' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' +import { buildTargetedSkillUpdateCommand } from '../../../../shared/skill-freshness' +import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal' +import { TooltipProvider } from '@/components/ui/tooltip' +import { groupSkillFreshness } from './skill-freshness-grouping' +import { SkillFreshnessGroup } from './skill-freshness-group' +import { + consumeSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest, + subscribeSkillFreshnessUpdateDialog +} from './skill-freshness-update-dialog' + +type FreshnessSummaryKind = 'loading' | 'empty' | 'eligible' | 'current' | 'attention' + +function summarizeInventory( + inventory: SkillFreshnessInventory | null, + hasBlockedGroup: boolean +): FreshnessSummaryKind { + if (!inventory) { + return 'loading' + } + if (inventory.installations.length === 0) { + return 'empty' + } + if (inventory.eligibleUpdateNames.length > 0) { + return 'eligible' + } + // Why: with nothing eligible, the modal is either genuinely all-clear or has + // out-of-date skills it can't safely update; the group filter already dropped + // the up-to-date and unrecognized-only noise, so a blocked group is the signal. + return hasBlockedGroup ? 'attention' : 'current' +} + +function SummaryHeadline({ + kind, + eligibleCount +}: { + kind: FreshnessSummaryKind + eligibleCount: number +}): React.JSX.Element { + if (kind === 'loading') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.checking', + 'Checking installed Orca skills…' + )} +
+ ) + } + if (kind === 'empty') { + return ( +

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.none', + 'No installed Orca skills found.' + )} +

+ ) + } + if (kind === 'current') { + return ( +
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.success', + 'All installed Orca skills are up to date.' + )} +
+ ) + } + if (kind === 'attention') { + return ( +
+
+ + {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attention', + 'Some installed Orca skills were left out of the update.' + )} +
+

+ {translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.attentionDescription', + 'Open Update details to see why each one was skipped.' + )} +

+
+ ) + } + return ( +
+

+ {eligibleCount === 1 + ? translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateOne', + '1 skill can be updated safely' + ) + : translate( + 'auto.components.skills.SkillFreshnessUpdateDialog.updateMany', + '{{value0}} skills can be updated safely', + { value0: eligibleCount } + )} +

+
+ ) +} + +export function SkillFreshnessUpdateDialog(): React.JSX.Element { + const state = useSkillFreshness() + const open = useSyncExternalStore( + subscribeSkillFreshnessUpdateDialog, + getSkillFreshnessUpdateDialogRequest, + getSkillFreshnessUpdateDialogRequest + ) + const [terminalCommand, setTerminalCommand] = useState(null) + const [awaitingExitRefresh, setAwaitingExitRefresh] = useState(false) + const terminalSubmittedRef = useRef(false) + const inventoryAtTerminalExitRef = useRef(null) + const inventory = state.inventory + const eligibleNames = useMemo(() => inventory?.eligibleUpdateNames ?? [], [inventory]) + const groups = useMemo( + () => + inventory ? groupSkillFreshness(inventory.installations, inventory.eligibleUpdateNames) : [], + [inventory] + ) + const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update') + const updateCommand = buildTargetedSkillUpdateCommand(eligibleNames) + const summaryKind = summarizeInventory(inventory, hasBlockedGroup) + + useEffect(() => { + if (!open) { + return + } + if (state.loading || state.error || !inventory) { + // Why: a scan invalidates the authorization behind an unsubmitted draft. + // A running command keeps its PTY until exit, but stale drafts fail closed. + if (!terminalSubmittedRef.current && terminalCommand !== null) { + setTerminalCommand(null) + } + return + } + if (awaitingExitRefresh) { + if (inventory === inventoryAtTerminalExitRef.current) { + return + } + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + return + } + if (terminalSubmittedRef.current || terminalCommand === updateCommand) { + return + } + // Why: changing the shared onboarding terminal's command pastes it again. + // Replace only an unsubmitted draft; a submitted command owns its PTY until exit. + setTerminalCommand(updateCommand) + }, [ + awaitingExitRefresh, + inventory, + open, + state.error, + state.loading, + terminalCommand, + updateCommand + ]) + + const handleOpenChange = (next: boolean): void => { + // Why: closing is the natural point to re-observe bytes so a completed update + // clears the state and the lingering nudge does not fire again. + if (!next) { + consumeSkillFreshnessUpdateDialogRequest() + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = null + setAwaitingExitRefresh(false) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + } + + const handleTerminalInteraction = ( + method: 'keyboard' | 'pointer', + event?: KeyboardEvent + ): void => { + if (method === 'keyboard' && event?.key === 'Enter') { + terminalSubmittedRef.current = true + } + } + + const handleTerminalExit = (): void => { + terminalSubmittedRef.current = false + inventoryAtTerminalExitRef.current = inventory + setAwaitingExitRefresh(true) + setTerminalCommand(null) + notifyInstalledAgentSkillsChanged() + } + + const hasVisibleGroups = groups.length > 0 + + return ( + + + + + {translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills')} + + + + {state.error ? ( +

{state.error}

+ ) : ( + + )} + + {terminalCommand ? ( + + ) : null} + + {/* Why: Radix reads defaultOpen only on mount. Remount when a scan + becomes blocked so the required diagnostic details actually open. */} + {hasVisibleGroups ? ( + + + + + + + {groups.map((group) => ( + + ))} + + + + ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-group.tsx b/src/renderer/src/components/skills/skill-freshness-group.tsx new file mode 100644 index 000000000..c6dac9647 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-group.tsx @@ -0,0 +1,203 @@ +import type { + SkillFreshnessGroupModel, + SkillLocationChip, + SkillLocationRow +} from './skill-freshness-grouping' +import { translate } from '@/i18n/i18n' +import { Badge } from '@/components/ui/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +function chipLabel(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current') + case 'unrecognized': + return translate('auto.components.skills.SkillFreshnessRow.chipUnrecognized', 'Unrecognized') + case 'inaccessible': + return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible') + case 'duplicate': + return translate('auto.components.skills.SkillFreshnessRow.chipDuplicate', 'Duplicate') + case 'external-link': + return translate('auto.components.skills.SkillFreshnessRow.chipExternalLink', 'External link') + case 'broken-link': + return translate('auto.components.skills.SkillFreshnessRow.chipBrokenLink', 'Broken link') + case 'read-only': + return translate('auto.components.skills.SkillFreshnessRow.chipReadOnly', 'Read only') + case 'in-a-repo': + return translate('auto.components.skills.SkillFreshnessRow.chipInRepo', 'In a repo') + case 'plugin-cache': + return translate('auto.components.skills.SkillFreshnessRow.chipPluginCache', 'Plugin cache') + } +} + +// Why: chips describe only what a location *is*; the effect on the update +// command lives in the per-skill sentence, so the two never say it twice. +function chipTooltip(chip: SkillLocationChip): string { + switch (chip) { + case 'current': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipCurrent', + 'This copy matches the current official version.' + ) + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipUnrecognized', + 'This copy doesn’t match any official version — it may be modified, or a different skill with the same name.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInaccessible', + 'Orca couldn’t read this copy (a permissions or file error).' + ) + case 'duplicate': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipDuplicate', + 'A separate copy of this skill, installed apart from the main one.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipExternalLink', + 'A shortcut pointing outside Orca’s skill folders.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipBrokenLink', + 'A shortcut to something that no longer exists.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipReadOnly', + 'This copy is in a read-only location.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipInRepo', + 'This copy lives inside a project, not your global skills.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.tipPluginCache', + 'This copy is managed by a plugin.' + ) + } +} + +// Why: a skill is skipped for one concrete reason; lead with the highest-priority +// blocking placement so the sentence explains the real cause (an edited copy is +// more useful to surface than a downstream symptom). +const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [ + 'unrecognized', + 'read-only', + 'inaccessible', + 'in-a-repo', + 'plugin-cache', + 'external-link', + 'broken-link' +] + +function skippedReason(locations: readonly SkillLocationRow[]): string { + const present = new Set(locations.map((location) => location.chip)) + const chip = SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate)) + switch (chip) { + case 'unrecognized': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonUnrecognized', + 'The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.' + ) + case 'read-only': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly', + 'This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.' + ) + case 'inaccessible': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInaccessible', + 'Orca couldn’t read this copy, so it left the skill out of the update.' + ) + case 'in-a-repo': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonInRepo', + 'This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.' + ) + case 'plugin-cache': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonPluginCache', + 'A plugin manages this skill, so Orca left it out of the update — update the plugin instead.' + ) + case 'external-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonExternalLink', + 'This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.' + ) + case 'broken-link': + return translate( + 'auto.components.skills.SkillFreshnessRow.skippedReasonBrokenLink', + 'This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.' + ) + default: + return translate( + 'auto.components.skills.SkillFreshnessRow.cantUpdateReason', + 'Orca left this skill out of the update command.' + ) + } +} + +export function SkillFreshnessGroup({ + group +}: { + group: SkillFreshnessGroupModel +}): React.JSX.Element { + const isBlocked = group.status === 'cannot-update' + return ( +
+
+ {group.name} + {isBlocked ? ( + + {translate('auto.components.skills.SkillFreshnessRow.statusCantUpdate', 'Skipped')} + + ) : ( + + {translate( + 'auto.components.skills.SkillFreshnessRow.statusUpdateAvailable', + 'Update available' + )} + + )} +
+ {isBlocked ? ( +

{skippedReason(group.locations)}

+ ) : null} +
+ {group.locations.map((location) => ( +
+ + {location.path} + + {location.chip ? ( + + + + {chipLabel(location.chip)} + + + + {chipTooltip(location.chip)} + + + ) : null} +
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.test.ts b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts new file mode 100644 index 000000000..03368ec07 --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' +import { groupSkillFreshness } from './skill-freshness-grouping' + +function placement( + name: string, + overrides: Partial = {} +): SkillFreshnessInstallation { + return { + id: `${name}-${overrides.rootId ?? 'home-agents'}-${overrides.unresolvedPath ?? 'a'}`, + name, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${name}`, + resolvedPath: `/home/.agents/skills/${name}`, + physicalIdentity: `physical-${name}`, + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: 'old', + errorCategory: null, + ...overrides + } +} + +describe('groupSkillFreshness', () => { + it('marks an eligible outdated skill as update-available with one location', () => { + const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli']) + expect(groups).toHaveLength(1) + expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' }) + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orca-cli', chip: null } + ]) + }) + + it('hides skills with nothing out of date (current, unrecognized-only, unreadable-only)', () => { + const groups = groupSkillFreshness( + [ + placement('orca-cli', { status: 'current' }), + placement('dataviz', { status: 'unrecognized', topology: 'independent-copy' }), + placement('linear-tickets', { status: 'inaccessible' }) + ], + [] + ) + expect(groups).toEqual([]) + }) + + it('groups a blocked skill and flags the culprit location, not the main copy', () => { + const groups = groupSkillFreshness( + [ + placement('orchestration'), + placement('orchestration', { + rootId: 'home-claude', + unresolvedPath: '/home/.claude/skills/orchestration', + status: 'unrecognized', + topology: 'independent-copy' + }) + ], + [] + ) + expect(groups).toHaveLength(1) + expect(groups[0]?.status).toBe('cannot-update') + // Why: the out-of-date main copy is bare; only the poisoning copy carries a chip. + expect(groups[0]?.locations).toEqual([ + { id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: null }, + { id: expect.any(String), path: '/home/.claude/skills/orchestration', chip: 'unrecognized' } + ]) + }) + + it('prefers a location status over its topology and maps every topology to a chip', () => { + const chipFor = (overrides: Partial): string | null => + groupSkillFreshness( + [placement('s', { status: 'outdated' }), placement('s', overrides)], + ['s'] + )[0]?.locations.find((location) => location.path.includes('culprit'))?.chip ?? null + const at = (path: string, rest: Partial) => ({ + unresolvedPath: `/culprit/${path}`, + ...rest + }) + expect(chipFor(at('a', { status: 'unrecognized', topology: 'independent-copy' }))).toBe( + 'unrecognized' + ) + expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible') + expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate') + expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link') + expect(chipFor(at('e', { topology: 'broken-link' }))).toBe('broken-link') + expect(chipFor(at('f', { topology: 'read-only' }))).toBe('read-only') + expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo') + expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache') + expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current') + }) +}) diff --git a/src/renderer/src/components/skills/skill-freshness-grouping.ts b/src/renderer/src/components/skills/skill-freshness-grouping.ts new file mode 100644 index 000000000..db76800fd --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-grouping.ts @@ -0,0 +1,90 @@ +import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness' + +export type SkillGroupStatus = 'update-available' | 'cannot-update' + +export type SkillLocationChip = + | 'current' + | 'unrecognized' + | 'inaccessible' + | 'duplicate' + | 'external-link' + | 'broken-link' + | 'read-only' + | 'in-a-repo' + | 'plugin-cache' + +export type SkillLocationRow = { + id: string + path: string + chip: SkillLocationChip | null +} + +export type SkillFreshnessGroupModel = { + name: string + status: SkillGroupStatus + locations: SkillLocationRow[] +} + +function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null { + // Why: a location's own status wins over its topology — "the contents don't + // match" is more useful to the user than "it's a duplicate". + if (installation.status === 'unrecognized') { + return 'unrecognized' + } + if (installation.status === 'inaccessible') { + return 'inaccessible' + } + switch (installation.topology) { + case 'independent-copy': + return 'duplicate' + case 'external-link': + return 'external-link' + case 'broken-link': + return 'broken-link' + case 'read-only': + return 'read-only' + case 'repo-scope': + return 'in-a-repo' + case 'plugin-cache': + return 'plugin-cache' + case 'canonical-copy': + case 'provider-alias': + // Why: a supported location only needs a chip when it's already up to date, + // to explain why the update won't touch it; the out-of-date main copy is bare. + return installation.status === 'current' ? 'current' : null + } +} + +/** + * Groups installations by skill for the update modal and derives each skill's + * update disposition. Only skills with an out-of-date official copy are returned — + * up-to-date, unrecognized-only, and unreadable-only skills have nothing to change + * here, so they are omitted entirely. + */ +export function groupSkillFreshness( + installations: readonly SkillFreshnessInstallation[], + eligibleUpdateNames: readonly string[] +): SkillFreshnessGroupModel[] { + const eligible = new Set(eligibleUpdateNames) + const byName = new Map() + for (const installation of installations) { + const entries = byName.get(installation.name) ?? [] + entries.push(installation) + byName.set(installation.name, entries) + } + const groups: SkillFreshnessGroupModel[] = [] + for (const [name, entries] of byName) { + if (!entries.some((entry) => entry.status === 'outdated')) { + continue + } + const locations = entries + .map((entry) => ({ id: entry.id, path: entry.unresolvedPath, chip: locationChip(entry) })) + .sort((left, right) => left.path.localeCompare(right.path, 'en')) + groups.push({ + name, + status: eligible.has(name) ? 'update-available' : 'cannot-update', + locations + }) + } + return groups.sort((left, right) => left.name.localeCompare(right.name, 'en')) +} diff --git a/src/renderer/src/components/skills/skill-freshness-update-dialog.ts b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts new file mode 100644 index 000000000..14a84fa4a --- /dev/null +++ b/src/renderer/src/components/skills/skill-freshness-update-dialog.ts @@ -0,0 +1,31 @@ +let pendingOpen = false +const listeners = new Set<() => void>() + +// Why: the nudge action can fire before the dialog subscribes. Keeping the +// request as an external snapshot prevents mount ordering from losing it. +export function requestSkillFreshnessUpdateDialog(): void { + pendingOpen = true + for (const listener of listeners) { + listener() + } +} + +export function consumeSkillFreshnessUpdateDialogRequest(): boolean { + const requested = pendingOpen + pendingOpen = false + if (requested) { + for (const listener of listeners) { + listener() + } + } + return requested +} + +export function getSkillFreshnessUpdateDialogRequest(): boolean { + return pendingOpen +} + +export function subscribeSkillFreshnessUpdateDialog(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/renderer/src/hooks/installed-agent-skills-change-event.ts b/src/renderer/src/hooks/installed-agent-skills-change-event.ts new file mode 100644 index 000000000..2e11a17ff --- /dev/null +++ b/src/renderer/src/hooks/installed-agent-skills-change-event.ts @@ -0,0 +1 @@ +export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' diff --git a/src/renderer/src/hooks/useInstalledAgentSkills.ts b/src/renderer/src/hooks/useInstalledAgentSkills.ts index e06a8a2a2..e19bac655 100644 --- a/src/renderer/src/hooks/useInstalledAgentSkills.ts +++ b/src/renderer/src/hooks/useInstalledAgentSkills.ts @@ -7,9 +7,9 @@ import type { } from '../../../shared/skills' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' import { useMountedRef } from './useMountedRef' -const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [ 'home' ] as const satisfies readonly SkillSourceKind[] diff --git a/src/renderer/src/hooks/useSkillFreshness.test.tsx b/src/renderer/src/hooks/useSkillFreshness.test.tsx new file mode 100644 index 000000000..812ad72ff --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { + _skillFreshnessCacheForTests, + type SkillFreshnessState, + useSkillFreshness +} from './useSkillFreshness' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (cause: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (cause: unknown) => void + const promise = new Promise((complete, fail) => { + resolve = complete + reject = fail + }) + return { promise, resolve, reject } +} + +function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory { + return { schemaVersion: 1, installations: [], eligibleUpdateNames, scannedAt } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null +let state: SkillFreshnessState | null = null +const states = new Map() + +function Probe({ id = 'default' }: { id?: string }): null { + state = useSkillFreshness() + states.set(id, state) + return null +} + +describe('useSkillFreshness', () => { + beforeEach(() => { + _skillFreshnessCacheForTests.reset() + state = null + states.clear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + vi.useRealTimers() + if (root) { + await act(async () => root?.unmount()) + } + root = null + container?.remove() + container = null + }) + + it('runs a follow-up scan when invalidated during an in-flight request', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('skips focus rescans inside the cooldown but honors install-change events', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('retracts stale update authority during the cooldown and runs one trailing focus scan', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-15T00:00:00Z')) + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1, ['orca-cli'])) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli']) + + await act(async () => window.dispatchEvent(new Event('focus'))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => vi.advanceTimersByTimeAsync(15_000)) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + await act(async () => second.resolve(inventory(2))) + expect(state?.inventory?.scannedAt).toBe(2) + }) + + it('coalesces multiple consumers into one rescan per invalidation event', async () => { + const first = deferred() + const second = deferred() + const freshnessInventory = vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValue(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + await act(async () => first.resolve(inventory(1))) + expect(freshnessInventory).toHaveBeenCalledTimes(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + await act(async () => second.resolve(inventory(2))) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('publishes a manual refresh to every consumer', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + expect(states.get('one')?.inventory?.scannedAt).toBe(1) + expect(states.get('two')?.inventory?.scannedAt).toBe(1) + + let refresh: Promise | undefined + await act(async () => { + refresh = states.get('one')?.refresh() + await Promise.resolve() + }) + expect(states.get('one')?.inventory).toBeNull() + expect(states.get('two')?.inventory).toBeNull() + + await act(async () => second.resolve(inventory(2))) + await refresh + expect(states.get('one')?.inventory?.scannedAt).toBe(2) + expect(states.get('two')?.inventory?.scannedAt).toBe(2) + expect(freshnessInventory).toHaveBeenCalledTimes(2) + }) + + it('fails closed when an invalidation scan rejects', async () => { + const second = deferred() + const freshnessInventory = vi + .fn() + .mockResolvedValueOnce(inventory(1)) + .mockReturnValueOnce(second.promise) + window.api = { skills: { freshnessInventory } } as never + + await act(async () => root?.render()) + expect(state?.inventory?.scannedAt).toBe(1) + + await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(true) + + await act(async () => second.reject(new Error('scan failed'))) + expect(state?.inventory).toBeNull() + expect(state?.loading).toBe(false) + expect(state?.error).toBe('scan failed') + }) + + it('installs one event-listener pair for multiple consumers and cleans it up', async () => { + const addEventListener = vi.spyOn(window, 'addEventListener') + const removeEventListener = vi.spyOn(window, 'removeEventListener') + window.api = { + skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) } + } as never + + await act(async () => + root?.render( + <> + + + + ) + ) + + expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed') + ).toHaveLength(1) + + await act(async () => root?.unmount()) + root = null + expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1) + expect( + removeEventListener.mock.calls.filter( + ([name]) => name === 'orca:installed-agent-skills-changed' + ) + ).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts new file mode 100644 index 000000000..91ea0c4bd --- /dev/null +++ b/src/renderer/src/hooks/useSkillFreshness.ts @@ -0,0 +1,178 @@ +import { useEffect, useSyncExternalStore } from 'react' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' +import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event' + +// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes +// every installed package; a just-completed scan stays authoritative briefly. +const FOCUS_RESCAN_COOLDOWN_MS = 15_000 +let cachedInventory: SkillFreshnessInventory | null = null +let pendingInventory: Promise | null = null +let invalidationRevision = 0 +let completedRevision = -1 +let lastCompletedScanAt = 0 +let refreshSequence = 0 +let scheduledFocusRescan: number | null = null + +type SkillFreshnessSnapshot = { + inventory: SkillFreshnessInventory | null + loading: boolean + error: string | null +} + +let snapshot: SkillFreshnessSnapshot = { + inventory: null, + loading: false, + error: null +} +const subscribers = new Set<() => void>() + +function publishSnapshot(next: SkillFreshnessSnapshot): void { + if ( + snapshot.inventory === next.inventory && + snapshot.loading === next.loading && + snapshot.error === next.error + ) { + return + } + snapshot = next + for (const subscriber of subscribers) { + subscriber() + } +} + +async function loadInventory(force: boolean): Promise { + if (force) { + invalidationRevision += 1 + } + const targetRevision = invalidationRevision + for (;;) { + if (cachedInventory && completedRevision >= targetRevision) { + return cachedInventory + } + if (!pendingInventory) { + const requestRevision = invalidationRevision + const request = window.api.skills + .freshnessInventory() + .then((inventory) => { + cachedInventory = inventory + completedRevision = Math.max(completedRevision, requestRevision) + lastCompletedScanAt = Date.now() + return inventory + }) + .finally(() => { + if (pendingInventory === request) { + pendingInventory = null + } + }) + pendingInventory = request + } + await pendingInventory + } +} + +async function refreshSkillFreshness(force = true): Promise { + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + const sequence = ++refreshSequence + // Why: eligibility is write authority for the draft command. Once invalidated, + // stale bytes must stop authorizing UI even if the replacement scan fails. + publishSnapshot({ inventory: null, loading: true, error: null }) + try { + const inventory = await loadInventory(force) + if (sequence === refreshSequence) { + publishSnapshot({ inventory, loading: false, error: null }) + } + } catch (cause) { + if (sequence === refreshSequence) { + publishSnapshot({ + inventory: null, + loading: false, + error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.' + }) + } + } +} + +function onWindowFocus(): void { + const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt) + if (cooldownRemaining <= 0) { + void refreshSkillFreshness(true) + return + } + if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) { + return + } + // Why: a focus event can follow an external edit. Retract stale update + // authority immediately, but keep rapid alt-tabs to one trailing disk scan. + publishSnapshot({ inventory: null, loading: true, error: null }) + scheduledFocusRescan = window.setTimeout( + () => { + scheduledFocusRescan = null + void refreshSkillFreshness(true) + }, + Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS) + ) +} + +function onInstalledSkillsChanged(): void { + void refreshSkillFreshness(true) +} + +function subscribe(subscriber: () => void): () => void { + subscribers.add(subscriber) + if (subscribers.size === 1) { + // Why: every consumer reads one external snapshot, so focus/install events + // install one listener and trigger one shared IPC scan regardless of UI count. + window.addEventListener('focus', onWindowFocus) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + return () => { + subscribers.delete(subscriber) + if (subscribers.size === 0) { + window.removeEventListener('focus', onWindowFocus) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged) + } + } +} + +function getSnapshot(): SkillFreshnessSnapshot { + return snapshot +} + +function ensureInventoryLoaded(): void { + if (!snapshot.inventory && !snapshot.loading) { + void refreshSkillFreshness(false) + } +} + +export type SkillFreshnessState = SkillFreshnessSnapshot & { + refresh: () => Promise +} + +export function useSkillFreshness(): SkillFreshnessState { + const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + + useEffect(() => { + ensureInventoryLoaded() + }, []) + + return { ...current, refresh: refreshSkillFreshness } +} + +export const _skillFreshnessCacheForTests = { + reset(): void { + cachedInventory = null + pendingInventory = null + invalidationRevision = 0 + completedRevision = -1 + lastCompletedScanAt = 0 + refreshSequence = 0 + if (scheduledFocusRescan !== null) { + window.clearTimeout(scheduledFocusRescan) + scheduledFocusRescan = null + } + snapshot = { inventory: null, loading: false, error: null } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6596b371b..f275b3923 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "Scanning skills", "35b9a724a0": "Available", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "An installed Orca skill is out of date", + "titleMany": "{{value0}} installed Orca skills are out of date", + "description": "Update {{value0}} so agents follow the current instructions for this version of Orca.", + "updateOne": "Update skill", + "updateMany": "Update skills" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Update available", + "statusCantUpdate": "Skipped", + "cantUpdateReason": "Orca left this skill out of the update command.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", + "chipCurrent": "Current", + "chipUnrecognized": "Unrecognized", + "chipInaccessible": "Inaccessible", + "chipDuplicate": "Duplicate", + "chipExternalLink": "External link", + "chipBrokenLink": "Broken link", + "chipReadOnly": "Read only", + "chipInRepo": "In a repo", + "chipPluginCache": "Plugin cache", + "tipCurrent": "This copy matches the current official version.", + "tipUnrecognized": "This copy doesn’t match any official version — it may be modified, or a different skill with the same name.", + "tipInaccessible": "Orca couldn’t read this copy (a permissions or file error).", + "tipDuplicate": "A separate copy of this skill, installed apart from the main one.", + "tipExternalLink": "A shortcut pointing outside Orca’s skill folders.", + "tipBrokenLink": "A shortcut to something that no longer exists.", + "tipReadOnly": "This copy is in a read-only location.", + "tipInRepo": "This copy lives inside a project, not your global skills.", + "tipPluginCache": "This copy is managed by a plugin." + }, + "SkillFreshnessUpdateDialog": { + "title": "Update skills", + "checking": "Checking installed Orca skills…", + "none": "No installed Orca skills found.", + "updateOne": "1 skill can be updated safely", + "updateMany": "{{value0}} skills can be updated safely", + "success": "All installed Orca skills are up to date.", + "attention": "Some installed Orca skills were left out of the update.", + "attentionDescription": "Open Update details to see why each one was skipped.", + "details": "Update details", + "terminalTitle": "Update Orca skills", + "terminalDescription": "Review the pre-filled command, then press Enter to run it.", + "terminalAria": "Orca skill update terminal", + "checkNow": "Re-check", + "close": "Close" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Update available", + "upToDate": "Up to date", + "installed": "Installed" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2b568c764..9261f9971 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "Escaneando skills", "35b9a724a0": "Disponible", "0c74e7ff34": "Local" + }, + "SkillFreshnessNudge": { + "titleOne": "Una skill de Orca instalada está desactualizada", + "titleMany": "{{value0}} skills de Orca instaladas están desactualizadas", + "updateOne": "Actualizar skill", + "updateMany": "Actualizar skills", + "description": "Actualiza {{value0}} para que los Agents sigan las instrucciones actuales de esta versión de Orca." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "Actualización disponible", + "statusCantUpdate": "No se puede actualizar", + "cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando npx skills update la deja sin cambios.", + "chipCurrent": "Actual", + "chipUnrecognized": "No reconocida", + "chipInaccessible": "Inaccesible", + "chipDuplicate": "Duplicada", + "chipExternalLink": "Enlace externo", + "chipBrokenLink": "Enlace roto", + "chipReadOnly": "Solo lectura", + "chipInRepo": "En un repo", + "chipPluginCache": "Caché de plugin", + "tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.", + "tipUnrecognized": "El contenido de la skill aquí no coincide con ninguna versión oficial, así que Orca no puede actualizarla de forma segura. Elimina o reemplaza lo que hay aquí para permitir las actualizaciones.", + "tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.", + "tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando npx skills update no puede alcanzarla. Elimínala para permitir las actualizaciones.", + "tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.", + "tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.", + "tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.", + "tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.", + "tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "Actualizar skills", + "checking": "Comprobando las skills de Orca instaladas…", + "none": "No se encontraron skills de Orca instaladas.", + "updateOne": "Se puede actualizar 1 skill de forma segura", + "updateMany": "Se pueden actualizar {{value0}} skills de forma segura", + "success": "Todas las skills de Orca instaladas están actualizadas.", + "attention": "Algunas skills de Orca instaladas no se pueden actualizar automáticamente.", + "attentionDescription": "Abre Detalles de la actualización para ver por qué no se puede actualizar cada una.", + "details": "Detalles de la actualización", + "terminalTitle": "Actualizar skills de Orca", + "terminalDescription": "Revisa el comando pre-rellenado y pulsa Intro para ejecutarlo.", + "terminalAria": "Terminal de actualización de skills de Orca", + "checkNow": "Comprobar ahora", + "close": "Cerrar" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "Actualización disponible", + "upToDate": "Actualizado", + "installed": "Instalado" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 70aaea124..e1cf3313c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "スキャンスキル", "35b9a724a0": "利用可能", "0c74e7ff34": "ローカル" + }, + "SkillFreshnessNudge": { + "titleOne": "インストール済みの Orca スキルが古くなっています", + "titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています", + "updateOne": "スキルを更新", + "updateMany": "スキルを更新", + "description": "Agents がこのバージョンの Orca の最新の手順に従うように、{{value0}} を更新してください。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "更新あり", + "statusCantUpdate": "更新できません", + "cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、npx skills update コマンドは変更しません。", + "chipCurrent": "最新", + "chipUnrecognized": "未認識", + "chipInaccessible": "アクセス不可", + "chipDuplicate": "重複", + "chipExternalLink": "外部リンク", + "chipBrokenLink": "リンク切れ", + "chipReadOnly": "読み取り専用", + "chipInRepo": "リポジトリ内", + "chipPluginCache": "プラグインキャッシュ", + "tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。", + "tipUnrecognized": "ここのスキルの内容が公式バージョンのいずれとも一致しないため、Orca は安全に更新できません。更新を許可するには、ここにあるものを削除または置き換えてください。", + "tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。", + "tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため npx skills update コマンドは到達できません。更新を許可するには削除してください。", + "tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。", + "tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。", + "tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。", + "tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。", + "tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "スキルを更新", + "checking": "インストール済みの Orca スキルを確認中…", + "none": "インストール済みの Orca スキルは見つかりませんでした。", + "updateOne": "1 件のスキルを安全に更新できます", + "updateMany": "{{value0}} 件のスキルを安全に更新できます", + "success": "インストール済みの Orca スキルはすべて最新です。", + "attention": "一部のインストール済み Orca スキルは自動的に更新できません。", + "attentionDescription": "更新できない理由は、更新の詳細を開いて確認してください。", + "details": "更新の詳細", + "terminalTitle": "Orca スキルを更新", + "terminalDescription": "入力済みのコマンドを確認し、Enter キーを押して実行してください。", + "terminalAria": "Orca スキル更新ターミナル", + "checkNow": "今すぐ確認", + "close": "閉じる" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "更新があります", + "upToDate": "最新です", + "installed": "インストール済み" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 98311b01d..d22ad9a5d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "스킬 스캔 중", "35b9a724a0": "사용 가능", "0c74e7ff34": "로컬" + }, + "SkillFreshnessNudge": { + "titleOne": "설치된 Orca 스킬이 오래되었습니다", + "titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다", + "updateOne": "스킬 업데이트", + "updateMany": "스킬 업데이트", + "description": "Agents가 이 버전의 Orca에 대한 최신 지침을 따르도록 {{value0}}을(를) 업데이트하세요." + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "업데이트 있음", + "statusCantUpdate": "업데이트할 수 없음", + "cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, npx skills update 명령이 변경하지 않습니다.", + "chipCurrent": "최신", + "chipUnrecognized": "인식 안 됨", + "chipInaccessible": "액세스 불가", + "chipDuplicate": "중복", + "chipExternalLink": "외부 링크", + "chipBrokenLink": "깨진 링크", + "chipReadOnly": "읽기 전용", + "chipInRepo": "저장소 내", + "chipPluginCache": "플러그인 캐시", + "tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.", + "tipUnrecognized": "여기 있는 스킬의 콘텐츠가 어떤 공식 버전과도 일치하지 않아 Orca가 안전하게 업데이트할 수 없습니다. 업데이트를 허용하려면 여기 있는 항목을 제거하거나 교체하세요.", + "tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.", + "tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 npx skills update 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.", + "tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.", + "tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.", + "tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.", + "tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.", + "tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "스킬 업데이트", + "checking": "설치된 Orca 스킬을 확인하는 중…", + "none": "설치된 Orca 스킬을 찾을 수 없습니다.", + "updateOne": "스킬 1개를 안전하게 업데이트할 수 있습니다", + "updateMany": "스킬 {{value0}}개를 안전하게 업데이트할 수 있습니다", + "success": "설치된 Orca 스킬이 모두 최신 상태입니다.", + "attention": "일부 설치된 Orca 스킬은 자동으로 업데이트할 수 없습니다.", + "attentionDescription": "각 항목을 업데이트할 수 없는 이유는 업데이트 세부 정보를 열어 확인하세요.", + "details": "업데이트 세부 정보", + "terminalTitle": "Orca 스킬 업데이트", + "terminalDescription": "미리 입력된 명령을 검토한 후 Enter 키를 눌러 실행하세요.", + "terminalAria": "Orca 스킬 업데이트 터미널", + "checkNow": "지금 확인", + "close": "닫기" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "업데이트 가능", + "upToDate": "최신 상태", + "installed": "설치됨" } }, "sidebar": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7a439d1ef..b9fe5b678 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3605,6 +3605,64 @@ "cd7893fbc1": "正在扫描技能", "35b9a724a0": "可用的", "0c74e7ff34": "当地的" + }, + "SkillFreshnessNudge": { + "titleOne": "已安装的 Orca 技能已过期", + "titleMany": "{{value0}} 个已安装的 Orca 技能已过期", + "updateOne": "更新技能", + "updateMany": "更新技能", + "description": "更新 {{value0}},让 Agents 遵循此版本 Orca 的最新说明。" + }, + "SkillFreshnessRow": { + "statusUpdateAvailable": "有更新", + "statusCantUpdate": "无法更新", + "cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 npx skills update 命令不会更改它。", + "chipCurrent": "最新", + "chipUnrecognized": "无法识别", + "chipInaccessible": "无法访问", + "chipDuplicate": "重复", + "chipExternalLink": "外部链接", + "chipBrokenLink": "失效链接", + "chipReadOnly": "只读", + "chipInRepo": "在仓库中", + "chipPluginCache": "插件缓存", + "tipCurrent": "这里的技能已是最新,更新不会更改它。", + "tipUnrecognized": "这里的技能内容与任何官方版本都不匹配,因此 Orca 无法安全更新它。请移除或替换此处的内容以允许更新。", + "tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。", + "tipDuplicate": "此技能除主副本外还安装在这里,因此 npx skills update 命令无法访问它。请移除它以允许更新。", + "tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。", + "tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。", + "tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。", + "tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。", + "tipPluginCache": "这里的技能由插件管理;请改为更新插件。", + "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", + "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", + "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", + "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", + "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", + "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", + "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it." + }, + "SkillFreshnessUpdateDialog": { + "title": "更新技能", + "checking": "正在检查已安装的 Orca 技能…", + "none": "未找到已安装的 Orca 技能。", + "updateOne": "可以安全更新 1 个技能", + "updateMany": "可以安全更新 {{value0}} 个技能", + "success": "所有已安装的 Orca 技能均为最新。", + "attention": "部分已安装的 Orca 技能无法自动更新。", + "attentionDescription": "打开“更新详情”查看为何每一项都无法更新。", + "details": "更新详情", + "terminalTitle": "更新 Orca 技能", + "terminalDescription": "检查预填的命令,然后按 Enter 键运行。", + "terminalAria": "Orca 技能更新终端", + "checkNow": "立即检查", + "close": "关闭" + }, + "SkillFreshnessStatusPill": { + "updateAvailable": "有可用更新", + "upToDate": "已是最新", + "installed": "已安装" } }, "sidebar": { diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 765940ffe..3541c0e3f 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -3,7 +3,7 @@ import type { LucideProps } from 'lucide-react' import type { SettingsSearchEntry } from '@/components/settings/settings-search' export type SettingsNavIcon = ComponentType -export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking' +export type SettingsNavInstallStatus = 'install' | 'installed' | 'update-available' | 'checking' export type SettingsNavTarget = | 'general' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a64f4abe2..d6f6d6e58 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -35,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, @@ -2743,7 +2744,16 @@ function createSkillsApi(): NonNullable['skills']> { skills: [], sources: [], scannedAt: Date.now() - })) + })), + // Why: browser clients have no local skill homes, and remote-host + // freshness stays disabled until its update rail has equivalent coverage. + freshnessInventory: (): Promise => + Promise.resolve({ + schemaVersion: 1, + installations: [], + eligibleUpdateNames: [], + scannedAt: Date.now() + }) } } diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts new file mode 100644 index 000000000..08383d5e5 --- /dev/null +++ b/src/shared/skill-freshness.ts @@ -0,0 +1,102 @@ +import type { SkillProvider, SkillSourceKind } from './skills' + +export type SkillBundleFileIdentity = { + path: string + size: number + executable: boolean + classification: 'text' | 'binary' + exactSha256: string + textNormalizedSha256: string | null + identitySha256: string +} + +export type SkillKnownSnapshot = { + releaseRevision: number + packageDigest: string + gitTreeSha: string + files: SkillBundleFileIdentity[] +} + +export type SkillCurrentBundleEntry = SkillKnownSnapshot & { + name: string + sourcePath: string + appVersion: string +} + +export type SkillBundleManifest = { + schemaVersion: 1 + appVersion: string + skills: SkillCurrentBundleEntry[] +} + +export type SkillSnapshotRegistry = { + schemaVersion: 1 + skills: Record +} + +export type SkillReleaseMapping = { + schemaVersion: 1 + releases: { appVersion: string; skills: Record }[] +} + +export type SkillFreshnessStatus = + | 'current' + | 'outdated' + | 'newer-known' + | 'unrecognized' + | 'inaccessible' + +export type SkillInstallationTopology = + | 'canonical-copy' + | 'provider-alias' + | 'independent-copy' + | 'external-link' + | 'broken-link' + | 'read-only' + | 'repo-scope' + | 'plugin-cache' + +// Why: eligibility and the explanation copy must agree on which placements the +// validated npx rail can converge; a drifted copy would blame a phantom sibling. +export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet = new Set([ + 'canonical-copy', + 'provider-alias' +]) + +export type SkillFreshnessInstallation = { + id: string + name: string + rootId: string + providers: SkillProvider[] + sourceKind: SkillSourceKind + sourceLabel: string + unresolvedPath: string + resolvedPath: string | null + physicalIdentity: string | null + topology: SkillInstallationTopology + status: SkillFreshnessStatus + installedReleaseRevision: number | null + installedAppVersion: string | null + currentReleaseRevision: number + currentPackageDigest: string + currentAppVersion: string + observedPackageDigest: string | null + errorCategory: string | null +} + +export type SkillFreshnessInventory = { + schemaVersion: 1 + installations: SkillFreshnessInstallation[] + eligibleUpdateNames: string[] + scannedAt: number +} + +export function buildTargetedSkillUpdateCommand(names: readonly string[]): string | null { + const canonicalNames = [...new Set(names)].sort((left, right) => left.localeCompare(right, 'en')) + // Why: names become editable shell input. Official manifests use this + // restricted package-name grammar so no entry can introduce shell syntax. + if (canonicalNames.some((name) => !/^[a-z0-9][a-z0-9._-]*$/.test(name))) { + return null + } + return canonicalNames.length > 0 ? `npx skills update ${canonicalNames.join(' ')} --global` : null +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 91b7785f0..3de8e943d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2875,6 +2875,9 @@ export type GlobalSettings = { /** Why: disabling must persist so startup does not reinstall global agent * hook entries right after the user removes them from Settings or CLI. */ agentStatusHooksEnabled: boolean + /** Dismissed freshness tuples grant no write authority; they only keep the + * same exact official placement/revision from nudging more than once. */ + dismissedSkillFreshnessNudges?: string[] /** Why: generated tab titles are semantic but subjective, so they stay opt-in * and manual renames remain the stronger user intent. */ tabAutoGenerateTitle: boolean