Files
orca/src/shared/skill-freshness.ts
T
Brennan Benson d4669c02ea fix(skills): decouple bundled skill artifacts from the release train
The current manifest stamped package.json's version into itself (9 lines),
so every RC/stable version bump made the committed artifact stale on every
open branch: lint failed until authors committed content-free regeneration
diffs, which also dragged the resources/skills-filtered update-roundtrip
matrix onto unrelated PRs. Cutting a release tag whose skills tree changed
had the same effect through release-mapping.json.

- current-manifest.json is now schema 2 and content-only; the generator no
  longer reads package.json. Registry and mapping stay schema 1 so the
  append-only released-history guard keeps its schema gate.
- The running build's version enters at the IPC boundary
  (skills:freshnessInventory passes app.getVersion()) and threads through
  the inventory to placement observation; current-revision placements are
  labeled with it while historical revisions keep resolving through the
  release mapping. The artifact loader and its cache stay content-only.
- verify tolerates a committed release mapping that is a byte-exact prefix
  of the derived one when every missing trailing row's revisions equal the
  current manifest (a just-cut tag over unchanged-since bytes); such rows
  are provably redundant until the next real regeneration adds them.

Artifacts now change only when skills/ content changes.
2026-07-16 18:40:01 -07:00

103 lines
2.8 KiB
TypeScript

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
}
// Why: schema 2 removed the stamped app version so the committed artifact is a
// pure function of skills/ content; the running build supplies its own version.
export type SkillBundleManifest = {
schemaVersion: 2
skills: SkillCurrentBundleEntry[]
}
export type SkillSnapshotRegistry = {
schemaVersion: 1
skills: Record<string, SkillKnownSnapshot[]>
}
export type SkillReleaseMapping = {
schemaVersion: 1
releases: { appVersion: string; skills: Record<string, number> }[]
}
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<SkillInstallationTopology> = 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
}