@hasna/skills 0.1.63 → 0.1.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +61 -22
  2. package/bin/index.js +2120 -670
  3. package/bin/mcp.js +555 -249
  4. package/bin/migrate.js +229 -33
  5. package/bin/server.js +1542 -327
  6. package/bin/worker.js +716 -207
  7. package/dist/cli/commands/registry-reconcile.d.ts +2 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +682 -276
  10. package/dist/lib/agent-sync.d.ts +26 -6
  11. package/dist/lib/auth-store.d.ts +37 -0
  12. package/dist/lib/config.d.ts +51 -0
  13. package/dist/lib/home-census.d.ts +4 -0
  14. package/dist/lib/home-migration.d.ts +8 -9
  15. package/dist/lib/native-storage.d.ts +29 -1
  16. package/dist/lib/portable-skills.d.ts +49 -6
  17. package/dist/lib/pull.d.ts +31 -0
  18. package/dist/lib/registry-reconcile.d.ts +114 -0
  19. package/dist/lib/registry-types.d.ts +9 -0
  20. package/dist/lib/registry.d.ts +7 -4
  21. package/dist/lib/remote-client.d.ts +118 -1
  22. package/dist/lib/remote-registry.d.ts +26 -0
  23. package/dist/lib/revision.d.ts +29 -0
  24. package/dist/lib/run-routing.d.ts +60 -0
  25. package/dist/sdk/index.js +14412 -13338
  26. package/dist/server/app.d.ts +4 -1
  27. package/dist/server/config.d.ts +12 -2
  28. package/dist/server/rows.d.ts +2 -1
  29. package/dist/server/skills-api.d.ts +108 -6
  30. package/dist/server/sqlite-store.d.ts +20 -3
  31. package/dist/server/store-fixtures.d.ts +6 -0
  32. package/dist/server/store.d.ts +31 -5
  33. package/dist/server/types.d.ts +98 -3
  34. package/dist/storage.d.ts +1 -1
  35. package/dist/storage.js +57 -2
  36. package/migrations/postgres/0004_hosted_pins.sql +28 -0
  37. package/migrations/postgres/0005_revision_tombstone_registry.sql +37 -0
  38. package/migrations/postgres/0005_tag_projection.sql +36 -0
  39. package/migrations/sqlite/0004_hosted_pins.sql +21 -0
  40. package/migrations/sqlite/0005_revision_tombstone_registry.sql +18 -0
  41. package/migrations/sqlite/0005_tag_projection.sql +25 -0
  42. package/package.json +3 -2
package/bin/server.js CHANGED
@@ -22936,6 +22936,119 @@ __export(exports_dist_es8, {
22936
22936
  var init_dist_es9 = __esm(() => {
22937
22937
  init_fromIni();
22938
22938
  });
22939
+ // package.json
22940
+ var package_default = {
22941
+ name: "@hasna/skills",
22942
+ version: "0.1.66",
22943
+ description: "Skills library for AI coding agents",
22944
+ type: "module",
22945
+ bin: {
22946
+ skills: "bin/index.js",
22947
+ "skills-mcp": "bin/mcp.js",
22948
+ "skills-server": "bin/server.js",
22949
+ "skills-worker": "bin/worker.js",
22950
+ "skills-migrate": "bin/migrate.js"
22951
+ },
22952
+ exports: {
22953
+ ".": {
22954
+ import: "./dist/index.js",
22955
+ types: "./dist/index.d.ts"
22956
+ },
22957
+ "./storage": {
22958
+ import: "./dist/storage.js",
22959
+ types: "./dist/storage.d.ts"
22960
+ },
22961
+ "./sdk": {
22962
+ import: "./dist/sdk/index.js",
22963
+ types: "./dist/sdk/index.d.ts"
22964
+ }
22965
+ },
22966
+ files: [
22967
+ "dist/",
22968
+ "!dist/**/*.test.d.ts",
22969
+ "!dist/test-preload.d.ts",
22970
+ "!dist/platform",
22971
+ "bin/",
22972
+ "migrations/",
22973
+ "docs/skill-standard.md",
22974
+ "schemas/",
22975
+ "LICENSE",
22976
+ "README.md"
22977
+ ],
22978
+ main: "./dist/index.js",
22979
+ types: "./dist/index.d.ts",
22980
+ scripts: {
22981
+ clean: "rm -rf bin/ dist/",
22982
+ build: "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist",
22983
+ test: "bun test --timeout 30000",
22984
+ dev: "bun run ./src/cli/index.tsx",
22985
+ "dev:watch": "bun --watch run ./src/cli/index.tsx",
22986
+ "dev:mcp": "bun --watch run ./src/mcp/index.ts",
22987
+ "dev:server": "bun --watch run ./src/server/index.ts",
22988
+ "dev:worker": "bun --watch run ./src/server/worker.ts",
22989
+ migrate: "bun run ./src/server/migrate.ts",
22990
+ typecheck: "tsc --noEmit",
22991
+ "verify:release": "bun run scripts/release-guard.ts",
22992
+ prepack: "bun run build && bun run verify:release",
22993
+ prepublishOnly: "bun run typecheck && bun run test",
22994
+ postinstall: "mkdir -p $HOME/.hasna/skills/custom 2>/dev/null || true"
22995
+ },
22996
+ keywords: [
22997
+ "skills",
22998
+ "ai",
22999
+ "agent",
23000
+ "cli",
23001
+ "typescript",
23002
+ "bun",
23003
+ "claude",
23004
+ "codex",
23005
+ "gemini",
23006
+ "mcp",
23007
+ "model-context-protocol",
23008
+ "open-source",
23009
+ "skills-library",
23010
+ "automation"
23011
+ ],
23012
+ author: "Hasna",
23013
+ license: "Apache-2.0",
23014
+ devDependencies: {
23015
+ "@types/bun": "latest",
23016
+ "@types/node": "25.2.3",
23017
+ "@types/react": "^18.2.0",
23018
+ "bun-types": "1.3.14",
23019
+ "react-devtools-core": "^7.0.1",
23020
+ typescript: "^5"
23021
+ },
23022
+ dependencies: {
23023
+ "@aws-sdk/client-ecs": "^3.1079.0",
23024
+ "@aws-sdk/client-s3": "^3.1079.0",
23025
+ "@hasna/events": "0.1.16",
23026
+ "@modelcontextprotocol/sdk": "^1.26.0",
23027
+ chalk: "^5.3.0",
23028
+ commander: "^12.1.0",
23029
+ ink: "^5.0.1",
23030
+ "ink-select-input": "^6.0.0",
23031
+ "ink-spinner": "^5.0.0",
23032
+ "ink-text-input": "^6.0.0",
23033
+ react: "^18.2.0",
23034
+ zod: "^4.3.6"
23035
+ },
23036
+ engines: {
23037
+ bun: ">=1.0.0"
23038
+ },
23039
+ publishConfig: {
23040
+ registry: "https://registry.npmjs.org",
23041
+ access: "public"
23042
+ },
23043
+ repository: {
23044
+ type: "git",
23045
+ url: "git+https://github.com/hasna/apps.git"
23046
+ },
23047
+ homepage: "https://github.com/hasna/skills",
23048
+ bugs: {
23049
+ url: "https://github.com/hasna/skills/issues"
23050
+ }
23051
+ };
22939
23052
 
22940
23053
  // src/lib/remote-run-contract.ts
22941
23054
  var REMOTE_SKILL_RUN_CONTRACT_VERSION = 1;
@@ -22955,6 +23068,7 @@ var ANY_SEGMENT_EXCLUDES = new Set([
22955
23068
  ".docker"
22956
23069
  ]);
22957
23070
  var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
23071
+ var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json"]);
22958
23072
  var CREDENTIAL_FILENAMES = new Set([
22959
23073
  ".npmrc",
22960
23074
  ".pypirc",
@@ -31994,7 +32108,7 @@ var WriteGetObjectResponse$ = [
31994
32108
  class CreateSessionCommand extends command(_ep4, _mw0, "CreateSession", CreateSession$) {
31995
32109
  }
31996
32110
  // ../../node_modules/.bun/@aws-sdk+client-s3@3.1106.0/node_modules/@aws-sdk/client-s3/package.json
31997
- var package_default = {
32111
+ var package_default2 = {
31998
32112
  name: "@aws-sdk/client-s3",
31999
32113
  version: "3.1106.0",
32000
32114
  description: "AWS SDK for JavaScript S3 Client for Node.js, Browser and React Native",
@@ -32544,7 +32658,7 @@ var getRuntimeConfig3 = (config) => {
32544
32658
  authSchemePreference: config?.authSchemePreference ?? import_config28.loadConfig(import_httpAuthSchemes3.NODE_AUTH_SCHEME_PREFERENCE_OPTIONS, loaderConfig),
32545
32659
  bodyLengthChecker: config?.bodyLengthChecker ?? import_serde4.calculateBodyLength,
32546
32660
  credentialDefaultProvider: config?.credentialDefaultProvider ?? defaultProvider,
32547
- defaultUserAgentProvider: config?.defaultUserAgentProvider ?? import_client19.createDefaultUserAgentProvider({ serviceId: clientSharedValues.serviceId, clientVersion: package_default.version }),
32661
+ defaultUserAgentProvider: config?.defaultUserAgentProvider ?? import_client19.createDefaultUserAgentProvider({ serviceId: clientSharedValues.serviceId, clientVersion: package_default2.version }),
32548
32662
  disableS3ExpressSessionAuth: config?.disableS3ExpressSessionAuth ?? import_config28.loadConfig($NODE_DISABLE_S3_EXPRESS_SESSION_AUTH_OPTIONS, loaderConfig),
32549
32663
  eventStreamSerdeProvider: config?.eventStreamSerdeProvider ?? import_event_streams.eventStreamSerdeProvider,
32550
32664
  maxAttempts: config?.maxAttempts ?? import_config28.loadConfig(import_retry3.NODE_MAX_ATTEMPT_CONFIG_OPTIONS, config),
@@ -32856,36 +32970,144 @@ function concat(chunks) {
32856
32970
  return merged;
32857
32971
  }
32858
32972
 
32859
- // src/server/auth.ts
32860
- import { createHash as createHash3 } from "crypto";
32861
- function hashApiKey(token) {
32862
- return createHash3("sha256").update(token).digest("hex");
32863
- }
32864
- function bearerToken(request) {
32865
- const header = request.headers.get("authorization") || "";
32866
- const match = header.match(/^Bearer\s+(.+)$/i);
32867
- const token = match?.[1]?.trim();
32868
- return token || null;
32973
+ // src/sdk/governance.ts
32974
+ var DEFAULT_OUTPUT_GOVERNANCE = {
32975
+ defaultVisibility: "private",
32976
+ redactPatterns: [
32977
+ /\bsk-[A-Za-z0-9_-]{8,}\b/g,
32978
+ /\bsk_[A-Za-z0-9_-]{8,}\b/g,
32979
+ /\bgh[opsur]_[A-Za-z0-9_]{8,}\b/g,
32980
+ /\bgithub_pat_[A-Za-z0-9_]{8,}\b/g,
32981
+ /\bnpm_[A-Za-z0-9_]{8,}\b/g,
32982
+ /\bAKIA[A-Z0-9]{12,}\b/g,
32983
+ /\bAIza[A-Za-z0-9_-]{10,}\b/g,
32984
+ /\b[A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|DATABASE_URL)[A-Z0-9_]*\s*[:=]\s*[^ \n\r\t]+/gi,
32985
+ /\bhttps?:\/\/[^ \n\r\t]+X-Amz-Signature=[^ \n\r\t]+/gi
32986
+ ],
32987
+ perOutputBytes: 10 * 1024 * 1024,
32988
+ perRunTotalBytes: 100 * 1024 * 1024,
32989
+ artifactTtlSeconds: 30 * 24 * 60 * 60
32990
+ };
32991
+ var DEFAULT_RUN_QUOTA = {
32992
+ cpu: 1,
32993
+ memoryMB: 2048,
32994
+ durationSeconds: 3600,
32995
+ networkMB: 100,
32996
+ artifactBytes: 100 * 1024 * 1024
32997
+ };
32998
+ var GOVERNANCE_ERROR_CODES = {
32999
+ ARTIFACT_LIMIT_EXCEEDED: "ARTIFACT_LIMIT_EXCEEDED",
33000
+ RUN_ARTIFACT_TOTAL_EXCEEDED: "RUN_ARTIFACT_TOTAL_EXCEEDED",
33001
+ RUN_BUDGET_EXHAUSTED: "RUN_BUDGET_EXHAUSTED",
33002
+ STALE_LEASE_GENERATION: "STALE_LEASE_GENERATION",
33003
+ FENCING_UNSUPPORTED: "FENCING_UNSUPPORTED",
33004
+ EVENT_PAYLOAD_REJECTED: "EVENT_PAYLOAD_REJECTED",
33005
+ SKILL_UNAVAILABLE_OFFLINE: "SKILL_UNAVAILABLE_OFFLINE",
33006
+ REMOTE_REQUIRED: "REMOTE_REQUIRED"
33007
+ };
33008
+
33009
+ class GovernanceError extends Error {
33010
+ code;
33011
+ gate;
33012
+ ceiling;
33013
+ constructor(code, message, options = {}) {
33014
+ super(message);
33015
+ this.name = "GovernanceError";
33016
+ this.code = code;
33017
+ this.gate = options.gate ?? code;
33018
+ this.ceiling = options.ceiling;
33019
+ }
32869
33020
  }
32870
- async function authenticateRequest(store, request) {
32871
- const token = bearerToken(request);
32872
- if (!token)
32873
- return null;
32874
- return store.authenticateApiKeyHash(hashApiKey(token));
33021
+ function runPointersOf(run) {
33022
+ return {
33023
+ runId: run.id,
33024
+ attemptId: run.id,
33025
+ leaseGeneration: run.leaseGeneration,
33026
+ correlationId: run.correlationId
33027
+ };
32875
33028
  }
32876
- function publicPrincipal(partial = {}) {
33029
+
33030
+ // src/sdk/cancel.ts
33031
+ function createCancelService(options) {
33032
+ const store = options.store;
33033
+ const governanceStore = options.governanceStore;
33034
+ const storage = options.storage ?? new ArtifactStorage;
32877
33035
  return {
32878
- apiKeyId: partial.apiKeyId || "key_dev",
32879
- orgId: partial.orgId || "org_dev",
32880
- orgSlug: partial.orgSlug || "dev",
32881
- orgName: partial.orgName || "Development",
32882
- userId: partial.userId || "user_dev",
32883
- email: partial.email || "dev@example.com",
32884
- role: partial.role || "owner",
32885
- scopes: partial.scopes || ["skills:read", "runs:write"]
33036
+ async cancel(principal, runId, requestedBy) {
33037
+ if (!store.transitionRun) {
33038
+ throw new GovernanceError(GOVERNANCE_ERROR_CODES.FENCING_UNSUPPORTED, `cannot cancel run ${runId}: the store has no transitionRun generation fencing`, { gate: "transitionRun" });
33039
+ }
33040
+ const run = await store.getRun(principal, runId);
33041
+ if (!run)
33042
+ throw new GovernanceError(GOVERNANCE_ERROR_CODES.STALE_LEASE_GENERATION, `run ${runId} not found`, { gate: "run" });
33043
+ const terminalStatuses = new Set(["succeeded", "failed", "cancelled", "expired", "refunded"]);
33044
+ if (terminalStatuses.has(run.status)) {
33045
+ const receipt2 = await governanceStore.appendReceipt({
33046
+ kind: "cancel",
33047
+ orgId: run.orgId,
33048
+ runId: run.id,
33049
+ requestedBy,
33050
+ metadata: { outcome: "already-terminal", status: run.status, ...runPointersOf(run) }
33051
+ });
33052
+ return { run, fencedGeneration: run.leaseGeneration, quarantined: [], receipt: receipt2, alreadyTerminal: true };
33053
+ }
33054
+ const fencedGeneration = run.leaseGeneration + 1;
33055
+ const cancelling = await store.transitionRun(runId, { status: "cancel_requested", leaseGeneration: fencedGeneration }, run.leaseGeneration);
33056
+ if (!cancelling) {
33057
+ throw new GovernanceError(GOVERNANCE_ERROR_CODES.STALE_LEASE_GENERATION, `run ${runId} changed while cancelling`, { gate: "transition" });
33058
+ }
33059
+ const artifacts = await store.listArtifacts(principal, runId);
33060
+ const quarantined = [];
33061
+ for (const artifact of artifacts) {
33062
+ const quarantineKey = await storage.moveToQuarantine?.(artifact);
33063
+ if (quarantineKey) {
33064
+ await governanceStore.updateArtifactStorageKey(artifact.id, artifact.orgId, quarantineKey);
33065
+ }
33066
+ await governanceStore.appendReceipt({
33067
+ kind: "quarantine",
33068
+ orgId: artifact.orgId,
33069
+ runId: run.id,
33070
+ artifactId: artifact.id,
33071
+ requestedBy,
33072
+ metadata: { relativePath: artifact.relativePath, byteSize: artifact.byteSize, quarantineKey: quarantineKey ?? null }
33073
+ });
33074
+ quarantined.push({ ...artifact, ...quarantineKey ? { storageKey: quarantineKey } : {} });
33075
+ }
33076
+ const cancelled = await store.transitionRun(runId, { status: "cancelled", completedAt: new Date().toISOString() }, fencedGeneration);
33077
+ if (!cancelled) {
33078
+ throw new GovernanceError(GOVERNANCE_ERROR_CODES.STALE_LEASE_GENERATION, `run ${runId} moved on while finalising cancellation`, { gate: "transition" });
33079
+ }
33080
+ const receipt = await governanceStore.appendReceipt({
33081
+ kind: "cancel",
33082
+ orgId: run.orgId,
33083
+ runId: run.id,
33084
+ requestedBy,
33085
+ metadata: {
33086
+ fencedGeneration,
33087
+ artifactCount: quarantined.length,
33088
+ pointers: runPointersOf(cancelled)
33089
+ }
33090
+ });
33091
+ return { run: cancelled, fencedGeneration, quarantined, receipt, alreadyTerminal: false };
33092
+ }
32886
33093
  };
32887
33094
  }
32888
33095
 
33096
+ // src/sdk/governance-store.ts
33097
+ import { Database as Database2 } from "bun:sqlite";
33098
+ import { randomUUID as randomUUID3 } from "crypto";
33099
+ import { mkdirSync as mkdirSync3 } from "fs";
33100
+ import { dirname as dirname5 } from "path";
33101
+
33102
+ // src/server/database-url.ts
33103
+ import { isAbsolute, join as join3 } from "path";
33104
+ import { fileURLToPath } from "url";
33105
+
33106
+ // src/lib/config.ts
33107
+ import { existsSync, readFileSync as readFileSync2, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
33108
+ import { join as join2, dirname as dirname2 } from "path";
33109
+ import { homedir as homedir2 } from "os";
33110
+
32889
33111
  // src/lib/retired-settings.ts
32890
33112
  var RETIRED_ENV_SUFFIXES = ["_STORAGE_MODE", "_DEPLOYMENT_MODE", "_CLOUD_MODE"];
32891
33113
  var RETIRED_CONFIG_KEYS = {
@@ -32932,53 +33154,7 @@ function assertNoRetiredConfigKeys(config, source) {
32932
33154
  }
32933
33155
  }
32934
33156
 
32935
- // src/server/config.ts
32936
- var DATABASE_URL_ENV = "HASNA_SKILLS_DATABASE_URL";
32937
- var SKILLS_ENV_NAMESPACE = "SKILLS";
32938
- function resolveServerConfig(env = process.env) {
32939
- assertNoRetiredModeEnvVars(env, {
32940
- app: SKILLS_ENV_NAMESPACE,
32941
- replacement: DATABASE_URL_ENV
32942
- });
32943
- const nodeEnv = env.NODE_ENV || "development";
32944
- const host = env.HOST || env.SKILLS_HOST || "0.0.0.0";
32945
- const port = parsePositiveInt(env.PORT || env.SKILLS_PORT, 8787);
32946
- return {
32947
- host,
32948
- port,
32949
- databaseUrl: env[DATABASE_URL_ENV] || env.DATABASE_URL || undefined,
32950
- bootstrapApiKey: env.HASNA_SKILLS_BOOTSTRAP_API_KEY || undefined,
32951
- artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
32952
- artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
32953
- inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
32954
- bundleSigningKey: env.HASNA_SKILLS_SIGNING_KEY || undefined,
32955
- requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
32956
- skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
32957
- publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
32958
- nodeEnv,
32959
- allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
32960
- };
32961
- }
32962
- function localOrigin(host, port) {
32963
- const hostname = host === "0.0.0.0" || host === "::" ? "localhost" : host;
32964
- return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`;
32965
- }
32966
- function parsePositiveInt(value, fallback) {
32967
- const parsed = Number.parseInt(value ?? "", 10);
32968
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
32969
- }
32970
- function normalizePrefix(value) {
32971
- return value.replace(/^\/+|\/+$/g, "") || "skills/artifacts";
32972
- }
32973
-
32974
- // src/server/database-url.ts
32975
- import { isAbsolute, join as join3 } from "path";
32976
- import { fileURLToPath } from "url";
32977
-
32978
33157
  // src/lib/config.ts
32979
- import { existsSync, readFileSync as readFileSync2, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "fs";
32980
- import { join as join2, dirname as dirname2 } from "path";
32981
- import { homedir as homedir2 } from "os";
32982
33158
  var ENUM_KEYS = {
32983
33159
  defaultAgent: ["claude", "codex", "gemini", "pi", "opencode", "all"],
32984
33160
  defaultScope: ["global", "project"],
@@ -33031,6 +33207,11 @@ function normalizeConfigValue(key, value) {
33031
33207
  }
33032
33208
  var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
33033
33209
  var INSTALLED_SKILLS_DIRNAME = "installed";
33210
+ var SKILLS_CACHE_DIRNAME = "skills";
33211
+ var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
33212
+ function isOwnerLayoutMigrated(appDir) {
33213
+ return existsSync(join2(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
33214
+ }
33034
33215
  function getDataDir() {
33035
33216
  const override = process.env[DATA_DIR_ENV];
33036
33217
  if (override) {
@@ -33153,26 +33334,78 @@ function looksLikeSqlitePath(value) {
33153
33334
  return SQLITE_EXTENSIONS.some((extension) => value.toLowerCase().endsWith(extension));
33154
33335
  }
33155
33336
 
33156
- // src/server/handlers.ts
33157
- import { createHash as createHash4 } from "crypto";
33337
+ // src/server/sqlite-store.ts
33338
+ import { Database } from "bun:sqlite";
33339
+ import { randomUUID as randomUUID2 } from "crypto";
33340
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
33341
+ import { dirname as dirname4, join as join5 } from "path";
33158
33342
 
33159
- // src/server/store.ts
33160
- import { randomUUID as randomUUID3 } from "crypto";
33343
+ // src/server/auth.ts
33344
+ import { createHash as createHash3 } from "crypto";
33345
+ function hashApiKey(token) {
33346
+ return createHash3("sha256").update(token).digest("hex");
33347
+ }
33348
+ function bearerToken(request) {
33349
+ const header = request.headers.get("authorization") || "";
33350
+ const match = header.match(/^Bearer\s+(.+)$/i);
33351
+ const token = match?.[1]?.trim();
33352
+ return token || null;
33353
+ }
33354
+ async function authenticateRequest(store, request) {
33355
+ const token = bearerToken(request);
33356
+ if (!token)
33357
+ return null;
33358
+ return store.authenticateApiKeyHash(hashApiKey(token));
33359
+ }
33360
+ function publicPrincipal(partial = {}) {
33361
+ return {
33362
+ apiKeyId: partial.apiKeyId || "key_dev",
33363
+ orgId: partial.orgId || "org_dev",
33364
+ orgSlug: partial.orgSlug || "dev",
33365
+ orgName: partial.orgName || "Development",
33366
+ userId: partial.userId || "user_dev",
33367
+ email: partial.email || "dev@example.com",
33368
+ role: partial.role || "owner",
33369
+ scopes: partial.scopes || ["skills:read", "runs:write"]
33370
+ };
33371
+ }
33161
33372
 
33162
- // src/server/types.ts
33163
- class StaleLeaseGenerationError extends Error {
33164
- runId;
33165
- expectedGeneration;
33166
- currentGeneration;
33167
- status;
33168
- constructor(runId, expectedGeneration, currentGeneration, status) {
33169
- super(`late transition rejected for run ${runId}: expected lease_generation ${expectedGeneration}, ` + `current ${currentGeneration} (status ${status})`);
33170
- this.name = "StaleLeaseGenerationError";
33171
- this.runId = runId;
33172
- this.expectedGeneration = expectedGeneration;
33173
- this.currentGeneration = currentGeneration;
33174
- this.status = status;
33373
+ // src/server/migrations-dir.ts
33374
+ import { existsSync as existsSync2 } from "fs";
33375
+ import { dirname as dirname3, join as join4 } from "path";
33376
+ var MIGRATION_DIALECTS = ["postgres", "sqlite"];
33377
+ var MAX_WALK_UP = 6;
33378
+ function findMigrationsRoot(startDirs = defaultStartDirs()) {
33379
+ for (const start of startDirs) {
33380
+ let dir = start;
33381
+ for (let level = 0;level < MAX_WALK_UP; level += 1) {
33382
+ const candidate = join4(dir, "migrations");
33383
+ if (MIGRATION_DIALECTS.some((dialect) => existsSync2(join4(candidate, dialect))))
33384
+ return candidate;
33385
+ const parent = dirname3(dir);
33386
+ if (parent === dir)
33387
+ break;
33388
+ dir = parent;
33389
+ }
33390
+ }
33391
+ return null;
33392
+ }
33393
+ function resolveMigrationsDir(dialect, root3 = findMigrationsRoot()) {
33394
+ if (!root3) {
33395
+ throw new Error(`could not locate the migrations directory for dialect "${dialect}". ` + `Expected a migrations/${dialect}/ folder alongside the package root.`);
33396
+ }
33397
+ const dir = join4(root3, dialect);
33398
+ if (!existsSync2(dir)) {
33399
+ throw new Error(`migrations directory not found: ${dir}`);
33175
33400
  }
33401
+ return dir;
33402
+ }
33403
+ function defaultStartDirs() {
33404
+ const dirs = [import.meta.dir];
33405
+ const cwd = process.cwd();
33406
+ if (cwd && cwd !== import.meta.dir)
33407
+ dirs.push(cwd);
33408
+ return dirs;
33176
33409
  }
33177
33410
 
33178
33411
  // src/server/rows.ts
@@ -33255,7 +33488,20 @@ function rowToSkill(row) {
33255
33488
  ...row.bundle_byte_size === null || row.bundle_byte_size === undefined ? {} : { bundleByteSize: Number(row.bundle_byte_size) },
33256
33489
  ...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
33257
33490
  createdAt: dateString(row.created_at),
33258
- updatedAt: dateString(row.updated_at)
33491
+ updatedAt: dateString(row.updated_at),
33492
+ revisionId: String(row.revision_id ?? ""),
33493
+ revisionNumber: Number(row.revision_number ?? 0),
33494
+ ...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
33495
+ ...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
33496
+ };
33497
+ }
33498
+ function rowToPin(row) {
33499
+ return {
33500
+ orgId: String(row.org_id),
33501
+ principal: String(row.principal),
33502
+ slug: String(row.slug),
33503
+ pinnedAt: dateString(row.pinned_at),
33504
+ metadata: parseJsonObject(row.metadata_json)
33259
33505
  };
33260
33506
  }
33261
33507
  function rowToSkillBundle(row) {
@@ -33307,53 +33553,62 @@ function dateString(value) {
33307
33553
  return String(value);
33308
33554
  }
33309
33555
 
33310
- // src/server/sqlite-store.ts
33311
- import { Database } from "bun:sqlite";
33312
- import { randomUUID as randomUUID2 } from "crypto";
33313
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
33314
- import { dirname as dirname4, join as join5 } from "path";
33315
-
33316
- // src/server/migrations-dir.ts
33317
- import { existsSync as existsSync2 } from "fs";
33318
- import { dirname as dirname3, join as join4 } from "path";
33319
- var MIGRATION_DIALECTS = ["postgres", "sqlite"];
33320
- var MAX_WALK_UP = 6;
33321
- function findMigrationsRoot(startDirs = defaultStartDirs()) {
33322
- for (const start of startDirs) {
33323
- let dir = start;
33324
- for (let level = 0;level < MAX_WALK_UP; level += 1) {
33325
- const candidate = join4(dir, "migrations");
33326
- if (MIGRATION_DIALECTS.some((dialect) => existsSync2(join4(candidate, dialect))))
33327
- return candidate;
33328
- const parent = dirname3(dir);
33329
- if (parent === dir)
33330
- break;
33331
- dir = parent;
33332
- }
33556
+ // src/server/types.ts
33557
+ class StaleLeaseGenerationError extends Error {
33558
+ runId;
33559
+ expectedGeneration;
33560
+ currentGeneration;
33561
+ status;
33562
+ constructor(runId2, expectedGeneration, currentGeneration, status) {
33563
+ super(`late transition rejected for run ${runId2}: expected lease_generation ${expectedGeneration}, ` + `current ${currentGeneration} (status ${status})`);
33564
+ this.name = "StaleLeaseGenerationError";
33565
+ this.runId = runId2;
33566
+ this.expectedGeneration = expectedGeneration;
33567
+ this.currentGeneration = currentGeneration;
33568
+ this.status = status;
33333
33569
  }
33334
- return null;
33335
33570
  }
33336
- function resolveMigrationsDir(dialect, root3 = findMigrationsRoot()) {
33337
- if (!root3) {
33338
- throw new Error(`could not locate the migrations directory for dialect "${dialect}". ` + `Expected a migrations/${dialect}/ folder alongside the package root.`);
33571
+
33572
+ class SkillRevisionConflictError extends Error {
33573
+ slug;
33574
+ expectedRevisionId;
33575
+ currentRevisionId;
33576
+ constructor(slug, expectedRevisionId, currentRevisionId) {
33577
+ super(`revision conflict for '${slug}': expected revision ${expectedRevisionId ?? "(none)"}, ` + `current is ${currentRevisionId ?? "(none)"}. Refused rather than silently overwriting a newer revision.`);
33578
+ this.name = "SkillRevisionConflictError";
33579
+ this.slug = slug;
33580
+ this.expectedRevisionId = expectedRevisionId;
33581
+ this.currentRevisionId = currentRevisionId;
33339
33582
  }
33340
- const dir = join4(root3, dialect);
33341
- if (!existsSync2(dir)) {
33342
- throw new Error(`migrations directory not found: ${dir}`);
33343
- }
33344
- return dir;
33345
33583
  }
33346
- function defaultStartDirs() {
33347
- const dirs = [import.meta.dir];
33348
- const cwd = process.cwd();
33349
- if (cwd && cwd !== import.meta.dir)
33350
- dirs.push(cwd);
33351
- return dirs;
33584
+
33585
+ // src/lib/revision.ts
33586
+ import { createHash as createHash4 } from "crypto";
33587
+ var REVISION_ID_PATTERN = /^[0-9a-f]{64}$/;
33588
+ function revisionIdOf(content) {
33589
+ const canonical = JSON.stringify({
33590
+ slug: content.slug,
33591
+ displayName: content.displayName,
33592
+ description: content.description,
33593
+ category: content.category,
33594
+ tags: content.tags,
33595
+ source: content.source,
33596
+ kind: content.kind,
33597
+ version: content.version ?? null,
33598
+ skillMd: content.skillMd ?? null,
33599
+ bundleSha256: content.bundleSha256 ?? null,
33600
+ bundleByteSize: content.bundleByteSize ?? null
33601
+ });
33602
+ return createHash4("sha256").update(canonical).digest("hex");
33603
+ }
33604
+ function revisionIdOfRecord(record) {
33605
+ return revisionIdOf(record);
33352
33606
  }
33353
33607
 
33354
33608
  // src/server/sqlite-store.ts
33355
33609
  var CLAIM_ATTEMPTS = 8;
33356
33610
  var CLAIMABLE_STATUSES = ["queued", "retrying"];
33611
+ var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
33357
33612
  var LAST_USED_RESOLUTION_MS = 60000;
33358
33613
 
33359
33614
  class SqliteSkillsStore {
@@ -33373,12 +33628,24 @@ class SqliteSkillsStore {
33373
33628
  if (options.migrate !== false) {
33374
33629
  applySqliteMigrations(this.db, options.migrationsDir);
33375
33630
  }
33631
+ this.backfillLegacyRevisions();
33376
33632
  this.backend = {
33377
33633
  kind: "sqlite",
33378
33634
  durable: !inMemory,
33379
33635
  label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
33380
33636
  };
33381
33637
  }
33638
+ backfillLegacyRevisions() {
33639
+ const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
33640
+ for (const row of rows) {
33641
+ const record = rowToSkill(row);
33642
+ this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
33643
+ revisionIdOfRecord(record),
33644
+ record.orgId,
33645
+ record.slug
33646
+ ]);
33647
+ }
33648
+ }
33382
33649
  get database() {
33383
33650
  return this.db;
33384
33651
  }
@@ -33602,8 +33869,29 @@ class SqliteSkillsStore {
33602
33869
  const orgId = input.principal.orgId;
33603
33870
  const now = nowIso();
33604
33871
  return this.db.transaction(() => {
33605
- const previous = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33872
+ const previous = this.get("SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33606
33873
  const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
33874
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
33875
+ const tombstoned = previous?.tombstoned_at != null;
33876
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
33877
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
33878
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
33879
+ }
33880
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
33881
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
33882
+ const revisionId = revisionIdOfRecord({
33883
+ slug: input.slug,
33884
+ displayName: input.displayName,
33885
+ description: input.description,
33886
+ category: input.category,
33887
+ tags: input.tags,
33888
+ source: input.source,
33889
+ kind: input.kind,
33890
+ ...input.version ? { version: input.version } : {},
33891
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
33892
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
33893
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
33894
+ });
33607
33895
  if (input.bundle) {
33608
33896
  this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
33609
33897
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -33624,8 +33912,8 @@ class SqliteSkillsStore {
33624
33912
  ]);
33625
33913
  }
33626
33914
  const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
33627
- bundle_sha256, bundle_byte_size, published_by_user_id, created_at, updated_at)
33628
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
33915
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
33916
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
33629
33917
  ON CONFLICT (org_id, slug) DO UPDATE SET
33630
33918
  display_name = excluded.display_name,
33631
33919
  description = excluded.description,
@@ -33643,7 +33931,16 @@ class SqliteSkillsStore {
33643
33931
  bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
33644
33932
  bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
33645
33933
  published_by_user_id = excluded.published_by_user_id,
33934
+ revision_id = excluded.revision_id,
33935
+ -- Current + 1, not the inserted 1: on the update path the ACTUAL row's
33936
+ -- counter is the truth (it may have advanced since the pre-read, which is
33937
+ -- exactly what the WHERE guard below detects).
33938
+ revision_number = skills_registry.revision_number + 1,
33939
+ tombstoned_at = NULL,
33940
+ tombstone_purge_after = NULL,
33646
33941
  updated_at = excluded.updated_at
33942
+ WHERE skills_registry.tombstoned_at IS NOT NULL
33943
+ OR skills_registry.revision_id = ?
33647
33944
  RETURNING *`, [
33648
33945
  orgId,
33649
33946
  input.slug,
@@ -33654,62 +33951,165 @@ class SqliteSkillsStore {
33654
33951
  input.source,
33655
33952
  input.kind,
33656
33953
  input.version ?? null,
33657
- input.skillMd ?? null,
33954
+ carriedSkillMd,
33658
33955
  input.bundle?.sha256 ?? null,
33659
33956
  input.bundle?.byteSize ?? null,
33660
33957
  input.principal.userId,
33958
+ revisionId,
33959
+ now,
33661
33960
  now,
33662
- now
33961
+ input.expectedRevisionId ?? NO_REVISION_SENTINEL
33663
33962
  ]);
33963
+ if (!row) {
33964
+ const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33965
+ const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
33966
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
33967
+ }
33664
33968
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
33665
33969
  this.collectOrphanBundle(orgId, previousSha);
33970
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
33971
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
33972
+ for (const tag of input.tags) {
33973
+ if (!tag.trim())
33974
+ continue;
33975
+ insertTag.run(orgId, input.slug, tag);
33976
+ }
33666
33977
  return rowToSkill(row);
33667
33978
  })();
33668
33979
  }
33669
33980
  async listSkills(principal) {
33670
- return this.all("SELECT * FROM skills_registry WHERE org_id = ? ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
33981
+ await this.purgeExpiredTombstones(principal);
33982
+ return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
33671
33983
  }
33672
33984
  async getSkill(principal, slug) {
33673
33985
  const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
33674
33986
  return row ? rowToSkill(row) : null;
33675
33987
  }
33676
- async updateSkill(principal, slug, patch) {
33988
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
33677
33989
  const current = await this.getSkill(principal, slug);
33678
- if (!current)
33990
+ if (!current || current.tombstonedAt)
33679
33991
  return null;
33992
+ if (expectedRevisionId !== current.revisionId) {
33993
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
33994
+ }
33680
33995
  const next = { ...current, ...patch };
33681
- const row = this.get(`UPDATE skills_registry
33682
- SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?, updated_at = ?
33683
- WHERE org_id = ? AND slug = ?
33684
- RETURNING *`, [
33685
- next.displayName,
33686
- next.description,
33687
- next.category,
33688
- JSON.stringify(next.tags),
33689
- next.kind,
33690
- next.version ?? null,
33691
- next.skillMd ?? null,
33692
- nowIso(),
33693
- principal.orgId,
33694
- slug
33695
- ]);
33696
- return row ? rowToSkill(row) : null;
33996
+ return this.db.transaction(() => {
33997
+ const revisionId = revisionIdOfRecord(next);
33998
+ const row = this.get(`UPDATE skills_registry
33999
+ SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
34000
+ revision_id = ?, revision_number = revision_number + 1, updated_at = ?
34001
+ WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
34002
+ RETURNING *`, [
34003
+ next.displayName,
34004
+ next.description,
34005
+ next.category,
34006
+ JSON.stringify(next.tags),
34007
+ next.kind,
34008
+ next.version ?? null,
34009
+ next.skillMd ?? null,
34010
+ revisionId,
34011
+ nowIso(),
34012
+ principal.orgId,
34013
+ slug,
34014
+ current.revisionId
34015
+ ]);
34016
+ if (!row) {
34017
+ const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
34018
+ principal.orgId,
34019
+ slug
34020
+ ]);
34021
+ if (nowRow && nowRow.tombstoned_at == null) {
34022
+ const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
34023
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
34024
+ }
34025
+ return null;
34026
+ }
34027
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
34028
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
34029
+ for (const tag of next.tags) {
34030
+ if (!tag.trim())
34031
+ continue;
34032
+ insertTag.run(principal.orgId, slug, tag);
34033
+ }
34034
+ return rowToSkill(row);
34035
+ })();
33697
34036
  }
33698
- async deleteSkill(principal, slug) {
34037
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
33699
34038
  return this.db.transaction(() => {
33700
- const existing = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
34039
+ const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33701
34040
  if (!existing)
33702
- return false;
33703
- this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
33704
- if (typeof existing.bundle_sha256 === "string")
33705
- this.collectOrphanBundle(principal.orgId, existing.bundle_sha256);
33706
- return true;
34041
+ return null;
34042
+ if (existing.tombstoned_at != null) {
34043
+ const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
34044
+ return rowToSkill(row2);
34045
+ }
34046
+ const tombstonedAt = nowIso();
34047
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
34048
+ const row = this.get(`UPDATE skills_registry
34049
+ SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
34050
+ WHERE org_id = ? AND slug = ?
34051
+ RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
34052
+ return rowToSkill(row);
34053
+ })();
34054
+ }
34055
+ async purgeExpiredTombstones(principal) {
34056
+ return this.db.transaction(() => {
34057
+ const now = nowIso();
34058
+ const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
34059
+ const purged = [];
34060
+ for (const row of expired) {
34061
+ const record = rowToSkill(row);
34062
+ this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
34063
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
34064
+ if (record.bundleSha256)
34065
+ this.collectOrphanBundle(principal.orgId, record.bundleSha256);
34066
+ purged.push(record);
34067
+ }
34068
+ return purged;
33707
34069
  })();
33708
34070
  }
33709
34071
  async getSkillBundle(principal, sha256) {
33710
34072
  const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
33711
34073
  return row ? rowToSkillBundle(row) : null;
33712
34074
  }
34075
+ async pinSkill(principal, slug, metadata = {}) {
34076
+ const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
34077
+ VALUES (?, ?, ?, ?, ?)
34078
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
34079
+ pinned_at = excluded.pinned_at,
34080
+ metadata_json = excluded.metadata_json
34081
+ RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
34082
+ return rowToPin(row);
34083
+ }
34084
+ async unpinSkill(principal, slug) {
34085
+ const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
34086
+ return result.changes > 0;
34087
+ }
34088
+ async listPins(principal) {
34089
+ return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
34090
+ }
34091
+ async listTags(principal) {
34092
+ await this.purgeExpiredTombstones(principal);
34093
+ return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
34094
+ }
34095
+ async listSkillsByTag(principal, tag) {
34096
+ await this.purgeExpiredTombstones(principal);
34097
+ return this.all(`SELECT s.* FROM skills_registry s
34098
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
34099
+ WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
34100
+ ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
34101
+ }
34102
+ async listPinsByTag(principal, tag) {
34103
+ await this.purgeExpiredTombstones(principal);
34104
+ return this.all(`SELECT p.* FROM skills_pins p
34105
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
34106
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
34107
+ WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
34108
+ ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
34109
+ }
34110
+ async listPublishedSlugs(principal) {
34111
+ return this.all("SELECT slug FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map((row) => String(row.slug));
34112
+ }
33713
34113
  collectOrphanBundle(orgId, sha256) {
33714
34114
  const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
33715
34115
  if (referenced)
@@ -33795,7 +34195,316 @@ function parseScopes(value) {
33795
34195
  }
33796
34196
  }
33797
34197
 
34198
+ // src/sdk/governance-store.ts
34199
+ function receiptId() {
34200
+ return `rcpt_${Date.now().toString(36)}_${randomUUID3().replace(/-/g, "").slice(0, 10)}`;
34201
+ }
34202
+ function reservationId() {
34203
+ return `res_${Date.now().toString(36)}_${randomUUID3().replace(/-/g, "").slice(0, 10)}`;
34204
+ }
34205
+ var EXPIRED_ARTIFACT_SQL = `
34206
+ SELECT a.* FROM skills_artifacts a
34207
+ JOIN skills_runs r ON r.id = a.run_id
34208
+ WHERE a.expires_at IS NOT NULL AND a.expires_at <= ?
34209
+ ORDER BY a.expires_at ASC
34210
+ `;
34211
+ var ACTIVE_RUN_SQL = `
34212
+ SELECT COUNT(*) AS n FROM skills_runs
34213
+ WHERE org_id = ? AND status IN ('queued','running','cancel_requested')
34214
+ `;
34215
+ class SqliteGovernanceStore {
34216
+ backend = "sqlite";
34217
+ db;
34218
+ closed = false;
34219
+ constructor(path = SQLITE_MEMORY_PATH, options = {}) {
34220
+ if (path !== SQLITE_MEMORY_PATH)
34221
+ mkdirSync3(dirname5(path), { recursive: true });
34222
+ this.db = new Database2(path, { create: true, readwrite: true });
34223
+ this.db.exec("PRAGMA busy_timeout = 5000");
34224
+ this.db.exec("PRAGMA foreign_keys = ON");
34225
+ if (options.migrate !== false)
34226
+ applySqliteMigrations(this.db);
34227
+ }
34228
+ async close() {
34229
+ if (this.closed)
34230
+ return;
34231
+ this.closed = true;
34232
+ this.db.close(false);
34233
+ }
34234
+ get database() {
34235
+ return this.db;
34236
+ }
34237
+ async appendReceipt(receipt) {
34238
+ const next = { ...receipt, id: receiptId(), createdAt: nowIso() };
34239
+ this.db.run(`INSERT INTO skills_lifecycle_receipts (id, kind, org_id, run_id, artifact_id, requested_by, metadata_json, created_at)
34240
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [next.id, next.kind, next.orgId, next.runId, next.artifactId ?? null, next.requestedBy, JSON.stringify(next.metadata), next.createdAt]);
34241
+ return next;
34242
+ }
34243
+ async listReceipts(orgId, runId2) {
34244
+ const rows = this.db.query("SELECT * FROM skills_lifecycle_receipts WHERE org_id = ? AND run_id = ? ORDER BY created_at ASC, rowid ASC").all(orgId, runId2);
34245
+ return rows.map((row) => ({
34246
+ id: String(row.id),
34247
+ kind: String(row.kind),
34248
+ orgId: String(row.org_id),
34249
+ runId: String(row.run_id),
34250
+ ...typeof row.artifact_id === "string" ? { artifactId: row.artifact_id } : {},
34251
+ requestedBy: String(row.requested_by),
34252
+ metadata: parseJsonObject(row.metadata_json),
34253
+ createdAt: String(row.created_at)
34254
+ }));
34255
+ }
34256
+ async createReservation(input) {
34257
+ const reservation = { ...input, id: reservationId(), status: "reserved", createdAt: nowIso() };
34258
+ this.db.run(`INSERT INTO skills_credit_reservations (id, org_id, run_id, estimated_cents, status, created_at)
34259
+ VALUES (?, ?, ?, ?, ?, ?)`, [reservation.id, reservation.orgId, reservation.runId, reservation.estimatedCents, reservation.status, reservation.createdAt]);
34260
+ return reservation;
34261
+ }
34262
+ async reservationsForRun(orgId, runId2) {
34263
+ const rows = this.db.query("SELECT * FROM skills_credit_reservations WHERE org_id = ? AND run_id = ? ORDER BY created_at ASC, rowid ASC").all(orgId, runId2);
34264
+ return rows.map((row) => this.reservationFrom(row));
34265
+ }
34266
+ async reconcileReservation(reservationId2, actualCents, status) {
34267
+ const row = this.db.query("SELECT * FROM skills_credit_reservations WHERE id = ? LIMIT 1").get(reservationId2);
34268
+ if (!row || String(row.status) !== "reserved")
34269
+ return row ? this.reservationFrom(row) : null;
34270
+ const reconciledAt = nowIso();
34271
+ this.db.run("UPDATE skills_credit_reservations SET actual_cents = ?, status = ?, reconciled_at = ? WHERE id = ?", [actualCents, status, reconciledAt, reservationId2]);
34272
+ const updated = this.db.query("SELECT * FROM skills_credit_reservations WHERE id = ? LIMIT 1").get(reservationId2);
34273
+ return this.reservationFrom(updated);
34274
+ }
34275
+ async monthlySpendCents(orgId, monthPrefix) {
34276
+ const from = `${monthPrefix}-01T00:00:00.000Z`;
34277
+ const nextMonth = nextMonthPrefix(monthPrefix);
34278
+ const spent = this.db.query("SELECT COALESCE(SUM(cost_cents), 0) AS spent FROM skills_runs WHERE org_id = ? AND created_at >= ? AND created_at < ?").get(orgId, from, `${nextMonth}-01T00:00:00.000Z`);
34279
+ const pending = this.db.query("SELECT COALESCE(SUM(estimated_cents), 0) AS pending FROM skills_credit_reservations WHERE org_id = ? AND status = 'reserved'").get(orgId);
34280
+ return Number(spent.spent ?? 0) + Number(pending.pending ?? 0);
34281
+ }
34282
+ async activeRunCount(orgId) {
34283
+ const row = this.db.query(ACTIVE_RUN_SQL).get(orgId);
34284
+ return Number(row.n ?? 0);
34285
+ }
34286
+ async listExpiredArtifacts(at2) {
34287
+ const rows = this.db.query(EXPIRED_ARTIFACT_SQL).all(at2);
34288
+ return rows.map(rowToArtifact);
34289
+ }
34290
+ async deleteArtifactRow(artifactId2, orgId) {
34291
+ const result = this.db.run("DELETE FROM skills_artifacts WHERE id = ? AND org_id = ?", [artifactId2, orgId]);
34292
+ return result.changes === 1;
34293
+ }
34294
+ async updateArtifactStorageKey(artifactId2, orgId, storageKey) {
34295
+ const result = this.db.run("UPDATE skills_artifacts SET storage_key = ? WHERE id = ? AND org_id = ?", [storageKey, artifactId2, orgId]);
34296
+ return result.changes === 1;
34297
+ }
34298
+ reservationFrom(row) {
34299
+ return {
34300
+ id: String(row.id),
34301
+ orgId: String(row.org_id),
34302
+ runId: String(row.run_id),
34303
+ estimatedCents: Number(row.estimated_cents ?? 0),
34304
+ ...row.actual_cents !== null && row.actual_cents !== undefined ? { actualCents: Number(row.actual_cents) } : {},
34305
+ status: String(row.status),
34306
+ createdAt: String(row.created_at),
34307
+ ...typeof row.reconciled_at === "string" ? { reconciledAt: row.reconciled_at } : {}
34308
+ };
34309
+ }
34310
+ }
34311
+
34312
+ class PostgresGovernanceStore {
34313
+ backend = "postgres";
34314
+ sql;
34315
+ constructor(databaseUrl) {
34316
+ const bunWithSql = Bun;
34317
+ this.sql = new bunWithSql.SQL(databaseUrl, { max: 2 });
34318
+ }
34319
+ async close() {
34320
+ await this.sql.close?.();
34321
+ }
34322
+ async withContext(orgId, worker, fn) {
34323
+ return this.sql.begin(async (tx) => {
34324
+ await tx`SELECT set_config('app.skills_org_id', ${orgId ?? ""}, true)`;
34325
+ await tx`SELECT set_config('app.skills_claim_context', ${worker ? "worker" : ""}, true)`;
34326
+ return await fn(tx);
34327
+ });
34328
+ }
34329
+ async appendReceipt(receipt) {
34330
+ const next = { ...receipt, id: receiptId(), createdAt: nowIso() };
34331
+ await this.sql`
34332
+ INSERT INTO skills_lifecycle_receipts (id, kind, org_id, run_id, artifact_id, requested_by, metadata_json)
34333
+ VALUES (${next.id}, ${next.kind}, ${next.orgId}, ${next.runId}, ${next.artifactId ?? null}, ${next.requestedBy}, ${JSON.stringify(next.metadata)}::jsonb)
34334
+ `;
34335
+ return next;
34336
+ }
34337
+ async listReceipts(orgId, runId2) {
34338
+ const rows = await this.sql`
34339
+ SELECT * FROM skills_lifecycle_receipts WHERE org_id = ${orgId} AND run_id = ${runId2} ORDER BY created_at ASC
34340
+ `;
34341
+ return rows.map((row) => ({
34342
+ id: String(row.id),
34343
+ kind: String(row.kind),
34344
+ orgId: String(row.org_id),
34345
+ runId: String(row.run_id),
34346
+ ...typeof row.artifact_id === "string" ? { artifactId: row.artifact_id } : {},
34347
+ requestedBy: String(row.requested_by),
34348
+ metadata: parseJsonObject(row.metadata_json),
34349
+ createdAt: String(row.created_at)
34350
+ }));
34351
+ }
34352
+ async createReservation(input) {
34353
+ const reservation = { ...input, id: reservationId(), status: "reserved", createdAt: nowIso() };
34354
+ await this.sql`
34355
+ INSERT INTO skills_credit_reservations (id, org_id, run_id, estimated_cents, status)
34356
+ VALUES (${reservation.id}, ${reservation.orgId}, ${reservation.runId}, ${reservation.estimatedCents}, ${reservation.status})
34357
+ `;
34358
+ return reservation;
34359
+ }
34360
+ async reservationsForRun(orgId, runId2) {
34361
+ const rows = await this.sql`
34362
+ SELECT * FROM skills_credit_reservations WHERE org_id = ${orgId} AND run_id = ${runId2} ORDER BY created_at ASC
34363
+ `;
34364
+ return rows.map((row) => this.reservationFrom(row));
34365
+ }
34366
+ async reconcileReservation(reservationId2, actualCents, status) {
34367
+ const rows = await this.sql`
34368
+ UPDATE skills_credit_reservations
34369
+ SET actual_cents = ${actualCents}, status = ${status}, reconciled_at = now()
34370
+ WHERE id = ${reservationId2} AND status = ${"reserved"}
34371
+ RETURNING *
34372
+ `;
34373
+ if (!rows[0]) {
34374
+ const existing = await this.sql`SELECT * FROM skills_credit_reservations WHERE id = ${reservationId2} LIMIT 1`;
34375
+ return existing[0] ? this.reservationFrom(existing[0]) : null;
34376
+ }
34377
+ return this.reservationFrom(rows[0]);
34378
+ }
34379
+ async monthlySpendCents(orgId, monthPrefix) {
34380
+ return this.withContext(null, true, async (tx) => {
34381
+ const rows = await tx`
34382
+ SELECT
34383
+ (SELECT COALESCE(SUM(cost_cents), 0) FROM skills_runs
34384
+ WHERE org_id = ${orgId} AND created_at >= ${`${monthPrefix}-01T00:00:00.000Z`} AND created_at < ${`${nextMonthPrefix(monthPrefix)}-01T00:00:00.000Z`}) AS spent,
34385
+ (SELECT COALESCE(SUM(estimated_cents), 0) FROM skills_credit_reservations
34386
+ WHERE org_id = ${orgId} AND status = ${"reserved"}) AS pending
34387
+ `;
34388
+ return Number(rows[0]?.spent ?? 0) + Number(rows[0]?.pending ?? 0);
34389
+ });
34390
+ }
34391
+ async activeRunCount(orgId) {
34392
+ return this.withContext(orgId, false, async (tx) => {
34393
+ const rows = await tx`SELECT COUNT(*) AS n FROM skills_runs WHERE org_id = ${orgId} AND status IN (${"queued"}, ${"running"}, ${"cancel_requested"})`;
34394
+ return Number(rows[0]?.n ?? 0);
34395
+ });
34396
+ }
34397
+ async listExpiredArtifacts(at2) {
34398
+ return this.withContext(null, true, async (tx) => {
34399
+ const rows = await tx`
34400
+ SELECT a.* FROM skills_artifacts a
34401
+ JOIN skills_runs r ON r.id = a.run_id
34402
+ WHERE a.expires_at IS NOT NULL AND a.expires_at <= ${at2}
34403
+ ORDER BY a.expires_at ASC
34404
+ `;
34405
+ return rows.map(rowToArtifact);
34406
+ });
34407
+ }
34408
+ async deleteArtifactRow(artifactId2, orgId) {
34409
+ return this.withContext(orgId, true, async (tx) => {
34410
+ const rows = await tx`DELETE FROM skills_artifacts WHERE id = ${artifactId2} AND org_id = ${orgId} RETURNING id`;
34411
+ return rows.length > 0;
34412
+ });
34413
+ }
34414
+ async updateArtifactStorageKey(artifactId2, orgId, storageKey) {
34415
+ return this.withContext(orgId, true, async (tx) => {
34416
+ const rows = await tx`
34417
+ UPDATE skills_artifacts SET storage_key = ${storageKey} WHERE id = ${artifactId2} AND org_id = ${orgId} RETURNING id
34418
+ `;
34419
+ return rows.length > 0;
34420
+ });
34421
+ }
34422
+ reservationFrom(row) {
34423
+ return {
34424
+ id: String(row.id),
34425
+ orgId: String(row.org_id),
34426
+ runId: String(row.run_id),
34427
+ estimatedCents: Number(row.estimated_cents ?? 0),
34428
+ ...row.actual_cents !== null && row.actual_cents !== undefined ? { actualCents: Number(row.actual_cents) } : {},
34429
+ status: String(row.status),
34430
+ createdAt: String(row.created_at),
34431
+ ...typeof row.reconciled_at === "string" ? { reconciledAt: row.reconciled_at } : {}
34432
+ };
34433
+ }
34434
+ }
34435
+ async function createGovernanceStore(databaseUrl) {
34436
+ const target = resolveDatabaseTarget(databaseUrl);
34437
+ if (target.kind === "postgres")
34438
+ return new PostgresGovernanceStore(target.url);
34439
+ if (target.kind === "memory")
34440
+ return new SqliteGovernanceStore(SQLITE_MEMORY_PATH);
34441
+ return new SqliteGovernanceStore(target.path);
34442
+ }
34443
+ function nextMonthPrefix(monthPrefix) {
34444
+ const [year, month] = monthPrefix.split("-").map(Number);
34445
+ return month === 12 ? `${year + 1}-01` : `${year}-${String(month + 1).padStart(2, "0")}`;
34446
+ }
34447
+
34448
+ // src/server/config.ts
34449
+ var DATABASE_URL_ENV = "HASNA_SKILLS_DATABASE_URL";
34450
+ var SKILLS_ENV_NAMESPACE = "SKILLS";
34451
+ function resolveServerConfig(env = process.env) {
34452
+ assertNoRetiredModeEnvVars(env, {
34453
+ app: SKILLS_ENV_NAMESPACE,
34454
+ replacement: DATABASE_URL_ENV
34455
+ });
34456
+ const nodeEnv = env.NODE_ENV || "development";
34457
+ const host = env.HOST || env.SKILLS_HOST || "0.0.0.0";
34458
+ const port = parsePositiveInt(env.PORT || env.SKILLS_PORT, 8787);
34459
+ return {
34460
+ host,
34461
+ port,
34462
+ databaseUrl: env[DATABASE_URL_ENV] || env.DATABASE_URL || undefined,
34463
+ bootstrapApiKey: env.HASNA_SKILLS_BOOTSTRAP_API_KEY || undefined,
34464
+ artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
34465
+ artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
34466
+ inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
34467
+ bundleSigningKey: env.HASNA_SKILLS_API_SIGNING_KEY || env.HASNA_SKILLS_SIGNING_KEY || undefined,
34468
+ requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
34469
+ skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
34470
+ tombstoneWindowMs: parsePositiveInt(env.HASNA_SKILLS_TOMBSTONE_WINDOW_MS, 7 * 24 * 60 * 60 * 1000),
34471
+ publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
34472
+ nodeEnv,
34473
+ allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
34474
+ };
34475
+ }
34476
+ function localOrigin(host, port) {
34477
+ const hostname = host === "0.0.0.0" || host === "::" ? "localhost" : host;
34478
+ return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`;
34479
+ }
34480
+ function parsePositiveInt(value, fallback) {
34481
+ const parsed = Number.parseInt(value ?? "", 10);
34482
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
34483
+ }
34484
+ function normalizePrefix(value) {
34485
+ return value.replace(/^\/+|\/+$/g, "") || "skills/artifacts";
34486
+ }
34487
+
34488
+ // src/server/handlers.ts
34489
+ import { createHash as createHash5 } from "crypto";
34490
+
33798
34491
  // src/server/store.ts
34492
+ import { randomUUID as randomUUID4 } from "crypto";
34493
+ function recordFieldsOf(input, carriedBundle, carriedSkillMd) {
34494
+ return {
34495
+ slug: input.slug,
34496
+ displayName: input.displayName,
34497
+ description: input.description,
34498
+ category: input.category,
34499
+ tags: input.tags,
34500
+ source: input.source,
34501
+ kind: input.kind,
34502
+ ...input.version ? { version: input.version } : {},
34503
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34504
+ bundleSha256: input.bundle?.sha256 ?? carriedBundle.bundleSha256,
34505
+ bundleByteSize: input.bundle?.byteSize ?? carriedBundle.bundleByteSize
34506
+ };
34507
+ }
33799
34508
  function resolvePoolMax(env = process.env) {
33800
34509
  const parsed = Number.parseInt(env.HASNA_SKILLS_DATABASE_POOL_MAX || env.SKILLS_DATABASE_POOL_MAX || "", 10);
33801
34510
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
@@ -33833,6 +34542,7 @@ class MemorySkillsStore {
33833
34542
  idempotency = new Map;
33834
34543
  skills = new Map;
33835
34544
  bundles = new Map;
34545
+ pins = new Map;
33836
34546
  constructor(apiKeys = []) {
33837
34547
  for (const key of apiKeys)
33838
34548
  this.addApiKey(key.token, key.principal);
@@ -33867,7 +34577,7 @@ class MemorySkillsStore {
33867
34577
  input: input.input,
33868
34578
  args: input.args,
33869
34579
  ...idemKey ? { idempotencyKey: idemKey } : {},
33870
- correlationId: randomUUID3(),
34580
+ correlationId: randomUUID4(),
33871
34581
  costCents: 0,
33872
34582
  leaseGeneration: 0,
33873
34583
  createdAt: now
@@ -33934,6 +34644,9 @@ class MemorySkillsStore {
33934
34644
  const key = skillKey(input.principal.orgId, input.slug);
33935
34645
  const now = nowIso();
33936
34646
  const previous = this.skills.get(key);
34647
+ if (previous && !previous.tombstonedAt && input.expectedRevisionId !== previous.revisionId) {
34648
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previous.revisionId);
34649
+ }
33937
34650
  if (input.bundle) {
33938
34651
  const bundleMapKey = skillKey(input.principal.orgId, input.bundle.sha256);
33939
34652
  this.bundles.set(bundleMapKey, {
@@ -33943,6 +34656,8 @@ class MemorySkillsStore {
33943
34656
  createdAt: this.bundles.get(bundleMapKey)?.createdAt ?? now
33944
34657
  });
33945
34658
  }
34659
+ const carriedBundle = !input.bundle && previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {};
34660
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : previous?.skillMd;
33946
34661
  const record = {
33947
34662
  orgId: input.principal.orgId,
33948
34663
  slug: input.slug,
@@ -33953,11 +34668,13 @@ class MemorySkillsStore {
33953
34668
  source: input.source,
33954
34669
  kind: input.kind,
33955
34670
  ...input.version ? { version: input.version } : {},
33956
- ...input.skillMd ? { skillMd: input.skillMd } : {},
33957
- ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {},
34671
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34672
+ ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : carriedBundle,
33958
34673
  publishedByUserId: input.principal.userId,
33959
34674
  createdAt: previous?.createdAt ?? now,
33960
- updatedAt: now
34675
+ updatedAt: now,
34676
+ revisionId: revisionIdOfRecord(recordFieldsOf(input, carriedBundle, carriedSkillMd)),
34677
+ revisionNumber: (previous?.revisionNumber ?? 0) + 1
33961
34678
  };
33962
34679
  this.skills.set(key, record);
33963
34680
  if (previous?.bundleSha256 && input.bundle && previous.bundleSha256 !== input.bundle.sha256) {
@@ -33966,33 +34683,107 @@ class MemorySkillsStore {
33966
34683
  return record;
33967
34684
  }
33968
34685
  async listSkills(principal) {
33969
- return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34686
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
33970
34687
  }
33971
34688
  async getSkill(principal, slug) {
33972
34689
  const skill = this.skills.get(skillKey(principal.orgId, slug));
33973
34690
  return skill && skill.orgId === principal.orgId ? skill : null;
33974
34691
  }
33975
- async updateSkill(principal, slug, patch) {
34692
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
33976
34693
  const current = await this.getSkill(principal, slug);
33977
- if (!current)
34694
+ if (!current || current.tombstonedAt)
33978
34695
  return null;
33979
- const next = { ...current, ...patch, updatedAt: nowIso() };
34696
+ if (expectedRevisionId !== current.revisionId) {
34697
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
34698
+ }
34699
+ const latest = this.skills.get(skillKey(principal.orgId, slug));
34700
+ if (!latest || latest.tombstonedAt)
34701
+ return null;
34702
+ if (latest.revisionId !== current.revisionId) {
34703
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, latest.revisionId);
34704
+ }
34705
+ const next = {
34706
+ ...latest,
34707
+ ...patch,
34708
+ updatedAt: nowIso(),
34709
+ revisionId: revisionIdOfRecord({ ...latest, ...patch }),
34710
+ revisionNumber: latest.revisionNumber + 1
34711
+ };
33980
34712
  this.skills.set(skillKey(principal.orgId, slug), next);
33981
34713
  return next;
33982
34714
  }
33983
- async deleteSkill(principal, slug) {
34715
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
33984
34716
  const current = await this.getSkill(principal, slug);
33985
34717
  if (!current)
33986
- return false;
33987
- this.skills.delete(skillKey(principal.orgId, slug));
33988
- if (current.bundleSha256)
33989
- this.collectOrphanBundle(principal.orgId, current.bundleSha256);
33990
- return true;
34718
+ return null;
34719
+ if (!current.tombstonedAt) {
34720
+ const tombstoned = nowIso();
34721
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
34722
+ const next = { ...current, tombstonedAt: tombstoned, tombstonePurgeAfter: purgeAfter, updatedAt: tombstoned };
34723
+ this.skills.set(skillKey(principal.orgId, slug), next);
34724
+ return next;
34725
+ }
34726
+ return current;
34727
+ }
34728
+ async purgeExpiredTombstones(principal) {
34729
+ const now = nowIso();
34730
+ const purged = [];
34731
+ for (const [key, skill] of this.skills) {
34732
+ if (skill.orgId !== principal.orgId || !skill.tombstonedAt || !skill.tombstonePurgeAfter)
34733
+ continue;
34734
+ if (skill.tombstonePurgeAfter > now)
34735
+ continue;
34736
+ this.skills.delete(key);
34737
+ if (skill.bundleSha256)
34738
+ this.collectOrphanBundle(principal.orgId, skill.bundleSha256);
34739
+ purged.push(skill);
34740
+ }
34741
+ return purged;
33991
34742
  }
33992
34743
  async getSkillBundle(principal, sha256) {
33993
34744
  const bundle = this.bundles.get(skillKey(principal.orgId, sha256));
33994
34745
  return bundle && bundle.orgId === principal.orgId ? bundle : null;
33995
34746
  }
34747
+ async pinSkill(principal, slug, metadata = {}) {
34748
+ const pin = { orgId: principal.orgId, principal: principal.apiKeyId, slug, pinnedAt: nowIso(), metadata: { ...metadata } };
34749
+ this.pins.set(pinKey(principal.orgId, principal.apiKeyId, slug), pin);
34750
+ return pin;
34751
+ }
34752
+ async unpinSkill(principal, slug) {
34753
+ return this.pins.delete(pinKey(principal.orgId, principal.apiKeyId, slug));
34754
+ }
34755
+ async listPins(principal) {
34756
+ return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34757
+ }
34758
+ async listTags(principal) {
34759
+ await this.purgeExpiredTombstones(principal);
34760
+ const tags = new Set;
34761
+ for (const skill of this.skills.values()) {
34762
+ if (skill.orgId !== principal.orgId)
34763
+ continue;
34764
+ for (const tag of skill.tags) {
34765
+ if (tag.trim())
34766
+ tags.add(tag);
34767
+ }
34768
+ }
34769
+ return [...tags].sort();
34770
+ }
34771
+ async listSkillsByTag(principal, tag) {
34772
+ await this.purgeExpiredTombstones(principal);
34773
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag)).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34774
+ }
34775
+ async listPinsByTag(principal, tag) {
34776
+ await this.purgeExpiredTombstones(principal);
34777
+ const taggedSlugs = new Set;
34778
+ for (const skill of this.skills.values()) {
34779
+ if (skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag))
34780
+ taggedSlugs.add(skill.slug);
34781
+ }
34782
+ return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId && taggedSlugs.has(pin.slug)).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34783
+ }
34784
+ async listPublishedSlugs(principal) {
34785
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).map((skill) => skill.slug).sort();
34786
+ }
33996
34787
  collectOrphanBundle(orgId, sha256) {
33997
34788
  const referenced = Array.from(this.skills.values()).some((skill) => skill.orgId === orgId && skill.bundleSha256 === sha256);
33998
34789
  if (!referenced)
@@ -34010,6 +34801,9 @@ class MemorySkillsStore {
34010
34801
  function skillKey(orgId, slug) {
34011
34802
  return `${orgId.length}:${orgId}:${slug}`;
34012
34803
  }
34804
+ function pinKey(orgId, principal, slug) {
34805
+ return `${orgId.length}:${orgId}:${principal.length}:${principal}:${slug}`;
34806
+ }
34013
34807
 
34014
34808
  class PostgresSkillsStore {
34015
34809
  backend = { kind: "postgres", durable: true, label: "postgres" };
@@ -34029,6 +34823,14 @@ class PostgresSkillsStore {
34029
34823
  } catch (error) {
34030
34824
  throw new Error("the configured Postgres database is reachable but has no skills schema. Run `skills-migrate` against it " + "before starting the server - unlike SQLite, Postgres is not migrated automatically, so that several " + `replicas cannot race to migrate a shared database. Driver reported: ${connectionFailureSummary(error)}`);
34031
34825
  }
34826
+ await this.backfillLegacyRevisions();
34827
+ }
34828
+ async backfillLegacyRevisions() {
34829
+ const rows = await this.sql`SELECT * FROM skills_registry WHERE revision_id = ${""}`;
34830
+ for (const row of rows) {
34831
+ const record = rowToSkill(row);
34832
+ await this.sql`UPDATE skills_registry SET revision_id = ${revisionIdOfRecord(record)} WHERE org_id = ${record.orgId} AND slug = ${record.slug}`;
34833
+ }
34032
34834
  }
34033
34835
  async close() {
34034
34836
  await this.sql.close?.();
@@ -34090,20 +34892,20 @@ class PostgresSkillsStore {
34090
34892
  });
34091
34893
  }
34092
34894
  async createRun(input) {
34093
- if (input.idempotencyKey) {
34094
- const existing = await this.sql`
34095
- SELECT * FROM skills_runs
34096
- WHERE org_id = ${input.principal.orgId} AND idempotency_key = ${input.idempotencyKey}
34097
- LIMIT 1
34098
- `;
34099
- if (existing[0])
34100
- return rowToRun(existing[0]);
34101
- }
34102
- const id = runId();
34103
34895
  return this.withContext(input.principal.orgId, false, async (tx) => {
34896
+ if (input.idempotencyKey) {
34897
+ const existing = await tx`
34898
+ SELECT * FROM skills_runs
34899
+ WHERE org_id = ${input.principal.orgId} AND idempotency_key = ${input.idempotencyKey}
34900
+ LIMIT 1
34901
+ `;
34902
+ if (existing[0])
34903
+ return rowToRun(existing[0]);
34904
+ }
34905
+ const id = runId();
34104
34906
  const rows = await tx`
34105
34907
  INSERT INTO skills_runs (id, org_id, user_id, skill_slug, requested_slug, status, input_json, args_json, idempotency_key, correlation_id)
34106
- VALUES (${id}, ${input.principal.orgId}, ${input.principal.userId}, ${input.slug}, ${input.slug}, ${"queued"}, ${JSON.stringify(input.input)}::jsonb, ${JSON.stringify(input.args)}::jsonb, ${input.idempotencyKey ?? null}, ${randomUUID3()})
34908
+ VALUES (${id}, ${input.principal.orgId}, ${input.principal.userId}, ${input.slug}, ${input.slug}, ${"queued"}, ${JSON.stringify(input.input)}::jsonb, ${JSON.stringify(input.args)}::jsonb, ${input.idempotencyKey ?? null}, ${randomUUID4()})
34107
34909
  RETURNING *
34108
34910
  `;
34109
34911
  return rowToRun(rows[0]);
@@ -34263,8 +35065,33 @@ class PostgresSkillsStore {
34263
35065
  async publishSkill(input) {
34264
35066
  const orgId = input.principal.orgId;
34265
35067
  return await this.sql.begin(async (tx) => {
34266
- const previousRows = await tx`SELECT bundle_sha256 FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
34267
- const previousSha = typeof previousRows[0]?.bundle_sha256 === "string" ? String(previousRows[0].bundle_sha256) : null;
35068
+ const previousRows = await tx`
35069
+ SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at
35070
+ FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1
35071
+ `;
35072
+ const previous = previousRows[0];
35073
+ const previousSha = typeof previous?.bundle_sha256 === "string" ? String(previous.bundle_sha256) : null;
35074
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? String(previous.revision_id) : null;
35075
+ const tombstoned = previous?.tombstoned_at != null;
35076
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? String(previous.skill_md) : null;
35077
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
35078
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
35079
+ }
35080
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
35081
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
35082
+ const revisionId = revisionIdOfRecord({
35083
+ slug: input.slug,
35084
+ displayName: input.displayName,
35085
+ description: input.description,
35086
+ category: input.category,
35087
+ tags: input.tags,
35088
+ source: input.source,
35089
+ kind: input.kind,
35090
+ ...input.version ? { version: input.version } : {},
35091
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
35092
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
35093
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
35094
+ });
34268
35095
  if (input.bundle) {
34269
35096
  await tx`
34270
35097
  INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob)
@@ -34279,10 +35106,10 @@ class PostgresSkillsStore {
34279
35106
  }
34280
35107
  const rows = await tx`
34281
35108
  INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
34282
- bundle_sha256, bundle_byte_size, published_by_user_id, updated_at)
35109
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, updated_at)
34283
35110
  VALUES (${orgId}, ${input.slug}, ${input.displayName}, ${input.description}, ${input.category}, ${JSON.stringify(input.tags)}::jsonb,
34284
- ${input.source}, ${input.kind}, ${input.version ?? null}, ${input.skillMd ?? null},
34285
- ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, now())
35111
+ ${input.source}, ${input.kind}, ${input.version ?? null}, ${carriedSkillMd},
35112
+ ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, ${revisionId}, 1, now())
34286
35113
  ON CONFLICT (org_id, slug) DO UPDATE SET
34287
35114
  display_name = EXCLUDED.display_name,
34288
35115
  description = EXCLUDED.description,
@@ -34297,9 +35124,23 @@ class PostgresSkillsStore {
34297
35124
  bundle_sha256 = COALESCE(EXCLUDED.bundle_sha256, skills_registry.bundle_sha256),
34298
35125
  bundle_byte_size = COALESCE(EXCLUDED.bundle_byte_size, skills_registry.bundle_byte_size),
34299
35126
  published_by_user_id = EXCLUDED.published_by_user_id,
35127
+ revision_id = EXCLUDED.revision_id,
35128
+ -- Current + 1, not EXCLUDED.revision_number: the insert path minted 1, but on
35129
+ -- the update path the ACTUAL row's counter is the truth (it may have advanced
35130
+ -- since the pre-read, which is exactly what the WHERE guard below detects).
35131
+ revision_number = skills_registry.revision_number + 1,
35132
+ tombstoned_at = NULL,
35133
+ tombstone_purge_after = NULL,
34300
35134
  updated_at = EXCLUDED.updated_at
35135
+ WHERE skills_registry.tombstoned_at IS NOT NULL
35136
+ OR skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL2}
34301
35137
  RETURNING *
34302
35138
  `;
35139
+ if (!rows[0]) {
35140
+ const current = await tx`SELECT revision_id FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
35141
+ const currentId = current[0] && typeof current[0].revision_id === "string" ? String(current[0].revision_id) : null;
35142
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
35143
+ }
34303
35144
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256) {
34304
35145
  await tx`
34305
35146
  DELETE FROM skills_bundles
@@ -34307,55 +35148,182 @@ class PostgresSkillsStore {
34307
35148
  AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${orgId} AND bundle_sha256 = ${previousSha})
34308
35149
  `;
34309
35150
  }
35151
+ await tx`DELETE FROM skills_tags WHERE org_id = ${orgId} AND slug = ${input.slug}`;
35152
+ for (const tag of input.tags) {
35153
+ if (!tag.trim())
35154
+ continue;
35155
+ await tx`
35156
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${orgId}, ${input.slug}, ${tag})
35157
+ ON CONFLICT DO NOTHING
35158
+ `;
35159
+ }
34310
35160
  return rowToSkill(rows[0]);
34311
35161
  });
34312
35162
  }
34313
35163
  async listSkills(principal) {
34314
- const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} ORDER BY slug ASC`;
35164
+ await this.purgeExpiredTombstones(principal);
35165
+ const rows = await this.sql`
35166
+ SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
35167
+ `;
34315
35168
  return rows.map(rowToSkill);
34316
35169
  }
34317
35170
  async getSkill(principal, slug) {
34318
35171
  const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
34319
35172
  return rows[0] ? rowToSkill(rows[0]) : null;
34320
35173
  }
34321
- async updateSkill(principal, slug, patch) {
35174
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
34322
35175
  const current = await this.getSkill(principal, slug);
34323
- if (!current)
35176
+ if (!current || current.tombstonedAt)
34324
35177
  return null;
35178
+ if (expectedRevisionId !== current.revisionId) {
35179
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
35180
+ }
34325
35181
  const next = { ...current, ...patch };
34326
- const rows = await this.sql`
34327
- UPDATE skills_registry
34328
- SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
34329
- tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
34330
- skill_md = ${next.skillMd ?? null}, updated_at = now()
34331
- WHERE org_id = ${principal.orgId} AND slug = ${slug}
34332
- RETURNING *
34333
- `;
34334
- return rows[0] ? rowToSkill(rows[0]) : null;
35182
+ return await this.sql.begin(async (tx) => {
35183
+ const revisionId = revisionIdOfRecord(next);
35184
+ const updated = await tx`
35185
+ UPDATE skills_registry
35186
+ SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
35187
+ tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
35188
+ skill_md = ${next.skillMd ?? null}, revision_id = ${revisionId}, revision_number = revision_number + 1, updated_at = now()
35189
+ WHERE org_id = ${principal.orgId} AND slug = ${slug} AND tombstoned_at IS NULL AND revision_id = ${current.revisionId}
35190
+ RETURNING *
35191
+ `;
35192
+ if (!updated[0]) {
35193
+ const nowRows = await tx`
35194
+ SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
35195
+ `;
35196
+ if (nowRows[0] && nowRows[0].tombstoned_at == null) {
35197
+ const currentId = String(nowRows[0].revision_id);
35198
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
35199
+ }
35200
+ return null;
35201
+ }
35202
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${slug}`;
35203
+ for (const tag of next.tags) {
35204
+ if (!tag.trim())
35205
+ continue;
35206
+ await tx`
35207
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${principal.orgId}, ${slug}, ${tag})
35208
+ ON CONFLICT DO NOTHING
35209
+ `;
35210
+ }
35211
+ return rowToSkill(updated[0]);
35212
+ });
34335
35213
  }
34336
- async deleteSkill(principal, slug) {
35214
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
34337
35215
  return await this.sql.begin(async (tx) => {
35216
+ const existingRows = await tx`
35217
+ SELECT tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
35218
+ `;
35219
+ if (!existingRows[0])
35220
+ return null;
35221
+ if (existingRows[0].tombstoned_at != null) {
35222
+ const rows2 = await tx`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
35223
+ return rowToSkill(rows2[0]);
35224
+ }
34338
35225
  const rows = await tx`
34339
- DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug}
34340
- RETURNING bundle_sha256
35226
+ UPDATE skills_registry
35227
+ SET tombstoned_at = now(), tombstone_purge_after = now() + (${tombstoneWindowMs}::int * interval '1 millisecond'), updated_at = now()
35228
+ WHERE org_id = ${principal.orgId} AND slug = ${slug}
35229
+ RETURNING *
34341
35230
  `;
34342
- if (!rows[0])
34343
- return false;
34344
- const sha = rows[0].bundle_sha256;
34345
- if (typeof sha === "string") {
35231
+ return rows[0] ? rowToSkill(rows[0]) : null;
35232
+ });
35233
+ }
35234
+ async purgeExpiredTombstones(principal) {
35235
+ return await this.sql.begin(async (tx) => {
35236
+ const expiredRows = await tx`
35237
+ SELECT * FROM skills_registry
35238
+ WHERE org_id = ${principal.orgId} AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= now()
35239
+ `;
35240
+ if (!expiredRows.length)
35241
+ return [];
35242
+ const purged = [];
35243
+ for (const row of expiredRows) {
35244
+ const record = rowToSkill(row);
34346
35245
  await tx`
34347
- DELETE FROM skills_bundles
34348
- WHERE org_id = ${principal.orgId} AND sha256 = ${sha}
34349
- AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${sha})
35246
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
35247
+ `;
35248
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${record.slug}`;
35249
+ await tx`
35250
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
34350
35251
  `;
35252
+ if (record.bundleSha256) {
35253
+ await tx`
35254
+ DELETE FROM skills_bundles
35255
+ WHERE org_id = ${principal.orgId} AND sha256 = ${record.bundleSha256}
35256
+ AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${record.bundleSha256})
35257
+ `;
35258
+ }
35259
+ purged.push(record);
34351
35260
  }
34352
- return true;
35261
+ return purged;
34353
35262
  });
34354
35263
  }
34355
35264
  async getSkillBundle(principal, sha256) {
34356
35265
  const rows = await this.sql`SELECT * FROM skills_bundles WHERE org_id = ${principal.orgId} AND sha256 = ${sha256} LIMIT 1`;
34357
35266
  return rows[0] ? rowToSkillBundle(rows[0]) : null;
34358
35267
  }
35268
+ async pinSkill(principal, slug, metadata = {}) {
35269
+ const rows = await this.sql`
35270
+ INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
35271
+ VALUES (${principal.orgId}, ${principal.apiKeyId}, ${slug}, now(), ${JSON.stringify(metadata)}::jsonb)
35272
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
35273
+ pinned_at = now(),
35274
+ metadata_json = EXCLUDED.metadata_json
35275
+ RETURNING *
35276
+ `;
35277
+ return rowToPin(rows[0]);
35278
+ }
35279
+ async unpinSkill(principal, slug) {
35280
+ const rows = await this.sql`
35281
+ DELETE FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} AND slug = ${slug}
35282
+ RETURNING 1 AS present
35283
+ `;
35284
+ return rows.length > 0;
35285
+ }
35286
+ async listPins(principal) {
35287
+ const rows = await this.sql`
35288
+ SELECT * FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} ORDER BY slug ASC
35289
+ `;
35290
+ return rows.map(rowToPin);
35291
+ }
35292
+ async listTags(principal) {
35293
+ await this.purgeExpiredTombstones(principal);
35294
+ const rows = await this.sql`
35295
+ SELECT DISTINCT tag FROM skills_tags WHERE org_id = ${principal.orgId} ORDER BY tag ASC
35296
+ `;
35297
+ return rows.map((row) => String(row.tag));
35298
+ }
35299
+ async listSkillsByTag(principal, tag) {
35300
+ await this.purgeExpiredTombstones(principal);
35301
+ const rows = await this.sql`
35302
+ SELECT s.* FROM skills_registry s
35303
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
35304
+ WHERE t.org_id = ${principal.orgId} AND t.tag = ${tag} AND s.tombstoned_at IS NULL
35305
+ ORDER BY s.slug ASC
35306
+ `;
35307
+ return rows.map(rowToSkill);
35308
+ }
35309
+ async listPinsByTag(principal, tag) {
35310
+ await this.purgeExpiredTombstones(principal);
35311
+ const rows = await this.sql`
35312
+ SELECT p.* FROM skills_pins p
35313
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
35314
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
35315
+ WHERE p.org_id = ${principal.orgId} AND p.principal = ${principal.apiKeyId}
35316
+ AND t.tag = ${tag} AND s.tombstoned_at IS NULL
35317
+ ORDER BY p.slug ASC
35318
+ `;
35319
+ return rows.map(rowToPin);
35320
+ }
35321
+ async listPublishedSlugs(principal) {
35322
+ const rows = await this.sql`
35323
+ SELECT slug FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
35324
+ `;
35325
+ return rows.map((row) => String(row.slug));
35326
+ }
34359
35327
  async collectOrphanBundle(orgId, sha256) {
34360
35328
  await this.sql`
34361
35329
  DELETE FROM skills_bundles
@@ -34364,6 +35332,7 @@ class PostgresSkillsStore {
34364
35332
  `;
34365
35333
  }
34366
35334
  }
35335
+ var NO_REVISION_SENTINEL2 = "0000000000000000000000000000000000000000000000000000000000000000";
34367
35336
  function isUniqueViolation(error) {
34368
35337
  const code = error?.code;
34369
35338
  if (code === "23505" || code === 23505)
@@ -34425,7 +35394,7 @@ ${summary}
34425
35394
  textArtifact(run, "show-notes.md", `# Show Notes
34426
35395
 
34427
35396
  - ${summary}
34428
- - Generated by the open-skills deterministic worker.
35397
+ - Generated by the skills deterministic worker.
34429
35398
  `),
34430
35399
  textArtifact(run, "clips.csv", `start,end,title,summary
34431
35400
  00:00,00:30,"Opening","${csv(summary)}"
@@ -34459,7 +35428,7 @@ function textArtifact(run, relativePath, bodyText, contentType = relativePath.en
34459
35428
  relativePath,
34460
35429
  contentType,
34461
35430
  byteSize: bytes.byteLength,
34462
- sha256: createHash4("sha256").update(bytes).digest("hex"),
35431
+ sha256: createHash5("sha256").update(bytes).digest("hex"),
34463
35432
  visibility: "private"
34464
35433
  },
34465
35434
  body: { relativePath, bodyText, contentType }
@@ -34475,7 +35444,9 @@ async function completeRun(store, run, preview) {
34475
35444
  return next ?? run;
34476
35445
  }
34477
35446
  async function failRun(store, run, code, message) {
34478
- await store.appendLog(run.id, run.orgId, "error", message);
35447
+ try {
35448
+ await store.appendLog(run.id, run.orgId, "error", message);
35449
+ } catch {}
34479
35450
  const next = await fencedTransition(store, run, {
34480
35451
  status: "failed",
34481
35452
  errorCode: code,
@@ -34489,7 +35460,9 @@ async function fencedTransition(store, run, patch) {
34489
35460
  return store.updateRun(run.id, patch);
34490
35461
  const next = await store.transitionRun(run.id, patch, run.leaseGeneration);
34491
35462
  if (!next) {
34492
- await store.appendLog(run.id, run.orgId, "warn", `late write rejected: run no longer owned at lease_generation ${run.leaseGeneration}`);
35463
+ try {
35464
+ await store.appendLog(run.id, run.orgId, "warn", `late write rejected: run no longer owned at lease_generation ${run.leaseGeneration}`);
35465
+ } catch {}
34493
35466
  }
34494
35467
  return next;
34495
35468
  }
@@ -34521,7 +35494,7 @@ function csv(value) {
34521
35494
  }
34522
35495
 
34523
35496
  // src/server/skills-api.ts
34524
- import { createHash as createHash5 } from "crypto";
35497
+ import { createHash as createHash6 } from "crypto";
34525
35498
 
34526
35499
  // src/lib/registry-merge.ts
34527
35500
  var SKILL_SOURCE_PRECEDENCE = {
@@ -34571,25 +35544,26 @@ function mergeSkillRegistryLists(...groups) {
34571
35544
  }
34572
35545
 
34573
35546
  // src/server/registry.ts
34574
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
35547
+ import { existsSync as existsSync9, readFileSync as readFileSync9 } from "fs";
34575
35548
  import { resolve, sep } from "path";
34576
35549
 
34577
35550
  // src/lib/registry.ts
34578
- import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
34579
- import { join as join8 } from "path";
35551
+ import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync5 } from "fs";
35552
+ import { join as join9 } from "path";
34580
35553
 
34581
35554
  // src/lib/portable-skills.ts
34582
35555
  import {
34583
35556
  cpSync as cpSync2,
34584
- existsSync as existsSync4,
34585
- mkdirSync as mkdirSync4,
34586
- readdirSync as readdirSync3,
35557
+ existsSync as existsSync5,
35558
+ mkdirSync as mkdirSync5,
35559
+ mkdtempSync,
35560
+ readdirSync as readdirSync4,
34587
35561
  renameSync,
34588
35562
  rmSync,
34589
- statSync as statSync3,
35563
+ statSync as statSync4,
34590
35564
  writeFileSync as writeFileSync3
34591
35565
  } from "fs";
34592
- import { basename as basename2, dirname as dirname6, isAbsolute as isAbsolute2, join as join7, normalize } from "path";
35566
+ import { basename as basename2, dirname as dirname7, isAbsolute as isAbsolute2, join as join8, normalize } from "path";
34593
35567
 
34594
35568
  // src/lib/registry-data/development-tools.ts
34595
35569
  var DEVELOPMENT_TOOLS_SKILLS = [
@@ -34749,7 +35723,7 @@ var DEVELOPMENT_TOOLS_SKILLS = [
34749
35723
  {
34750
35724
  name: "monitor",
34751
35725
  displayName: "Monitor",
34752
- description: "Operate the open-monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
35726
+ description: "Operate the monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
34753
35727
  category: "Development Tools",
34754
35728
  tags: ["monitoring", "mcp", "processes", "operations"]
34755
35729
  },
@@ -34795,6 +35769,14 @@ var DEVELOPMENT_TOOLS_SKILLS = [
34795
35769
  description: "Validate configuration files for syntax and schema compliance",
34796
35770
  category: "Development Tools",
34797
35771
  tags: ["config", "validation", "schema", "linting"]
35772
+ },
35773
+ {
35774
+ name: "session-inject-monitor",
35775
+ displayName: "Session Inject Monitor",
35776
+ description: "Set up a declarative monitor that injects a prompt into a live coding-agent session when a watched source (conversations, email, todos, knowledge, command output) has new content",
35777
+ category: "Development Tools",
35778
+ tags: ["monitor", "session", "injection", "automation", "wake"],
35779
+ kind: "instruction"
34798
35780
  }
34799
35781
  ];
34800
35782
 
@@ -35182,7 +36164,7 @@ var DESIGN_BRANDING_SKILLS = [
35182
36164
  displayName: "Site Analyze",
35183
36165
  description: "Analyze any website's design system \u2014 detects shadcn/ui, Tailwind, extracts colors, typography, and components via Playwright + Claude Vision.",
35184
36166
  category: "Design & Branding",
35185
- tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "open-styles"]
36167
+ tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "styles"]
35186
36168
  }
35187
36169
  ];
35188
36170
 
@@ -35293,8 +36275,29 @@ var SKILLS = [
35293
36275
  ];
35294
36276
 
35295
36277
  // src/lib/hosted-skill-set.ts
36278
+ import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
36279
+ import { join as join6 } from "path";
35296
36280
  var HOSTED_RUNTIMES = new Set(["hosted"]);
35297
36281
  var HOSTED_SOURCES = new Set(["remote", "private-hosted"]);
36282
+ function normalizeMarker(value) {
36283
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
36284
+ }
36285
+ function isHostedMetadataPackage(pkg) {
36286
+ const skills = pkg?.skills;
36287
+ if (!skills || typeof skills !== "object")
36288
+ return false;
36289
+ return HOSTED_RUNTIMES.has(normalizeMarker(skills.runtime)) || HOSTED_SOURCES.has(normalizeMarker(skills.source));
36290
+ }
36291
+ function isHostedMetadataSkillDir(skillDir) {
36292
+ const pkgPath = join6(skillDir, "package.json");
36293
+ if (!existsSync3(pkgPath))
36294
+ return false;
36295
+ try {
36296
+ return isHostedMetadataPackage(JSON.parse(readFileSync4(pkgPath, "utf8")));
36297
+ } catch {
36298
+ return false;
36299
+ }
36300
+ }
35298
36301
  var HOSTED_METADATA_SET_EMPTY_ERROR = [
35299
36302
  "The hosted metadata skill set is empty, but the packaging guards that depend on it",
35300
36303
  "only mean anything while it is non-empty: an empty set makes every one of them pass",
@@ -35416,14 +36419,14 @@ var PORTABLE_SKILL_DEFAULT_VERSION = "0.1.0";
35416
36419
  // src/lib/portable-skills-files.ts
35417
36420
  import {
35418
36421
  cpSync,
35419
- existsSync as existsSync3,
36422
+ existsSync as existsSync4,
35420
36423
  lstatSync,
35421
- mkdirSync as mkdirSync3,
35422
- readFileSync as readFileSync4,
36424
+ mkdirSync as mkdirSync4,
36425
+ readFileSync as readFileSync5,
35423
36426
  realpathSync,
35424
36427
  writeFileSync as writeFileSync2
35425
36428
  } from "fs";
35426
- import { basename, dirname as dirname5, join as join6, relative } from "path";
36429
+ import { basename, dirname as dirname6, join as join7, relative } from "path";
35427
36430
  var ANY_SEGMENT_COPY_EXCLUDES = new Set([
35428
36431
  ".git",
35429
36432
  ".DS_Store",
@@ -35451,12 +36454,12 @@ function normalizePortableSkillName(name) {
35451
36454
  return normalized;
35452
36455
  }
35453
36456
  function readPortableSkillManifest(skillPath, fallbackName = basename(skillPath)) {
35454
- const skillJsonPath = join6(skillPath, "skill.json");
35455
- const skillMdPath = join6(skillPath, "SKILL.md");
35456
- const pkgPath = join6(skillPath, "package.json");
35457
- const jsonManifest = existsSync3(skillJsonPath) ? readJsonObject(skillJsonPath) : undefined;
35458
- const frontmatter = existsSync3(skillMdPath) ? parseSkillFrontmatter(readFileSync4(skillMdPath, "utf-8")) ?? undefined : undefined;
35459
- const pkg = existsSync3(pkgPath) ? readJsonObject(pkgPath) : undefined;
36457
+ const skillJsonPath = join7(skillPath, "skill.json");
36458
+ const skillMdPath = join7(skillPath, "SKILL.md");
36459
+ const pkgPath = join7(skillPath, "package.json");
36460
+ const jsonManifest = existsSync4(skillJsonPath) ? readJsonObject(skillJsonPath) : undefined;
36461
+ const frontmatter = existsSync4(skillMdPath) ? parseSkillFrontmatter(readFileSync5(skillMdPath, "utf-8")) ?? undefined : undefined;
36462
+ const pkg = existsSync4(pkgPath) ? readJsonObject(pkgPath) : undefined;
35460
36463
  const name = normalizePortableSkillName(stringField(jsonManifest, "name") ?? frontmatter?.name ?? stringValue(pkg?.name) ?? fallbackName);
35461
36464
  const description = stringField(jsonManifest, "description") ?? frontmatter?.description ?? stringValue(pkg?.description) ?? `${name} skill`;
35462
36465
  const version2 = stringField(jsonManifest, "version") ?? frontmatter?.version ?? stringValue(pkg?.version) ?? PORTABLE_SKILL_DEFAULT_VERSION;
@@ -35583,7 +36586,7 @@ function inferPackageCommands(pkg, fallbackName) {
35583
36586
  return;
35584
36587
  }
35585
36588
  function readJsonObject(path) {
35586
- const parsed = JSON.parse(readFileSync4(path, "utf-8"));
36589
+ const parsed = JSON.parse(readFileSync5(path, "utf-8"));
35587
36590
  if (!isRecord(parsed))
35588
36591
  throw new Error(`${basename(path)} must contain a JSON object`);
35589
36592
  return parsed;
@@ -35612,33 +36615,36 @@ var LEGACY_CUSTOM_DIRNAME = "custom";
35612
36615
  function getPortableSkillsRoot(options = {}) {
35613
36616
  if (options.rootDir)
35614
36617
  return options.rootDir;
35615
- const appDir = options.homeDir ? join7(options.homeDir, ".hasna", "skills") : getDataDir();
35616
- const installed = join7(appDir, INSTALLED_SKILLS_DIRNAME);
36618
+ const appDir = options.homeDir ? join8(options.homeDir, ".hasna", "skills") : getDataDir();
36619
+ const cache3 = join8(appDir, SKILLS_CACHE_DIRNAME);
36620
+ if (isOwnerLayoutMigrated(appDir) && safeIsDirectory(cache3))
36621
+ return cache3;
36622
+ const installed = join8(appDir, INSTALLED_SKILLS_DIRNAME);
35617
36623
  migrateLegacySkillLayout(appDir, installed);
35618
36624
  return installed;
35619
36625
  }
35620
36626
  function looksLikeSkillDirectory(path) {
35621
36627
  if (!safeIsDirectory(path))
35622
36628
  return false;
35623
- return existsSync4(join7(path, "SKILL.md")) || existsSync4(join7(path, "skill.json")) || existsSync4(join7(path, "package.json"));
36629
+ return existsSync5(join8(path, "SKILL.md")) || existsSync5(join8(path, "skill.json")) || existsSync5(join8(path, "package.json"));
35624
36630
  }
35625
36631
  function migrateLegacySkillLayout(appDir, installed) {
35626
36632
  if (!safeIsDirectory(appDir))
35627
36633
  return;
35628
36634
  const candidates = [];
35629
36635
  try {
35630
- for (const entry of readdirSync3(appDir)) {
36636
+ for (const entry of readdirSync4(appDir)) {
35631
36637
  if (entry.startsWith(".") || entry === INSTALLED_SKILLS_DIRNAME)
35632
36638
  continue;
35633
- const path = join7(appDir, entry);
36639
+ const path = join8(appDir, entry);
35634
36640
  if (entry === LEGACY_CUSTOM_DIRNAME) {
35635
36641
  if (!safeIsDirectory(path))
35636
36642
  continue;
35637
36643
  try {
35638
- for (const nested of readdirSync3(path)) {
36644
+ for (const nested of readdirSync4(path)) {
35639
36645
  if (nested.startsWith("."))
35640
36646
  continue;
35641
- const nestedPath = join7(path, nested);
36647
+ const nestedPath = join8(path, nested);
35642
36648
  if (looksLikeSkillDirectory(nestedPath))
35643
36649
  candidates.push({ from: nestedPath, name: nested });
35644
36650
  }
@@ -35652,10 +36658,10 @@ function migrateLegacySkillLayout(appDir, installed) {
35652
36658
  return;
35653
36659
  }
35654
36660
  for (const { from, name } of candidates) {
35655
- const target = join7(installed, name);
35656
- if (existsSync4(target))
36661
+ const target = join8(installed, name);
36662
+ if (existsSync5(target))
35657
36663
  continue;
35658
- const staging = join7(installed, `.migrating-${name}-${process.pid}`);
36664
+ const staging = join8(installed, `.migrating-${name}-${process.pid}`);
35659
36665
  try {
35660
36666
  rmSync(staging, { recursive: true, force: true });
35661
36667
  cpSync2(from, staging, { recursive: true, errorOnExist: false });
@@ -35668,7 +36674,7 @@ function migrateLegacySkillLayout(appDir, installed) {
35668
36674
  }
35669
36675
  }
35670
36676
  function getPortableSkillPath(name, options = {}) {
35671
- return join7(getPortableSkillsRoot(options), normalizePortableSkillName(name));
36677
+ return join8(getPortableSkillsRoot(options), normalizePortableSkillName(name));
35672
36678
  }
35673
36679
  function findPortableSkill(name, options = {}) {
35674
36680
  let normalized;
@@ -35678,7 +36684,7 @@ function findPortableSkill(name, options = {}) {
35678
36684
  return null;
35679
36685
  }
35680
36686
  const path = getPortableSkillPath(normalized, options);
35681
- if (!existsSync4(path) || !statSync3(path).isDirectory())
36687
+ if (!existsSync5(path) || !statSync4(path).isDirectory())
35682
36688
  return null;
35683
36689
  try {
35684
36690
  return summarizePortableSkill(path, normalized);
@@ -35691,10 +36697,10 @@ function listPortableSkills(options = {}) {
35691
36697
  if (!safeIsDirectory(root3))
35692
36698
  return [];
35693
36699
  const skills = [];
35694
- for (const entry of readdirSync3(root3).sort()) {
36700
+ for (const entry of readdirSync4(root3).sort()) {
35695
36701
  if (entry.startsWith("."))
35696
36702
  continue;
35697
- const path = join7(root3, entry);
36703
+ const path = join8(root3, entry);
35698
36704
  if (!safeIsDirectory(path))
35699
36705
  continue;
35700
36706
  try {
@@ -35716,7 +36722,8 @@ function listPortableSkillMetas(options = {}) {
35716
36722
  tags: manifest.tags || ["custom"],
35717
36723
  version: skill.version,
35718
36724
  ...manifest.kind ? { kind: manifest.kind } : {},
35719
- source: "custom"
36725
+ source: "custom",
36726
+ ...isHostedMetadataSkillDir(skill.path) ? { serverOwned: true } : {}
35720
36727
  };
35721
36728
  });
35722
36729
  }
@@ -35736,7 +36743,7 @@ function summarizePortableSkill(skillPath, fallbackName) {
35736
36743
  }
35737
36744
  function safeIsDirectory(path) {
35738
36745
  try {
35739
- return statSync3(path).isDirectory();
36746
+ return statSync4(path).isDirectory();
35740
36747
  } catch {
35741
36748
  return false;
35742
36749
  }
@@ -35786,20 +36793,20 @@ function parseSkillMdFrontmatter(content) {
35786
36793
  return Object.keys(result).length > 0 ? result : null;
35787
36794
  }
35788
36795
  function discoverSkillsInDir(dir, source = "custom") {
35789
- if (!existsSync5(dir))
36796
+ if (!existsSync6(dir))
35790
36797
  return [];
35791
36798
  const result = [];
35792
36799
  try {
35793
- const entries = readdirSync4(dir, { withFileTypes: true });
36800
+ const entries = readdirSync5(dir, { withFileTypes: true });
35794
36801
  for (const entry of entries) {
35795
36802
  if (!entry.isDirectory())
35796
36803
  continue;
35797
- const skillMdPath = join8(dir, entry.name, "SKILL.md");
35798
- if (!existsSync5(skillMdPath))
36804
+ const skillMdPath = join9(dir, entry.name, "SKILL.md");
36805
+ if (!existsSync6(skillMdPath))
35799
36806
  continue;
35800
36807
  let content;
35801
36808
  try {
35802
- content = readFileSync5(skillMdPath, "utf-8");
36809
+ content = readFileSync6(skillMdPath, "utf-8");
35803
36810
  } catch {
35804
36811
  continue;
35805
36812
  }
@@ -35814,6 +36821,7 @@ function discoverSkillsInDir(dir, source = "custom") {
35814
36821
  category: fm.category || "Development Tools",
35815
36822
  tags: fm.tags || [],
35816
36823
  ...fm.kind ? { kind: fm.kind } : {},
36824
+ ...isHostedMetadataSkillDir(join9(dir, entry.name)) ? { serverOwned: true } : {},
35817
36825
  source
35818
36826
  });
35819
36827
  }
@@ -35822,20 +36830,20 @@ function discoverSkillsInDir(dir, source = "custom") {
35822
36830
  }
35823
36831
  function findExtensionSkillPath(name) {
35824
36832
  const config = loadConfig4();
35825
- if (!config.extensionsDir || !existsSync5(config.extensionsDir))
36833
+ if (!config.extensionsDir || !existsSync6(config.extensionsDir))
35826
36834
  return null;
35827
36835
  try {
35828
- const entries = readdirSync4(config.extensionsDir, { withFileTypes: true });
36836
+ const entries = readdirSync5(config.extensionsDir, { withFileTypes: true });
35829
36837
  for (const entry of entries) {
35830
36838
  if (!entry.isDirectory())
35831
36839
  continue;
35832
- const skillDir = join8(config.extensionsDir, entry.name);
35833
- const skillMdPath = join8(skillDir, "SKILL.md");
35834
- if (!existsSync5(skillMdPath))
36840
+ const skillDir = join9(config.extensionsDir, entry.name);
36841
+ const skillMdPath = join9(skillDir, "SKILL.md");
36842
+ if (!existsSync6(skillMdPath))
35835
36843
  continue;
35836
36844
  let content;
35837
36845
  try {
35838
- content = readFileSync5(skillMdPath, "utf-8");
36846
+ content = readFileSync6(skillMdPath, "utf-8");
35839
36847
  } catch {
35840
36848
  continue;
35841
36849
  }
@@ -35868,7 +36876,7 @@ function loadRegistry(cwd) {
35868
36876
  const official = SKILLS.map((s2) => ({ ...s2, source: "official" }));
35869
36877
  const extensions = config.extensionsDir ? discoverSkillsInDir(config.extensionsDir, "extension") : [];
35870
36878
  const portableCustom = listPortableSkillMetas();
35871
- const legacyCustom = discoverSkillsInDir(join8(dataDir, "custom"));
36879
+ const legacyCustom = discoverSkillsInDir(join9(dataDir, "custom"));
35872
36880
  const globalCustom = mergeCustomSkills([...legacyCustom, ...portableCustom]);
35873
36881
  registryCache = mergeSkillRegistryLists(official, extensions, globalCustom);
35874
36882
  registryCacheTime = now;
@@ -35888,30 +36896,29 @@ function mergeCustomSkills(skills) {
35888
36896
  }
35889
36897
 
35890
36898
  // src/lib/skillinfo.ts
35891
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
35892
- import { join as join10 } from "path";
36899
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
36900
+ import { join as join11 } from "path";
35893
36901
 
35894
36902
  // src/lib/installer.ts
35895
- import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync as rmSync2 } from "fs";
35896
- import { dirname as dirname7, join as join9 } from "path";
36903
+ import { existsSync as existsSync7, readFileSync as readFileSync7, rmSync as rmSync2 } from "fs";
36904
+ import { dirname as dirname8, join as join10 } from "path";
35897
36905
  import { fileURLToPath as fileURLToPath2 } from "url";
35898
-
35899
36906
  // src/lib/utils.ts
35900
36907
  function normalizeSkillName(name) {
35901
36908
  return name;
35902
36909
  }
35903
36910
 
35904
36911
  // src/lib/installer.ts
35905
- var __dirname2 = dirname7(fileURLToPath2(import.meta.url));
36912
+ var __dirname2 = dirname8(fileURLToPath2(import.meta.url));
35906
36913
  function findSkillsDir() {
35907
36914
  let dir = __dirname2;
35908
36915
  for (let i3 = 0;i3 < 5; i3++) {
35909
- const candidate = join9(dir, "skills");
35910
- if (existsSync6(candidate) && !dir.includes(".skills"))
36916
+ const candidate = join10(dir, "skills");
36917
+ if (existsSync7(candidate) && !dir.includes(".skills"))
35911
36918
  return candidate;
35912
- dir = dirname7(dir);
36919
+ dir = dirname8(dir);
35913
36920
  }
35914
- return join9(__dirname2, "..", "skills");
36921
+ return join10(__dirname2, "..", "skills");
35915
36922
  }
35916
36923
  var SKILLS_DIR = findSkillsDir();
35917
36924
  function getSkillPath(name) {
@@ -35919,13 +36926,13 @@ function getSkillPath(name) {
35919
36926
  const portable = findPortableSkill(skillName);
35920
36927
  if (portable)
35921
36928
  return portable.path;
35922
- const legacyCustomPath = join9(getDataDir(), "custom", skillName);
35923
- if (existsSync6(legacyCustomPath))
36929
+ const legacyCustomPath = join10(getDataDir(), "custom", skillName);
36930
+ if (existsSync7(legacyCustomPath))
35924
36931
  return legacyCustomPath;
35925
36932
  const extensionPath = findExtensionSkillPath(skillName);
35926
36933
  if (extensionPath)
35927
36934
  return extensionPath;
35928
- return join9(SKILLS_DIR, skillName);
36935
+ return join10(SKILLS_DIR, skillName);
35929
36936
  }
35930
36937
  function getCanonicalSkillName(name) {
35931
36938
  return getSkill(name)?.name ?? resolveSkillAlias(normalizeSkillSlug(name));
@@ -35934,18 +36941,18 @@ function getCanonicalSkillName(name) {
35934
36941
  // src/lib/skillinfo.ts
35935
36942
  function getSkillDocs(name) {
35936
36943
  const skillPath = getSkillPath(name);
35937
- if (!existsSync7(skillPath))
36944
+ if (!existsSync8(skillPath))
35938
36945
  return null;
35939
36946
  return {
35940
- skillMd: readIfExists(join10(skillPath, "SKILL.md")),
35941
- readme: readIfExists(join10(skillPath, "README.md")),
35942
- claudeMd: readIfExists(join10(skillPath, "CLAUDE.md"))
36947
+ skillMd: readIfExists(join11(skillPath, "SKILL.md")),
36948
+ readme: readIfExists(join11(skillPath, "README.md")),
36949
+ claudeMd: readIfExists(join11(skillPath, "CLAUDE.md"))
35943
36950
  };
35944
36951
  }
35945
36952
  function readIfExists(path) {
35946
36953
  try {
35947
- if (existsSync7(path)) {
35948
- return readFileSync7(path, "utf-8");
36954
+ if (existsSync8(path)) {
36955
+ return readFileSync8(path, "utf-8");
35949
36956
  }
35950
36957
  } catch {}
35951
36958
  return null;
@@ -35986,7 +36993,7 @@ function getServerSkillMd(slug) {
35986
36993
  const path = resolve(skillsDir, name, "SKILL.md");
35987
36994
  if (!isInsideDir(skillsDir, path))
35988
36995
  return null;
35989
- return existsSync8(path) ? readFileSync8(path, "utf8") : null;
36996
+ return existsSync9(path) ? readFileSync9(path, "utf8") : null;
35990
36997
  }
35991
36998
 
35992
36999
  // src/server/skills-api.ts
@@ -35996,6 +37003,17 @@ var MAX_SLUG_LENGTH = 128;
35996
37003
  var MAX_SKILL_MD_BYTES = 512000;
35997
37004
  var MAX_MANIFEST_BYTES = MAX_SKILL_MD_BYTES + 64000;
35998
37005
  var ALLOWED_PUBLISH_PARTS = new Set(["manifest", "bundle"]);
37006
+ function pinPayload(pin) {
37007
+ return { slug: pin.slug, pinnedAt: pin.pinnedAt, metadata: pin.metadata };
37008
+ }
37009
+ function pinMetadataField(body) {
37010
+ if (body.metadata === undefined)
37011
+ return {};
37012
+ if (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata)) {
37013
+ throw new SkillRequestError(400, "INVALID_METADATA", "`metadata` must be a JSON object");
37014
+ }
37015
+ return body.metadata;
37016
+ }
35999
37017
 
36000
37018
  class SkillRequestError extends Error {
36001
37019
  status;
@@ -36024,33 +37042,130 @@ function publishedPayload(record) {
36024
37042
  ...publishedSkillMeta(record),
36025
37043
  slug: record.slug,
36026
37044
  publishedSource: record.source,
37045
+ ...record.skillMd ? { skillMd: record.skillMd } : {},
36027
37046
  ...record.bundleSha256 ? { bundleSha256: record.bundleSha256, bundleByteSize: record.bundleByteSize } : {},
36028
37047
  publishedAt: record.createdAt,
36029
- updatedAt: record.updatedAt
37048
+ updatedAt: record.updatedAt,
37049
+ revisionId: record.revisionId,
37050
+ revisionNumber: record.revisionNumber
37051
+ };
37052
+ }
37053
+ function revisionEtag(revisionId) {
37054
+ return `"${revisionId}"`;
37055
+ }
37056
+ function parseIfMatch(value) {
37057
+ if (value === null || value.trim() === "")
37058
+ return;
37059
+ const trimmed = value.trim();
37060
+ if (trimmed === "*") {
37061
+ throw new SkillRequestError(400, "INVALID_IF_MATCH", "If-Match must name the exact revision id (the ETag of the current revision); '*' is not accepted");
37062
+ }
37063
+ const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
37064
+ if (!REVISION_ID_PATTERN.test(unquoted)) {
37065
+ throw new SkillRequestError(400, "INVALID_IF_MATCH", "If-Match must carry a revision id: a 64-character lowercase hex sha-256, quoted as the server's ETag");
37066
+ }
37067
+ return unquoted;
37068
+ }
37069
+ async function tombstoneStatus(store, artifactStorage, principal, record) {
37070
+ if (!record.tombstonedAt)
37071
+ return "live";
37072
+ if (record.tombstonePurgeAfter && record.tombstonePurgeAfter <= new Date().toISOString()) {
37073
+ const purged = await store.purgeExpiredTombstones(principal);
37074
+ for (const removed of purged) {
37075
+ if (removed.bundleSha256)
37076
+ await discardCollectedObject(store, artifactStorage, principal, removed.bundleSha256);
37077
+ }
37078
+ return "purged";
37079
+ }
37080
+ return {
37081
+ slug: record.slug,
37082
+ deleted: true,
37083
+ code: "TOMBSTONED",
37084
+ tombstonedAt: record.tombstonedAt,
37085
+ tombstonePurgeAfter: record.tombstonePurgeAfter,
37086
+ revisionId: record.revisionId
36030
37087
  };
36031
37088
  }
36032
37089
  async function listMergedSkills(store, principal) {
36033
37090
  const published = await store.listSkills(principal);
37091
+ return mergedSkillPayloads(published, listServerSkills());
37092
+ }
37093
+ function mergedSkillPayloads(published, bundled) {
36034
37094
  const publishedBySlug = new Map(published.map((record) => [record.slug, record]));
36035
- const merged = mergeSkillRegistryLists(listServerSkills(), published.map(publishedSkillMeta));
37095
+ const merged = mergeSkillRegistryLists(bundled, published.map(publishedSkillMeta));
36036
37096
  return merged.map((skill) => {
36037
37097
  const record = publishedBySlug.get(skill.name);
36038
37098
  return record ? publishedPayload(record) : skill;
36039
37099
  });
36040
37100
  }
36041
- async function getMergedSkill(store, principal, slug) {
37101
+ async function resolvePublishedSkill(store, artifactStorage, principal, slug) {
36042
37102
  const record = await store.getSkill(principal, slug);
36043
- if (record)
36044
- return publishedPayload(record);
37103
+ if (!record)
37104
+ return { kind: "absent" };
37105
+ const status = await tombstoneStatus(store, artifactStorage, principal, record);
37106
+ if (status === "purged")
37107
+ return { kind: "absent" };
37108
+ if (status !== "live")
37109
+ return { kind: "tombstone", payload: status };
37110
+ return { kind: "published", record };
37111
+ }
37112
+ async function listOrgTags(store, principal) {
37113
+ const publishedSlugs = await store.listPublishedSlugs(principal);
37114
+ const tags = new Set;
37115
+ for (const tag of await store.listTags(principal)) {
37116
+ if (tag.trim())
37117
+ tags.add(tag);
37118
+ }
37119
+ for (const skill of listServerSkills()) {
37120
+ if (publishedSlugs.includes(skill.name))
37121
+ continue;
37122
+ for (const tag of skill.tags) {
37123
+ if (tag.trim())
37124
+ tags.add(tag);
37125
+ }
37126
+ }
37127
+ return [...tags].sort();
37128
+ }
37129
+ async function listMergedSkillsByTag(store, principal, tag) {
37130
+ const published = await store.listSkillsByTag(principal, tag);
37131
+ const publishedSlugs = await store.listPublishedSlugs(principal);
37132
+ const bundled = listServerSkills().filter((skill) => skill.tags.includes(tag) && !publishedSlugs.includes(skill.name));
37133
+ return mergedSkillPayloads(published, bundled);
37134
+ }
37135
+ function skillSummary(skill) {
37136
+ return {
37137
+ slug: String(skill.slug ?? skill.name),
37138
+ ...typeof skill.name === "string" ? { name: skill.name } : {},
37139
+ ...typeof skill.version === "string" ? { version: skill.version } : {},
37140
+ ...typeof skill.updatedAt === "string" ? { updatedAt: skill.updatedAt } : {}
37141
+ };
37142
+ }
37143
+ async function listPinsByTag(store, principal, tag) {
37144
+ const publishedSlugs = await store.listPublishedSlugs(principal);
37145
+ const bundledTaggedSlugs = new Set;
37146
+ for (const skill of listServerSkills()) {
37147
+ if (skill.tags.includes(tag) && !publishedSlugs.includes(skill.name))
37148
+ bundledTaggedSlugs.add(skill.name);
37149
+ }
37150
+ const publishedPins = await store.listPinsByTag(principal, tag);
37151
+ const bundledPins = bundledTaggedSlugs.size ? (await store.listPins(principal)).filter((pin) => bundledTaggedSlugs.has(pin.slug)) : [];
37152
+ return [...publishedPins, ...bundledPins].sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map(pinPayload);
37153
+ }
37154
+ async function getMergedSkill(store, artifactStorage, principal, slug) {
37155
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
37156
+ if (resolved.kind === "tombstone")
37157
+ return resolved.payload;
37158
+ if (resolved.kind === "published")
37159
+ return publishedPayload(resolved.record);
36045
37160
  const bundled = getServerSkill(slug);
36046
37161
  return bundled ? bundled : null;
36047
37162
  }
36048
- async function getMergedSkillMd(store, principal, slug) {
36049
- const record = await store.getSkill(principal, slug);
36050
- if (record?.skillMd)
36051
- return record.skillMd;
36052
- if (record)
37163
+ async function getMergedSkillMd(store, artifactStorage, principal, slug) {
37164
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
37165
+ if (resolved.kind === "tombstone")
36053
37166
  return null;
37167
+ if (resolved.kind === "published")
37168
+ return resolved.record.skillMd ?? null;
36054
37169
  return getServerSkillMd(slug);
36055
37170
  }
36056
37171
  async function parsePublishRequest(request, config) {
@@ -36092,7 +37207,7 @@ async function parsePublishRequest(request, config) {
36092
37207
  if (bundleBytes.byteLength === 0) {
36093
37208
  throw new SkillRequestError(400, "BUNDLE_EMPTY", "the uploaded bundle is empty");
36094
37209
  }
36095
- const sha256 = createHash5("sha256").update(bundleBytes).digest("hex");
37210
+ const sha256 = createHash6("sha256").update(bundleBytes).digest("hex");
36096
37211
  const claimed = optionalString(manifest.bundleSha256);
36097
37212
  if (claimed)
36098
37213
  assertSha2562(claimed);
@@ -36120,28 +37235,33 @@ async function parsePublishRequest(request, config) {
36120
37235
  }
36121
37236
  return { input: buildPublishInput(parseManifestJson(text)) };
36122
37237
  }
36123
- async function storePublishedSkill(store, artifactStorage, principal, parsed) {
37238
+ async function storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId) {
36124
37239
  const superseded = (await store.getSkill(principal, parsed.input.slug))?.bundleSha256;
36125
- let input = { ...parsed.input, principal };
37240
+ let input = {
37241
+ ...parsed.input,
37242
+ principal,
37243
+ ...expectedRevisionId ? { expectedRevisionId } : {}
37244
+ };
36126
37245
  if (parsed.bundleBytes && input.bundle) {
36127
37246
  const placement = await artifactStorage.putBundle(principal.orgId, input.bundle.sha256, parsed.bundleBytes, input.bundle.contentType);
36128
37247
  input = { ...input, bundle: { ...input.bundle, ...placement } };
36129
37248
  }
36130
- const record = await store.publishSkill(input);
37249
+ let record;
37250
+ try {
37251
+ record = await store.publishSkill(input);
37252
+ } catch (error) {
37253
+ if (input.bundle?.sha256) {
37254
+ await discardCollectedObject(store, artifactStorage, principal, input.bundle.sha256);
37255
+ }
37256
+ throw error;
37257
+ }
36131
37258
  if (superseded && superseded !== record.bundleSha256) {
36132
37259
  await discardCollectedObject(store, artifactStorage, principal, superseded);
36133
37260
  }
36134
37261
  return record;
36135
37262
  }
36136
- async function deletePublishedSkill(store, artifactStorage, principal, slug) {
36137
- const record = await store.getSkill(principal, slug);
36138
- if (!record)
36139
- return false;
36140
- const deleted = await store.deleteSkill(principal, slug);
36141
- if (deleted && record.bundleSha256) {
36142
- await discardCollectedObject(store, artifactStorage, principal, record.bundleSha256);
36143
- }
36144
- return deleted;
37263
+ async function deletePublishedSkill(store, artifactStorage, principal, slug, tombstoneWindowMs) {
37264
+ return store.deleteSkill(principal, slug, tombstoneWindowMs);
36145
37265
  }
36146
37266
  async function discardCollectedObject(store, artifactStorage, principal, sha256) {
36147
37267
  if (await store.getSkillBundle(principal, sha256))
@@ -36161,7 +37281,7 @@ async function readPublishedBundle(store, artifactStorage, principal, slug) {
36161
37281
  if (!bytes) {
36162
37282
  throw new SkillRequestError(503, "BUNDLE_BACKEND_UNAVAILABLE", "bundle storage backend unavailable");
36163
37283
  }
36164
- const actual = createHash5("sha256").update(bytes).digest("hex");
37284
+ const actual = createHash6("sha256").update(bytes).digest("hex");
36165
37285
  if (actual !== record.bundleSha256) {
36166
37286
  throw new SkillRequestError(500, "BUNDLE_DIGEST_DRIFT", `stored bundle for '${slug}' hashes to ${actual} but was published as ${record.bundleSha256}`);
36167
37287
  }
@@ -36206,7 +37326,7 @@ function buildPublishInput(manifest) {
36206
37326
  if (skillMd && byteLength(skillMd) > MAX_SKILL_MD_BYTES) {
36207
37327
  throw new SkillRequestError(413, "SKILL_MD_TOO_LARGE", `skillMd exceeds ${MAX_SKILL_MD_BYTES} bytes`);
36208
37328
  }
36209
- const kindValue = optionalString(manifest.kind) ?? "executable";
37329
+ const kindValue = optionalString(manifest.kind) ?? "instruction";
36210
37330
  if (kindValue !== "executable" && kindValue !== "instruction") {
36211
37331
  throw new SkillRequestError(400, "INVALID_KIND", "`kind` must be 'executable' or 'instruction'");
36212
37332
  }
@@ -36268,6 +37388,7 @@ async function createSkillsFetchHandler(options = {}) {
36268
37388
  bootstrapApiKey: config.bootstrapApiKey
36269
37389
  });
36270
37390
  assertDurableStore(store, config);
37391
+ const governanceStore = options.governanceStore ?? await createGovernanceStore(config.databaseUrl);
36271
37392
  const artifactStorage = new ArtifactStorage({
36272
37393
  bucket: config.artifactBucket,
36273
37394
  prefix: config.artifactPrefix
@@ -36277,10 +37398,10 @@ async function createSkillsFetchHandler(options = {}) {
36277
37398
  const segments = pathSegments(url.pathname);
36278
37399
  try {
36279
37400
  if (request.method === "GET" && url.pathname === "/health") {
36280
- return json({ ok: true, service: "open-skills", time: new Date().toISOString() });
37401
+ return json({ ok: true, service: "skills", time: new Date().toISOString() });
36281
37402
  }
36282
37403
  if (request.method === "GET" && url.pathname === "/ready") {
36283
- return json({ ok: true, service: "open-skills" });
37404
+ return json({ ok: true, service: "skills" });
36284
37405
  }
36285
37406
  if (url.pathname.startsWith("/api/")) {
36286
37407
  const principal = await authenticateRequest(store, request);
@@ -36290,7 +37411,7 @@ async function createSkillsFetchHandler(options = {}) {
36290
37411
  return json(identityPayload(principal));
36291
37412
  }
36292
37413
  if (segments[0] === "api" && segments[1] === "v1") {
36293
- return await handleApiV1(store, principal, request, segments.slice(2), config, artifactStorage);
37414
+ return await handleApiV1(store, governanceStore, principal, request, segments.slice(2), config, artifactStorage);
36294
37415
  }
36295
37416
  }
36296
37417
  return json({ error: "not found", code: "NOT_FOUND" }, { status: 404 });
@@ -36298,6 +37419,14 @@ async function createSkillsFetchHandler(options = {}) {
36298
37419
  if (error instanceof SkillRequestError) {
36299
37420
  return json({ error: error.message, code: error.code }, { status: error.status });
36300
37421
  }
37422
+ if (error instanceof SkillRevisionConflictError) {
37423
+ return json({
37424
+ error: error.message,
37425
+ code: "REVISION_CONFLICT",
37426
+ slug: error.slug,
37427
+ ...error.currentRevisionId ? { currentRevisionId: error.currentRevisionId } : {}
37428
+ }, { status: 409 });
37429
+ }
36301
37430
  return json({ error: "internal server error", detail: error.message }, { status: 500 });
36302
37431
  }
36303
37432
  };
@@ -36311,30 +37440,49 @@ function skillsServeLimits(config) {
36311
37440
  return { maxRequestBodySize: Math.max(config.skillBundleLimitBytes, config.requestBodyLimitBytes) + BODY_LIMIT_HEADROOM_BYTES };
36312
37441
  }
36313
37442
  var BODY_LIMIT_HEADROOM_BYTES = 1e6;
36314
- async function handleApiV1(store, principal, request, parts, config, artifactStorage) {
37443
+ async function handleApiV1(store, governanceStore, principal, request, parts, config, artifactStorage) {
36315
37444
  const [resource, id, subresource, childId] = parts;
36316
37445
  if (parts.some(segmentEscapesPath)) {
36317
37446
  return json({ error: "invalid path segment", code: "INVALID_PATH" }, { status: 400 });
36318
37447
  }
36319
37448
  if (resource === "skills") {
36320
- if (request.method === "GET" && !id)
37449
+ if (request.method === "GET" && !id) {
37450
+ const tag = new URL(request.url).searchParams.get("tag");
37451
+ if (tag !== null && tag !== "")
37452
+ return json(await listMergedSkillsByTag(store, principal, tag));
36321
37453
  return json(await listMergedSkills(store, principal));
37454
+ }
36322
37455
  if (request.method === "POST" && !id) {
37456
+ const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
36323
37457
  const parsed = await parsePublishRequest(request, config);
36324
- const record = await storePublishedSkill(store, artifactStorage, principal, parsed);
36325
- return json(publishedPayload(record), { status: 201 });
37458
+ const record = await storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId);
37459
+ return json(publishedPayload(record), { status: 201, headers: { ETag: revisionEtag(record.revisionId) } });
36326
37460
  }
36327
37461
  if (request.method === "GET" && id && subresource === "skill.md") {
36328
- const docs = await getMergedSkillMd(store, principal, id);
37462
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
37463
+ if (resolved.kind === "tombstone") {
37464
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
37465
+ }
37466
+ const docs = await getMergedSkillMd(store, artifactStorage, principal, id);
36329
37467
  return docs ? new Response(docs, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "no-store" } }) : json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36330
37468
  }
36331
37469
  if (request.method === "GET" && id && subresource === "bundle") {
37470
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
37471
+ if (resolved.kind === "tombstone") {
37472
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
37473
+ }
37474
+ if (resolved.kind === "absent") {
37475
+ return json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
37476
+ }
36332
37477
  const { record, bytes } = await readPublishedBundle(store, artifactStorage, principal, id);
36333
37478
  const headers = {
36334
37479
  "Content-Type": "application/gzip",
36335
37480
  "Content-Length": String(bytes.byteLength),
36336
37481
  "Content-Disposition": `attachment; filename="${record.slug}.tar.gz"`,
36337
37482
  "X-Skill-Bundle-Sha256": record.bundleSha256 ?? "",
37483
+ "X-Skill-Revision-Id": record.revisionId,
37484
+ "X-Skill-Revision-Number": String(record.revisionNumber),
37485
+ ETag: revisionEtag(record.revisionId),
36338
37486
  "Cache-Control": "no-store"
36339
37487
  };
36340
37488
  if (config.bundleSigningKey) {
@@ -36343,17 +37491,56 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
36343
37491
  return new Response(bytes, { headers });
36344
37492
  }
36345
37493
  if (request.method === "GET" && id && !subresource) {
36346
- const skill = await getMergedSkill(store, principal, id);
37494
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
37495
+ if (resolved.kind === "tombstone") {
37496
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
37497
+ }
37498
+ if (resolved.kind === "published") {
37499
+ return json(publishedPayload(resolved.record), { headers: { ETag: revisionEtag(resolved.record.revisionId) } });
37500
+ }
37501
+ const skill = await getMergedSkill(store, artifactStorage, principal, id);
36347
37502
  return skill ? json(skill) : json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36348
37503
  }
36349
37504
  if ((request.method === "PUT" || request.method === "PATCH") && id && !subresource) {
37505
+ const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
36350
37506
  const body = await readJson(request, config.requestBodyLimitBytes);
36351
- const updated = await store.updateSkill(principal, id, skillPatch(body));
36352
- return updated ? json(publishedPayload(updated)) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
37507
+ const updated = await store.updateSkill(principal, id, skillPatch(body), expectedRevisionId);
37508
+ return updated ? json(publishedPayload(updated), { headers: { ETag: revisionEtag(updated.revisionId) } }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
36353
37509
  }
36354
37510
  if (request.method === "DELETE" && id && !subresource) {
36355
- const removed = await deletePublishedSkill(store, artifactStorage, principal, id);
36356
- return removed ? json({ deleted: true, slug: id }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
37511
+ const removed = await deletePublishedSkill(store, artifactStorage, principal, id, config.tombstoneWindowMs);
37512
+ return removed ? json({
37513
+ deleted: true,
37514
+ slug: id,
37515
+ ...removed.tombstonedAt ? { tombstonedAt: removed.tombstonedAt, tombstonePurgeAfter: removed.tombstonePurgeAfter } : {}
37516
+ }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
37517
+ }
37518
+ }
37519
+ if (resource === "pins") {
37520
+ if (request.method === "GET" && !id) {
37521
+ const tag = new URL(request.url).searchParams.get("tag");
37522
+ if (tag !== null && tag !== "")
37523
+ return json(await listPinsByTag(store, principal, tag));
37524
+ return json((await store.listPins(principal)).map(pinPayload));
37525
+ }
37526
+ if (request.method === "PUT" && id && !subresource) {
37527
+ assertPublishableSlug(id);
37528
+ const body = await readJson(request, config.requestBodyLimitBytes);
37529
+ const pin = await store.pinSkill(principal, id, pinMetadataField(body));
37530
+ return json(pinPayload(pin));
37531
+ }
37532
+ if (request.method === "DELETE" && id && !subresource) {
37533
+ assertPublishableSlug(id);
37534
+ const removed = await store.unpinSkill(principal, id);
37535
+ return removed ? json({ deleted: true, slug: id }) : json({ error: "pin not found", code: "PIN_NOT_FOUND" }, { status: 404 });
37536
+ }
37537
+ }
37538
+ if (resource === "tags") {
37539
+ if (request.method === "GET" && !id) {
37540
+ return json(await listOrgTags(store, principal));
37541
+ }
37542
+ if (request.method === "GET" && id && subresource === "skills") {
37543
+ return json((await listMergedSkillsByTag(store, principal, id)).map(skillSummary));
36357
37544
  }
36358
37545
  }
36359
37546
  if (resource === "runs") {
@@ -36412,8 +37599,18 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
36412
37599
  const run = await store.getRun(principal, id);
36413
37600
  if (!run)
36414
37601
  return json({ error: "run not found", code: "RUN_NOT_FOUND" }, { status: 404 });
36415
- const next = await store.updateRun(id, { status: "cancel_requested" });
36416
- return json(runPayload(next ?? run));
37602
+ try {
37603
+ const outcome = await createCancelService({ store, governanceStore, storage: artifactStorage }).cancel(principal, id, principal.email);
37604
+ return json(runPayload(outcome.run));
37605
+ } catch (error) {
37606
+ if (error instanceof GovernanceError && error.code === GOVERNANCE_ERROR_CODES.STALE_LEASE_GENERATION) {
37607
+ return json({ error: error.message, code: error.code }, { status: 409 });
37608
+ }
37609
+ if (error instanceof StaleLeaseGenerationError) {
37610
+ return json({ error: error.message, code: GOVERNANCE_ERROR_CODES.STALE_LEASE_GENERATION }, { status: 409 });
37611
+ }
37612
+ throw error;
37613
+ }
36417
37614
  }
36418
37615
  }
36419
37616
  return json({ error: "not found", code: "NOT_FOUND" }, { status: 404 });
@@ -36503,7 +37700,25 @@ function clampInt(value, fallback, max) {
36503
37700
  }
36504
37701
 
36505
37702
  // src/server/index.ts
37703
+ var EARLY_ARGV = process.argv.slice(2);
37704
+ if (EARLY_ARGV.includes("--version") || EARLY_ARGV.includes("-V")) {
37705
+ console.log(package_default.version);
37706
+ process.exit(0);
37707
+ }
37708
+ if (EARLY_ARGV.includes("--help") || EARLY_ARGV.includes("-h")) {
37709
+ console.log(`Usage: skills-server [options]
37710
+
37711
+ Runs the @hasna/skills HTTP API.
37712
+
37713
+ Options:
37714
+ -V, --version output the version number
37715
+ -h, --help display help for command
37716
+
37717
+ Environment:
37718
+ SKILLS_PORT Listen port (default: 8787)`);
37719
+ process.exit(0);
37720
+ }
36506
37721
  var config = resolveServerConfig();
36507
37722
  var server = await startSkillsServer({ config });
36508
- console.log(`open-skills API listening on http://${config.host}:${server.port}`);
37723
+ console.log(`skills API listening on http://${config.host}:${server.port}`);
36509
37724
  console.log(`storage: ${resolveDatabaseTarget(config.databaseUrl).label}`);