diff --git a/.trajectories/completed/2026-05/traj_47ulsb0rwbid.json b/.trajectories/completed/2026-05/traj_47ulsb0rwbid.json new file mode 100644 index 0000000..f875d23 --- /dev/null +++ b/.trajectories/completed/2026-05/traj_47ulsb0rwbid.json @@ -0,0 +1,144 @@ +{ + "id": "traj_47ulsb0rwbid", + "version": 1, + "task": { + "title": "Persistent skill cache + upstream drift detection" + }, + "status": "completed", + "startedAt": "2026-05-15T20:47:56.786Z", + "agents": [ + { + "name": "claude-skill-cache", + "role": "lead", + "joinedAt": "2026-05-15T20:47:56.808Z" + } + ], + "chapters": [ + { + "id": "chap_p2cn73gpqanw", + "title": "Initial work", + "agentName": "claude-skill-cache", + "startedAt": "2026-05-15T20:47:56.808Z", + "events": [ + { + "ts": 1778878092151, + "type": "decision", + "content": "Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins//: Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins//", + "raw": { + "question": "Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins//", + "chosen": "Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins//", + "alternatives": [ + { + "option": "Per-session install (status quo", + "reason": "" + }, + { + "option": "slow); global shared plugin dir (skill collisions across personas); TTL-only cache (still pays install on expiry)", + "reason": "" + } + ], + "reasoning": "The reported slowness was npx prpm install / npx skills add re-running every launch. A persistent dir keyed by a stable fingerprint lets repeat launches skip the install entirely. Local .md sources fold their content hash in so edits auto-invalidate without a version bump." + }, + "significance": "high" + }, + { + "ts": 1778878092386, + "type": "decision", + "content": "Never auto-invalidate on the source-key fingerprint; cover all three harnesses: Never auto-invalidate on the source-key fingerprint; cover all three harnesses", + "raw": { + "question": "Never auto-invalidate on the source-key fingerprint; cover all three harnesses", + "chosen": "Never auto-invalidate on the source-key fingerprint; cover all three harnesses", + "alternatives": [ + { + "option": "Daily TTL on the fingerprint; claude-only scope with mount harnesses as follow-up", + "reason": "" + } + ], + "reasoning": "User explicitly chose 'never auto-invalidate' for the fingerprint layer and 'all harnesses now' when asked. Claude reuses the cache dir as --plugin-dir; opencode/codex mirror it into the relayfile mount before launch (mount-ignored patterns stop syncback)." + }, + "significance": "high" + }, + { + "ts": 1778878111454, + "type": "decision", + "content": "Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open: Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open", + "raw": { + "question": "Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open", + "chosen": "Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open", + "alternatives": [ + { + "option": "Manual --refresh-skills only (user must remember); always-check (slows every launch); coarse repo-HEAD commit SHA for github (over-invalidates monorepos)", + "reason": "" + } + ], + "reasoning": "User asked how a new upstream skill version is consumed when the source string is unchanged. Explored prpm info / registry HTTP API (latest_version.version) and skill.sh — both expose cheap version probes. A 24h TTL keeps most launches network-free; only the daily check launch pays ~150-500ms parallel probes. Fail-open so a flaky registry never blocks a launch." + }, + "significance": "high" + }, + { + "ts": 1778878111681, + "type": "decision", + "content": "Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match: Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match", + "raw": { + "question": "Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match", + "chosen": "Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match", + "alternatives": [ + { + "option": "repos///commits?per_page=1 repo-HEAD (1 call", + "reason": "" + }, + { + "option": "but any push invalidates); re-download SKILL.md and hash (heavier", + "reason": "" + }, + { + "option": "needs path anyway)", + "reason": "" + } + ], + "reasoning": "skill.sh writes skills-lock.json with skillPath + computedHash per skill. Building the Contents API URL from skillPath gives per-file drift (a monorepo of 50 skills doesn't invalidate on an unrelated commit). The blob SHA is also the ETag, so If-None-Match returns 304 with no body — cheapest possible check." + }, + "significance": "high" + }, + { + "ts": 1778878111884, + "type": "decision", + "content": "Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version: Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version", + "raw": { + "question": "Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version", + "chosen": "Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version", + "alternatives": [ + { + "option": "Hard v2 cutover (invalidates all caches); separate sidecar file for upstream metadata (more files to keep consistent)", + "reason": "" + } + ], + "reasoning": "Bumping the marker schema must not invalidate every cache entry in the wild. readSkillCacheMarker accepts v1+v2 and upgrades v1 in place with no upstream records (next drift pass captures identity). The fingerprint's internal 'v' stays 1 so existing dirs keep resolving." + }, + "significance": "high" + }, + { + "ts": 1778878613570, + "type": "reflection", + "content": "Verified end-to-end against live prpm.dev + api.github.com: cache miss records resolved version; in-TTL launches skip probing; --check-upstream detects a tampered stale version (1.0.0→1.1.3) and reinstalls; marker self-heals; --no-check-upstream bypasses. GitHub 304 If-None-Match path confirmed.", + "significance": "high" + } + ], + "endedAt": "2026-05-15T20:58:19.304Z" + } + ], + "commits": [], + "filesChanged": [], + "projectId": "/Users/khaliqgant/Projects/AgentWorkforce/workforce", + "tags": [], + "_trace": { + "startRef": "09caf5b8db32f9d1c2c71735b7e231a7efc7ff8e", + "endRef": "09caf5b8db32f9d1c2c71735b7e231a7efc7ff8e" + }, + "completedAt": "2026-05-15T20:58:19.304Z", + "retrospective": { + "summary": "Extended the persistent skill-cache PR with opt-in upstream drift detection. Marker bumped to schema v2 (v1 read-compatible) recording per-skill upstream identity (prpm resolved version / GitHub blob SHA). TTL-gated (24h default) parallel probes on launch flip a cache hit to a reinstall when upstream moved; fail-open on any probe error. Added --check-upstream/--no-check-upstream + AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL. 22 new unit tests (mocked HTTP) + verified end-to-end against live prpm.dev and api.github.com.", + "approach": "Reused installer lockfiles (prpm.lock version, skills-lock.json skillPath) for precise per-file identity; conditional GET (If-None-Match) for the cheapest GitHub check; mutable cache-hit flag downgraded by an awaited drift probe before the install decision.", + "confidence": 0.86 + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-05/traj_47ulsb0rwbid.md b/.trajectories/completed/2026-05/traj_47ulsb0rwbid.md new file mode 100644 index 0000000..a0b9fb4 --- /dev/null +++ b/.trajectories/completed/2026-05/traj_47ulsb0rwbid.md @@ -0,0 +1,57 @@ +# Trajectory: Persistent skill cache + upstream drift detection + +> **Status:** ✅ Completed +> **Confidence:** 86% +> **Started:** May 15, 2026 at 10:47 PM +> **Completed:** May 15, 2026 at 10:58 PM + +--- + +## Summary + +Extended the persistent skill-cache PR with opt-in upstream drift detection. Marker bumped to schema v2 (v1 read-compatible) recording per-skill upstream identity (prpm resolved version / GitHub blob SHA). TTL-gated (24h default) parallel probes on launch flip a cache hit to a reinstall when upstream moved; fail-open on any probe error. Added --check-upstream/--no-check-upstream + AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL. 22 new unit tests (mocked HTTP) + verified end-to-end against live prpm.dev and api.github.com. + +**Approach:** Reused installer lockfiles (prpm.lock version, skills-lock.json skillPath) for precise per-file identity; conditional GET (If-None-Match) for the cheapest GitHub check; mutable cache-hit flag downgraded by an awaited drift probe before the install decision. + +--- + +## Key Decisions + +### Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins// +- **Chose:** Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins// +- **Rejected:** Per-session install (status quo, slow); global shared plugin dir (skill collisions across personas); TTL-only cache (still pays install on expiry) +- **Reasoning:** The reported slowness was npx prpm install / npx skills add re-running every launch. A persistent dir keyed by a stable fingerprint lets repeat launches skip the install entirely. Local .md sources fold their content hash in so edits auto-invalidate without a version bump. + +### Never auto-invalidate on the source-key fingerprint; cover all three harnesses +- **Chose:** Never auto-invalidate on the source-key fingerprint; cover all three harnesses +- **Rejected:** Daily TTL on the fingerprint; claude-only scope with mount harnesses as follow-up +- **Reasoning:** User explicitly chose 'never auto-invalidate' for the fingerprint layer and 'all harnesses now' when asked. Claude reuses the cache dir as --plugin-dir; opencode/codex mirror it into the relayfile mount before launch (mount-ignored patterns stop syncback). + +### Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open +- **Chose:** Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open +- **Rejected:** Manual --refresh-skills only (user must remember); always-check (slows every launch); coarse repo-HEAD commit SHA for github (over-invalidates monorepos) +- **Reasoning:** User asked how a new upstream skill version is consumed when the source string is unchanged. Explored prpm info / registry HTTP API (latest_version.version) and skill.sh — both expose cheap version probes. A 24h TTL keeps most launches network-free; only the daily check launch pays ~150-500ms parallel probes. Fail-open so a flaky registry never blocks a launch. + +### Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match +- **Chose:** Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match +- **Rejected:** repos///commits?per_page=1 repo-HEAD (1 call, but any push invalidates); re-download SKILL.md and hash (heavier, needs path anyway) +- **Reasoning:** skill.sh writes skills-lock.json with skillPath + computedHash per skill. Building the Contents API URL from skillPath gives per-file drift (a monorepo of 50 skills doesn't invalidate on an unrelated commit). The blob SHA is also the ETag, so If-None-Match returns 304 with no body — cheapest possible check. + +### Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version +- **Chose:** Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version +- **Rejected:** Hard v2 cutover (invalidates all caches); separate sidecar file for upstream metadata (more files to keep consistent) +- **Reasoning:** Bumping the marker schema must not invalidate every cache entry in the wild. readSkillCacheMarker accepts v1+v2 and upgrades v1 in place with no upstream records (next drift pass captures identity). The fingerprint's internal 'v' stays 1 so existing dirs keep resolving. + +--- + +## Chapters + +### 1. Initial work +*Agent: claude-skill-cache* + +- Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins//: Content-addressed cache keyed by (harness, sorted skill sources, local-file SHA) under ~/.agentworkforce/workforce/cache/plugins// +- Never auto-invalidate on the source-key fingerprint; cover all three harnesses: Never auto-invalidate on the source-key fingerprint; cover all three harnesses +- Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open: Add opt-in upstream drift detection: prpm registry GET + GitHub Contents API blob SHA, TTL-gated (24h default), fail-open +- Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match: Precise per-file GitHub blob SHA via skills-lock.json skillPath, not coarse repo-HEAD; conditional GET with If-None-Match +- Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version: Marker schema v2, v1 read-compatible; fingerprint content-version pinned to 1 independent of marker version +- Verified end-to-end against live prpm.dev + api.github.com: cache miss records resolved version; in-TTL launches skip probing; --check-upstream detects a tampered stale version (1.0.0→1.1.3) and reinstalls; marker self-heals; --no-check-upstream bypasses. GitHub 304 If-None-Match path confirmed. diff --git a/.trajectories/index.json b/.trajectories/index.json index 57abb28..f4df228 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-05-01T19:08:35.768Z", + "lastUpdated": "2026-05-15T20:58:19.420Z", "trajectories": { "traj_1775734701264_ba65c69b": { "title": "finish-npm-provenance-persona-workflow", @@ -29,6 +29,13 @@ "startedAt": "2026-05-01T19:06:48.954Z", "completedAt": "2026-05-01T19:08:35.648Z", "path": "/Users/khaliqgant/Projects/AgentWorkforce/workforce/.trajectories/completed/2026-05/traj_cntjweljhmft.json" + }, + "traj_47ulsb0rwbid": { + "title": "Persistent skill cache + upstream drift detection", + "status": "completed", + "startedAt": "2026-05-15T20:47:56.786Z", + "completedAt": "2026-05-15T20:58:19.304Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/workforce/.trajectories/completed/2026-05/traj_47ulsb0rwbid.json" } } } \ No newline at end of file diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 721e42d..58b63f2 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -9,6 +9,7 @@ import { CLEAN_IGNORED_PATTERNS, CREATE_SELECTOR, SKILL_INSTALL_IGNORED_PATTERNS, + acquireSkillCacheLock, applyAcceptedPatches, assertSafeRelativePath, buildPickCandidates, @@ -24,6 +25,7 @@ import { parseProposals, promptYesNoSync, readSingleCharChoice, + resolveEnvCheckIntervalMs, resolveSystemPromptPlaceholders, stripAgentFlag, type ImproverProposal, @@ -139,6 +141,58 @@ test('parseAgentArgs: --dry-run sets flag and preserves positional selector', () assert.deepEqual(positional, ['local-codex@best']); }); +test('parseAgentArgs: --no-skill-cache and --refresh-skills set flags', () => { + // parseAgentArgs reads AGENTWORKFORCE_NO_SKILL_CACHE for the default; isolate + // it so the env on the running machine can't make the default-false cases flaky. + const prevNoCache = process.env.AGENTWORKFORCE_NO_SKILL_CACHE; + delete process.env.AGENTWORKFORCE_NO_SKILL_CACHE; + try { + const a = parseAgentArgs(['--no-skill-cache', 'persona@best']); + assert.equal(a.flags.noSkillCache, true); + assert.equal(a.flags.refreshSkills, false); + assert.deepEqual(a.positional, ['persona@best']); + + const b = parseAgentArgs(['--refresh-skills', 'persona@best']); + assert.equal(b.flags.refreshSkills, true); + assert.equal(b.flags.noSkillCache, false); + assert.deepEqual(b.positional, ['persona@best']); + + // Both compose with each other and with the existing flags. + const c = parseAgentArgs([ + '--no-skill-cache', + '--refresh-skills', + '--install-in-repo', + 'persona@best' + ]); + assert.equal(c.flags.noSkillCache, true); + assert.equal(c.flags.refreshSkills, true); + assert.equal(c.flags.installInRepo, true); + } finally { + if (prevNoCache === undefined) delete process.env.AGENTWORKFORCE_NO_SKILL_CACHE; + else process.env.AGENTWORKFORCE_NO_SKILL_CACHE = prevNoCache; + } +}); + +test('parseAgentArgs: --check-upstream and --no-check-upstream set flags', () => { + const a = parseAgentArgs(['--check-upstream', 'p@best']); + assert.equal(a.flags.checkUpstream, true); + assert.equal(a.flags.noCheckUpstream, false); + + const b = parseAgentArgs(['--no-check-upstream', 'p@best']); + assert.equal(b.flags.noCheckUpstream, true); + assert.equal(b.flags.checkUpstream, false); + + const c = parseAgentArgs([ + '--check-upstream', + '--refresh-skills', + '--no-skill-cache', + 'p@best' + ]); + assert.equal(c.flags.checkUpstream, true); + assert.equal(c.flags.refreshSkills, true); + assert.equal(c.flags.noSkillCache, true); +}); + test('parseAgentArgs: --dry-run composes with other flags', () => { const { flags, positional } = parseAgentArgs([ '--dry-run', @@ -1390,3 +1444,95 @@ test('readSingleCharChoice: invalid input loops until valid arrives', () => { const invalidLines = writes.filter((w) => w.includes('invalid choice')); assert.equal(invalidLines.length, 2); }); + +// --- resolveEnvCheckIntervalMs (PR #124 review: `never` must disable) ---- + +test('resolveEnvCheckIntervalMs: unset → 24h default', () => { + const prev = process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL; + delete process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL; + try { + assert.equal(resolveEnvCheckIntervalMs(), 24 * 3_600_000); + } finally { + if (prev === undefined) delete process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL; + else process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL = prev; + } +}); + +test('resolveEnvCheckIntervalMs: never/off → null (NOT coalesced to default)', () => { + const prev = process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL; + try { + for (const v of ['never', 'off', 'false']) { + process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL = v; + assert.equal(resolveEnvCheckIntervalMs(), null, `"${v}" must disable checks`); + } + process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL = '0'; + assert.equal(resolveEnvCheckIntervalMs(), 0); // 0 = always, distinct from null + process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL = '30m'; + assert.equal(resolveEnvCheckIntervalMs(), 1_800_000); + process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL = 'garbage'; + assert.equal(resolveEnvCheckIntervalMs(), 24 * 3_600_000); // unparseable → default + } finally { + if (prev === undefined) delete process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL; + else process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL = prev; + } +}); + +// --- acquireSkillCacheLock (PR #124 review: per-fingerprint locking) ----- + +test('acquireSkillCacheLock: acquires, blocks re-acquire, releases', async () => { + const dir = mkdtempSync(join(tmpdir(), 'cli-lock-')); + try { + const cacheDir = join(dir, 'fp'); + const lock = await acquireSkillCacheLock(cacheDir); + assert.ok(lock, 'first acquire succeeds'); + assert.equal(existsSync(`${cacheDir}.lock`), true); + + // A held, fresh lock from a live pid (us) is not stolen → second acquire + // blocks; assert it doesn't resolve within a short window. + const second = acquireSkillCacheLock(cacheDir); + const raced = await Promise.race([ + second.then(() => 'acquired'), + new Promise((r) => setTimeout(() => r('still-blocked'), 600)) + ]); + assert.equal(raced, 'still-blocked'); + + lock.release(); + assert.equal(existsSync(`${cacheDir}.lock`), false); + + // Now the pending second acquire (250ms poll) can proceed. + const lock2 = await second; + assert.ok(lock2); + lock2.release(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('acquireSkillCacheLock: steals a stale (old-timestamp) lock', async () => { + const dir = mkdtempSync(join(tmpdir(), 'cli-lock-stale-')); + try { + const cacheDir = join(dir, 'fp'); + // Live pid, but timestamp ~16min old → stale, must be stolen. + const old = Date.now() - 16 * 60_000; + writeFileSync(`${cacheDir}.lock`, `${process.pid}\n${old}\n`); + const lock = await acquireSkillCacheLock(cacheDir); + assert.ok(lock, 'stale lock stolen and re-acquired'); + lock.release(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('acquireSkillCacheLock: steals a lock held by a dead pid', async () => { + const dir = mkdtempSync(join(tmpdir(), 'cli-lock-deadpid-')); + try { + const cacheDir = join(dir, 'fp'); + // Fresh timestamp but an almost-certainly-dead pid → stolen via pid check. + writeFileSync(`${cacheDir}.lock`, `999999999\n${Date.now()}\n`); + const lock = await acquireSkillCacheLock(cacheDir); + assert.ok(lock, 'dead-pid lock stolen and re-acquired'); + lock.release(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b3f0328..efd6f93 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import { appendFileSync, closeSync, + cpSync, existsSync, mkdirSync, mkdtempSync, @@ -25,17 +26,27 @@ import { buildInstallArtifacts, buildInteractiveSpec, buildNonInteractiveSpec, + buildUpstreamRecordsFromCacheDir, + computeSkillCacheFingerprint, detectHarnesses, + detectSkillUpstreamDrift, formatDropWarnings, HARNESS_VALUES, + isSkillCacheValid, + isUpstreamCheckDue, materializeSkills, MissingPersonaInputError, + parseCheckInterval, PERSONA_TAGS, + readSkillCacheMarker, renderPersonaInputs, resolveMcpServersLenient, resolvePersonaInputs, resolveSidecar, + resolveSkillCacheDir, resolveStringMapLenient, + updateSkillCacheMarkerUpstream, + writeSkillCacheMarker, type Harness, type HarnessAvailability, type InteractiveSpec, @@ -127,6 +138,26 @@ Commands: Disable launch metadata recording. Also disabled by AGENTWORKFORCE_LAUNCH_METADATA=0. + --no-skill-cache Bypass the persistent skill-install + cache (under + ~/.agentworkforce/workforce/cache/plugins/) + and install fresh into a per-session + dir. Also disabled by + AGENTWORKFORCE_NO_SKILL_CACHE=1. + --refresh-skills Force a fresh install of the skill + cache entry for this persona, + overwriting any existing cache dir. + --check-upstream Force an upstream drift check this + launch (prpm version / GitHub blob + SHA) regardless of the TTL. A drifted + skill triggers a reinstall. + --no-check-upstream Skip the upstream drift check this + launch regardless of the TTL. + Interval is otherwise 24h, tunable + via + AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL + (e.g. 24h, 30m, 0=always, + never=disable). --dry-run Validate the persona without spawning the harness or burning tier-model tokens. Three checks: @@ -643,6 +674,198 @@ function sessionMountDir(sessionRoot: string): string { return join(sessionRoot, 'mount'); } +/** Default upstream drift-check interval when neither flag nor env overrides. */ +const DEFAULT_SKILL_CACHE_CHECK_INTERVAL_MS = 24 * 3_600_000; + +/** + * Resolve the env-configured drift-check interval. + * + * `parseCheckInterval` returns three things we must keep distinct: + * - a number → an explicit interval (incl. `0` = always) + * - `null` → the user explicitly disabled checks (`never`/`off`/`false`) + * - `undefined` → unset or unparseable → fall back to the 24h default + * + * A plain `?? DEFAULT` is wrong here because `??` also coalesces `null`, + * silently re-enabling checks the user asked to turn off. + */ +export function resolveEnvCheckIntervalMs(): number | null { + const parsed = parseCheckInterval( + process.env.AGENTWORKFORCE_SKILL_CACHE_CHECK_INTERVAL + ); + return parsed === undefined ? DEFAULT_SKILL_CACHE_CHECK_INTERVAL_MS : parsed; +} + +interface SkillCacheLockHandle { + release(): void; +} + +// A lock is considered abandoned if its heartbeat timestamp is older than +// this — covers a holder that was SIGKILLed without releasing. +const SKILL_CACHE_LOCK_STALE_MS = 15 * 60_000; +// Upper bound on how long we'll wait for a peer's install before proceeding +// best-effort (unlocked). A real skill install is seconds; this is generous. +const SKILL_CACHE_LOCK_WAIT_MS = 10 * 60_000; +const SKILL_CACHE_LOCK_POLL_MS = 250; + +function skillCachePidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e) { + // EPERM => process exists but we can't signal it (still alive). + return (e as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +function skillCacheLockIsStale(lockPath: string): boolean { + try { + const [pidRaw, tsRaw] = readFileSync(lockPath, 'utf8').split('\n'); + const ts = Number(tsRaw); + if (Number.isFinite(ts) && Date.now() - ts > SKILL_CACHE_LOCK_STALE_MS) { + return true; + } + const pid = Number(pidRaw); + if (Number.isInteger(pid) && pid > 0 && !skillCachePidAlive(pid)) { + return true; + } + return false; + } catch { + // Unreadable (just removed by the holder?) — treat as stale so we retry + // the exclusive create rather than spin forever. + return true; + } +} + +/** + * Acquire a per-fingerprint advisory lock so two concurrent + * `agentworkforce agent ` launches don't both miss the cache + * and install into the same dir simultaneously (which can interleave + * `prpm install` writes and corrupt the cache). + * + * The lock is a sibling file `.lock` (NOT inside the cache dir, so + * a `--refresh-skills` wipe of the dir can't delete a live lock). Created + * with `wx` (O_EXCL) for atomicity. A held lock is stolen only if its holder + * is dead or its heartbeat is stale. If we can't get it within the wait + * budget we return null and the caller proceeds unlocked (best-effort — + * never hang a launch on locking). + */ +export async function acquireSkillCacheLock( + skillCacheDir: string +): Promise { + const lockPath = `${skillCacheDir}.lock`; + try { + mkdirSync(dirname(lockPath), { recursive: true }); + } catch { + return null; + } + const deadline = Date.now() + SKILL_CACHE_LOCK_WAIT_MS; + for (;;) { + try { + writeFileSync(lockPath, `${process.pid}\n${Date.now()}\n`, { flag: 'wx' }); + let released = false; + return { + release() { + if (released) return; + released = true; + try { + rmSync(lockPath, { force: true }); + } catch { + /* best-effort — a stale-steal by another proc is harmless */ + } + } + }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + // Unexpected FS error: don't block the launch on locking. + return null; + } + if (skillCacheLockIsStale(lockPath)) { + try { + rmSync(lockPath, { force: true }); + } catch { + /* lost the steal race — loop and re-evaluate */ + } + continue; + } + if (Date.now() >= deadline) return null; + await new Promise((r) => setTimeout(r, SKILL_CACHE_LOCK_POLL_MS)); + } + } +} + +/** + * Persist the cache marker after a successful install, folding in each + * skill's upstream identity (prpm version / github blob SHA) read from the + * lockfiles the installers just wrote into `cacheDir`. Sets + * `lastUpstreamCheckAt` to now so the first launch within the TTL window + * doesn't immediately re-probe what we just resolved. + * + * Best-effort on the upstream side: a probe failure for a given skill simply + * omits its `upstream` record (the next drift pass will try again). The + * marker itself is always written — its presence is what makes the cache + * dir "valid". + */ +async function writeMarkerWithUpstream( + cacheDir: string, + fingerprint: string, + harness: Harness, + skills: readonly { id: string; source: string }[] +): Promise { + let upstream: Map | undefined; + try { + upstream = (await buildUpstreamRecordsFromCacheDir(cacheDir, skills)) as Map< + string, + { kind: string } | undefined + >; + } catch { + upstream = undefined; + } + writeSkillCacheMarker(cacheDir, { + fingerprint, + harness, + lastUpstreamCheckAt: new Date().toISOString(), + skills: skills.map((s) => { + const rec = upstream?.get(s.id); + return { + id: s.id, + source: s.source, + ...(rec ? { upstream: rec as never } : {}) + }; + }) + }); +} + +/** + * Mirror the contents of a populated skill cache dir into a target dir + * (typically a relayfile mount root). Skips the cache marker file so it + * never lands inside the harness's view. + * + * Used by the mount-harness branch to give opencode / codex the same + * "install once, reuse forever" behaviour that claude gets for free via + * `--plugin-dir `. The destination is expected to be the mount + * root: skill artifacts under `.skills/`, `.opencode/`, `.agents/`, etc. are + * declared as mount-ignored patterns so the mirror does not sync back to the + * user's real repo on session exit. + */ +function mirrorSkillCacheInto(cacheDir: string, targetDir: string): void { + if (!existsSync(cacheDir)) return; + for (const entry of readdirSync(cacheDir, { withFileTypes: true })) { + if (entry.name === '.aw-skill-cache.json') continue; + const from = join(cacheDir, entry.name); + const to = join(targetDir, entry.name); + cpSync(from, to, { + recursive: true, + // Don't overwrite a path the mount mirror or persona setup already + // created — surfacing the conflict as an error here would be loud and + // unactionable; the mount-ignored patterns guarantee any pre-existing + // entry under these names came from a prior cache mirror in this same + // session, which is fine to leave alone. + force: false, + errorOnExist: false + }); + } +} + /** * Remove every `--agent ` pair from a harness argv. Used on the non-mount * opencode path where we cannot safely materialize the persona's @@ -1163,6 +1386,14 @@ async function runInteractive( options: { installInRepo?: boolean; noLaunchMetadata?: boolean; + /** Bypass the persistent skill cache for this run (install fresh into a session dir). */ + noSkillCache?: boolean; + /** Force reinstall into the cache dir, replacing any existing entry. */ + refreshSkills?: boolean; + /** Force an upstream drift check this launch regardless of the TTL. */ + checkUpstream?: boolean; + /** Skip the upstream drift check this launch regardless of the TTL. */ + noCheckUpstream?: boolean; personaSpec: PersonaSpec; personaSource: PersonaSource; capture?: RunInteractiveCapture; @@ -1228,9 +1459,44 @@ async function runInteractive( const useSessionDir = !options.installInRepo && (harness === 'claude' || useClean); const sessionRoot = useSessionDir ? generateSessionRoot(personaId) : undefined; + + // Persistent skill-install cache. Keyed by a content-addressed fingerprint + // of (harness, sorted skill sources, local file contents). On a hit we reuse + // the dir directly — for claude as `--plugin-dir`, for mount harnesses by + // mirroring its contents into the mount before launch. Disabled when the + // user opts into in-repo installs (the cache lives outside the repo, so + // mirroring it back would re-introduce the very leakage `--install-in-repo` + // is designed to surface to the user explicitly). + const skillCachingEnabled = !options.installInRepo && options.noSkillCache !== true; + const skillCacheFingerprint = skillCachingEnabled + ? computeSkillCacheFingerprint({ + harness, + skills: effectiveSelection.skills, + repoRoot: process.cwd() + }) + : undefined; + const skillCacheDir = skillCacheFingerprint + ? resolveSkillCacheDir(skillCacheFingerprint) + : undefined; + // Source-key hit: marker present and fingerprint matches. The upstream + // drift check below may still flip this to a miss. + const skillCacheSourceHit = + skillCacheDir !== undefined && + skillCacheFingerprint !== undefined && + !options.refreshSkills && + isSkillCacheValid(skillCacheDir, skillCacheFingerprint); + // Mutable: a detected upstream drift downgrades the hit into a miss so the + // install path reruns and rebuilds the cache dir in place. + let skillCacheHit = skillCacheSourceHit; + + // installRoot: for claude this is the dir passed to `--plugin-dir`. With + // caching enabled it points at the persistent cache; without, at the + // ephemeral session dir (pre-cache behavior). Other harnesses do not use + // claude's plugin-dir flag — for them the cache (if any) is mirrored into + // the mount in `onBeforeLaunch`, not surfaced as an installRoot. const installRoot = - sessionRoot && harness === 'claude' - ? sessionInstallRoot(sessionRoot) + harness === 'claude' && !options.installInRepo + ? skillCacheDir ?? (sessionRoot ? sessionInstallRoot(sessionRoot) : undefined) : undefined; // `repoRoot` lets the local skill provider (kind: 'local') resolve // relative source paths like `.agentworkforce/workforce/skills/foo.md` to @@ -1243,6 +1509,71 @@ async function runInteractive( }); process.stderr.write(`→ ${personaId} via ${harness} (${model})\n`); + // Upstream drift check (opt-in, TTL-gated). Only meaningful on a + // source-key hit — a miss reinstalls anyway, and a refresh is + // unconditional. Resolve the interval: --check-upstream forces (0 = always + // this launch), --no-check-upstream disables (null = never), otherwise the + // env var, otherwise the 24h default. + if (skillCacheSourceHit && skillCacheDir && skillCacheFingerprint) { + const intervalMs = options.checkUpstream + ? 0 + : options.noCheckUpstream + ? null + : resolveEnvCheckIntervalMs(); + const marker = readSkillCacheMarker(skillCacheDir); + if (marker && isUpstreamCheckDue(marker, intervalMs)) { + const spinner = ora({ + text: 'Checking skills for upstream updates…', + stream: process.stderr + }).start(); + try { + const { drifted, details } = await detectSkillUpstreamDrift(marker); + const moved = details.filter((d) => d.drifted); + if (drifted) { + spinner.warn( + `Upstream changed for ${moved.length} skill(s): ${moved + .map((d) => `${d.skillId} (${d.note})`) + .join(', ')}` + ); + skillCacheHit = false; + } else { + spinner.succeed( + `Skills up to date (${details.length} checked) — reusing cache` + ); + // Record the clean check so we don't re-probe until the TTL lapses. + updateSkillCacheMarkerUpstream(skillCacheDir, { + lastUpstreamCheckAt: new Date().toISOString() + }); + } + } catch (err) { + // Fail-open: a probe-layer crash must never block the launch. + spinner.warn( + `Upstream check failed (${(err as Error).message}); using cached skills` + ); + } + } + } + + if (skillCacheFingerprint && skillCacheDir) { + if (skillCacheHit) { + process.stderr.write( + `• skill cache hit (${skillCacheFingerprint.slice(0, 12)}) → reusing ${skillCacheDir}\n` + ); + } else if (options.refreshSkills) { + process.stderr.write( + `• skill cache refresh requested (${skillCacheFingerprint.slice(0, 12)}) → reinstalling into ${skillCacheDir}\n` + ); + } else if (skillCacheSourceHit) { + process.stderr.write( + `• skill cache stale (${skillCacheFingerprint.slice(0, 12)}) → upstream drift, reinstalling into ${skillCacheDir}\n` + ); + } else { + process.stderr.write( + `• skill cache miss (${skillCacheFingerprint.slice(0, 12)}) → installing into ${skillCacheDir}\n` + ); + } + } + const startLaunchMetadataForLaunch = (cwd = process.cwd()) => startLaunchMetadataRecording({ selection: effectiveSelection, @@ -1281,8 +1612,48 @@ async function runInteractive( // `onBeforeLaunch` below instead of pre-running here. const deferInstallToMount = useClean && harness !== 'claude' && install.commandString !== ':'; - if (install.commandString !== ':' && !deferInstallToMount) { - await runInstall(install.command, installLabel); + if ( + install.commandString !== ':' && + !deferInstallToMount && + !skillCacheHit + ) { + const cacheable = skillCachingEnabled && !!skillCacheDir && !!skillCacheFingerprint; + const lock = + cacheable && skillCacheDir ? await acquireSkillCacheLock(skillCacheDir) : null; + try { + // Re-check under the lock: a concurrent launch may have populated the + // cache while we waited. (Not on refresh — that's an explicit rebuild.) + const peerPopulated = + cacheable && + !options.refreshSkills && + skillCacheDir !== undefined && + skillCacheFingerprint !== undefined && + isSkillCacheValid(skillCacheDir, skillCacheFingerprint); + if (peerPopulated) { + process.stderr.write( + `• skill cache populated by a concurrent launch → reusing ${skillCacheDir}\n` + ); + } else { + // `--refresh-skills`: wipe the entry first so the reinstall is a true + // rebuild — no stale files from a prior version, and a partial + // failure can't leave the old marker behind to fake a later hit. + // Done under the lock so a peer's in-progress install isn't clobbered. + if (options.refreshSkills && cacheable && skillCacheDir) { + rmSync(skillCacheDir, { recursive: true, force: true }); + } + await runInstall(install.command, installLabel); + if (skillCacheFingerprint && skillCacheDir) { + await writeMarkerWithUpstream( + skillCacheDir, + skillCacheFingerprint, + harness, + effectiveSelection.skills.map((s) => ({ id: s.id, source: s.source })) + ); + } + } + } finally { + lock?.release(); + } } const spec = buildInteractiveSpec({ @@ -1478,11 +1849,60 @@ async function runInteractive( // flagged in the index, just hidden via the `.git/info/exclude` block. configureGitForMount(handle.mountDir, ignoredPatterns); if (deferInstallToMount) { - // Hand the line off to the install spinner so the two don't fight - // for the same stream, then resume the setup spinner afterwards. - setupSpinner?.stop(); - await runInstallOrThrow(install.command, installLabel, handle.mountDir); - setupSpinner?.start(); + // Cache-aware mount install: + // - If we have a cache hit, skip the install and just mirror the + // pre-populated cache dir into the mount. `npx prpm install` + // never runs and no install spinner is needed. + // - On miss (or `--refresh-skills`), run the install once with + // cwd set to the cache dir so artifacts land in the persistent + // location, then mirror into the mount and write the marker. + // - With caching disabled (`--install-in-repo` or + // `--no-skill-cache`), fall back to the legacy in-mount install. + // + // The setup spinner is only stopped around branches that print their + // own spinner (install) so the two don't fight for the same stream. + // The pure cache-hit path is a near-instant cpSync — leaving the + // setup spinner running avoids a redundant "Setting up sandbox + // mount…" redraw in the user-visible output. + if (skillCacheHit && skillCacheDir) { + mirrorSkillCacheInto(skillCacheDir, handle.mountDir); + } else if (skillCachingEnabled && skillCacheDir && skillCacheFingerprint) { + const lock = await acquireSkillCacheLock(skillCacheDir); + try { + // A concurrent launch may have populated the cache while we + // waited for the lock — mirror it instead of reinstalling. + // (Skipped on refresh, which is an explicit rebuild.) + if ( + !options.refreshSkills && + isSkillCacheValid(skillCacheDir, skillCacheFingerprint) + ) { + mirrorSkillCacheInto(skillCacheDir, handle.mountDir); + } else { + setupSpinner?.stop(); + // `--refresh-skills`: wipe first so the reinstall is a true + // rebuild and a partial failure can't leave a stale marker. + if (options.refreshSkills) { + rmSync(skillCacheDir, { recursive: true, force: true }); + } + mkdirSync(skillCacheDir, { recursive: true }); + await runInstallOrThrow(install.command, installLabel, skillCacheDir); + await writeMarkerWithUpstream( + skillCacheDir, + skillCacheFingerprint, + harness, + effectiveSelection.skills.map((s) => ({ id: s.id, source: s.source })) + ); + mirrorSkillCacheInto(skillCacheDir, handle.mountDir); + setupSpinner?.start(); + } + } finally { + lock?.release(); + } + } else { + setupSpinner?.stop(); + await runInstallOrThrow(install.command, installLabel, handle.mountDir); + setupSpinner?.start(); + } } for (const file of spec.configFiles) { assertSafeRelativePath(file.path); @@ -1615,7 +2035,12 @@ async function runInteractive( // potentially `rm -rf`ing pre-existing user content. The mount dir is // removed wholesale by `removeSessionRoot` below, so the install's // cleanup is redundant anyway in that case. - if (!deferInstallToMount) { + // + // Skill caching also suppresses the cleanup: the cache dir IS the + // persistence layer, so the rm command (which targets the + // sessionInstallRoot — now pointing at the cache dir for claude) would + // wipe the very thing we want to keep across launches. + if (!deferInstallToMount && !skillCachingEnabled) { runCleanup(install.cleanupCommand, install.cleanupCommandString); } removeSessionRoot(sessionRoot); @@ -1635,7 +2060,12 @@ async function runInteractive( const finish = (code: number) => { if (settled) return; settled = true; - runCleanup(install.cleanupCommand, install.cleanupCommandString); + // Skill caching takes ownership of the install dir; running cleanup + // here would `rm -rf` the persistent cache. Skip in that case and + // rely on the cache marker as the source of truth for "valid install". + if (!skillCachingEnabled) { + runCleanup(install.cleanupCommand, install.cleanupCommandString); + } removeSessionRoot(sessionRoot); void launchMetadata.stop().finally(() => resolve(code)); }; @@ -2494,6 +2924,10 @@ async function runAgentSelector( const code = await runInteractive(selection, { installInRepo: flags.installInRepo, noLaunchMetadata: flags.noLaunchMetadata, + noSkillCache: flags.noSkillCache, + refreshSkills: flags.refreshSkills, + checkUpstream: flags.checkUpstream, + noCheckUpstream: flags.noCheckUpstream, personaSpec: target.spec, personaSource: target.source, capture @@ -3577,7 +4011,11 @@ async function runInteractivePicker(): Promise { await runAgentSelector(selected, { installInRepo: false, noLaunchMetadata: false, - dryRun: false + dryRun: false, + noSkillCache: process.env.AGENTWORKFORCE_NO_SKILL_CACHE === '1', + refreshSkills: false, + checkUpstream: false, + noCheckUpstream: false }); // runAgentSelector has Promise return type; this is unreachable. process.exit(0); @@ -3724,7 +4162,15 @@ async function runPick(args: readonly string[]): Promise { }; await runAgentSelector( CREATE_SELECTOR, - { installInRepo: false, noLaunchMetadata: false, dryRun: false }, + { + installInRepo: false, + noLaunchMetadata: false, + dryRun: false, + noSkillCache: process.env.AGENTWORKFORCE_NO_SKILL_CACHE === '1', + refreshSkills: false, + checkUpstream: false, + noCheckUpstream: false + }, inputValues ); // runAgentSelector terminates via process.exit; this satisfies TS's @@ -3848,6 +4294,14 @@ export interface AgentFlags { installInRepo: boolean; noLaunchMetadata: boolean; dryRun: boolean; + /** Bypass the persistent skill-install cache for this launch. */ + noSkillCache: boolean; + /** Force a fresh install even if the cache entry exists (rebuilds it in place). */ + refreshSkills: boolean; + /** Force an upstream drift check this launch regardless of the TTL. */ + checkUpstream: boolean; + /** Skip the upstream drift check this launch regardless of the TTL. */ + noCheckUpstream: boolean; } export interface CreateFlags extends AgentFlags { @@ -3862,7 +4316,11 @@ export function parseAgentArgs(args: readonly string[]): { const flags: AgentFlags = { installInRepo: false, noLaunchMetadata: false, - dryRun: false + dryRun: false, + noSkillCache: process.env.AGENTWORKFORCE_NO_SKILL_CACHE === '1', + refreshSkills: false, + checkUpstream: false, + noCheckUpstream: false }; const positional: string[] = []; let seenDoubleDash = false; @@ -3887,6 +4345,22 @@ export function parseAgentArgs(args: readonly string[]): { flags.dryRun = true; continue; } + if (arg === '--no-skill-cache') { + flags.noSkillCache = true; + continue; + } + if (arg === '--refresh-skills') { + flags.refreshSkills = true; + continue; + } + if (arg === '--check-upstream') { + flags.checkUpstream = true; + continue; + } + if (arg === '--no-check-upstream') { + flags.noCheckUpstream = true; + continue; + } if (arg === '-h' || arg === '--help') { process.stdout.write(USAGE); process.exit(0); @@ -3905,6 +4379,10 @@ export function parseCreateArgs(args: readonly string[]): { installInRepo: false, noLaunchMetadata: false, dryRun: false, + noSkillCache: process.env.AGENTWORKFORCE_NO_SKILL_CACHE === '1', + refreshSkills: false, + checkUpstream: false, + noCheckUpstream: false, saveDefault: false }; let seenDoubleDash = false; diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 14e274b..c302106 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -98,6 +98,32 @@ export { resolveSkillSource } from './skills.js'; +// Persistent skill-install cache +export { + computeSkillCacheFingerprint, + isSkillCacheValid, + readSkillCacheMarker, + resolveSkillCacheDir, + skillCacheRoot, + updateSkillCacheMarkerUpstream, + writeSkillCacheMarker, + type SkillCacheFingerprintInput, + type SkillCacheMarker, + type SkillCacheMarkerSkill, + type SkillUpstreamRecord +} from './skill-cache.js'; + +// Upstream drift detection (opt-in, TTL-gated) +export { + buildUpstreamRecordsFromCacheDir, + detectSkillUpstreamDrift, + isUpstreamCheckDue, + parseCheckInterval, + type ProbeDeps, + type SkillDriftDetail, + type UpstreamDriftResult +} from './skill-upstream-probe.js'; + // Env-ref resolution export { MissingEnvRefError, diff --git a/packages/persona-kit/src/skill-cache.test.ts b/packages/persona-kit/src/skill-cache.test.ts new file mode 100644 index 0000000..04c3653 --- /dev/null +++ b/packages/persona-kit/src/skill-cache.test.ts @@ -0,0 +1,316 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + computeSkillCacheFingerprint, + isSkillCacheValid, + readSkillCacheMarker, + resolveSkillCacheDir, + skillCacheRoot, + updateSkillCacheMarkerUpstream, + writeSkillCacheMarker +} from './skill-cache.js'; +import type { PersonaSkill } from './types.js'; + +async function withTmpDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'persona-kit-skill-cache-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const remoteSkill: PersonaSkill = { + id: 'choosing-swarm-patterns', + source: '@agent-relay/choosing-swarm-patterns', + description: 'remote' +}; +const remoteSkill2: PersonaSkill = { + id: 'writing-workflows', + source: '@agent-relay/writing-agent-relay-workflows', + description: 'remote-2' +}; + +test('computeSkillCacheFingerprint: deterministic for the same input', () => { + const a = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [remoteSkill, remoteSkill2] + }); + const b = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [remoteSkill, remoteSkill2] + }); + assert.equal(a, b); + assert.match(a, /^[0-9a-f]{32}$/); +}); + +test('computeSkillCacheFingerprint: order-independent over skills', () => { + const a = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [remoteSkill, remoteSkill2] + }); + const b = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [remoteSkill2, remoteSkill] + }); + assert.equal(a, b); +}); + +test('computeSkillCacheFingerprint: harness change invalidates', () => { + const a = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [remoteSkill] + }); + const b = computeSkillCacheFingerprint({ + harness: 'opencode', + skills: [remoteSkill] + }); + assert.notEqual(a, b); +}); + +test('computeSkillCacheFingerprint: source change invalidates', () => { + const a = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [{ ...remoteSkill, source: '@scope/v1' }] + }); + const b = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [{ ...remoteSkill, source: '@scope/v2' }] + }); + assert.notEqual(a, b); +}); + +test('computeSkillCacheFingerprint: description change does NOT invalidate', () => { + // description is documentation metadata, not a behavioral input — flipping it + // should not force a fresh install. + const a = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [{ ...remoteSkill, description: 'one' }] + }); + const b = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [{ ...remoteSkill, description: 'two' }] + }); + assert.equal(a, b); +}); + +test('computeSkillCacheFingerprint: local skill content changes invalidate', async () => { + await withTmpDir(async (dir) => { + const skillPath = 'skills/foo.md'; + await writeFile(join(dir, 'skills', 'foo.md').replace('foo.md', ''), '', 'utf8').catch( + () => undefined + ); + // Ensure the dir exists, then write content. + await import('node:fs/promises').then((fs) => + fs.mkdir(join(dir, 'skills'), { recursive: true }) + ); + await writeFile(join(dir, skillPath), '# original', 'utf8'); + const before = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [{ id: 'foo', source: `./${skillPath}`, description: 'local' }], + repoRoot: dir + }); + await writeFile(join(dir, skillPath), '# edited', 'utf8'); + const after = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [{ id: 'foo', source: `./${skillPath}`, description: 'local' }], + repoRoot: dir + }); + assert.notEqual(before, after); + }); +}); + +test('computeSkillCacheFingerprint: missing local file does not throw', () => { + const fp = computeSkillCacheFingerprint({ + harness: 'claude', + skills: [ + { id: 'gone', source: './no/such/file.md', description: 'missing' } + ], + repoRoot: '/nonexistent' + }); + assert.match(fp, /^[0-9a-f]{32}$/); +}); + +test('skillCacheRoot / resolveSkillCacheDir: under ~/.agentworkforce/workforce/cache/plugins', () => { + const root = skillCacheRoot(); + assert.match(root, /\.agentworkforce[\\/]workforce[\\/]cache[\\/]plugins$/); + const fp = 'a'.repeat(32); + assert.equal(resolveSkillCacheDir(fp), join(root, fp)); +}); + +test('writeSkillCacheMarker / readSkillCacheMarker round-trips', async () => { + await withTmpDir(async (dir) => { + writeSkillCacheMarker(dir, { + fingerprint: 'abc123', + harness: 'claude', + skills: [{ id: 'foo', source: 'x/y' }] + }); + const read = readSkillCacheMarker(dir); + assert.ok(read); + assert.equal(read.schemaVersion, 2); + assert.equal(read.fingerprint, 'abc123'); + assert.equal(read.harness, 'claude'); + assert.equal(read.skills.length, 1); + assert.equal(read.skills[0]?.source, 'x/y'); + assert.match(read.installedAt, /\d{4}-\d{2}-\d{2}T/); + }); +}); + +test('readSkillCacheMarker: returns null for missing/malformed marker', async () => { + await withTmpDir(async (dir) => { + assert.equal(readSkillCacheMarker(dir), null); + await writeFile(join(dir, '.aw-skill-cache.json'), '{not json', 'utf8'); + assert.equal(readSkillCacheMarker(dir), null); + }); +}); + +test('readSkillCacheMarker: returns null for unknown schema version', async () => { + await withTmpDir(async (dir) => { + await writeFile( + join(dir, '.aw-skill-cache.json'), + JSON.stringify({ + schemaVersion: 99, + fingerprint: 'x', + harness: 'claude', + installedAt: '2026-01-01T00:00:00Z', + skills: [] + }), + 'utf8' + ); + assert.equal(readSkillCacheMarker(dir), null); + }); +}); + +test('isSkillCacheValid: requires fingerprint match', async () => { + await withTmpDir(async (dir) => { + writeSkillCacheMarker(dir, { + fingerprint: 'fp-real', + harness: 'claude', + skills: [] + }); + assert.equal(isSkillCacheValid(dir, 'fp-real'), true); + assert.equal(isSkillCacheValid(dir, 'fp-different'), false); + }); +}); + +// --- schema v2: upstream metadata --------------------------------------- + +test('readSkillCacheMarker: upgrades a v1 marker in place (no upstream records)', async () => { + await withTmpDir(async (dir) => { + await writeFile( + join(dir, '.aw-skill-cache.json'), + JSON.stringify({ + schemaVersion: 1, + fingerprint: 'fp1', + harness: 'claude', + installedAt: '2026-01-01T00:00:00.000Z', + skills: [{ id: 'foo', source: '@org/foo' }] + }), + 'utf8' + ); + const read = readSkillCacheMarker(dir); + assert.ok(read); + assert.equal(read.schemaVersion, 2); + assert.equal(read.fingerprint, 'fp1'); + assert.equal(read.lastUpstreamCheckAt, undefined); + assert.equal(read.skills[0]?.upstream, undefined); + }); +}); + +test('writeSkillCacheMarker / readSkillCacheMarker: prpm upstream round-trip', async () => { + await withTmpDir(async (dir) => { + writeSkillCacheMarker(dir, { + fingerprint: 'fp2', + harness: 'claude', + lastUpstreamCheckAt: '2026-05-15T00:00:00.000Z', + skills: [ + { + id: 'swarm', + source: '@agent-relay/choosing-swarm-patterns', + upstream: { + kind: 'prpm', + packageRef: '@agent-relay/choosing-swarm-patterns', + version: '1.0.0' + } + } + ] + }); + const read = readSkillCacheMarker(dir); + assert.ok(read); + assert.equal(read.lastUpstreamCheckAt, '2026-05-15T00:00:00.000Z'); + const u = read.skills[0]?.upstream; + assert.ok(u && u.kind === 'prpm'); + assert.equal(u.version, '1.0.0'); + assert.equal(u.packageRef, '@agent-relay/choosing-swarm-patterns'); + }); +}); + +test('readSkillCacheMarker: drops a malformed upstream block but keeps the skill', async () => { + await withTmpDir(async (dir) => { + await writeFile( + join(dir, '.aw-skill-cache.json'), + JSON.stringify({ + schemaVersion: 2, + fingerprint: 'fp3', + harness: 'claude', + installedAt: '2026-05-15T00:00:00.000Z', + skills: [ + { id: 'bad', source: '@org/x', upstream: { kind: 'prpm' } }, + { + id: 'good', + source: 'gh#y', + upstream: { kind: 'github-blob', blobUrl: 'https://u', sha: 'abc' } + } + ] + }), + 'utf8' + ); + const read = readSkillCacheMarker(dir); + assert.ok(read); + assert.equal(read.skills[0]?.upstream, undefined); // malformed prpm dropped + assert.equal(read.skills[0]?.id, 'bad'); // skill itself preserved + const g = read.skills[1]?.upstream; + assert.ok(g && g.kind === 'github-blob'); + assert.equal(g.sha, 'abc'); + }); +}); + +test('updateSkillCacheMarkerUpstream: bumps timestamp, overlays records, keeps others', async () => { + await withTmpDir(async (dir) => { + writeSkillCacheMarker(dir, { + fingerprint: 'fp4', + harness: 'claude', + lastUpstreamCheckAt: '2026-05-01T00:00:00.000Z', + skills: [ + { id: 'a', source: '@o/a', upstream: { kind: 'prpm', packageRef: '@o/a', version: '1.0.0' } }, + { id: 'b', source: '@o/b' } + ] + }); + updateSkillCacheMarkerUpstream(dir, { + lastUpstreamCheckAt: '2026-05-15T12:00:00.000Z', + skills: new Map([ + ['a', { kind: 'prpm', packageRef: '@o/a', version: '2.0.0' }] + ]) + }); + const read = readSkillCacheMarker(dir); + assert.ok(read); + assert.equal(read.lastUpstreamCheckAt, '2026-05-15T12:00:00.000Z'); + const a = read.skills.find((s) => s.id === 'a')?.upstream; + assert.ok(a && a.kind === 'prpm' && a.version === '2.0.0'); + // 'b' had no overlay key → untouched (still no upstream) + assert.equal(read.skills.find((s) => s.id === 'b')?.upstream, undefined); + }); +}); + +test('updateSkillCacheMarkerUpstream: no-ops when marker missing', async () => { + await withTmpDir(async (dir) => { + // Should not throw even though there is no marker file. + updateSkillCacheMarkerUpstream(dir, { + lastUpstreamCheckAt: '2026-05-15T12:00:00.000Z' + }); + assert.equal(readSkillCacheMarker(dir), null); + }); +}); diff --git a/packages/persona-kit/src/skill-cache.ts b/packages/persona-kit/src/skill-cache.ts new file mode 100644 index 0000000..64a8ac2 --- /dev/null +++ b/packages/persona-kit/src/skill-cache.ts @@ -0,0 +1,334 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import type { Harness, PersonaSkill, SkillSourceKind } from './types.js'; + +// --------------------------------------------------------------------------- +// Persistent skill-install cache +// --------------------------------------------------------------------------- +// +// Each interactive launch (`agentworkforce agent `) used to spawn a +// fresh `npx prpm install …` for every declared skill, even when nothing about +// the skill set had changed since the last run — `npx`, registry resolution, +// and tarball fetches added several seconds of latency to every spawn. +// +// To skip that work we maintain a content-addressed cache under +// `~/.agentworkforce/workforce/cache/plugins//` keyed by a stable +// hash of the persona's skill set (and, for local sources, the file contents). +// The first launch with a given fingerprint installs into the cache dir; later +// launches with the same fingerprint reuse the existing dir directly: +// +// • claude: pass the cache dir to `claude --plugin-dir `. +// • opencode / codex (mount mode): rsync the cache contents into the mount +// before launch so the harness sees the expected layout. +// +// Cache invalidation has two layers: +// +// 1. Source-key (always-on). The fingerprint folds in the harness, each +// skill's `(id, source)` pair, and the SHA-256 of any local `.md` +// sources. Changing a source string or editing a local file rotates the +// fingerprint and produces a fresh cache entry — no upstream traffic. +// +// 2. Upstream drift check (opt-in, on a TTL). At install time we record +// each remote skill's upstream identity (prpm version, github blob SHA). +// On launch, if the marker's `lastUpstreamCheckAt` is older than the +// configured interval, lightweight HTTPS probes (one per skill, in +// parallel) compare the recorded identity to current upstream — any +// mismatch flips the cache hit into a miss so the reinstall picks up +// the new content. Failures fail-open (treated as "no drift detected") +// so the launch never blocks on a flaky registry. +// +// `--refresh-skills` is the unconditional override — it always reinstalls. +// `--no-skill-cache` (and `AGENTWORKFORCE_NO_SKILL_CACHE=1`) bypass the cache +// entirely. + +const MARKER_FILENAME = '.aw-skill-cache.json'; +/** Latest marker schema version this module writes. v1 reads remain supported. */ +const MARKER_SCHEMA_VERSION = 2 as const; +const LOCAL_MD_RE = /\.md$/i; +const URL_PREFIX_RE = /^[a-z][a-z0-9+.-]*:\/\//i; + +export interface SkillCacheFingerprintInput { + harness: Harness; + skills: readonly PersonaSkill[]; + /** + * Repo root used to resolve relative local skill paths so the fingerprint + * folds in the .md file contents — an edit invalidates the cache without + * the user needing to bump a version. + */ + repoRoot?: string; +} + +/** + * Per-skill upstream identity stored at install time and probed on launch to + * detect drift past a fingerprint that hasn't changed. + * + * - `prpm`: the registry's `latest_version.version` at install time. A + * subsequent launch compares to the registry's current latest. + * - `github-blob`: the blob SHA of the SKILL.md file at install time. A + * subsequent launch fetches the file's blob SHA (or sends an + * `If-None-Match: ""` conditional GET) and compares. + * + * Skills with no usable upstream representation (e.g. local `.md` files) are + * recorded without an `upstream` field — they're covered by the content + * hash already folded into the fingerprint. + */ +export type SkillUpstreamRecord = + | { kind: 'prpm'; packageRef: string; version: string } + | { kind: 'github-blob'; blobUrl: string; sha: string }; + +export interface SkillCacheMarkerSkill { + id: string; + source: string; + sourceKind?: SkillSourceKind; + upstream?: SkillUpstreamRecord; +} + +export interface SkillCacheMarker { + schemaVersion: typeof MARKER_SCHEMA_VERSION; + fingerprint: string; + harness: Harness; + /** ISO-8601 timestamp of the most recent successful install into this dir. */ + installedAt: string; + /** + * ISO-8601 timestamp of the most recent successful (or attempted-and-clean) + * upstream probe. Omitted on freshly-installed markers that haven't been + * probed yet — but the install path also writes an initial value so the + * first launch within the TTL window doesn't need to probe. + */ + lastUpstreamCheckAt?: string; + skills: readonly SkillCacheMarkerSkill[]; +} + +/** + * Stable fingerprint identifying a (harness, skill set) combination. + * + * The fingerprint is the SHA-256 (first 32 hex chars) of a canonical JSON + * encoding of: + * - the harness name + * - each skill's `id` and `source`, sorted by id + * - for local `.md` sources resolvable against `repoRoot`, the SHA-256 of + * the file's bytes (so edits to a local skill invalidate the cache) + * + * Remote sources (prpm refs, github URLs) are hashed only by the source + * string — upstream version bumps are picked up by the separate drift-check + * machinery in `detectSkillUpstreamDrift`, not by the fingerprint. + */ +export function computeSkillCacheFingerprint( + input: SkillCacheFingerprintInput +): string { + const skills = [...input.skills] + .map((skill) => ({ + id: skill.id, + source: skill.source, + localHash: hashLocalSourceIfPresent(skill.source, input.repoRoot) + })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const canonical = stableStringify({ + // Pinned to 1 even though the marker schema is v2: the fingerprint is a + // CONTENT key, not a marker version key. Bumping it would gratuitously + // invalidate every cache entry in the wild. + v: 1, + harness: input.harness, + skills + }); + return createHash('sha256').update(canonical).digest('hex').slice(0, 32); +} + +function stableStringify(value: unknown): string { + return JSON.stringify(sortKeysDeep(value)); +} + +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => [k, sortKeysDeep(v)]) + ); + } + return value; +} + +function hashLocalSourceIfPresent( + source: string, + repoRoot: string | undefined +): string | null { + if (URL_PREFIX_RE.test(source)) return null; + if (!LOCAL_MD_RE.test(source)) return null; + const trimmed = source.startsWith('./') ? source.slice(2) : source; + const abs = isAbsolute(trimmed) + ? trimmed + : repoRoot + ? resolve(repoRoot, trimmed) + : null; + if (!abs) return null; + try { + return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16); + } catch { + // File missing or unreadable at fingerprint time: omit the content hash + // so the fingerprint still stabilizes off the source string alone. The + // install will fail later with a clearer error than we could surface + // here. + return null; + } +} + +/** + * Root directory under `~/.agentworkforce/workforce/cache/plugins/` that + * holds one subdir per fingerprint. Distinct from + * `~/.agentworkforce/workforce/sessions/` (which is wiped after each run) so + * cached installs persist across launches. + */ +export function skillCacheRoot(): string { + return join(homedir(), '.agentworkforce', 'workforce', 'cache', 'plugins'); +} + +/** Cache dir for a given fingerprint. May or may not exist yet. */ +export function resolveSkillCacheDir(fingerprint: string): string { + return join(skillCacheRoot(), fingerprint); +} + +/** + * Read the cache marker if present and well-formed. Returns null on any + * I/O or JSON failure so callers can transparently fall through to a fresh + * install. + * + * Accepts both schema v1 (the original, no upstream metadata) and v2 (adds + * `lastUpstreamCheckAt` and per-skill `upstream`). v1 markers are upgraded + * in-place to the v2 shape with no upstream records — the next drift-check + * pass will treat the absence as "needs to be recorded" and probe live. + */ +export function readSkillCacheMarker(cacheDir: string): SkillCacheMarker | null { + try { + const raw = readFileSync(join(cacheDir, MARKER_FILENAME), 'utf8'); + const parsed = JSON.parse(raw) as { + schemaVersion?: number; + fingerprint?: unknown; + harness?: unknown; + installedAt?: unknown; + lastUpstreamCheckAt?: unknown; + skills?: unknown; + }; + if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) return null; + if (typeof parsed.fingerprint !== 'string') return null; + if (typeof parsed.harness !== 'string') return null; + if (typeof parsed.installedAt !== 'string') return null; + if (!Array.isArray(parsed.skills)) return null; + return { + schemaVersion: MARKER_SCHEMA_VERSION, + fingerprint: parsed.fingerprint, + harness: parsed.harness as Harness, + installedAt: parsed.installedAt, + ...(typeof parsed.lastUpstreamCheckAt === 'string' + ? { lastUpstreamCheckAt: parsed.lastUpstreamCheckAt } + : {}), + skills: parsed.skills.map(normalizeMarkerSkill) + }; + } catch { + return null; + } +} + +function normalizeMarkerSkill(input: unknown): SkillCacheMarkerSkill { + const obj = (input ?? {}) as Partial & { + upstream?: unknown; + }; + const out: SkillCacheMarkerSkill = { + id: typeof obj.id === 'string' ? obj.id : '', + source: typeof obj.source === 'string' ? obj.source : '' + }; + if (typeof obj.sourceKind === 'string') out.sourceKind = obj.sourceKind as SkillSourceKind; + const upstream = obj.upstream; + if (upstream && typeof upstream === 'object') { + const u = upstream as Record; + if (u.kind === 'prpm' && typeof u.packageRef === 'string' && typeof u.version === 'string') { + out.upstream = { kind: 'prpm', packageRef: u.packageRef, version: u.version }; + } else if ( + u.kind === 'github-blob' && + typeof u.blobUrl === 'string' && + typeof u.sha === 'string' + ) { + out.upstream = { kind: 'github-blob', blobUrl: u.blobUrl, sha: u.sha }; + } + } + return out; +} + +/** + * Write the marker after a successful install. The marker's presence is the + * sole signal that the dir is a complete cache entry — never call this if + * the install subprocess exited non-zero. + */ +export function writeSkillCacheMarker( + cacheDir: string, + marker: Omit & { + installedAt?: string; + } +): void { + const body: SkillCacheMarker = { + schemaVersion: MARKER_SCHEMA_VERSION, + fingerprint: marker.fingerprint, + harness: marker.harness, + installedAt: marker.installedAt ?? new Date().toISOString(), + ...(marker.lastUpstreamCheckAt !== undefined + ? { lastUpstreamCheckAt: marker.lastUpstreamCheckAt } + : {}), + skills: marker.skills + }; + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, MARKER_FILENAME), JSON.stringify(body, null, 2)); +} + +/** + * Update an existing marker's `lastUpstreamCheckAt` (and optionally any + * per-skill upstream records that were freshly probed). Used by the launch + * path after a drift-free probe so subsequent launches within the TTL window + * can skip the check entirely. + * + * No-ops silently if the marker file is missing or malformed — callers + * should already have validated it. + */ +export function updateSkillCacheMarkerUpstream( + cacheDir: string, + patch: { + lastUpstreamCheckAt: string; + /** Optional per-skill upstream records to overlay; missing entries keep their existing values. */ + skills?: ReadonlyMap; + } +): void { + const existing = readSkillCacheMarker(cacheDir); + if (!existing) return; + const updated: SkillCacheMarker = { + ...existing, + lastUpstreamCheckAt: patch.lastUpstreamCheckAt, + skills: existing.skills.map((skill) => { + const overlay = patch.skills?.get(skill.id); + if (overlay === undefined && !patch.skills?.has(skill.id)) return skill; + const next: SkillCacheMarkerSkill = { + id: skill.id, + source: skill.source, + ...(skill.sourceKind !== undefined ? { sourceKind: skill.sourceKind } : {}) + }; + if (overlay !== undefined) next.upstream = overlay; + return next; + }) + }; + writeFileSync(join(cacheDir, MARKER_FILENAME), JSON.stringify(updated, null, 2)); +} + +/** + * True when the cache dir has a marker file whose fingerprint matches the + * expected value. Mismatches are treated as invalid (caller should reinstall); + * since the cache dir name IS the fingerprint, a mismatch only happens on + * partial writes or manual tampering. + * + * This is purely a source-key check — it does NOT consult upstream. Use + * `detectSkillUpstreamDrift` separately to fold in drift detection. + */ +export function isSkillCacheValid(cacheDir: string, fingerprint: string): boolean { + const marker = readSkillCacheMarker(cacheDir); + return marker !== null && marker.fingerprint === fingerprint; +} diff --git a/packages/persona-kit/src/skill-upstream-probe.test.ts b/packages/persona-kit/src/skill-upstream-probe.test.ts new file mode 100644 index 0000000..3b71979 --- /dev/null +++ b/packages/persona-kit/src/skill-upstream-probe.test.ts @@ -0,0 +1,426 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + buildUpstreamRecordsFromCacheDir, + detectSkillUpstreamDrift, + isUpstreamCheckDue, + parseCheckInterval +} from './skill-upstream-probe.js'; +import type { SkillCacheMarker } from './skill-cache.js'; + +async function withTmpDir(fn: (dir: string) => Promise): Promise { + const dir = await mkdtemp(join(tmpdir(), 'persona-kit-upstream-')); + try { + return await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** + * Minimal Response-like stub. Only the fields the probe touches: + * `ok`, `status`, `json()`. + */ +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + } as unknown as Response; +} + +function statusResponse(status: number): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + throw new Error('no body'); + } + } as unknown as Response; +} + +type FetchStub = (url: string, init?: RequestInit) => Promise; + +function recordingFetch( + handler: (url: string, init?: RequestInit) => Response | Promise +): { fetchImpl: FetchStub; calls: Array<{ url: string; init?: RequestInit }> } { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fetchImpl: FetchStub = async (url, init) => { + calls.push({ url, init }); + return handler(url, init); + }; + return { fetchImpl, calls }; +} + +// --- parseCheckInterval -------------------------------------------------- + +test('parseCheckInterval: units', () => { + assert.equal(parseCheckInterval('500ms'), 500); + assert.equal(parseCheckInterval('90s'), 90_000); + assert.equal(parseCheckInterval('30m'), 1_800_000); + assert.equal(parseCheckInterval('24h'), 86_400_000); + assert.equal(parseCheckInterval('2d'), 172_800_000); + assert.equal(parseCheckInterval('12'), 12 * 3_600_000); // bare number = hours +}); + +test('parseCheckInterval: sentinels', () => { + assert.equal(parseCheckInterval('0'), 0); // always + assert.equal(parseCheckInterval('never'), null); + assert.equal(parseCheckInterval('off'), null); + assert.equal(parseCheckInterval('false'), null); +}); + +test('parseCheckInterval: unparseable / empty → undefined (caller default)', () => { + assert.equal(parseCheckInterval(undefined), undefined); + assert.equal(parseCheckInterval(''), undefined); + assert.equal(parseCheckInterval(' '), undefined); + assert.equal(parseCheckInterval('soon'), undefined); + assert.equal(parseCheckInterval('10x'), undefined); +}); + +// --- isUpstreamCheckDue -------------------------------------------------- + +function marker(partial: Partial = {}): SkillCacheMarker { + return { + schemaVersion: 2, + fingerprint: 'fp', + harness: 'claude', + installedAt: '2026-05-01T00:00:00.000Z', + skills: [], + ...partial + }; +} + +test('isUpstreamCheckDue: never (null) is always false', () => { + assert.equal(isUpstreamCheckDue(marker(), null), false); +}); + +test('isUpstreamCheckDue: always (0) is always true', () => { + assert.equal( + isUpstreamCheckDue(marker({ lastUpstreamCheckAt: new Date().toISOString() }), 0), + true + ); +}); + +test('isUpstreamCheckDue: no prior check → due', () => { + assert.equal(isUpstreamCheckDue(marker(), 3_600_000), true); +}); + +test('isUpstreamCheckDue: respects the interval window', () => { + const now = Date.parse('2026-05-15T12:00:00.000Z'); + const fresh = marker({ lastUpstreamCheckAt: '2026-05-15T11:30:00.000Z' }); + const stale = marker({ lastUpstreamCheckAt: '2026-05-15T10:00:00.000Z' }); + assert.equal(isUpstreamCheckDue(fresh, 3_600_000, now), false); // 30m < 1h + assert.equal(isUpstreamCheckDue(stale, 3_600_000, now), true); // 2h > 1h +}); + +test('isUpstreamCheckDue: corrupt timestamp → due', () => { + assert.equal( + isUpstreamCheckDue(marker({ lastUpstreamCheckAt: 'not-a-date' }), 3_600_000), + true + ); +}); + +// --- buildUpstreamRecordsFromCacheDir ------------------------------------ + +test('buildUpstreamRecordsFromCacheDir: prpm version from prpm.lock', async () => { + await withTmpDir(async (dir) => { + await writeFile( + join(dir, 'prpm.lock'), + JSON.stringify({ + packages: { + '@agent-relay/foo#claude': { version: '1.2.3' } + } + }), + 'utf8' + ); + const { fetchImpl } = recordingFetch(() => jsonResponse({})); + const recs = await buildUpstreamRecordsFromCacheDir( + dir, + [{ id: 'foo', source: '@agent-relay/foo' }], + { fetchImpl } + ); + const r = recs.get('foo'); + assert.ok(r && r.kind === 'prpm'); + assert.equal(r.version, '1.2.3'); + assert.equal(r.packageRef, '@agent-relay/foo'); + }); +}); + +test('buildUpstreamRecordsFromCacheDir: github blob sha from skills-lock.json + GitHub GET', async () => { + await withTmpDir(async (dir) => { + await writeFile( + join(dir, 'skills-lock.json'), + JSON.stringify({ + skills: { + 'find-skills': { + source: 'vercel-labs/skills', + sourceType: 'github', + skillPath: 'skills/find-skills/SKILL.md' + } + } + }), + 'utf8' + ); + const { fetchImpl, calls } = recordingFetch((url) => { + assert.match( + url, + /api\.github\.com\/repos\/vercel-labs\/skills\/contents\/skills\/find-skills\/SKILL\.md/ + ); + return jsonResponse({ sha: 'deadbeef' }); + }); + const recs = await buildUpstreamRecordsFromCacheDir( + dir, + [{ id: 'fs', source: 'https://github.com/vercel-labs/skills#find-skills' }], + { fetchImpl } + ); + const r = recs.get('fs'); + assert.ok(r && r.kind === 'github-blob'); + assert.equal(r.sha, 'deadbeef'); + assert.equal(calls.length, 1); + }); +}); + +test('buildUpstreamRecordsFromCacheDir: local source → undefined record', async () => { + await withTmpDir(async (dir) => { + const { fetchImpl } = recordingFetch(() => jsonResponse({})); + const recs = await buildUpstreamRecordsFromCacheDir( + dir, + [{ id: 'loc', source: './skills/local.md' }], + { fetchImpl } + ); + assert.ok(recs.has('loc')); + assert.equal(recs.get('loc'), undefined); + }); +}); + +test('buildUpstreamRecordsFromCacheDir: github GET failure → undefined (fail-open)', async () => { + await withTmpDir(async (dir) => { + await writeFile( + join(dir, 'skills-lock.json'), + JSON.stringify({ + skills: { x: { skillPath: 'skills/x/SKILL.md' } } + }), + 'utf8' + ); + const { fetchImpl } = recordingFetch(() => statusResponse(503)); + const recs = await buildUpstreamRecordsFromCacheDir( + dir, + [{ id: 'x', source: 'https://github.com/o/r#x' }], + { fetchImpl } + ); + assert.equal(recs.get('x'), undefined); + }); +}); + +// --- detectSkillUpstreamDrift ------------------------------------------- + +test('detectSkillUpstreamDrift: prpm version unchanged → no drift', async () => { + const { fetchImpl } = recordingFetch(() => + jsonResponse({ latest_version: { version: '1.0.0' } }) + ); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'foo', + source: '@o/foo', + upstream: { kind: 'prpm', packageRef: '@o/foo', version: '1.0.0' } + } + ] + }), + { fetchImpl } + ); + assert.equal(res.drifted, false); + assert.equal(res.details[0]?.drifted, false); +}); + +test('detectSkillUpstreamDrift: prpm version bumped → drift', async () => { + const { fetchImpl } = recordingFetch(() => + jsonResponse({ latest_version: { version: '1.1.3' } }) + ); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'foo', + source: '@o/foo', + upstream: { kind: 'prpm', packageRef: '@o/foo', version: '1.0.0' } + } + ] + }), + { fetchImpl } + ); + assert.equal(res.drifted, true); + assert.match(res.details[0]?.note ?? '', /1\.0\.0 → 1\.1\.3/); +}); + +test('detectSkillUpstreamDrift: prpm registry 500 → fail-open (no drift)', async () => { + const { fetchImpl } = recordingFetch(() => statusResponse(500)); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'foo', + source: '@o/foo', + upstream: { kind: 'prpm', packageRef: '@o/foo', version: '1.0.0' } + } + ] + }), + { fetchImpl } + ); + assert.equal(res.drifted, false); + assert.match(res.details[0]?.note ?? '', /fail-open/); +}); + +test('detectSkillUpstreamDrift: github 304 (If-None-Match) → no drift', async () => { + const { fetchImpl, calls } = recordingFetch(() => statusResponse(304)); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'gh', + source: 'https://github.com/o/r#x', + upstream: { + kind: 'github-blob', + blobUrl: 'https://api.github.com/repos/o/r/contents/skills/x/SKILL.md', + sha: 'abc123' + } + } + ] + }), + { fetchImpl } + ); + assert.equal(res.drifted, false); + assert.equal(calls[0]?.init?.headers && (calls[0].init.headers as Record)['if-none-match'], '"abc123"'); +}); + +test('detectSkillUpstreamDrift: github new sha → drift', async () => { + const { fetchImpl } = recordingFetch(() => jsonResponse({ sha: 'newsha999' })); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'gh', + source: 'https://github.com/o/r#x', + upstream: { + kind: 'github-blob', + blobUrl: 'https://api.github.com/repos/o/r/contents/skills/x/SKILL.md', + sha: 'oldsha000' + } + } + ] + }), + { fetchImpl } + ); + assert.equal(res.drifted, true); + assert.match(res.details[0]?.note ?? '', /oldsha000.*→.*newsha999/); +}); + +test('detectSkillUpstreamDrift: github 404 → fail-open (no drift)', async () => { + const { fetchImpl } = recordingFetch(() => statusResponse(404)); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'gh', + source: 'https://github.com/o/r#x', + upstream: { + kind: 'github-blob', + blobUrl: 'https://api.github.com/repos/o/r/contents/skills/x/SKILL.md', + sha: 'abc' + } + } + ] + }), + { fetchImpl } + ); + assert.equal(res.drifted, false); + assert.match(res.details[0]?.note ?? '', /fail-open/); +}); + +test('detectSkillUpstreamDrift: remote skill missing upstream record → drift (capture next time)', async () => { + const { fetchImpl } = recordingFetch(() => jsonResponse({})); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [{ id: 'foo', source: '@agent-relay/foo' }] // no upstream + }), + { fetchImpl } + ); + assert.equal(res.drifted, true); + assert.match(res.details[0]?.note ?? '', /no upstream record/); +}); + +test('detectSkillUpstreamDrift: local skill missing upstream record → skipped (no drift)', async () => { + const { fetchImpl } = recordingFetch(() => jsonResponse({})); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [{ id: 'loc', source: './skills/x.md' }] + }), + { fetchImpl } + ); + assert.equal(res.drifted, false); + assert.equal(res.details.length, 0); +}); + +test('detectSkillUpstreamDrift: mixed set — one drift flips the whole result', async () => { + const { fetchImpl } = recordingFetch((url) => { + if (url.includes('registry.prpm.dev')) { + return jsonResponse({ latest_version: { version: '1.0.0' } }); // unchanged + } + return jsonResponse({ sha: 'moved' }); // github drifted + }); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'p', + source: '@o/p', + upstream: { kind: 'prpm', packageRef: '@o/p', version: '1.0.0' } + }, + { + id: 'g', + source: 'https://github.com/o/r#x', + upstream: { + kind: 'github-blob', + blobUrl: 'https://api.github.com/repos/o/r/contents/x/SKILL.md', + sha: 'orig' + } + } + ] + }), + { fetchImpl } + ); + assert.equal(res.drifted, true); + assert.equal(res.details.find((d) => d.skillId === 'p')?.drifted, false); + assert.equal(res.details.find((d) => d.skillId === 'g')?.drifted, true); +}); + +test('detectSkillUpstreamDrift: probe timeout → fail-open', async () => { + const fetchImpl: FetchStub = (_url, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal) { + signal.addEventListener('abort', () => + reject(new Error('aborted')) + ); + } + }); + const res = await detectSkillUpstreamDrift( + marker({ + skills: [ + { + id: 'foo', + source: '@o/foo', + upstream: { kind: 'prpm', packageRef: '@o/foo', version: '1.0.0' } + } + ] + }), + { fetchImpl, timeoutMs: 50 } + ); + assert.equal(res.drifted, false); + assert.match(res.details[0]?.note ?? '', /fail-open/); +}); diff --git a/packages/persona-kit/src/skill-upstream-probe.ts b/packages/persona-kit/src/skill-upstream-probe.ts new file mode 100644 index 0000000..416ff8f --- /dev/null +++ b/packages/persona-kit/src/skill-upstream-probe.ts @@ -0,0 +1,464 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { resolveSkillSource } from './skills.js'; +import type { SkillCacheMarker, SkillUpstreamRecord } from './skill-cache.js'; + +// --------------------------------------------------------------------------- +// Upstream drift detection +// --------------------------------------------------------------------------- +// +// The persistent skill cache is keyed by a source-string fingerprint, so an +// upstream publish that doesn't change the persona's `source` string (the +// common case: floating refs like `@org/skill` or `github.com/org/repo#x`) +// never rotates the fingerprint on its own. This module adds a cheap, +// opt-in second check: +// +// • At install time, `buildUpstreamRecordsFromCacheDir` reads the +// lockfiles the installers wrote into the cache dir (`prpm.lock`, +// `skills-lock.json`) and resolves a stable upstream identity per skill: +// - prpm → the resolved `version` +// - skill.sh → the GitHub blob SHA of the installed SKILL.md +// • On launch, `detectSkillUpstreamDrift` re-probes each recorded skill in +// parallel and reports whether ANY of them moved. prpm is one registry +// GET; github is a conditional GET (`If-None-Match: ""`) that comes +// back 304 with no body when nothing changed. +// +// Everything fails OPEN: a network error, timeout, rate-limit, or malformed +// response for a given skill is treated as "no drift detected" for that +// skill. A flaky registry must never block or slow a launch beyond the +// timeout — the worst case is running slightly stale until the next clean +// probe. + +const DEFAULT_TIMEOUT_MS = 5000; +const PRPM_REGISTRY_BASE = 'https://registry.prpm.dev/api/v1/packages'; +const GITHUB_API_BASE = 'https://api.github.com'; + +/** + * Minimal fetch surface the probes actually use. Deliberately narrower than + * the DOM `fetch` overload set so tests can pass a plain stub without + * wrestling the `URL | RequestInfo` union. + */ +export type MinimalFetch = ( + url: string, + init?: { signal?: AbortSignal; headers?: Record } +) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; +}>; + +export interface ProbeDeps { + /** Injectable fetch (defaults to global). Tests pass a stub. */ + fetchImpl?: MinimalFetch; + /** Per-request timeout. Defaults to 5s. */ + timeoutMs?: number; +} + +export interface SkillDriftDetail { + skillId: string; + source: string; + kind: SkillUpstreamRecord['kind']; + /** True when upstream moved relative to the recorded identity. */ + drifted: boolean; + /** Human-readable note for logs (recorded → current, or the fail-open reason). */ + note: string; +} + +export interface UpstreamDriftResult { + drifted: boolean; + details: SkillDriftDetail[]; +} + +function withTimeout(timeoutMs: number): { + signal: AbortSignal; + cancel: () => void; +} { + const controller = new AbortController(); + // Not unref'd: the whole point of this timer is to enforce the probe + // deadline. `cancel()` clears it on the normal (settled) path so it never + // lingers; on a hung fetch it must stay live so the abort actually fires. + const timer = setTimeout(() => controller.abort(), timeoutMs); + return { signal: controller.signal, cancel: () => clearTimeout(timer) }; +} + +// --------------------------------------------------------------------------- +// prpm +// --------------------------------------------------------------------------- + +/** + * Parse the prpm.lock the installer wrote into `cacheDir` into a + * packageRef → resolved-version map. prpm.lock keys are `#` + * (e.g. `@agent-relay/foo#claude`); we index by the bare packageRef so a + * persona source string can find its row regardless of harness format. + */ +function readPrpmLockVersions(cacheDir: string): Map { + const out = new Map(); + let raw: string; + try { + raw = readFileSync(join(cacheDir, 'prpm.lock'), 'utf8'); + } catch { + return out; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return out; + } + const packages = (parsed as { packages?: Record })?.packages; + if (!packages || typeof packages !== 'object') return out; + for (const [key, value] of Object.entries(packages)) { + const version = (value as { version?: unknown })?.version; + if (typeof version !== 'string') continue; + const hashIdx = key.lastIndexOf('#'); + const packageRef = hashIdx >= 0 ? key.slice(0, hashIdx) : key; + // First write wins; multiple harness formats of the same package resolve + // to the same version in practice. + if (!out.has(packageRef)) out.set(packageRef, version); + } + return out; +} + +async function probePrpmLatestVersion( + packageRef: string, + deps: Required +): Promise { + const url = `${PRPM_REGISTRY_BASE}/${encodeURIComponent(packageRef)}`; + const { signal, cancel } = withTimeout(deps.timeoutMs); + try { + const res = await deps.fetchImpl(url, { + signal, + headers: { accept: 'application/json' } + }); + if (!res.ok) return null; + const body = (await res.json()) as { + latest_version?: { version?: unknown }; + }; + const v = body?.latest_version?.version; + return typeof v === 'string' ? v : null; + } catch { + return null; + } finally { + cancel(); + } +} + +// --------------------------------------------------------------------------- +// skill.sh / GitHub blob +// --------------------------------------------------------------------------- + +interface SkillsLockEntry { + source?: string; + sourceType?: string; + skillPath?: string; + computedHash?: string; +} + +function readSkillsLock(cacheDir: string): Record { + try { + const raw = readFileSync(join(cacheDir, 'skills-lock.json'), 'utf8'); + const parsed = JSON.parse(raw) as { skills?: Record }; + return parsed?.skills && typeof parsed.skills === 'object' ? parsed.skills : {}; + } catch { + return {}; + } +} + +/** + * Owner/repo + optional ref extracted from a resolved skill.sh source. The + * resolver normalizes both supported forms into `packageRef`: + * - `#` (default branch) + * - `/tree/#` (explicit ref) + */ +function parseGithubRepoRef( + resolvedPackageRef: string +): { owner: string; repo: string; ref?: string } | null { + const [left] = resolvedPackageRef.split('#'); + const treeMatch = left.match( + /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/tree\/([^/\s]+)$/i + ); + if (treeMatch) { + return { owner: treeMatch[1], repo: treeMatch[2], ref: treeMatch[3] }; + } + const repoMatch = left.match( + /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i + ); + if (repoMatch) { + return { owner: repoMatch[1], repo: repoMatch[2] }; + } + return null; +} + +/** + * Build the GitHub Contents API URL for a skill's SKILL.md from the + * skills-lock `skillPath` plus the repo/ref parsed off the resolved source. + * Returns null when we can't form a precise file URL (caller fails open). + */ +function buildGithubBlobUrl( + resolvedPackageRef: string, + skillPath: string +): string | null { + const repo = parseGithubRepoRef(resolvedPackageRef); + if (!repo) return null; + const path = skillPath.replace(/^\/+/, ''); + const base = `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.repo}/contents/${path}`; + return repo.ref ? `${base}?ref=${encodeURIComponent(repo.ref)}` : base; +} + +async function probeGithubBlobSha( + blobUrl: string, + knownSha: string | undefined, + deps: Required +): Promise<{ sha: string | null; unchanged: boolean }> { + const { signal, cancel } = withTimeout(deps.timeoutMs); + try { + const headers: Record = { + accept: 'application/vnd.github+json', + 'user-agent': 'agentworkforce-skill-cache' + }; + // Conditional GET: GitHub returns the blob SHA as the ETag, so an + // unchanged file comes back 304 with no body — the cheapest possible + // "did it move?" check. + if (knownSha) headers['if-none-match'] = `"${knownSha}"`; + const res = await deps.fetchImpl(blobUrl, { signal, headers }); + if (res.status === 304) return { sha: knownSha ?? null, unchanged: true }; + if (!res.ok) return { sha: null, unchanged: false }; + const body = (await res.json()) as { sha?: unknown }; + const sha = typeof body?.sha === 'string' ? body.sha : null; + return { sha, unchanged: sha !== null && sha === knownSha }; + } catch { + return { sha: null, unchanged: false }; + } finally { + cancel(); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * After a successful install, derive each skill's upstream identity from the + * lockfiles the installers wrote into `cacheDir`. Returns a map keyed by + * skill id; a skill maps to `undefined` when it has no usable upstream + * representation (local `.md` files — already covered by the fingerprint). + * + * skill.sh entries require one extra GitHub GET apiece to resolve the current + * blob SHA; that cost is paid on the (already slow) install path, not on + * cache-hit launches. + */ +export async function buildUpstreamRecordsFromCacheDir( + cacheDir: string, + skills: readonly { id: string; source: string }[], + deps?: ProbeDeps +): Promise> { + const resolved: Required = { + fetchImpl: deps?.fetchImpl ?? globalThis.fetch, + timeoutMs: deps?.timeoutMs ?? DEFAULT_TIMEOUT_MS + }; + const prpmVersions = readPrpmLockVersions(cacheDir); + const skillsLock = readSkillsLock(cacheDir); + const out = new Map(); + + await Promise.all( + skills.map(async (skill) => { + let parsed; + try { + parsed = resolveSkillSource(skill.source); + } catch { + out.set(skill.id, undefined); + return; + } + if (parsed.kind === 'prpm') { + const version = prpmVersions.get(parsed.packageRef); + out.set( + skill.id, + version + ? { kind: 'prpm', packageRef: parsed.packageRef, version } + : undefined + ); + return; + } + if (parsed.kind === 'skill.sh') { + const lockEntry = skillsLock[parsed.installedName]; + const blobUrl = + lockEntry?.skillPath !== undefined + ? buildGithubBlobUrl(parsed.packageRef, lockEntry.skillPath) + : null; + if (!blobUrl) { + out.set(skill.id, undefined); + return; + } + const { sha } = await probeGithubBlobSha(blobUrl, undefined, resolved); + out.set( + skill.id, + sha ? { kind: 'github-blob', blobUrl, sha } : undefined + ); + return; + } + // local + anything else: no upstream identity. + out.set(skill.id, undefined); + }) + ); + return out; +} + +/** + * Re-probe each skill that has a recorded upstream identity and report + * whether any moved. Fail-open per skill: probe errors count as "no drift". + * + * Skills with no `upstream` record are skipped — either local files (covered + * by the fingerprint) or skills whose install-time probe failed. A skill that + * SHOULD have an upstream but doesn't (e.g. a v1 marker upgraded in place) is + * reported as drifted so the reinstall captures its identity for next time. + */ +export async function detectSkillUpstreamDrift( + marker: SkillCacheMarker, + deps?: ProbeDeps +): Promise { + const resolved: Required = { + fetchImpl: deps?.fetchImpl ?? globalThis.fetch, + timeoutMs: deps?.timeoutMs ?? DEFAULT_TIMEOUT_MS + }; + + const details = await Promise.all( + marker.skills.map(async (skill): Promise => { + const upstream = skill.upstream; + if (!upstream) { + // Distinguish "remote skill missing its record" (force reinstall to + // capture it) from "local skill, nothing to probe" (skip). We treat a + // skill whose source resolves to prpm/skill.sh but has no record as + // drifted; local/unknown sources are skipped. + let remote = false; + try { + const k = resolveSkillSource(skill.source).kind; + remote = k === 'prpm' || k === 'skill.sh'; + } catch { + remote = false; + } + if (!remote) return null; + return { + skillId: skill.id, + source: skill.source, + kind: 'prpm', + drifted: true, + note: 'no upstream record (pre-v2 marker) — reinstall to capture identity' + }; + } + + if (upstream.kind === 'prpm') { + const current = await probePrpmLatestVersion(upstream.packageRef, resolved); + if (current === null) { + return { + skillId: skill.id, + source: skill.source, + kind: 'prpm', + drifted: false, + note: `probe failed (fail-open); staying on ${upstream.version}` + }; + } + const drifted = current !== upstream.version; + return { + skillId: skill.id, + source: skill.source, + kind: 'prpm', + drifted, + note: drifted + ? `prpm ${upstream.version} → ${current}` + : `prpm ${upstream.version} (current)` + }; + } + + // github-blob + const { sha, unchanged } = await probeGithubBlobSha( + upstream.blobUrl, + upstream.sha, + resolved + ); + if (unchanged) { + return { + skillId: skill.id, + source: skill.source, + kind: 'github-blob', + drifted: false, + note: `github blob ${upstream.sha.slice(0, 12)} (unchanged)` + }; + } + if (sha === null) { + return { + skillId: skill.id, + source: skill.source, + kind: 'github-blob', + drifted: false, + note: 'probe failed (fail-open); staying on cached blob' + }; + } + return { + skillId: skill.id, + source: skill.source, + kind: 'github-blob', + drifted: true, + note: `github blob ${upstream.sha.slice(0, 12)} → ${sha.slice(0, 12)}` + }; + }) + ); + + const filtered = details.filter((d): d is SkillDriftDetail => d !== null); + return { + drifted: filtered.some((d) => d.drifted), + details: filtered + }; +} + +/** + * Parse a duration like `24h`, `30m`, `90s`, `0`, or `never` into + * milliseconds. Returns: + * - a positive number → TTL window + * - `0` → always check (every launch) + * - `null` → never check (drift detection disabled) + * - `undefined` → unparseable (caller applies its own default) + */ +export function parseCheckInterval( + raw: string | undefined +): number | null | undefined { + if (raw === undefined) return undefined; + const v = raw.trim().toLowerCase(); + if (v === '') return undefined; + if (v === 'never' || v === 'off' || v === 'false') return null; + if (v === '0') return 0; + const m = v.match(/^(\d+)\s*(ms|s|m|h|d)?$/); + if (!m) return undefined; + const n = Number(m[1]); + switch (m[2]) { + case 'ms': + return n; + case 's': + return n * 1000; + case 'm': + return n * 60_000; + case 'd': + return n * 86_400_000; + case 'h': + case undefined: // a bare number means hours + return n * 3_600_000; + default: + return undefined; + } +} + +/** + * Decide whether a drift check is due given the marker's last check time and + * the configured interval (ms; `0` = always, `null` = never). + */ +export function isUpstreamCheckDue( + marker: SkillCacheMarker, + intervalMs: number | null, + now: number = Date.now() +): boolean { + if (intervalMs === null) return false; + if (intervalMs === 0) return true; + if (!marker.lastUpstreamCheckAt) return true; + const last = Date.parse(marker.lastUpstreamCheckAt); + if (Number.isNaN(last)) return true; + return now - last >= intervalMs; +}