diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 47bf6b83a40..1e3fb4e6bdb 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 00000000000..6c9418e1f64
--- /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/README.md b/README.md
index 81b9a5c5aea..d07692e1edd 100644
--- a/README.md
+++ b/README.md
@@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
-- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
+- **WeChat:** First WeChat group is full, now you can join the second one.
-
-
+
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 0dd6a3d85c7..98beb0c1c1f 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}'
@@ -66,6 +76,10 @@ module.exports = {
// authoring sources too would duplicate content without a runtime consumer.
'!skill-guides{,/**/*}',
'!tests{,/**/*}',
+ // Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
+ // it is gitignored, but exclude it defensively so a stray local capture at
+ // package time never bloats app.asar.
+ '!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
@@ -75,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/reliability-gates.jsonc b/config/reliability-gates.jsonc
index ec1cd450561..448b4beac63 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -1950,7 +1950,6 @@
"daemon stream",
"main PTY batching",
"runtime path provenance",
- "runtime serve-sim detection",
"runtime terminal wait detection",
"renderer ACK",
"xterm scheduler",
@@ -1971,7 +1970,7 @@
"macos"
],
"coveredProviders": [],
- "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
+ "coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6836",
"https://github.com/stablyai/orca/pull/6858",
@@ -1979,17 +1978,15 @@
"https://github.com/stablyai/orca/pull/7054"
],
"invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.",
- "oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
+ "oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
- "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
],
"testFiles": [
"src/main/ipc/pty.test.ts",
"src/main/runtime/orca-runtime-path-candidate-history.test.ts",
- "src/main/emulator/serve-sim-state-watcher.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
],
@@ -2009,13 +2006,6 @@
"copies history when new output adds a path candidate"
]
},
- {
- "file": "src/main/emulator/serve-sim-state-watcher.test.ts",
- "assertions": [
- "does not buffer repeated brace-free PTY output while waiting for metadata",
- "detects serve-sim metadata split across PTY chunks and releases the partial object"
- ]
- },
{
"file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
"assertions": [
@@ -2053,15 +2043,6 @@
"durationSeconds": 3.49,
"summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded."
},
- {
- "date": "2026-07-10",
- "runner": "local",
- "platform": "macos",
- "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
- "result": "passed",
- "durationSeconds": 0.37,
- "summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b."
- },
{
"date": "2026-07-10",
"runner": "local",
@@ -2078,11 +2059,11 @@
},
"flakeHistory": {
"status": "unknown",
- "evidence": "Daemon stream backpressure and focused main/renderer backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion."
+ "evidence": "Focused main-process backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion."
},
"redGreenEvidence": {
"status": "partial",
- "evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion."
+ "evidence": "Tests assert pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs daemon-stream contract coverage and broader hidden-output/input-latency perf artifacts before promotion."
},
"performanceBudget": {
"required": true,
@@ -2094,9 +2075,8 @@
"Keep stress variants non-blocking until stable runtime history exists."
],
"knownGaps": [
- "Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.",
+ "Daemon stream write(false)/drain/cleanup and bounded queued-tail contracts are not registered in the current command.",
"Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.",
- "Serve-sim detector coverage is deterministic and does not include a live external simulator helper.",
"Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.",
"Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.",
"Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved."
@@ -3896,6 +3876,7 @@
"layer": "runtime-rpc-contract",
"surfaces": [
"mobile terminal",
+ "native-chat terminal overlay",
"runtime RPC",
"binary multiplex",
"JSON subscribe fallback",
@@ -3917,21 +3898,25 @@
"macos"
],
"coveredProviders": [],
- "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
+ "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/7009"
],
- "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
- "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.",
+ "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
+ "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"commands": [
- "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts"
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts",
+ "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
+ "pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
"testFiles": [
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/rpc/terminal-output-batching.test.ts",
- "src/main/runtime/rpc/terminal-multiplex.test.ts"
+ "src/main/runtime/rpc/terminal-multiplex.test.ts",
+ "mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
+ "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
"assertionRefs": [
{
@@ -3956,6 +3941,21 @@
"oversized live output frames are bounded for subscribed binary streams",
"multibyte live output flushes when encoded bytes reach the batch budget"
]
+ },
+ {
+ "file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
+ "assertions": [
+ "an active terminal stream pauses while native chat covers its WebView",
+ "the stream resumes only for the ready active terminal after chat closes",
+ "non-terminal and already-settled stream states perform no lifecycle work"
+ ]
+ },
+ {
+ "file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts",
+ "assertions": [
+ "a late WebView-ready transition converts a cold-start lease-only stream back to output",
+ "ordinary WebView readiness does not rerender the mobile session route"
+ ]
}
],
"evidenceRuns": [
@@ -3967,6 +3967,15 @@
"result": "passed",
"durationSeconds": 3.8,
"summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout."
+ },
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.2,
+ "summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree."
}
],
"runtimeBudget": {
@@ -3991,7 +4000,7 @@
"Record byte counts and order/cursor artifacts on failure."
],
"knownGaps": [
- "Current command covers binary runtime/mobile stream contracts only.",
+ "The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.",
"Legacy JSON subscribe parity is undecided."
],
"demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering."
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index 7d4fd9af56b..d5671498468 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -29,7 +29,9 @@ describe('electron-builder config', () => {
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
+ '!resources/skills/**',
'!tests{,/**/*}',
+ '!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
@@ -38,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 00000000000..c3542d6f0e2
--- /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 00000000000..cf4a8a100e5
--- /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/serve-sim-seen-keys-leak-benchmark.mjs b/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs
deleted file mode 100644
index 512191599d0..00000000000
--- a/config/scripts/serve-sim-seen-keys-leak-benchmark.mjs
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/usr/bin/env node
-// Benchmark: main-process retained-Set growth of the serve-sim state watcher's
-// external-helper dedupe across worktree switches.
-//
-// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl,
-// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once.
-// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never
-// touched seenExternalKeys, so its keys accumulated for the renderer/main
-// process lifetime — only cleared at app shutdown (stop()). This script
-// simulates N bind→detect→forget cycles and reports retained Set size with the
-// prune disabled vs enabled.
-import { performance } from 'node:perf_hooks'
-
-const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10)
-const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10)
-
-for (const [name, value] of [
- ['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES],
- ['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE]
-]) {
- if (!Number.isInteger(value) || value <= 0) {
- throw new Error(`${name} must be a positive integer, received ${value}`)
- }
-}
-
-// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a
-// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix
-// adds to forgetWorktree().
-function makeWatcher({ prune }) {
- const seenExternalKeys = new Set()
- return {
- set: seenExternalKeys,
- emitIfExternal(worktreeId, instanceKey) {
- const dedupeKey = `${worktreeId}::${instanceKey}`
- if (seenExternalKeys.has(dedupeKey)) {
- return false
- }
- seenExternalKeys.add(dedupeKey)
- return true
- },
- forgetWorktree(worktreeId) {
- if (!prune) {
- return
- }
- const prefix = `${worktreeId}::`
- for (const key of seenExternalKeys) {
- if (key.startsWith(prefix)) {
- seenExternalKeys.delete(key)
- }
- }
- }
- }
-}
-
-function run({ prune }) {
- const watcher = makeWatcher({ prune })
- const start = performance.now()
- for (let cycle = 0; cycle < CYCLES; cycle++) {
- const worktreeId = `worktree-${cycle}`
- for (let h = 0; h < HELPERS_PER_WORKTREE; h++) {
- // A realistic instance key: device udid :: pid :: wsUrl :: streamUrl.
- const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg`
- watcher.emitIfExternal(worktreeId, instanceKey)
- }
- watcher.forgetWorktree(worktreeId)
- }
- return { retained: watcher.set.size, elapsedMs: performance.now() - start }
-}
-
-console.log(
- `serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` +
- `${HELPERS_PER_WORKTREE} helper(s)/worktree\n`
-)
-
-const before = run({ prune: false })
-const after = run({ prune: true })
-
-// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs).
-const APPROX_BYTES_PER_KEY = 140
-const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024
-
-console.log(
- `before (no prune) retained=${before.retained} keys ` +
- `(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms`
-)
-console.log(
- `after (prune) retained=${after.retained} keys ` +
- `(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms`
-)
-console.log(
- `\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` +
- `retained keys after ${CYCLES} worktree switches.`
-)
diff --git a/config/scripts/verify-skill-update-roundtrip.mjs b/config/scripts/verify-skill-update-roundtrip.mjs
new file mode 100644
index 00000000000..bfb10a50494
--- /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/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index ee1674f4124..417924fc997 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-