@hasna/skills 0.1.63 → 0.1.64

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 (38) hide show
  1. package/README.md +28 -6
  2. package/bin/index.js +1593 -228
  3. package/bin/mcp.js +290 -24
  4. package/bin/migrate.js +229 -33
  5. package/bin/server.js +774 -116
  6. package/bin/worker.js +558 -79
  7. package/dist/cli/commands/registry-reconcile.d.ts +2 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +559 -251
  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 +1 -1
  16. package/dist/lib/portable-skills.d.ts +35 -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.d.ts +7 -4
  20. package/dist/lib/remote-client.d.ts +118 -1
  21. package/dist/lib/remote-registry.d.ts +26 -0
  22. package/dist/lib/revision.d.ts +29 -0
  23. package/dist/sdk/index.js +1351 -353
  24. package/dist/server/app.d.ts +1 -1
  25. package/dist/server/config.d.ts +12 -2
  26. package/dist/server/rows.d.ts +2 -1
  27. package/dist/server/skills-api.d.ts +108 -6
  28. package/dist/server/sqlite-store.d.ts +20 -3
  29. package/dist/server/store.d.ts +31 -5
  30. package/dist/server/types.d.ts +98 -3
  31. package/dist/storage.js +7 -2
  32. package/migrations/postgres/0004_hosted_pins.sql +28 -0
  33. package/migrations/postgres/0005_revision_tombstone_registry.sql +37 -0
  34. package/migrations/postgres/0005_tag_projection.sql +36 -0
  35. package/migrations/sqlite/0004_hosted_pins.sql +21 -0
  36. package/migrations/sqlite/0005_revision_tombstone_registry.sql +18 -0
  37. package/migrations/sqlite/0005_tag_projection.sql +25 -0
  38. package/package.json +3 -2
package/dist/sdk/index.js CHANGED
@@ -22936,11 +22936,12 @@ var init_dist_es9 = __esm(() => {
22936
22936
  init_fromIni();
22937
22937
  });
22938
22938
 
22939
- // ../../node_modules/.bun/@hasna+events@0.1.7/node_modules/@hasna/events/dist/index.js
22939
+ // ../events/dist/index.js
22940
22940
  import { chmod, mkdir, readFile, rename, writeFile as writeFile2 } from "fs/promises";
22941
- import { existsSync as existsSync14 } from "fs";
22941
+ import { Buffer as Buffer2 } from "buffer";
22942
+ import { existsSync as existsSync13 } from "fs";
22942
22943
  import { homedir as homedir5 } from "os";
22943
- import { join as join16 } from "path";
22944
+ import { join as join15 } from "path";
22944
22945
  import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual2 } from "crypto";
22945
22946
  import { randomUUID as randomUUID5 } from "crypto";
22946
22947
  import { spawn } from "child_process";
@@ -22953,29 +22954,83 @@ function getPathValue(input, path) {
22953
22954
  return;
22954
22955
  }, input);
22955
22956
  }
22956
- function wildcardToRegExp(pattern) {
22957
- const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
22958
- return new RegExp(`^${escaped}$`);
22957
+ function getFieldValues(input, path) {
22958
+ const values = [];
22959
+ const push = (value) => {
22960
+ if (!values.some((item) => Object.is(item, value)))
22961
+ values.push(value);
22962
+ };
22963
+ if (path.includes(".") && path in input)
22964
+ push(input[path]);
22965
+ const nestedValue = getPathValue(input, path);
22966
+ if (nestedValue !== undefined || !path.includes("."))
22967
+ push(nestedValue);
22968
+ return values;
22969
+ }
22970
+ function wildcardToRegExp(pattern, options = {}) {
22971
+ let body = "";
22972
+ for (let index = 0;index < pattern.length; index += 1) {
22973
+ const char = pattern[index];
22974
+ if (char === "*") {
22975
+ if (pattern[index + 1] === "*") {
22976
+ body += ".*";
22977
+ index += 1;
22978
+ } else {
22979
+ body += options.segmentSafe ? "[^/]*" : ".*";
22980
+ }
22981
+ } else {
22982
+ body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
22983
+ }
22984
+ }
22985
+ return new RegExp(`^${body}$`);
22959
22986
  }
22960
- function matchString(value, matcher) {
22987
+ function matchString(value, matcher, options = {}) {
22961
22988
  if (matcher === undefined)
22962
22989
  return true;
22963
22990
  if (value === undefined)
22964
22991
  return false;
22965
22992
  const matchers = Array.isArray(matcher) ? matcher : [matcher];
22966
- return matchers.some((item) => wildcardToRegExp(item).test(value));
22993
+ return matchers.some((item) => wildcardToRegExp(item, options).test(value));
22967
22994
  }
22968
22995
  function matchRecord(input, matcher) {
22969
22996
  if (!matcher)
22970
22997
  return true;
22971
22998
  return Object.entries(matcher).every(([path, expected]) => {
22972
- const actual = getPathValue(input, path);
22973
- if (typeof expected === "string" || Array.isArray(expected)) {
22974
- return matchString(actual === undefined ? undefined : String(actual), expected);
22975
- }
22976
- return actual === expected;
22999
+ const actualValues = getFieldValues(input, path);
23000
+ return matchField(actualValues, expected, path);
22977
23001
  });
22978
23002
  }
23003
+ function matchField(actualValues, expected, path) {
23004
+ if (isNegativeMatcher(expected)) {
23005
+ return !actualValues.some((actual) => matchPositiveField(actual, expected.not, path));
23006
+ }
23007
+ return actualValues.some((actual) => matchPositiveField(actual, expected, path));
23008
+ }
23009
+ function matchPositiveField(actual, expected, path) {
23010
+ if (typeof expected === "string" || Array.isArray(expected)) {
23011
+ return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
23012
+ segmentSafe: path.endsWith("_path") || path.endsWith(".path")
23013
+ }));
23014
+ }
23015
+ if (Array.isArray(actual)) {
23016
+ return actual.some((item) => item === expected);
23017
+ }
23018
+ return actual === expected;
23019
+ }
23020
+ function stringCandidates(actual) {
23021
+ if (actual === undefined)
23022
+ return [];
23023
+ if (Array.isArray(actual)) {
23024
+ return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
23025
+ }
23026
+ return [String(actual)];
23027
+ }
23028
+ function isPrimitiveFieldValue(value) {
23029
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
23030
+ }
23031
+ function isNegativeMatcher(value) {
23032
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
23033
+ }
22979
23034
  function eventMatchesFilter(event, filter) {
22980
23035
  return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
22981
23036
  }
@@ -22987,19 +23042,21 @@ function channelMatchesEvent(channel, event) {
22987
23042
  return channel.filters.some((filter) => eventMatchesFilter(event, filter));
22988
23043
  }
22989
23044
  function getEventsDataDir(override) {
22990
- return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join16(homedir5(), ".hasna", "events");
23045
+ return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join15(homedir5(), ".hasna", "events");
22991
23046
  }
22992
23047
 
22993
23048
  class JsonEventsStore {
22994
23049
  dataDir;
23050
+ runtime;
22995
23051
  channelsPath;
22996
23052
  eventsPath;
22997
23053
  deliveriesPath;
22998
23054
  constructor(dataDir = getEventsDataDir()) {
22999
23055
  this.dataDir = dataDir;
23000
- this.channelsPath = join16(dataDir, "channels.json");
23001
- this.eventsPath = join16(dataDir, "events.json");
23002
- this.deliveriesPath = join16(dataDir, "deliveries.json");
23056
+ this.runtime = localJsonRuntime(dataDir);
23057
+ this.channelsPath = join15(dataDir, "channels.json");
23058
+ this.eventsPath = join15(dataDir, "events.json");
23059
+ this.deliveriesPath = join15(dataDir, "deliveries.json");
23003
23060
  }
23004
23061
  async init() {
23005
23062
  await mkdir(this.dataDir, { recursive: true, mode: 448 });
@@ -23044,13 +23101,58 @@ class JsonEventsStore {
23044
23101
  await this.writeJson(this.eventsPath, events);
23045
23102
  return event;
23046
23103
  }
23047
- async listEvents() {
23104
+ async appendEventOnce(event, options = {}) {
23048
23105
  await this.init();
23049
- return this.readJson(this.eventsPath, []);
23106
+ const events = await this.readJson(this.eventsPath, []);
23107
+ const dedupe = options.dedupe !== false;
23108
+ if (dedupe) {
23109
+ const existing = findEventByIdentity(events, { id: event.id, dedupeKey: event.dedupeKey });
23110
+ if (existing) {
23111
+ return {
23112
+ event: existing,
23113
+ stored: false,
23114
+ deduped: true,
23115
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
23116
+ };
23117
+ }
23118
+ }
23119
+ events.push(event);
23120
+ await this.writeJson(this.eventsPath, events);
23121
+ return {
23122
+ event,
23123
+ stored: true,
23124
+ deduped: false,
23125
+ identity: { id: event.id, dedupeKey: event.dedupeKey }
23126
+ };
23127
+ }
23128
+ async listEvents(options = {}) {
23129
+ await this.init();
23130
+ const events = await this.readJson(this.eventsPath, []);
23131
+ return queryEvents(events, options);
23132
+ }
23133
+ async listEventsPage(options = {}) {
23134
+ await this.init();
23135
+ const events = await this.readJson(this.eventsPath, []);
23136
+ const queried = queryEvents(events, {
23137
+ eventId: options.eventId,
23138
+ source: options.source,
23139
+ type: options.type
23140
+ });
23141
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
23142
+ const limit = normalizeEventPageLimit(options.limit);
23143
+ const pageEvents = queried.slice(offset, offset + limit);
23144
+ const nextOffset = offset + pageEvents.length;
23145
+ const hasMore = nextOffset < queried.length;
23146
+ return {
23147
+ events: pageEvents,
23148
+ cursor: options.cursor,
23149
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
23150
+ hasMore
23151
+ };
23050
23152
  }
23051
23153
  async findEventByIdentity(identity) {
23052
23154
  const events = await this.listEvents();
23053
- return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
23155
+ return findEventByIdentity(events, identity);
23054
23156
  }
23055
23157
  async appendDelivery(result) {
23056
23158
  await this.init();
@@ -23071,7 +23173,7 @@ class JsonEventsStore {
23071
23173
  };
23072
23174
  }
23073
23175
  async ensureArrayFile(path) {
23074
- if (!existsSync14(path)) {
23176
+ if (!existsSync13(path)) {
23075
23177
  await writeFile2(path, `[]
23076
23178
  `, { encoding: "utf-8", mode: 384 });
23077
23179
  }
@@ -23101,6 +23203,83 @@ class JsonEventsStore {
23101
23203
  });
23102
23204
  }
23103
23205
  }
23206
+ function localJsonRuntime(dataDir = getEventsDataDir()) {
23207
+ return {
23208
+ mode: "local-files",
23209
+ name: "json-events-store",
23210
+ remote: false,
23211
+ localFiles: true,
23212
+ localSqlite: false,
23213
+ postgres: false,
23214
+ s3: false,
23215
+ aws: false,
23216
+ durable: true,
23217
+ idempotency: "best-effort-local",
23218
+ replayCursors: true,
23219
+ description: `Local JSON files in ${dataDir}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
23220
+ };
23221
+ }
23222
+ function encodeLocalJsonEventCursor(offset, options = {}) {
23223
+ if (!Number.isInteger(offset) || offset < 0)
23224
+ throw new Error(`Invalid event cursor offset: ${offset}`);
23225
+ const payload = {
23226
+ offset,
23227
+ eventId: options.eventId,
23228
+ source: options.source,
23229
+ type: options.type
23230
+ };
23231
+ return `${LOCAL_JSON_EVENT_CURSOR_PREFIX}${Buffer2.from(JSON.stringify(payload), "utf-8").toString("base64url")}`;
23232
+ }
23233
+ function decodeLocalJsonEventCursor(cursor, options = {}) {
23234
+ if (!cursor)
23235
+ return 0;
23236
+ if (!cursor.startsWith(LOCAL_JSON_EVENT_CURSOR_PREFIX))
23237
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
23238
+ const rawPayload = cursor.slice(LOCAL_JSON_EVENT_CURSOR_PREFIX.length);
23239
+ let payload;
23240
+ try {
23241
+ payload = JSON.parse(Buffer2.from(rawPayload, "base64url").toString("utf-8"));
23242
+ } catch {
23243
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
23244
+ }
23245
+ const offset = payload.offset;
23246
+ if (!Number.isInteger(offset) || offset < 0)
23247
+ throw new Error(`Invalid local JSON event cursor: ${cursor}`);
23248
+ assertCursorFilter("eventId", payload.eventId, options.eventId);
23249
+ assertCursorFilter("source", payload.source, options.source);
23250
+ assertCursorFilter("type", payload.type, options.type);
23251
+ return offset;
23252
+ }
23253
+ function normalizeEventPageLimit(limit) {
23254
+ if (limit === undefined)
23255
+ return DEFAULT_EVENT_PAGE_LIMIT;
23256
+ if (!Number.isInteger(limit) || limit < 1)
23257
+ throw new Error(`Event page limit must be a positive integer, got ${limit}`);
23258
+ return Math.min(limit, MAX_EVENT_PAGE_LIMIT);
23259
+ }
23260
+ function queryEvents(events, options) {
23261
+ let rows = events;
23262
+ if (options.eventId)
23263
+ rows = rows.filter((event) => event.id === options.eventId);
23264
+ if (options.source)
23265
+ rows = rows.filter((event) => event.source === options.source);
23266
+ if (options.type)
23267
+ rows = rows.filter((event) => event.type === options.type);
23268
+ if (options.cursor) {
23269
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
23270
+ rows = rows.slice(offset);
23271
+ }
23272
+ if (options.limit !== undefined)
23273
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
23274
+ return rows;
23275
+ }
23276
+ function assertCursorFilter(name, cursorValue, optionValue) {
23277
+ if (cursorValue !== optionValue)
23278
+ throw new Error(`Local JSON event cursor ${name} filter mismatch`);
23279
+ }
23280
+ function findEventByIdentity(events, identity) {
23281
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
23282
+ }
23104
23283
  function buildSignatureBase(timestamp, body) {
23105
23284
  return `${timestamp}.${body}`;
23106
23285
  }
@@ -23114,21 +23293,27 @@ function now() {
23114
23293
  function truncate(value, max = 4096) {
23115
23294
  return value.length > max ? `${value.slice(0, max)}...` : value;
23116
23295
  }
23117
- function buildWebhookRequest(event, channel) {
23296
+ function buildWebhookRequest(event, channel, options = {}) {
23118
23297
  if (!channel.webhook)
23119
23298
  throw new Error(`Channel ${channel.id} has no webhook config`);
23299
+ for (const name of Object.keys(channel.webhook.headers ?? {})) {
23300
+ if (/^x-hasna-/i.test(name)) {
23301
+ throw new Error(`Webhook header ${name} is reserved for signed delivery metadata`);
23302
+ }
23303
+ }
23120
23304
  const body = JSON.stringify(event);
23121
- const timestamp = event.time;
23305
+ const timestamp = options.timestamp ?? new Date().toISOString();
23122
23306
  const headers = {
23123
23307
  "Content-Type": "application/json",
23124
23308
  "User-Agent": "@hasna/events",
23125
23309
  "X-Hasna-Event-Id": event.id,
23126
23310
  "X-Hasna-Event-Type": event.type,
23127
- "X-Hasna-Timestamp": timestamp,
23128
- ...channel.webhook.headers
23311
+ ...channel.webhook.headers,
23312
+ "X-Hasna-Timestamp": timestamp
23129
23313
  };
23130
- if (channel.webhook.secret) {
23131
- headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
23314
+ const secret = options.secret ?? channel.webhook.secret;
23315
+ if (secret) {
23316
+ headers["X-Hasna-Signature"] = signPayload(secret, timestamp, body);
23132
23317
  }
23133
23318
  return { body, headers };
23134
23319
  }
@@ -23136,7 +23321,21 @@ async function dispatchWebhook(event, channel, options = {}) {
23136
23321
  if (!channel.webhook)
23137
23322
  throw new Error(`Channel ${channel.id} has no webhook config`);
23138
23323
  const startedAt = now();
23139
- const { body, headers } = buildWebhookRequest(event, channel);
23324
+ let secret = channel.webhook.secret;
23325
+ if (channel.webhook.secretRef) {
23326
+ if (!options.secretResolver) {
23327
+ return failedAttempt(startedAt, "Webhook secret reference has no runtime resolver");
23328
+ }
23329
+ try {
23330
+ secret = await options.secretResolver(channel.webhook.secretRef);
23331
+ } catch {
23332
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
23333
+ }
23334
+ if (!secret)
23335
+ return failedAttempt(startedAt, "Webhook secret reference could not be resolved");
23336
+ }
23337
+ const timestamp = (options.now?.() ?? new Date).toISOString();
23338
+ const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp });
23140
23339
  const controller = new AbortController;
23141
23340
  const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
23142
23341
  try {
@@ -23168,6 +23367,15 @@ async function dispatchWebhook(event, channel, options = {}) {
23168
23367
  clearTimeout(timeout);
23169
23368
  }
23170
23369
  }
23370
+ function failedAttempt(startedAt, error) {
23371
+ return {
23372
+ attempt: 1,
23373
+ status: "failed",
23374
+ startedAt,
23375
+ completedAt: now(),
23376
+ error
23377
+ };
23378
+ }
23171
23379
  async function dispatchCommand(event, channel) {
23172
23380
  if (!channel.command)
23173
23381
  throw new Error(`Channel ${channel.id} has no command config`);
@@ -23256,6 +23464,76 @@ function createDeliveryResult(event, channel, attempts) {
23256
23464
  completedAt: attempts.at(-1)?.completedAt ?? now()
23257
23465
  };
23258
23466
  }
23467
+
23468
+ class EventTypeCatalog {
23469
+ definitions = new Map;
23470
+ register(definition) {
23471
+ this.definitions.set(definition.type, definition);
23472
+ return this;
23473
+ }
23474
+ unregister(type) {
23475
+ return this.definitions.delete(type);
23476
+ }
23477
+ has(type) {
23478
+ return this.definitions.has(type);
23479
+ }
23480
+ get(type) {
23481
+ return this.definitions.get(type);
23482
+ }
23483
+ list() {
23484
+ return [...this.definitions.values()];
23485
+ }
23486
+ validateEvent(event) {
23487
+ const definition = this.definitions.get(event.type);
23488
+ if (!definition)
23489
+ return { ok: true };
23490
+ return definition.validate(event.data, event);
23491
+ }
23492
+ assertEventValid(event) {
23493
+ const result = this.validateEvent(event);
23494
+ if (!result.ok) {
23495
+ throw new EventValidationError(event.type, result.issues);
23496
+ }
23497
+ }
23498
+ }
23499
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
23500
+ if (paths.length === 0)
23501
+ return event;
23502
+ const copy = structuredClone(event);
23503
+ for (const path of paths) {
23504
+ setPath(copy, path, replacement);
23505
+ }
23506
+ return copy;
23507
+ }
23508
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
23509
+ return redactValue(event, replacement);
23510
+ }
23511
+ function shouldRedactKey(key) {
23512
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
23513
+ }
23514
+ function redactValue(value, replacement) {
23515
+ if (Array.isArray(value))
23516
+ return value.map((item) => redactValue(item, replacement));
23517
+ if (!value || typeof value !== "object")
23518
+ return value;
23519
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
23520
+ key,
23521
+ shouldRedactKey(key) ? replacement : redactValue(item, replacement)
23522
+ ]));
23523
+ }
23524
+ function setPath(input, path, replacement) {
23525
+ const parts = path.split(".");
23526
+ let cursor = input;
23527
+ for (const part of parts.slice(0, -1)) {
23528
+ const next = cursor[part];
23529
+ if (!next || typeof next !== "object")
23530
+ return;
23531
+ cursor = next;
23532
+ }
23533
+ const last = parts.at(-1);
23534
+ if (last && last in cursor)
23535
+ cursor[last] = replacement;
23536
+ }
23259
23537
  function createEvent(input) {
23260
23538
  return {
23261
23539
  id: input.id ?? randomUUID22(),
@@ -23276,10 +23554,18 @@ class EventsClient {
23276
23554
  store;
23277
23555
  redactors;
23278
23556
  transportOptions;
23557
+ catalog;
23558
+ validateCatalogTypes;
23279
23559
  constructor(options = {}) {
23280
23560
  this.store = options.store ?? new JsonEventsStore(options.dataDir);
23281
23561
  this.redactors = options.redactors ?? [];
23282
- this.transportOptions = { fetchImpl: options.fetchImpl };
23562
+ this.transportOptions = {
23563
+ fetchImpl: options.fetchImpl,
23564
+ secretResolver: options.secretResolver,
23565
+ now: options.now
23566
+ };
23567
+ this.catalog = options.catalog ?? defaultEventTypeCatalog;
23568
+ this.validateCatalogTypes = options.validateCatalogTypes ?? false;
23283
23569
  }
23284
23570
  async addChannel(input) {
23285
23571
  const timestamp = new Date().toISOString();
@@ -23297,18 +23583,40 @@ class EventsClient {
23297
23583
  }
23298
23584
  async emit(input, options = {}) {
23299
23585
  const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
23300
- if (options.dedupe !== false) {
23301
- const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
23302
- if (existing) {
23303
- return { event: existing, deliveries: [], deduped: true };
23304
- }
23305
- }
23306
- await this.store.appendEvent(event);
23307
- const deliveries = options.deliver === false ? [] : await this.deliver(event);
23308
- return { event, deliveries, deduped: false };
23309
- }
23310
- async listEvents() {
23311
- return this.store.listEvents();
23586
+ if (options.validate ?? this.validateCatalogTypes) {
23587
+ this.catalog.assertEventValid(event);
23588
+ }
23589
+ const append = await this.appendEvent(event, { dedupe: options.dedupe !== false });
23590
+ if (append.deduped) {
23591
+ return { event: append.event, deliveries: [], deduped: true };
23592
+ }
23593
+ const deliveries = options.deliver === false ? [] : await this.deliver(append.event);
23594
+ return { event: append.event, deliveries, deduped: false };
23595
+ }
23596
+ async listEvents(options = {}) {
23597
+ if (Object.keys(options).length === 0)
23598
+ return this.store.listEvents();
23599
+ return queryClientEvents(await this.store.listEvents(), options);
23600
+ }
23601
+ async listEventsPage(options = {}) {
23602
+ if (this.store.listEventsPage)
23603
+ return this.store.listEventsPage(options);
23604
+ const events = queryClientEvents(await this.store.listEvents(), {
23605
+ eventId: options.eventId,
23606
+ source: options.source,
23607
+ type: options.type
23608
+ });
23609
+ const offset = decodeLocalJsonEventCursor(options.cursor, options);
23610
+ const limit = normalizeEventPageLimit(options.limit);
23611
+ const pageEvents = events.slice(offset, offset + limit);
23612
+ const nextOffset = offset + pageEvents.length;
23613
+ const hasMore = nextOffset < events.length;
23614
+ return {
23615
+ events: pageEvents,
23616
+ cursor: options.cursor,
23617
+ nextCursor: hasMore ? encodeLocalJsonEventCursor(nextOffset, options) : undefined,
23618
+ hasMore
23619
+ };
23312
23620
  }
23313
23621
  async listDeliveries() {
23314
23622
  return this.store.listDeliveries();
@@ -23325,7 +23633,7 @@ class EventsClient {
23325
23633
  }
23326
23634
  return deliveries;
23327
23635
  }
23328
- async testChannel(id, input = {}) {
23636
+ async matchChannel(id, input = {}) {
23329
23637
  const channel = await this.store.getChannel(id);
23330
23638
  if (!channel)
23331
23639
  throw new Error(`Channel not found: ${id}`);
@@ -23342,28 +23650,71 @@ class EventsClient {
23342
23650
  time: input.time,
23343
23651
  id: input.id
23344
23652
  });
23653
+ const matched = channelMatchesEvent(channel, event);
23654
+ return {
23655
+ channelId: channel.id,
23656
+ matched,
23657
+ event,
23658
+ filters: channel.filters,
23659
+ reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
23660
+ };
23661
+ }
23662
+ async testChannel(id, input = {}, options = {}) {
23663
+ const channel = await this.store.getChannel(id);
23664
+ if (!channel)
23665
+ throw new Error(`Channel not found: ${id}`);
23666
+ const match = await this.matchChannel(id, input);
23667
+ const event = match.event;
23668
+ if (options.honorFilters && !match.matched) {
23669
+ const timestamp = new Date().toISOString();
23670
+ const result2 = createDeliveryResult(event, channel, [{
23671
+ attempt: 1,
23672
+ status: "skipped",
23673
+ startedAt: timestamp,
23674
+ completedAt: timestamp,
23675
+ error: match.reason
23676
+ }]);
23677
+ result2.metadata = { reason: "filter_mismatch" };
23678
+ await this.store.appendDelivery(result2);
23679
+ return result2;
23680
+ }
23345
23681
  const eventForChannel = await this.applyRedaction(event, channel);
23346
23682
  const result = await this.deliverWithRetry(eventForChannel, channel);
23347
23683
  await this.store.appendDelivery(result);
23348
23684
  return result;
23349
23685
  }
23350
23686
  async replay(options = {}) {
23351
- const events = (await this.store.listEvents()).filter((event) => {
23352
- if (options.eventId && event.id !== options.eventId)
23353
- return false;
23354
- if (options.source && event.source !== options.source)
23355
- return false;
23356
- if (options.type && event.type !== options.type)
23357
- return false;
23358
- return true;
23359
- });
23687
+ const page = options.cursor || options.limit !== undefined ? await this.listEventsPage(options) : { events: await this.listEvents(options), hasMore: false };
23360
23688
  if (options.dryRun)
23361
- return { events, deliveries: [] };
23689
+ return { events: page.events, deliveries: [], cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
23362
23690
  const deliveries = [];
23363
- for (const event of events) {
23691
+ for (const event of page.events) {
23364
23692
  deliveries.push(...await this.deliver(event));
23365
23693
  }
23366
- return { events, deliveries };
23694
+ return { events: page.events, deliveries, cursor: page.cursor, nextCursor: page.nextCursor, hasMore: page.hasMore };
23695
+ }
23696
+ async appendEvent(event, options) {
23697
+ if (this.store.appendEventOnce) {
23698
+ return this.store.appendEventOnce(event, { dedupe: options.dedupe });
23699
+ }
23700
+ if (options.dedupe) {
23701
+ const existing = await this.store.findEventByIdentity({ id: event.id, dedupeKey: event.dedupeKey });
23702
+ if (existing) {
23703
+ return {
23704
+ event: existing,
23705
+ stored: false,
23706
+ deduped: true,
23707
+ identity: { id: existing.id, dedupeKey: existing.dedupeKey }
23708
+ };
23709
+ }
23710
+ }
23711
+ const stored = await this.store.appendEvent(event);
23712
+ return {
23713
+ event: stored,
23714
+ stored: true,
23715
+ deduped: false,
23716
+ identity: { id: stored.id, dedupeKey: stored.dedupeKey }
23717
+ };
23367
23718
  }
23368
23719
  async applyRedaction(event, channel) {
23369
23720
  let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
@@ -23390,43 +23741,19 @@ class EventsClient {
23390
23741
  return createDeliveryResult(event, channel, attempts);
23391
23742
  }
23392
23743
  }
23393
- function redactPaths(event, paths, replacement = "[REDACTED]") {
23394
- if (paths.length === 0)
23395
- return event;
23396
- const copy = structuredClone(event);
23397
- for (const path of paths) {
23398
- setPath(copy, path, replacement);
23399
- }
23400
- return copy;
23401
- }
23402
- function redactSensitiveKeys(event, replacement = "[REDACTED]") {
23403
- return redactValue(event, replacement);
23404
- }
23405
- function shouldRedactKey(key) {
23406
- return /secret|token|password|api[_-]?key|authorization/i.test(key);
23407
- }
23408
- function redactValue(value, replacement) {
23409
- if (Array.isArray(value))
23410
- return value.map((item) => redactValue(item, replacement));
23411
- if (!value || typeof value !== "object")
23412
- return value;
23413
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
23414
- key,
23415
- shouldRedactKey(key) ? replacement : redactValue(item, replacement)
23416
- ]));
23417
- }
23418
- function setPath(input, path, replacement) {
23419
- const parts = path.split(".");
23420
- let cursor = input;
23421
- for (const part of parts.slice(0, -1)) {
23422
- const next = cursor[part];
23423
- if (!next || typeof next !== "object")
23424
- return;
23425
- cursor = next;
23426
- }
23427
- const last = parts.at(-1);
23428
- if (last && last in cursor)
23429
- cursor[last] = replacement;
23744
+ function queryClientEvents(events, options) {
23745
+ let rows = events;
23746
+ if (options.eventId)
23747
+ rows = rows.filter((event) => event.id === options.eventId);
23748
+ if (options.source)
23749
+ rows = rows.filter((event) => event.source === options.source);
23750
+ if (options.type)
23751
+ rows = rows.filter((event) => event.type === options.type);
23752
+ if (options.cursor)
23753
+ rows = rows.slice(decodeLocalJsonEventCursor(options.cursor, options));
23754
+ if (options.limit !== undefined)
23755
+ rows = rows.slice(0, normalizeEventPageLimit(options.limit));
23756
+ return rows;
23430
23757
  }
23431
23758
  function normalizeTime(value) {
23432
23759
  if (!value)
@@ -23440,9 +23767,22 @@ function normalizeRetryPolicy(policy) {
23440
23767
  multiplier: Math.max(1, policy?.multiplier ?? 2)
23441
23768
  };
23442
23769
  }
23443
- var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", DEFAULT_SIGNATURE_TOLERANCE_MS;
23770
+ var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
23444
23771
  var init_dist = __esm(() => {
23445
23772
  DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
23773
+ EventValidationError = class EventValidationError extends Error {
23774
+ eventType;
23775
+ issues;
23776
+ constructor(eventType, issues) {
23777
+ const detail = issues.map((issue) => `${issue.path || "<root>"}: ${issue.message}`).join("; ");
23778
+ super(`Event validation failed for type "${eventType}": ${detail}`);
23779
+ this.name = "EventValidationError";
23780
+ this.eventType = eventType;
23781
+ this.issues = issues;
23782
+ }
23783
+ };
23784
+ defaultEventTypeCatalog = new EventTypeCatalog;
23785
+ APP_EVENT_V1_MAX_DATA_BYTES = 32 * 1024;
23446
23786
  });
23447
23787
 
23448
23788
  // src/sdk/event-sink.ts
@@ -23526,6 +23866,7 @@ var ANY_SEGMENT_EXCLUDES = new Set([
23526
23866
  ".docker"
23527
23867
  ]);
23528
23868
  var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
23869
+ var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json"]);
23529
23870
  var CREDENTIAL_FILENAMES = new Set([
23530
23871
  ".npmrc",
23531
23872
  ".pypirc",
@@ -33591,9 +33932,10 @@ function resolveServerConfig(env = process.env) {
33591
33932
  artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
33592
33933
  artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
33593
33934
  inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
33594
- bundleSigningKey: env.HASNA_SKILLS_SIGNING_KEY || undefined,
33935
+ bundleSigningKey: env.HASNA_SKILLS_API_SIGNING_KEY || env.HASNA_SKILLS_SIGNING_KEY || undefined,
33595
33936
  requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
33596
33937
  skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
33938
+ tombstoneWindowMs: parsePositiveInt(env.HASNA_SKILLS_TOMBSTONE_WINDOW_MS, 7 * 24 * 60 * 60 * 1000),
33597
33939
  publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
33598
33940
  nodeEnv,
33599
33941
  allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
@@ -33671,6 +34013,11 @@ function normalizeConfigValue(key, value) {
33671
34013
  }
33672
34014
  var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
33673
34015
  var INSTALLED_SKILLS_DIRNAME = "installed";
34016
+ var SKILLS_CACHE_DIRNAME = "skills";
34017
+ var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
34018
+ function isOwnerLayoutMigrated(appDir) {
34019
+ return existsSync(join2(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
34020
+ }
33674
34021
  function getDataDir() {
33675
34022
  const override = process.env[DATA_DIR_ENV];
33676
34023
  if (override) {
@@ -33849,7 +34196,7 @@ function looksLikeSqlitePath(value) {
33849
34196
  }
33850
34197
 
33851
34198
  // src/server/handlers.ts
33852
- import { createHash as createHash5 } from "crypto";
34199
+ import { createHash as createHash6 } from "crypto";
33853
34200
 
33854
34201
  // src/server/store.ts
33855
34202
  import { randomUUID as randomUUID3 } from "crypto";
@@ -33870,6 +34217,19 @@ class StaleLeaseGenerationError extends Error {
33870
34217
  }
33871
34218
  }
33872
34219
 
34220
+ class SkillRevisionConflictError extends Error {
34221
+ slug;
34222
+ expectedRevisionId;
34223
+ currentRevisionId;
34224
+ constructor(slug, expectedRevisionId, currentRevisionId) {
34225
+ super(`revision conflict for '${slug}': expected revision ${expectedRevisionId ?? "(none)"}, ` + `current is ${currentRevisionId ?? "(none)"}. Refused rather than silently overwriting a newer revision.`);
34226
+ this.name = "SkillRevisionConflictError";
34227
+ this.slug = slug;
34228
+ this.expectedRevisionId = expectedRevisionId;
34229
+ this.currentRevisionId = currentRevisionId;
34230
+ }
34231
+ }
34232
+
33873
34233
  // src/server/rows.ts
33874
34234
  import { randomUUID } from "crypto";
33875
34235
  function nowIso() {
@@ -33950,7 +34310,20 @@ function rowToSkill(row) {
33950
34310
  ...row.bundle_byte_size === null || row.bundle_byte_size === undefined ? {} : { bundleByteSize: Number(row.bundle_byte_size) },
33951
34311
  ...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
33952
34312
  createdAt: dateString(row.created_at),
33953
- updatedAt: dateString(row.updated_at)
34313
+ updatedAt: dateString(row.updated_at),
34314
+ revisionId: String(row.revision_id ?? ""),
34315
+ revisionNumber: Number(row.revision_number ?? 0),
34316
+ ...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
34317
+ ...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
34318
+ };
34319
+ }
34320
+ function rowToPin(row) {
34321
+ return {
34322
+ orgId: String(row.org_id),
34323
+ principal: String(row.principal),
34324
+ slug: String(row.slug),
34325
+ pinnedAt: dateString(row.pinned_at),
34326
+ metadata: parseJsonObject(row.metadata_json)
33954
34327
  };
33955
34328
  }
33956
34329
  function rowToSkillBundle(row) {
@@ -34002,6 +34375,29 @@ function dateString(value) {
34002
34375
  return String(value);
34003
34376
  }
34004
34377
 
34378
+ // src/lib/revision.ts
34379
+ import { createHash as createHash5 } from "crypto";
34380
+ var REVISION_ID_PATTERN = /^[0-9a-f]{64}$/;
34381
+ function revisionIdOf(content) {
34382
+ const canonical = JSON.stringify({
34383
+ slug: content.slug,
34384
+ displayName: content.displayName,
34385
+ description: content.description,
34386
+ category: content.category,
34387
+ tags: content.tags,
34388
+ source: content.source,
34389
+ kind: content.kind,
34390
+ version: content.version ?? null,
34391
+ skillMd: content.skillMd ?? null,
34392
+ bundleSha256: content.bundleSha256 ?? null,
34393
+ bundleByteSize: content.bundleByteSize ?? null
34394
+ });
34395
+ return createHash5("sha256").update(canonical).digest("hex");
34396
+ }
34397
+ function revisionIdOfRecord(record) {
34398
+ return revisionIdOf(record);
34399
+ }
34400
+
34005
34401
  // src/server/sqlite-store.ts
34006
34402
  import { Database } from "bun:sqlite";
34007
34403
  import { randomUUID as randomUUID2 } from "crypto";
@@ -34049,6 +34445,7 @@ function defaultStartDirs() {
34049
34445
  // src/server/sqlite-store.ts
34050
34446
  var CLAIM_ATTEMPTS = 8;
34051
34447
  var CLAIMABLE_STATUSES = ["queued", "retrying"];
34448
+ var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
34052
34449
  var LAST_USED_RESOLUTION_MS = 60000;
34053
34450
 
34054
34451
  class SqliteSkillsStore {
@@ -34068,12 +34465,24 @@ class SqliteSkillsStore {
34068
34465
  if (options.migrate !== false) {
34069
34466
  applySqliteMigrations(this.db, options.migrationsDir);
34070
34467
  }
34468
+ this.backfillLegacyRevisions();
34071
34469
  this.backend = {
34072
34470
  kind: "sqlite",
34073
34471
  durable: !inMemory,
34074
34472
  label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
34075
34473
  };
34076
34474
  }
34475
+ backfillLegacyRevisions() {
34476
+ const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
34477
+ for (const row of rows) {
34478
+ const record = rowToSkill(row);
34479
+ this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
34480
+ revisionIdOfRecord(record),
34481
+ record.orgId,
34482
+ record.slug
34483
+ ]);
34484
+ }
34485
+ }
34077
34486
  get database() {
34078
34487
  return this.db;
34079
34488
  }
@@ -34297,8 +34706,29 @@ class SqliteSkillsStore {
34297
34706
  const orgId = input.principal.orgId;
34298
34707
  const now = nowIso();
34299
34708
  return this.db.transaction(() => {
34300
- const previous = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
34709
+ 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]);
34301
34710
  const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
34711
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
34712
+ const tombstoned = previous?.tombstoned_at != null;
34713
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
34714
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
34715
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
34716
+ }
34717
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
34718
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
34719
+ const revisionId = revisionIdOfRecord({
34720
+ slug: input.slug,
34721
+ displayName: input.displayName,
34722
+ description: input.description,
34723
+ category: input.category,
34724
+ tags: input.tags,
34725
+ source: input.source,
34726
+ kind: input.kind,
34727
+ ...input.version ? { version: input.version } : {},
34728
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
34729
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
34730
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
34731
+ });
34302
34732
  if (input.bundle) {
34303
34733
  this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
34304
34734
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -34319,8 +34749,8 @@ class SqliteSkillsStore {
34319
34749
  ]);
34320
34750
  }
34321
34751
  const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
34322
- bundle_sha256, bundle_byte_size, published_by_user_id, created_at, updated_at)
34323
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
34752
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
34753
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
34324
34754
  ON CONFLICT (org_id, slug) DO UPDATE SET
34325
34755
  display_name = excluded.display_name,
34326
34756
  description = excluded.description,
@@ -34338,7 +34768,16 @@ class SqliteSkillsStore {
34338
34768
  bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
34339
34769
  bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
34340
34770
  published_by_user_id = excluded.published_by_user_id,
34771
+ revision_id = excluded.revision_id,
34772
+ -- Current + 1, not the inserted 1: on the update path the ACTUAL row's
34773
+ -- counter is the truth (it may have advanced since the pre-read, which is
34774
+ -- exactly what the WHERE guard below detects).
34775
+ revision_number = skills_registry.revision_number + 1,
34776
+ tombstoned_at = NULL,
34777
+ tombstone_purge_after = NULL,
34341
34778
  updated_at = excluded.updated_at
34779
+ WHERE skills_registry.tombstoned_at IS NOT NULL
34780
+ OR skills_registry.revision_id = ?
34342
34781
  RETURNING *`, [
34343
34782
  orgId,
34344
34783
  input.slug,
@@ -34349,62 +34788,165 @@ class SqliteSkillsStore {
34349
34788
  input.source,
34350
34789
  input.kind,
34351
34790
  input.version ?? null,
34352
- input.skillMd ?? null,
34791
+ carriedSkillMd,
34353
34792
  input.bundle?.sha256 ?? null,
34354
34793
  input.bundle?.byteSize ?? null,
34355
34794
  input.principal.userId,
34795
+ revisionId,
34356
34796
  now,
34357
- now
34797
+ now,
34798
+ input.expectedRevisionId ?? NO_REVISION_SENTINEL
34358
34799
  ]);
34800
+ if (!row) {
34801
+ const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
34802
+ const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
34803
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
34804
+ }
34359
34805
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
34360
34806
  this.collectOrphanBundle(orgId, previousSha);
34807
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
34808
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
34809
+ for (const tag of input.tags) {
34810
+ if (!tag.trim())
34811
+ continue;
34812
+ insertTag.run(orgId, input.slug, tag);
34813
+ }
34361
34814
  return rowToSkill(row);
34362
34815
  })();
34363
34816
  }
34364
34817
  async listSkills(principal) {
34365
- return this.all("SELECT * FROM skills_registry WHERE org_id = ? ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
34818
+ await this.purgeExpiredTombstones(principal);
34819
+ return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
34366
34820
  }
34367
34821
  async getSkill(principal, slug) {
34368
34822
  const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
34369
34823
  return row ? rowToSkill(row) : null;
34370
34824
  }
34371
- async updateSkill(principal, slug, patch) {
34825
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
34372
34826
  const current = await this.getSkill(principal, slug);
34373
- if (!current)
34827
+ if (!current || current.tombstonedAt)
34374
34828
  return null;
34829
+ if (expectedRevisionId !== current.revisionId) {
34830
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
34831
+ }
34375
34832
  const next = { ...current, ...patch };
34376
- const row = this.get(`UPDATE skills_registry
34377
- SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?, updated_at = ?
34378
- WHERE org_id = ? AND slug = ?
34379
- RETURNING *`, [
34380
- next.displayName,
34381
- next.description,
34382
- next.category,
34383
- JSON.stringify(next.tags),
34384
- next.kind,
34385
- next.version ?? null,
34386
- next.skillMd ?? null,
34387
- nowIso(),
34388
- principal.orgId,
34389
- slug
34390
- ]);
34391
- return row ? rowToSkill(row) : null;
34833
+ return this.db.transaction(() => {
34834
+ const revisionId = revisionIdOfRecord(next);
34835
+ const row = this.get(`UPDATE skills_registry
34836
+ SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
34837
+ revision_id = ?, revision_number = revision_number + 1, updated_at = ?
34838
+ WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
34839
+ RETURNING *`, [
34840
+ next.displayName,
34841
+ next.description,
34842
+ next.category,
34843
+ JSON.stringify(next.tags),
34844
+ next.kind,
34845
+ next.version ?? null,
34846
+ next.skillMd ?? null,
34847
+ revisionId,
34848
+ nowIso(),
34849
+ principal.orgId,
34850
+ slug,
34851
+ current.revisionId
34852
+ ]);
34853
+ if (!row) {
34854
+ const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
34855
+ principal.orgId,
34856
+ slug
34857
+ ]);
34858
+ if (nowRow && nowRow.tombstoned_at == null) {
34859
+ const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
34860
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
34861
+ }
34862
+ return null;
34863
+ }
34864
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
34865
+ const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
34866
+ for (const tag of next.tags) {
34867
+ if (!tag.trim())
34868
+ continue;
34869
+ insertTag.run(principal.orgId, slug, tag);
34870
+ }
34871
+ return rowToSkill(row);
34872
+ })();
34392
34873
  }
34393
- async deleteSkill(principal, slug) {
34874
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
34394
34875
  return this.db.transaction(() => {
34395
- const existing = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
34876
+ const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
34396
34877
  if (!existing)
34397
- return false;
34398
- this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
34399
- if (typeof existing.bundle_sha256 === "string")
34400
- this.collectOrphanBundle(principal.orgId, existing.bundle_sha256);
34401
- return true;
34878
+ return null;
34879
+ if (existing.tombstoned_at != null) {
34880
+ const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
34881
+ return rowToSkill(row2);
34882
+ }
34883
+ const tombstonedAt = nowIso();
34884
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
34885
+ const row = this.get(`UPDATE skills_registry
34886
+ SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
34887
+ WHERE org_id = ? AND slug = ?
34888
+ RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
34889
+ return rowToSkill(row);
34890
+ })();
34891
+ }
34892
+ async purgeExpiredTombstones(principal) {
34893
+ return this.db.transaction(() => {
34894
+ const now = nowIso();
34895
+ const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
34896
+ const purged = [];
34897
+ for (const row of expired) {
34898
+ const record = rowToSkill(row);
34899
+ this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
34900
+ this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
34901
+ if (record.bundleSha256)
34902
+ this.collectOrphanBundle(principal.orgId, record.bundleSha256);
34903
+ purged.push(record);
34904
+ }
34905
+ return purged;
34402
34906
  })();
34403
34907
  }
34404
34908
  async getSkillBundle(principal, sha256) {
34405
34909
  const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
34406
34910
  return row ? rowToSkillBundle(row) : null;
34407
34911
  }
34912
+ async pinSkill(principal, slug, metadata = {}) {
34913
+ const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
34914
+ VALUES (?, ?, ?, ?, ?)
34915
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
34916
+ pinned_at = excluded.pinned_at,
34917
+ metadata_json = excluded.metadata_json
34918
+ RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
34919
+ return rowToPin(row);
34920
+ }
34921
+ async unpinSkill(principal, slug) {
34922
+ const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
34923
+ return result.changes > 0;
34924
+ }
34925
+ async listPins(principal) {
34926
+ return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
34927
+ }
34928
+ async listTags(principal) {
34929
+ await this.purgeExpiredTombstones(principal);
34930
+ return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
34931
+ }
34932
+ async listSkillsByTag(principal, tag) {
34933
+ await this.purgeExpiredTombstones(principal);
34934
+ return this.all(`SELECT s.* FROM skills_registry s
34935
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
34936
+ WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
34937
+ ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
34938
+ }
34939
+ async listPinsByTag(principal, tag) {
34940
+ await this.purgeExpiredTombstones(principal);
34941
+ return this.all(`SELECT p.* FROM skills_pins p
34942
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
34943
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
34944
+ WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
34945
+ ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
34946
+ }
34947
+ async listPublishedSlugs(principal) {
34948
+ 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));
34949
+ }
34408
34950
  collectOrphanBundle(orgId, sha256) {
34409
34951
  const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
34410
34952
  if (referenced)
@@ -34491,6 +35033,21 @@ function parseScopes(value) {
34491
35033
  }
34492
35034
 
34493
35035
  // src/server/store.ts
35036
+ function recordFieldsOf(input, carriedBundle, carriedSkillMd) {
35037
+ return {
35038
+ slug: input.slug,
35039
+ displayName: input.displayName,
35040
+ description: input.description,
35041
+ category: input.category,
35042
+ tags: input.tags,
35043
+ source: input.source,
35044
+ kind: input.kind,
35045
+ ...input.version ? { version: input.version } : {},
35046
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
35047
+ bundleSha256: input.bundle?.sha256 ?? carriedBundle.bundleSha256,
35048
+ bundleByteSize: input.bundle?.byteSize ?? carriedBundle.bundleByteSize
35049
+ };
35050
+ }
34494
35051
  function resolvePoolMax(env = process.env) {
34495
35052
  const parsed = Number.parseInt(env.HASNA_SKILLS_DATABASE_POOL_MAX || env.SKILLS_DATABASE_POOL_MAX || "", 10);
34496
35053
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
@@ -34528,6 +35085,7 @@ class MemorySkillsStore {
34528
35085
  idempotency = new Map;
34529
35086
  skills = new Map;
34530
35087
  bundles = new Map;
35088
+ pins = new Map;
34531
35089
  constructor(apiKeys = []) {
34532
35090
  for (const key of apiKeys)
34533
35091
  this.addApiKey(key.token, key.principal);
@@ -34629,6 +35187,9 @@ class MemorySkillsStore {
34629
35187
  const key = skillKey(input.principal.orgId, input.slug);
34630
35188
  const now = nowIso();
34631
35189
  const previous = this.skills.get(key);
35190
+ if (previous && !previous.tombstonedAt && input.expectedRevisionId !== previous.revisionId) {
35191
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previous.revisionId);
35192
+ }
34632
35193
  if (input.bundle) {
34633
35194
  const bundleMapKey = skillKey(input.principal.orgId, input.bundle.sha256);
34634
35195
  this.bundles.set(bundleMapKey, {
@@ -34638,6 +35199,8 @@ class MemorySkillsStore {
34638
35199
  createdAt: this.bundles.get(bundleMapKey)?.createdAt ?? now
34639
35200
  });
34640
35201
  }
35202
+ const carriedBundle = !input.bundle && previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {};
35203
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : previous?.skillMd;
34641
35204
  const record = {
34642
35205
  orgId: input.principal.orgId,
34643
35206
  slug: input.slug,
@@ -34648,11 +35211,13 @@ class MemorySkillsStore {
34648
35211
  source: input.source,
34649
35212
  kind: input.kind,
34650
35213
  ...input.version ? { version: input.version } : {},
34651
- ...input.skillMd ? { skillMd: input.skillMd } : {},
34652
- ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {},
35214
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
35215
+ ...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : carriedBundle,
34653
35216
  publishedByUserId: input.principal.userId,
34654
35217
  createdAt: previous?.createdAt ?? now,
34655
- updatedAt: now
35218
+ updatedAt: now,
35219
+ revisionId: revisionIdOfRecord(recordFieldsOf(input, carriedBundle, carriedSkillMd)),
35220
+ revisionNumber: (previous?.revisionNumber ?? 0) + 1
34656
35221
  };
34657
35222
  this.skills.set(key, record);
34658
35223
  if (previous?.bundleSha256 && input.bundle && previous.bundleSha256 !== input.bundle.sha256) {
@@ -34661,33 +35226,107 @@ class MemorySkillsStore {
34661
35226
  return record;
34662
35227
  }
34663
35228
  async listSkills(principal) {
34664
- return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
35229
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
34665
35230
  }
34666
35231
  async getSkill(principal, slug) {
34667
35232
  const skill = this.skills.get(skillKey(principal.orgId, slug));
34668
35233
  return skill && skill.orgId === principal.orgId ? skill : null;
34669
35234
  }
34670
- async updateSkill(principal, slug, patch) {
35235
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
34671
35236
  const current = await this.getSkill(principal, slug);
34672
- if (!current)
35237
+ if (!current || current.tombstonedAt)
35238
+ return null;
35239
+ if (expectedRevisionId !== current.revisionId) {
35240
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
35241
+ }
35242
+ const latest = this.skills.get(skillKey(principal.orgId, slug));
35243
+ if (!latest || latest.tombstonedAt)
34673
35244
  return null;
34674
- const next = { ...current, ...patch, updatedAt: nowIso() };
35245
+ if (latest.revisionId !== current.revisionId) {
35246
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, latest.revisionId);
35247
+ }
35248
+ const next = {
35249
+ ...latest,
35250
+ ...patch,
35251
+ updatedAt: nowIso(),
35252
+ revisionId: revisionIdOfRecord({ ...latest, ...patch }),
35253
+ revisionNumber: latest.revisionNumber + 1
35254
+ };
34675
35255
  this.skills.set(skillKey(principal.orgId, slug), next);
34676
35256
  return next;
34677
35257
  }
34678
- async deleteSkill(principal, slug) {
35258
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
34679
35259
  const current = await this.getSkill(principal, slug);
34680
35260
  if (!current)
34681
- return false;
34682
- this.skills.delete(skillKey(principal.orgId, slug));
34683
- if (current.bundleSha256)
34684
- this.collectOrphanBundle(principal.orgId, current.bundleSha256);
34685
- return true;
35261
+ return null;
35262
+ if (!current.tombstonedAt) {
35263
+ const tombstoned = nowIso();
35264
+ const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
35265
+ const next = { ...current, tombstonedAt: tombstoned, tombstonePurgeAfter: purgeAfter, updatedAt: tombstoned };
35266
+ this.skills.set(skillKey(principal.orgId, slug), next);
35267
+ return next;
35268
+ }
35269
+ return current;
35270
+ }
35271
+ async purgeExpiredTombstones(principal) {
35272
+ const now = nowIso();
35273
+ const purged = [];
35274
+ for (const [key, skill] of this.skills) {
35275
+ if (skill.orgId !== principal.orgId || !skill.tombstonedAt || !skill.tombstonePurgeAfter)
35276
+ continue;
35277
+ if (skill.tombstonePurgeAfter > now)
35278
+ continue;
35279
+ this.skills.delete(key);
35280
+ if (skill.bundleSha256)
35281
+ this.collectOrphanBundle(principal.orgId, skill.bundleSha256);
35282
+ purged.push(skill);
35283
+ }
35284
+ return purged;
34686
35285
  }
34687
35286
  async getSkillBundle(principal, sha256) {
34688
35287
  const bundle = this.bundles.get(skillKey(principal.orgId, sha256));
34689
35288
  return bundle && bundle.orgId === principal.orgId ? bundle : null;
34690
35289
  }
35290
+ async pinSkill(principal, slug, metadata = {}) {
35291
+ const pin = { orgId: principal.orgId, principal: principal.apiKeyId, slug, pinnedAt: nowIso(), metadata: { ...metadata } };
35292
+ this.pins.set(pinKey(principal.orgId, principal.apiKeyId, slug), pin);
35293
+ return pin;
35294
+ }
35295
+ async unpinSkill(principal, slug) {
35296
+ return this.pins.delete(pinKey(principal.orgId, principal.apiKeyId, slug));
35297
+ }
35298
+ async listPins(principal) {
35299
+ return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
35300
+ }
35301
+ async listTags(principal) {
35302
+ await this.purgeExpiredTombstones(principal);
35303
+ const tags = new Set;
35304
+ for (const skill of this.skills.values()) {
35305
+ if (skill.orgId !== principal.orgId)
35306
+ continue;
35307
+ for (const tag of skill.tags) {
35308
+ if (tag.trim())
35309
+ tags.add(tag);
35310
+ }
35311
+ }
35312
+ return [...tags].sort();
35313
+ }
35314
+ async listSkillsByTag(principal, tag) {
35315
+ await this.purgeExpiredTombstones(principal);
35316
+ 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));
35317
+ }
35318
+ async listPinsByTag(principal, tag) {
35319
+ await this.purgeExpiredTombstones(principal);
35320
+ const taggedSlugs = new Set;
35321
+ for (const skill of this.skills.values()) {
35322
+ if (skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag))
35323
+ taggedSlugs.add(skill.slug);
35324
+ }
35325
+ 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));
35326
+ }
35327
+ async listPublishedSlugs(principal) {
35328
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).map((skill) => skill.slug).sort();
35329
+ }
34691
35330
  collectOrphanBundle(orgId, sha256) {
34692
35331
  const referenced = Array.from(this.skills.values()).some((skill) => skill.orgId === orgId && skill.bundleSha256 === sha256);
34693
35332
  if (!referenced)
@@ -34705,6 +35344,9 @@ class MemorySkillsStore {
34705
35344
  function skillKey(orgId, slug) {
34706
35345
  return `${orgId.length}:${orgId}:${slug}`;
34707
35346
  }
35347
+ function pinKey(orgId, principal, slug) {
35348
+ return `${orgId.length}:${orgId}:${principal.length}:${principal}:${slug}`;
35349
+ }
34708
35350
 
34709
35351
  class PostgresSkillsStore {
34710
35352
  backend = { kind: "postgres", durable: true, label: "postgres" };
@@ -34724,6 +35366,14 @@ class PostgresSkillsStore {
34724
35366
  } catch (error) {
34725
35367
  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)}`);
34726
35368
  }
35369
+ await this.backfillLegacyRevisions();
35370
+ }
35371
+ async backfillLegacyRevisions() {
35372
+ const rows = await this.sql`SELECT * FROM skills_registry WHERE revision_id = ${""}`;
35373
+ for (const row of rows) {
35374
+ const record = rowToSkill(row);
35375
+ await this.sql`UPDATE skills_registry SET revision_id = ${revisionIdOfRecord(record)} WHERE org_id = ${record.orgId} AND slug = ${record.slug}`;
35376
+ }
34727
35377
  }
34728
35378
  async close() {
34729
35379
  await this.sql.close?.();
@@ -34958,8 +35608,33 @@ class PostgresSkillsStore {
34958
35608
  async publishSkill(input) {
34959
35609
  const orgId = input.principal.orgId;
34960
35610
  return await this.sql.begin(async (tx) => {
34961
- const previousRows = await tx`SELECT bundle_sha256 FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
34962
- const previousSha = typeof previousRows[0]?.bundle_sha256 === "string" ? String(previousRows[0].bundle_sha256) : null;
35611
+ const previousRows = await tx`
35612
+ SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at
35613
+ FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1
35614
+ `;
35615
+ const previous = previousRows[0];
35616
+ const previousSha = typeof previous?.bundle_sha256 === "string" ? String(previous.bundle_sha256) : null;
35617
+ const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? String(previous.revision_id) : null;
35618
+ const tombstoned = previous?.tombstoned_at != null;
35619
+ const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? String(previous.skill_md) : null;
35620
+ if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
35621
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
35622
+ }
35623
+ const carriedSha = input.bundle?.sha256 ?? previousSha;
35624
+ const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
35625
+ const revisionId = revisionIdOfRecord({
35626
+ slug: input.slug,
35627
+ displayName: input.displayName,
35628
+ description: input.description,
35629
+ category: input.category,
35630
+ tags: input.tags,
35631
+ source: input.source,
35632
+ kind: input.kind,
35633
+ ...input.version ? { version: input.version } : {},
35634
+ ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
35635
+ ...carriedSha ? { bundleSha256: carriedSha } : {},
35636
+ ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
35637
+ });
34963
35638
  if (input.bundle) {
34964
35639
  await tx`
34965
35640
  INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob)
@@ -34974,10 +35649,10 @@ class PostgresSkillsStore {
34974
35649
  }
34975
35650
  const rows = await tx`
34976
35651
  INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
34977
- bundle_sha256, bundle_byte_size, published_by_user_id, updated_at)
35652
+ bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, updated_at)
34978
35653
  VALUES (${orgId}, ${input.slug}, ${input.displayName}, ${input.description}, ${input.category}, ${JSON.stringify(input.tags)}::jsonb,
34979
- ${input.source}, ${input.kind}, ${input.version ?? null}, ${input.skillMd ?? null},
34980
- ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, now())
35654
+ ${input.source}, ${input.kind}, ${input.version ?? null}, ${carriedSkillMd},
35655
+ ${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, ${revisionId}, 1, now())
34981
35656
  ON CONFLICT (org_id, slug) DO UPDATE SET
34982
35657
  display_name = EXCLUDED.display_name,
34983
35658
  description = EXCLUDED.description,
@@ -34992,9 +35667,23 @@ class PostgresSkillsStore {
34992
35667
  bundle_sha256 = COALESCE(EXCLUDED.bundle_sha256, skills_registry.bundle_sha256),
34993
35668
  bundle_byte_size = COALESCE(EXCLUDED.bundle_byte_size, skills_registry.bundle_byte_size),
34994
35669
  published_by_user_id = EXCLUDED.published_by_user_id,
35670
+ revision_id = EXCLUDED.revision_id,
35671
+ -- Current + 1, not EXCLUDED.revision_number: the insert path minted 1, but on
35672
+ -- the update path the ACTUAL row's counter is the truth (it may have advanced
35673
+ -- since the pre-read, which is exactly what the WHERE guard below detects).
35674
+ revision_number = skills_registry.revision_number + 1,
35675
+ tombstoned_at = NULL,
35676
+ tombstone_purge_after = NULL,
34995
35677
  updated_at = EXCLUDED.updated_at
35678
+ WHERE skills_registry.tombstoned_at IS NOT NULL
35679
+ OR skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL2}
34996
35680
  RETURNING *
34997
35681
  `;
35682
+ if (!rows[0]) {
35683
+ const current = await tx`SELECT revision_id FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
35684
+ const currentId = current[0] && typeof current[0].revision_id === "string" ? String(current[0].revision_id) : null;
35685
+ throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
35686
+ }
34998
35687
  if (previousSha && input.bundle && previousSha !== input.bundle.sha256) {
34999
35688
  await tx`
35000
35689
  DELETE FROM skills_bundles
@@ -35002,55 +35691,182 @@ class PostgresSkillsStore {
35002
35691
  AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${orgId} AND bundle_sha256 = ${previousSha})
35003
35692
  `;
35004
35693
  }
35694
+ await tx`DELETE FROM skills_tags WHERE org_id = ${orgId} AND slug = ${input.slug}`;
35695
+ for (const tag of input.tags) {
35696
+ if (!tag.trim())
35697
+ continue;
35698
+ await tx`
35699
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${orgId}, ${input.slug}, ${tag})
35700
+ ON CONFLICT DO NOTHING
35701
+ `;
35702
+ }
35005
35703
  return rowToSkill(rows[0]);
35006
35704
  });
35007
35705
  }
35008
35706
  async listSkills(principal) {
35009
- const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} ORDER BY slug ASC`;
35707
+ await this.purgeExpiredTombstones(principal);
35708
+ const rows = await this.sql`
35709
+ SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
35710
+ `;
35010
35711
  return rows.map(rowToSkill);
35011
35712
  }
35012
35713
  async getSkill(principal, slug) {
35013
35714
  const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
35014
35715
  return rows[0] ? rowToSkill(rows[0]) : null;
35015
35716
  }
35016
- async updateSkill(principal, slug, patch) {
35717
+ async updateSkill(principal, slug, patch, expectedRevisionId) {
35017
35718
  const current = await this.getSkill(principal, slug);
35018
- if (!current)
35719
+ if (!current || current.tombstonedAt)
35019
35720
  return null;
35721
+ if (expectedRevisionId !== current.revisionId) {
35722
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
35723
+ }
35020
35724
  const next = { ...current, ...patch };
35021
- const rows = await this.sql`
35022
- UPDATE skills_registry
35023
- SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
35024
- tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
35025
- skill_md = ${next.skillMd ?? null}, updated_at = now()
35026
- WHERE org_id = ${principal.orgId} AND slug = ${slug}
35027
- RETURNING *
35028
- `;
35029
- return rows[0] ? rowToSkill(rows[0]) : null;
35725
+ return await this.sql.begin(async (tx) => {
35726
+ const revisionId = revisionIdOfRecord(next);
35727
+ const updated = await tx`
35728
+ UPDATE skills_registry
35729
+ SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
35730
+ tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
35731
+ skill_md = ${next.skillMd ?? null}, revision_id = ${revisionId}, revision_number = revision_number + 1, updated_at = now()
35732
+ WHERE org_id = ${principal.orgId} AND slug = ${slug} AND tombstoned_at IS NULL AND revision_id = ${current.revisionId}
35733
+ RETURNING *
35734
+ `;
35735
+ if (!updated[0]) {
35736
+ const nowRows = await tx`
35737
+ SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
35738
+ `;
35739
+ if (nowRows[0] && nowRows[0].tombstoned_at == null) {
35740
+ const currentId = String(nowRows[0].revision_id);
35741
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
35742
+ }
35743
+ return null;
35744
+ }
35745
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${slug}`;
35746
+ for (const tag of next.tags) {
35747
+ if (!tag.trim())
35748
+ continue;
35749
+ await tx`
35750
+ INSERT INTO skills_tags (org_id, slug, tag) VALUES (${principal.orgId}, ${slug}, ${tag})
35751
+ ON CONFLICT DO NOTHING
35752
+ `;
35753
+ }
35754
+ return rowToSkill(updated[0]);
35755
+ });
35030
35756
  }
35031
- async deleteSkill(principal, slug) {
35757
+ async deleteSkill(principal, slug, tombstoneWindowMs) {
35032
35758
  return await this.sql.begin(async (tx) => {
35759
+ const existingRows = await tx`
35760
+ SELECT tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
35761
+ `;
35762
+ if (!existingRows[0])
35763
+ return null;
35764
+ if (existingRows[0].tombstoned_at != null) {
35765
+ const rows2 = await tx`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
35766
+ return rowToSkill(rows2[0]);
35767
+ }
35033
35768
  const rows = await tx`
35034
- DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug}
35035
- RETURNING bundle_sha256
35769
+ UPDATE skills_registry
35770
+ SET tombstoned_at = now(), tombstone_purge_after = now() + (${tombstoneWindowMs}::int * interval '1 millisecond'), updated_at = now()
35771
+ WHERE org_id = ${principal.orgId} AND slug = ${slug}
35772
+ RETURNING *
35036
35773
  `;
35037
- if (!rows[0])
35038
- return false;
35039
- const sha = rows[0].bundle_sha256;
35040
- if (typeof sha === "string") {
35774
+ return rows[0] ? rowToSkill(rows[0]) : null;
35775
+ });
35776
+ }
35777
+ async purgeExpiredTombstones(principal) {
35778
+ return await this.sql.begin(async (tx) => {
35779
+ const expiredRows = await tx`
35780
+ SELECT * FROM skills_registry
35781
+ WHERE org_id = ${principal.orgId} AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= now()
35782
+ `;
35783
+ if (!expiredRows.length)
35784
+ return [];
35785
+ const purged = [];
35786
+ for (const row of expiredRows) {
35787
+ const record = rowToSkill(row);
35041
35788
  await tx`
35042
- DELETE FROM skills_bundles
35043
- WHERE org_id = ${principal.orgId} AND sha256 = ${sha}
35044
- AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${sha})
35789
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
35790
+ `;
35791
+ await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${record.slug}`;
35792
+ await tx`
35793
+ DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
35045
35794
  `;
35795
+ if (record.bundleSha256) {
35796
+ await tx`
35797
+ DELETE FROM skills_bundles
35798
+ WHERE org_id = ${principal.orgId} AND sha256 = ${record.bundleSha256}
35799
+ AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${record.bundleSha256})
35800
+ `;
35801
+ }
35802
+ purged.push(record);
35046
35803
  }
35047
- return true;
35804
+ return purged;
35048
35805
  });
35049
35806
  }
35050
35807
  async getSkillBundle(principal, sha256) {
35051
35808
  const rows = await this.sql`SELECT * FROM skills_bundles WHERE org_id = ${principal.orgId} AND sha256 = ${sha256} LIMIT 1`;
35052
35809
  return rows[0] ? rowToSkillBundle(rows[0]) : null;
35053
35810
  }
35811
+ async pinSkill(principal, slug, metadata = {}) {
35812
+ const rows = await this.sql`
35813
+ INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
35814
+ VALUES (${principal.orgId}, ${principal.apiKeyId}, ${slug}, now(), ${JSON.stringify(metadata)}::jsonb)
35815
+ ON CONFLICT (org_id, principal, slug) DO UPDATE SET
35816
+ pinned_at = now(),
35817
+ metadata_json = EXCLUDED.metadata_json
35818
+ RETURNING *
35819
+ `;
35820
+ return rowToPin(rows[0]);
35821
+ }
35822
+ async unpinSkill(principal, slug) {
35823
+ const rows = await this.sql`
35824
+ DELETE FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} AND slug = ${slug}
35825
+ RETURNING 1 AS present
35826
+ `;
35827
+ return rows.length > 0;
35828
+ }
35829
+ async listPins(principal) {
35830
+ const rows = await this.sql`
35831
+ SELECT * FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} ORDER BY slug ASC
35832
+ `;
35833
+ return rows.map(rowToPin);
35834
+ }
35835
+ async listTags(principal) {
35836
+ await this.purgeExpiredTombstones(principal);
35837
+ const rows = await this.sql`
35838
+ SELECT DISTINCT tag FROM skills_tags WHERE org_id = ${principal.orgId} ORDER BY tag ASC
35839
+ `;
35840
+ return rows.map((row) => String(row.tag));
35841
+ }
35842
+ async listSkillsByTag(principal, tag) {
35843
+ await this.purgeExpiredTombstones(principal);
35844
+ const rows = await this.sql`
35845
+ SELECT s.* FROM skills_registry s
35846
+ JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
35847
+ WHERE t.org_id = ${principal.orgId} AND t.tag = ${tag} AND s.tombstoned_at IS NULL
35848
+ ORDER BY s.slug ASC
35849
+ `;
35850
+ return rows.map(rowToSkill);
35851
+ }
35852
+ async listPinsByTag(principal, tag) {
35853
+ await this.purgeExpiredTombstones(principal);
35854
+ const rows = await this.sql`
35855
+ SELECT p.* FROM skills_pins p
35856
+ JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
35857
+ JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
35858
+ WHERE p.org_id = ${principal.orgId} AND p.principal = ${principal.apiKeyId}
35859
+ AND t.tag = ${tag} AND s.tombstoned_at IS NULL
35860
+ ORDER BY p.slug ASC
35861
+ `;
35862
+ return rows.map(rowToPin);
35863
+ }
35864
+ async listPublishedSlugs(principal) {
35865
+ const rows = await this.sql`
35866
+ SELECT slug FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
35867
+ `;
35868
+ return rows.map((row) => String(row.slug));
35869
+ }
35054
35870
  async collectOrphanBundle(orgId, sha256) {
35055
35871
  await this.sql`
35056
35872
  DELETE FROM skills_bundles
@@ -35059,6 +35875,7 @@ class PostgresSkillsStore {
35059
35875
  `;
35060
35876
  }
35061
35877
  }
35878
+ var NO_REVISION_SENTINEL2 = "0000000000000000000000000000000000000000000000000000000000000000";
35062
35879
  function isUniqueViolation(error) {
35063
35880
  const code = error?.code;
35064
35881
  if (code === "23505" || code === 23505)
@@ -35120,7 +35937,7 @@ ${summary}
35120
35937
  textArtifact(run, "show-notes.md", `# Show Notes
35121
35938
 
35122
35939
  - ${summary}
35123
- - Generated by the open-skills deterministic worker.
35940
+ - Generated by the skills deterministic worker.
35124
35941
  `),
35125
35942
  textArtifact(run, "clips.csv", `start,end,title,summary
35126
35943
  00:00,00:30,"Opening","${csv(summary)}"
@@ -35154,7 +35971,7 @@ function textArtifact(run, relativePath, bodyText, contentType = relativePath.en
35154
35971
  relativePath,
35155
35972
  contentType,
35156
35973
  byteSize: bytes.byteLength,
35157
- sha256: createHash5("sha256").update(bytes).digest("hex"),
35974
+ sha256: createHash6("sha256").update(bytes).digest("hex"),
35158
35975
  visibility: "private"
35159
35976
  },
35160
35977
  body: { relativePath, bodyText, contentType }
@@ -35216,7 +36033,7 @@ function csv(value) {
35216
36033
  }
35217
36034
 
35218
36035
  // src/server/skills-api.ts
35219
- import { createHash as createHash7 } from "crypto";
36036
+ import { createHash as createHash8 } from "crypto";
35220
36037
 
35221
36038
  // src/lib/registry-merge.ts
35222
36039
  var SKILL_SOURCE_PRECEDENCE = {
@@ -35266,7 +36083,7 @@ function mergeSkillRegistryLists(...groups) {
35266
36083
  }
35267
36084
 
35268
36085
  // src/server/registry.ts
35269
- import { existsSync as existsSync13, readFileSync as readFileSync12 } from "fs";
36086
+ import { existsSync as existsSync12, readFileSync as readFileSync12 } from "fs";
35270
36087
  import { resolve, sep as sep2 } from "path";
35271
36088
 
35272
36089
  // src/lib/registry.ts
@@ -35444,7 +36261,7 @@ var DEVELOPMENT_TOOLS_SKILLS = [
35444
36261
  {
35445
36262
  name: "monitor",
35446
36263
  displayName: "Monitor",
35447
- description: "Operate the open-monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
36264
+ description: "Operate the monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
35448
36265
  category: "Development Tools",
35449
36266
  tags: ["monitoring", "mcp", "processes", "operations"]
35450
36267
  },
@@ -35490,6 +36307,14 @@ var DEVELOPMENT_TOOLS_SKILLS = [
35490
36307
  description: "Validate configuration files for syntax and schema compliance",
35491
36308
  category: "Development Tools",
35492
36309
  tags: ["config", "validation", "schema", "linting"]
36310
+ },
36311
+ {
36312
+ name: "session-inject-monitor",
36313
+ displayName: "Session Inject Monitor",
36314
+ 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",
36315
+ category: "Development Tools",
36316
+ tags: ["monitor", "session", "injection", "automation", "wake"],
36317
+ kind: "instruction"
35493
36318
  }
35494
36319
  ];
35495
36320
 
@@ -35877,7 +36702,7 @@ var DESIGN_BRANDING_SKILLS = [
35877
36702
  displayName: "Site Analyze",
35878
36703
  description: "Analyze any website's design system \u2014 detects shadcn/ui, Tailwind, extracts colors, typography, and components via Playwright + Claude Vision.",
35879
36704
  category: "Design & Branding",
35880
- tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "open-styles"]
36705
+ tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "styles"]
35881
36706
  }
35882
36707
  ];
35883
36708
 
@@ -36361,7 +37186,7 @@ function validateRegistryConsistency(registry, skillsDir) {
36361
37186
  }
36362
37187
 
36363
37188
  // src/lib/skill-hash.ts
36364
- import { createHash as createHash6 } from "crypto";
37189
+ import { createHash as createHash7 } from "crypto";
36365
37190
  import { existsSync as existsSync4, readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync3 } from "fs";
36366
37191
  import { join as join7, sep } from "path";
36367
37192
  var CONTENT_HASH_ALGORITHM = "sha256";
@@ -36469,7 +37294,7 @@ function collectFile(files, absolute, rel) {
36469
37294
  files.push({ rel: rel.split(sep).join("/"), content: buffer });
36470
37295
  }
36471
37296
  function computeContentHash(skillPath) {
36472
- const hash = createHash6(CONTENT_HASH_ALGORITHM);
37297
+ const hash = createHash7(CONTENT_HASH_ALGORITHM);
36473
37298
  for (const file of collectBundleFiles(skillPath)) {
36474
37299
  hash.update(new TextEncoder().encode(file.rel));
36475
37300
  hash.update(new TextEncoder().encode(`\x00${file.content.length}\x00`));
@@ -37243,6 +38068,9 @@ function getPortableSkillsRoot(options = {}) {
37243
38068
  if (options.rootDir)
37244
38069
  return options.rootDir;
37245
38070
  const appDir = options.homeDir ? join9(options.homeDir, ".hasna", "skills") : getDataDir();
38071
+ const cache3 = join9(appDir, SKILLS_CACHE_DIRNAME);
38072
+ if (isOwnerLayoutMigrated(appDir) && safeIsDirectory(cache3))
38073
+ return cache3;
37246
38074
  const installed = join9(appDir, INSTALLED_SKILLS_DIRNAME);
37247
38075
  migrateLegacySkillLayout(appDir, installed);
37248
38076
  return installed;
@@ -37463,10 +38291,7 @@ function writeCorpusSkill(input, options = {}) {
37463
38291
  const skillPath = join9(root3, name);
37464
38292
  const created = !existsSync6(skillPath);
37465
38293
  mkdirSync4(skillPath, { recursive: true });
37466
- const skillMd = input.skillMd.endsWith(`
37467
- `) ? input.skillMd : `${input.skillMd}
37468
- `;
37469
- writeFileSync3(join9(skillPath, "SKILL.md"), skillMd);
38294
+ writeFileSync3(join9(skillPath, "SKILL.md"), input.skillMd);
37470
38295
  const frontmatter = parseSkillFrontmatter(input.skillMd) ?? undefined;
37471
38296
  const kind = input.meta?.kind ?? parseSkillKind(frontmatter?.kind) ?? "instruction";
37472
38297
  const manifest = {
@@ -37954,49 +38779,32 @@ function getAllTags() {
37954
38779
  }
37955
38780
 
37956
38781
  // src/lib/skillinfo.ts
37957
- import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs";
37958
- import { join as join15 } from "path";
38782
+ import { existsSync as existsSync11, readFileSync as readFileSync11 } from "fs";
38783
+ import { join as join14 } from "path";
37959
38784
 
37960
38785
  // src/lib/installer.ts
37961
- import { existsSync as existsSync11, readFileSync as readFileSync10, rmSync as rmSync4 } from "fs";
37962
- import { dirname as dirname8, join as join14 } from "path";
38786
+ import { existsSync as existsSync10, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
38787
+ import { dirname as dirname8, join as join13 } from "path";
37963
38788
  import { homedir as homedir4 } from "os";
37964
38789
  import { fileURLToPath as fileURLToPath2 } from "url";
37965
38790
 
37966
38791
  // src/lib/agent-sync.ts
37967
38792
  import {
37968
38793
  cpSync as cpSync3,
37969
- existsSync as existsSync9,
37970
- mkdirSync as mkdirSync6,
38794
+ existsSync as existsSync8,
38795
+ mkdirSync as mkdirSync5,
37971
38796
  mkdtempSync,
37972
38797
  readFileSync as readFileSync8,
37973
- readdirSync as readdirSync8,
37974
- renameSync as renameSync3,
37975
- rmSync as rmSync3,
37976
- statSync as statSync7,
37977
- writeFileSync as writeFileSync5
38798
+ readdirSync as readdirSync7,
38799
+ renameSync as renameSync2,
38800
+ rmSync as rmSync2,
38801
+ statSync as statSync6,
38802
+ writeFileSync as writeFileSync4
37978
38803
  } from "fs";
37979
38804
  import { homedir as homedir3 } from "os";
37980
- import { basename as basename3, dirname as dirname7, join as join12 } from "path";
37981
-
38805
+ import { basename as basename3, dirname as dirname7, join as join11 } from "path";
37982
38806
  // src/lib/home-migration.ts
37983
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readdirSync as readdirSync7, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync6, writeFileSync as writeFileSync4 } from "fs";
37984
- import { join as join11 } from "path";
37985
- var SKILLS_CACHE_DIRNAME = "skills";
37986
- var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
37987
- function layoutMigrationRecordPath(appDir) {
37988
- return join11(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD);
37989
- }
37990
- function isOwnerLayoutMigrated(appDir) {
37991
- return existsSync8(layoutMigrationRecordPath(appDir));
37992
- }
37993
38807
  function resolveCorpusRoot(options = {}) {
37994
- if (options.rootDir)
37995
- return options.rootDir;
37996
- const appDir = options.homeDir ? join11(options.homeDir, ".hasna", "skills") : getDataDir();
37997
- const cache3 = join11(appDir, SKILLS_CACHE_DIRNAME);
37998
- if (isOwnerLayoutMigrated(appDir) && existsSync8(cache3))
37999
- return cache3;
38000
38808
  return getPortableSkillsRoot(options);
38001
38809
  }
38002
38810
 
@@ -38019,9 +38827,9 @@ function resolveSyncAgents(arg) {
38019
38827
  function agentGlobalSkillsDir(agent, homeDir = homedir3()) {
38020
38828
  switch (agent) {
38021
38829
  case "opencode":
38022
- return join12(homeDir, ".config", "opencode", "skills");
38830
+ return join11(homeDir, ".config", "opencode", "skills");
38023
38831
  default:
38024
- return join12(homeDir, `.${agent}`, "skills");
38832
+ return join11(homeDir, `.${agent}`, "skills");
38025
38833
  }
38026
38834
  }
38027
38835
  function adaptSkillMdForAgent(skillMd, agent) {
@@ -38042,6 +38850,10 @@ ${lines.join(`
38042
38850
  ---
38043
38851
  ${body}`;
38044
38852
  }
38853
+ var POINTER_MARKER_PHRASE = "This is an executable skill from the @hasna/skills catalog";
38854
+ function isPointerSkillMd(markdown) {
38855
+ return markdown.includes(POINTER_MARKER_PHRASE) && /^kind:\s*executable\b/m.test(markdown);
38856
+ }
38045
38857
  function pointerSkillMd(name, description) {
38046
38858
  const display = name.replace(/[-_]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
38047
38859
  return [
@@ -38067,7 +38879,7 @@ function resolveSyncCorpus(options = {}) {
38067
38879
  if (explicit) {
38068
38880
  const roots = packageSourceRoots(explicit);
38069
38881
  if (roots.length === 0) {
38070
- throw new Error(`SKILLS_SOURCE '${explicit}' contains no skills: expected a corpus directory or a package root with skills/ and/or agent-skills/`);
38882
+ throw new Error(`SKILLS_SOURCE '${explicit}' contains no skills: expected a corpus directory or a package root with skills/`);
38071
38883
  }
38072
38884
  return { roots, source: "source" };
38073
38885
  }
@@ -38075,9 +38887,9 @@ function resolveSyncCorpus(options = {}) {
38075
38887
  }
38076
38888
  function packageSourceRoots(source) {
38077
38889
  const roots = [];
38078
- for (const sub of ["skills", "agent-skills"]) {
38079
- const candidate = join12(source, sub);
38080
- if (existsSync9(candidate) && isDirectory(candidate))
38890
+ for (const sub of ["skills"]) {
38891
+ const candidate = join11(source, sub);
38892
+ if (existsSync8(candidate) && isDirectory(candidate))
38081
38893
  roots.push(candidate);
38082
38894
  }
38083
38895
  if (roots.length > 0)
@@ -38087,20 +38899,20 @@ function packageSourceRoots(source) {
38087
38899
  function containsSkillDirectories(path) {
38088
38900
  let entries;
38089
38901
  try {
38090
- entries = readdirSync8(path);
38902
+ entries = readdirSync7(path);
38091
38903
  } catch {
38092
38904
  return false;
38093
38905
  }
38094
38906
  return entries.some((entry) => {
38095
- const candidate = join12(path, entry);
38907
+ const candidate = join11(path, entry);
38096
38908
  if (!isDirectory(candidate))
38097
38909
  return false;
38098
- return existsSync9(join12(candidate, "SKILL.md")) || existsSync9(join12(candidate, "skill.json")) || existsSync9(join12(candidate, "package.json"));
38910
+ return existsSync8(join11(candidate, "SKILL.md")) || existsSync8(join11(candidate, "skill.json")) || existsSync8(join11(candidate, "package.json"));
38099
38911
  });
38100
38912
  }
38101
38913
  function isDirectory(path) {
38102
38914
  try {
38103
- return statSync7(path).isDirectory();
38915
+ return statSync6(path).isDirectory();
38104
38916
  } catch {
38105
38917
  return false;
38106
38918
  }
@@ -38134,7 +38946,7 @@ function syncSkillsToAgents(options = {}) {
38134
38946
  actions.push({
38135
38947
  skill: name,
38136
38948
  agent,
38137
- path: join12(agentGlobalSkillsDir(agent, homeDir), name, "SKILL.md"),
38949
+ path: join11(agentGlobalSkillsDir(agent, homeDir), name, "SKILL.md"),
38138
38950
  action: "skip",
38139
38951
  reason: "not found in this machine's corpus"
38140
38952
  });
@@ -38164,7 +38976,7 @@ function syncSkillsToAgents(options = {}) {
38164
38976
  }
38165
38977
  function writeManagedAgentSkill(params) {
38166
38978
  const homeDir = params.homeDir ?? homedir3();
38167
- const dir = join12(agentGlobalSkillsDir(params.agent, homeDir), params.skill);
38979
+ const dir = join11(agentGlobalSkillsDir(params.agent, homeDir), params.skill);
38168
38980
  const result = writeManagedSkillDir(dir, params.skillMd, {
38169
38981
  skill: params.skill,
38170
38982
  source: params.source,
@@ -38181,11 +38993,11 @@ function writeManagedAgentSkill(params) {
38181
38993
  };
38182
38994
  }
38183
38995
  function writeManagedSkillDir(dir, skillMd, options) {
38184
- const skillMdPath = join12(dir, "SKILL.md");
38185
- const markerPath = join12(dir, SYNC_MARKER_FILE);
38186
- const dirExists = existsSync9(dir);
38187
- const managed = existsSync9(markerPath);
38188
- const hasSkillMd = existsSync9(skillMdPath);
38996
+ const skillMdPath = join11(dir, "SKILL.md");
38997
+ const markerPath = join11(dir, SYNC_MARKER_FILE);
38998
+ const dirExists = existsSync8(dir);
38999
+ const managed = existsSync8(markerPath);
39000
+ const hasSkillMd = existsSync8(skillMdPath);
38189
39001
  if (dirExists && !managed && !hasSkillMd) {
38190
39002
  return {
38191
39003
  action: "skip",
@@ -38200,35 +39012,50 @@ function writeManagedSkillDir(dir, skillMd, options) {
38200
39012
  reason: "an unmanaged SKILL.md already exists here (hand-authored); pass --force to overwrite"
38201
39013
  };
38202
39014
  }
39015
+ if (dirExists && managed && hasSkillMd && !options.force && isPointerSkillMd(skillMd)) {
39016
+ let existingIsStub = false;
39017
+ try {
39018
+ existingIsStub = isPointerSkillMd(readFileSync8(skillMdPath, "utf-8"));
39019
+ } catch {
39020
+ existingIsStub = false;
39021
+ }
39022
+ if (!existingIsStub) {
39023
+ return {
39024
+ action: "skip",
39025
+ path: skillMdPath,
39026
+ reason: "refusing to replace a content-bearing managed home with an executable pointer stub (the corpus entry lacks kind: instruction); pass --force to overwrite"
39027
+ };
39028
+ }
39029
+ }
38203
39030
  const action = dirExists ? "update" : "create";
38204
39031
  if (options.dryRun)
38205
39032
  return { action, path: skillMdPath };
38206
39033
  const parentDir = dirname7(dir);
38207
- mkdirSync6(parentDir, { recursive: true });
38208
- const transactionDir = mkdtempSync(join12(parentDir, `.hasna-skills-write-${basename3(dir)}-`));
38209
- const candidateDir = join12(transactionDir, "candidate");
38210
- const backupDir = join12(transactionDir, "backup");
38211
- const candidateSkillMdPath = join12(candidateDir, "SKILL.md");
38212
- const candidateMarkerPath = join12(candidateDir, SYNC_MARKER_FILE);
39034
+ mkdirSync5(parentDir, { recursive: true });
39035
+ const transactionDir = mkdtempSync(join11(parentDir, `.hasna-skills-write-${basename3(dir)}-`));
39036
+ const candidateDir = join11(transactionDir, "candidate");
39037
+ const backupDir = join11(transactionDir, "backup");
39038
+ const candidateSkillMdPath = join11(candidateDir, "SKILL.md");
39039
+ const candidateMarkerPath = join11(candidateDir, SYNC_MARKER_FILE);
38213
39040
  const marker = {
38214
39041
  managedBy: SYNC_MARKER_MANAGED_BY,
38215
39042
  skill: options.skill,
38216
39043
  source: options.source ?? "corpus",
38217
39044
  syncedAt: new Date().toISOString()
38218
39045
  };
38219
- const renameDirectory = options.renameDirectory ?? renameSync3;
39046
+ const renameDirectory = options.renameDirectory ?? renameSync2;
38220
39047
  let originalMoved = false;
38221
39048
  let preserveTransaction = false;
38222
39049
  try {
38223
39050
  if (options.resourceDir) {
38224
39051
  cpSync3(options.resourceDir, candidateDir, { recursive: true, force: true });
38225
39052
  } else {
38226
- mkdirSync6(candidateDir, { recursive: true });
39053
+ mkdirSync5(candidateDir, { recursive: true });
38227
39054
  }
38228
- writeFileSync5(candidateSkillMdPath, skillMd.endsWith(`
39055
+ writeFileSync4(candidateSkillMdPath, skillMd.endsWith(`
38229
39056
  `) ? skillMd : `${skillMd}
38230
39057
  `);
38231
- writeFileSync5(candidateMarkerPath, `${JSON.stringify(marker, null, 2)}
39058
+ writeFileSync4(candidateMarkerPath, `${JSON.stringify(marker, null, 2)}
38232
39059
  `);
38233
39060
  if (dirExists) {
38234
39061
  originalMoved = true;
@@ -38236,10 +39063,10 @@ function writeManagedSkillDir(dir, skillMd, options) {
38236
39063
  }
38237
39064
  renameDirectory(candidateDir, dir);
38238
39065
  } catch (error) {
38239
- if (originalMoved && existsSync9(backupDir)) {
39066
+ if (originalMoved && existsSync8(backupDir)) {
38240
39067
  try {
38241
- if (existsSync9(dir))
38242
- rmSync3(dir, { recursive: true, force: true });
39068
+ if (existsSync8(dir))
39069
+ rmSync2(dir, { recursive: true, force: true });
38243
39070
  renameDirectory(backupDir, dir);
38244
39071
  originalMoved = false;
38245
39072
  } catch (rollbackError) {
@@ -38251,23 +39078,23 @@ function writeManagedSkillDir(dir, skillMd, options) {
38251
39078
  } finally {
38252
39079
  if (!preserveTransaction) {
38253
39080
  try {
38254
- rmSync3(transactionDir, { recursive: true, force: true });
39081
+ rmSync2(transactionDir, { recursive: true, force: true });
38255
39082
  } catch {}
38256
39083
  }
38257
39084
  }
38258
39085
  return { action, path: skillMdPath };
38259
39086
  }
38260
39087
  function removeManagedAgentSkill(skill, agent, homeDir = homedir3()) {
38261
- const dir = join12(agentGlobalSkillsDir(agent, homeDir), skill);
38262
- if (!existsSync9(join12(dir, SYNC_MARKER_FILE)))
39088
+ const dir = join11(agentGlobalSkillsDir(agent, homeDir), skill);
39089
+ if (!existsSync8(join11(dir, SYNC_MARKER_FILE)))
38263
39090
  return false;
38264
- rmSync3(dir, { recursive: true, force: true });
39091
+ rmSync2(dir, { recursive: true, force: true });
38265
39092
  return true;
38266
39093
  }
38267
39094
  function sourceSkillMd(skillPath, name, description, kind, preferBundledDocs = false) {
38268
39095
  if (kind === "instruction" || preferBundledDocs) {
38269
- const skillMdPath = join12(skillPath, "SKILL.md");
38270
- if (existsSync9(skillMdPath))
39096
+ const skillMdPath = join11(skillPath, "SKILL.md");
39097
+ if (existsSync8(skillMdPath))
38271
39098
  return readFileSync8(skillMdPath, "utf-8");
38272
39099
  }
38273
39100
  return pointerSkillMd(name, description);
@@ -38298,8 +39125,8 @@ function normalizeSkillName(name) {
38298
39125
  }
38299
39126
 
38300
39127
  // src/lib/project-state.ts
38301
- import { existsSync as existsSync10, mkdirSync as mkdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
38302
- import { join as join13 } from "path";
39128
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "fs";
39129
+ import { join as join12 } from "path";
38303
39130
  var VALID_PIN_SOURCES = [
38304
39131
  "official",
38305
39132
  "custom",
@@ -38314,14 +39141,14 @@ var SKILLS_PROJECT_DIR = ".skills";
38314
39141
  var PROJECT_CONFIG_FILE = "project.json";
38315
39142
  var DEFAULT_EXPORT_DIR = ".skills/exports";
38316
39143
  function getProjectStateDir(targetDir = process.cwd()) {
38317
- return join13(targetDir, SKILLS_PROJECT_DIR);
39144
+ return join12(targetDir, SKILLS_PROJECT_DIR);
38318
39145
  }
38319
39146
  function getProjectConfigPath(targetDir = process.cwd()) {
38320
- return join13(getProjectStateDir(targetDir), PROJECT_CONFIG_FILE);
39147
+ return join12(getProjectStateDir(targetDir), PROJECT_CONFIG_FILE);
38321
39148
  }
38322
39149
  function loadProjectConfig(targetDir = process.cwd()) {
38323
39150
  const path = getProjectConfigPath(targetDir);
38324
- if (!existsSync10(path))
39151
+ if (!existsSync9(path))
38325
39152
  return null;
38326
39153
  try {
38327
39154
  return normalizeProjectConfig(JSON.parse(readFileSync9(path, "utf-8")));
@@ -38345,9 +39172,9 @@ function ensureProjectConfig(targetDir = process.cwd()) {
38345
39172
  }
38346
39173
  function saveProjectConfig(config, targetDir = process.cwd()) {
38347
39174
  const dir = getProjectStateDir(targetDir);
38348
- mkdirSync7(dir, { recursive: true });
39175
+ mkdirSync6(dir, { recursive: true });
38349
39176
  const normalized = normalizeProjectConfig({ ...config, updatedAt: new Date().toISOString() });
38350
- writeFileSync6(getProjectConfigPath(targetDir), JSON.stringify(normalized, null, 2) + `
39177
+ writeFileSync5(getProjectConfigPath(targetDir), JSON.stringify(normalized, null, 2) + `
38351
39178
  `);
38352
39179
  }
38353
39180
  function pinProjectSkill(name, details = {}, targetDir = process.cwd()) {
@@ -38442,12 +39269,12 @@ var __dirname2 = dirname8(fileURLToPath2(import.meta.url));
38442
39269
  function findSkillsDir() {
38443
39270
  let dir = __dirname2;
38444
39271
  for (let i3 = 0;i3 < 5; i3++) {
38445
- const candidate = join14(dir, "skills");
38446
- if (existsSync11(candidate) && !dir.includes(".skills"))
39272
+ const candidate = join13(dir, "skills");
39273
+ if (existsSync10(candidate) && !dir.includes(".skills"))
38447
39274
  return candidate;
38448
39275
  dir = dirname8(dir);
38449
39276
  }
38450
- return join14(__dirname2, "..", "skills");
39277
+ return join13(__dirname2, "..", "skills");
38451
39278
  }
38452
39279
  var SKILLS_DIR = findSkillsDir();
38453
39280
  function getSkillPath(name) {
@@ -38455,25 +39282,25 @@ function getSkillPath(name) {
38455
39282
  const portable = findPortableSkill(skillName);
38456
39283
  if (portable)
38457
39284
  return portable.path;
38458
- const legacyCustomPath = join14(getDataDir(), "custom", skillName);
38459
- if (existsSync11(legacyCustomPath))
39285
+ const legacyCustomPath = join13(getDataDir(), "custom", skillName);
39286
+ if (existsSync10(legacyCustomPath))
38460
39287
  return legacyCustomPath;
38461
39288
  const extensionPath = findExtensionSkillPath(skillName);
38462
39289
  if (extensionPath)
38463
39290
  return extensionPath;
38464
- return join14(SKILLS_DIR, skillName);
39291
+ return join13(SKILLS_DIR, skillName);
38465
39292
  }
38466
39293
  function getCanonicalSkillName(name) {
38467
39294
  return getSkill(name)?.name ?? resolveSkillAlias(normalizeSkillSlug(name));
38468
39295
  }
38469
39296
  function skillExists(name) {
38470
- return existsSync11(getSkillPath(name));
39297
+ return existsSync10(getSkillPath(name));
38471
39298
  }
38472
39299
  function installSkill(name, options = {}) {
38473
39300
  const { targetDir = process.cwd(), overwrite = false } = options;
38474
39301
  const canonicalName = getCanonicalSkillName(name);
38475
39302
  const skillName = normalizeSkillName(canonicalName);
38476
- if (!existsSync11(getSkillPath(name))) {
39303
+ if (!existsSync10(getSkillPath(name))) {
38477
39304
  const knownOfficial = Boolean(getSkill(name));
38478
39305
  return {
38479
39306
  skill: canonicalName,
@@ -38500,7 +39327,7 @@ function installSkill(name, options = {}) {
38500
39327
  }
38501
39328
  function installSkillSource(name, _options = {}) {
38502
39329
  const canonicalName = getCanonicalSkillName(name);
38503
- if (!existsSync11(getSkillPath(name))) {
39330
+ if (!existsSync10(getSkillPath(name))) {
38504
39331
  return { skill: canonicalName, success: false, error: `Skill '${name}' not found`, mode: "source" };
38505
39332
  }
38506
39333
  return {
@@ -38521,11 +39348,11 @@ function installSkillManifest(manifest, _options = {}) {
38521
39348
  }
38522
39349
  function createLocalSkillManifest(name, generateSkillMd) {
38523
39350
  const sourcePath = getSkillPath(name);
38524
- if (!existsSync11(sourcePath))
39351
+ if (!existsSync10(sourcePath))
38525
39352
  return null;
38526
39353
  let skillMd = "";
38527
- const skillMdPath = join14(sourcePath, "SKILL.md");
38528
- if (existsSync11(skillMdPath)) {
39354
+ const skillMdPath = join13(sourcePath, "SKILL.md");
39355
+ if (existsSync10(skillMdPath)) {
38529
39356
  skillMd = readFileSync10(skillMdPath, "utf-8");
38530
39357
  } else if (generateSkillMd) {
38531
39358
  skillMd = generateSkillMd(name) ?? "";
@@ -38598,20 +39425,20 @@ function getAgentSkillsDir(agent, scope = "global", projectDir) {
38598
39425
  const base = projectDir || process.cwd();
38599
39426
  switch (agent) {
38600
39427
  case "pi":
38601
- return scope === "project" ? join14(base, ".pi", "skills") : join14(homedir4(), ".pi", "agent", "skills");
39428
+ return scope === "project" ? join13(base, ".pi", "skills") : join13(homedir4(), ".pi", "agent", "skills");
38602
39429
  case "opencode":
38603
- return scope === "project" ? join14(base, ".opencode", "skills") : join14(homedir4(), ".config", "opencode", "skills");
39430
+ return scope === "project" ? join13(base, ".opencode", "skills") : join13(homedir4(), ".config", "opencode", "skills");
38604
39431
  default:
38605
- return scope === "project" ? join14(base, `.${agent}`, "skills") : join14(homedir4(), `.${agent}`, "skills");
39432
+ return scope === "project" ? join13(base, `.${agent}`, "skills") : join13(homedir4(), `.${agent}`, "skills");
38606
39433
  }
38607
39434
  }
38608
39435
  function getAgentSkillPath(name, agent, scope = "global", projectDir) {
38609
39436
  const skillName = normalizeSkillName(getCanonicalSkillName(name));
38610
- return join14(getAgentSkillsDir(agent, scope, projectDir), skillName);
39437
+ return join13(getAgentSkillsDir(agent, scope, projectDir), skillName);
38611
39438
  }
38612
39439
  function installSkillForAgent(name, options, generateSkillMd) {
38613
39440
  const canonicalName = getCanonicalSkillName(name);
38614
- if (!existsSync11(getSkillPath(name))) {
39441
+ if (!existsSync10(getSkillPath(name))) {
38615
39442
  return { skill: canonicalName, success: false, error: `Skill '${name}' not found` };
38616
39443
  }
38617
39444
  const scope = options.scope ?? "global";
@@ -38635,15 +39462,15 @@ function removeSkillForAgent(name, options) {
38635
39462
  const canonicalName = getCanonicalSkillName(name);
38636
39463
  const scope = options.scope ?? "global";
38637
39464
  const dir = getAgentSkillPath(canonicalName, options.agent, scope, options.projectDir);
38638
- if (!existsSync11(join14(dir, SYNC_MARKER_FILE)))
39465
+ if (!existsSync10(join13(dir, SYNC_MARKER_FILE)))
38639
39466
  return false;
38640
- rmSync4(dir, { recursive: true, force: true });
39467
+ rmSync3(dir, { recursive: true, force: true });
38641
39468
  return true;
38642
39469
  }
38643
39470
  function resolveAgentSkillMd(name, generateSkillMd) {
38644
39471
  const sourcePath = getSkillPath(name);
38645
- const skillMdPath = join14(sourcePath, "SKILL.md");
38646
- if (existsSync11(skillMdPath))
39472
+ const skillMdPath = join13(sourcePath, "SKILL.md");
39473
+ if (existsSync10(skillMdPath))
38647
39474
  return readFileSync10(skillMdPath, "utf-8");
38648
39475
  if (generateSkillMd)
38649
39476
  return generateSkillMd(name);
@@ -38662,7 +39489,7 @@ function warnMissingDependencies(name, targetDir) {
38662
39489
  }
38663
39490
  function generateMinimalSkillMd(name) {
38664
39491
  const sourcePath = getSkillPath(name);
38665
- if (!existsSync11(sourcePath))
39492
+ if (!existsSync10(sourcePath))
38666
39493
  return null;
38667
39494
  const canonicalName = getCanonicalSkillName(name);
38668
39495
  const meta = getSkill(canonicalName);
@@ -38677,7 +39504,7 @@ function generateMinimalSkillMd(name) {
38677
39504
  "---",
38678
39505
  ""
38679
39506
  ].filter(Boolean);
38680
- const fallbackDoc = readFileIfExists(join14(sourcePath, "README.md")) || readFileIfExists(join14(sourcePath, "CLAUDE.md"));
39507
+ const fallbackDoc = readFileIfExists(join13(sourcePath, "README.md")) || readFileIfExists(join13(sourcePath, "CLAUDE.md"));
38681
39508
  if (fallbackDoc)
38682
39509
  return `${frontmatter.join(`
38683
39510
  `)}${fallbackDoc.trim()}
@@ -38696,8 +39523,8 @@ skills run ${canonicalName}
38696
39523
  `;
38697
39524
  }
38698
39525
  function readBundledSkillVersion(name) {
38699
- const pkgPath = join14(getSkillPath(name), "package.json");
38700
- if (!existsSync11(pkgPath))
39526
+ const pkgPath = join13(getSkillPath(name), "package.json");
39527
+ if (!existsSync10(pkgPath))
38701
39528
  return "unknown";
38702
39529
  try {
38703
39530
  const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
@@ -38707,7 +39534,7 @@ function readBundledSkillVersion(name) {
38707
39534
  }
38708
39535
  }
38709
39536
  function readFileIfExists(path) {
38710
- return existsSync11(path) ? readFileSync10(path, "utf-8") : null;
39537
+ return existsSync10(path) ? readFileSync10(path, "utf-8") : null;
38711
39538
  }
38712
39539
  function loadProjectConfigCompat(targetDir) {
38713
39540
  return loadProjectConfig(targetDir);
@@ -38717,8 +39544,8 @@ function loadProjectConfigCompat(targetDir) {
38717
39544
  function isInstructionSkillDir(skillPath, meta) {
38718
39545
  if (meta?.kind === "instruction")
38719
39546
  return true;
38720
- const skillMdPath = join15(skillPath, "SKILL.md");
38721
- if (!existsSync12(skillMdPath))
39547
+ const skillMdPath = join14(skillPath, "SKILL.md");
39548
+ if (!existsSync11(skillMdPath))
38722
39549
  return false;
38723
39550
  try {
38724
39551
  return parseSkillFrontmatter(readFileSync11(skillMdPath, "utf-8"))?.kind === "instruction";
@@ -38744,12 +39571,12 @@ var HOSTED_PROVIDER_ENV_PREFIXES = [
38744
39571
  ];
38745
39572
  function getSkillDocs(name) {
38746
39573
  const skillPath = getSkillPath(name);
38747
- if (!existsSync12(skillPath))
39574
+ if (!existsSync11(skillPath))
38748
39575
  return null;
38749
39576
  return {
38750
- skillMd: readIfExists(join15(skillPath, "SKILL.md")),
38751
- readme: readIfExists(join15(skillPath, "README.md")),
38752
- claudeMd: readIfExists(join15(skillPath, "CLAUDE.md"))
39577
+ skillMd: readIfExists(join14(skillPath, "SKILL.md")),
39578
+ readme: readIfExists(join14(skillPath, "README.md")),
39579
+ claudeMd: readIfExists(join14(skillPath, "CLAUDE.md"))
38753
39580
  };
38754
39581
  }
38755
39582
  function getSkillBestDoc(name) {
@@ -38760,11 +39587,11 @@ function getSkillBestDoc(name) {
38760
39587
  }
38761
39588
  function getSkillRequirements(name) {
38762
39589
  const skillPath = getSkillPath(name);
38763
- if (!existsSync12(skillPath))
39590
+ if (!existsSync11(skillPath))
38764
39591
  return null;
38765
39592
  const texts = [];
38766
39593
  for (const file of ["SKILL.md", "README.md", "CLAUDE.md", ".env.example", ".env.local.example"]) {
38767
- const content = readIfExists(join15(skillPath, file));
39594
+ const content = readIfExists(join14(skillPath, file));
38768
39595
  if (content)
38769
39596
  texts.push(content);
38770
39597
  }
@@ -38803,8 +39630,8 @@ function getSkillRequirements(name) {
38803
39630
  const skillName = normalizeSkillName(name);
38804
39631
  let cliCommand = `skills run ${skillName}`;
38805
39632
  let dependencies = {};
38806
- const pkgPath = join15(skillPath, "package.json");
38807
- if (existsSync12(pkgPath)) {
39633
+ const pkgPath = join14(skillPath, "package.json");
39634
+ if (existsSync11(pkgPath)) {
38808
39635
  try {
38809
39636
  const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
38810
39637
  dependencies = pkg.dependencies || {};
@@ -38824,7 +39651,7 @@ async function runSkill(name, args, options = {}) {
38824
39651
  const meta = getSkill(name);
38825
39652
  const canonicalName = meta?.name ?? name;
38826
39653
  const skillPath = getSkillPath(canonicalName);
38827
- if (!existsSync12(skillPath)) {
39654
+ if (!existsSync11(skillPath)) {
38828
39655
  return { exitCode: 1, error: `Skill '${name}' not found` };
38829
39656
  }
38830
39657
  if (isInstructionSkillDir(skillPath, meta)) {
@@ -38833,8 +39660,8 @@ async function runSkill(name, args, options = {}) {
38833
39660
  error: `Skill '${name}' is an instruction skill (kind: instruction) and is not runnable. Instruction skills are consumed by coding agents via SKILL.md, not executed with 'skills run'.`
38834
39661
  };
38835
39662
  }
38836
- const pkgPath = join15(skillPath, "package.json");
38837
- if (!existsSync12(pkgPath)) {
39663
+ const pkgPath = join14(skillPath, "package.json");
39664
+ if (!existsSync11(pkgPath)) {
38838
39665
  return { exitCode: 1, error: `No package.json in skill '${name}'` };
38839
39666
  }
38840
39667
  let entryPoint;
@@ -38853,12 +39680,12 @@ async function runSkill(name, args, options = {}) {
38853
39680
  } catch {
38854
39681
  return { exitCode: 1, error: `Failed to parse package.json for skill '${name}'` };
38855
39682
  }
38856
- const entryPath = join15(skillPath, entryPoint);
38857
- if (!existsSync12(entryPath)) {
39683
+ const entryPath = join14(skillPath, entryPoint);
39684
+ if (!existsSync11(entryPath)) {
38858
39685
  return { exitCode: 1, error: `Entry point '${entryPoint}' not found in skill '${name}'` };
38859
39686
  }
38860
- const nodeModules = join15(skillPath, "node_modules");
38861
- if (!existsSync12(nodeModules)) {
39687
+ const nodeModules = join14(skillPath, "node_modules");
39688
+ if (!existsSync11(nodeModules)) {
38862
39689
  const install = Bun.spawn(["bun", "install", "--no-save"], {
38863
39690
  cwd: skillPath,
38864
39691
  stdout: "pipe",
@@ -38930,7 +39757,7 @@ function generateSkillMd(name) {
38930
39757
  if (!meta)
38931
39758
  return null;
38932
39759
  const skillPath = getSkillPath(name);
38933
- if (!existsSync12(skillPath))
39760
+ if (!existsSync11(skillPath))
38934
39761
  return null;
38935
39762
  const frontmatter = [
38936
39763
  "---",
@@ -38939,11 +39766,11 @@ function generateSkillMd(name) {
38939
39766
  "---"
38940
39767
  ].join(`
38941
39768
  `);
38942
- const readme = readIfExists(join15(skillPath, "README.md"));
38943
- const claudeMd = readIfExists(join15(skillPath, "CLAUDE.md"));
39769
+ const readme = readIfExists(join14(skillPath, "README.md"));
39770
+ const claudeMd = readIfExists(join14(skillPath, "CLAUDE.md"));
38944
39771
  let cliCommand = null;
38945
- const pkgPath = join15(skillPath, "package.json");
38946
- if (existsSync12(pkgPath)) {
39772
+ const pkgPath = join14(skillPath, "package.json");
39773
+ if (existsSync11(pkgPath)) {
38947
39774
  try {
38948
39775
  const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
38949
39776
  if (pkg.bin) {
@@ -39020,7 +39847,7 @@ function extractEnvVars(text) {
39020
39847
  }
39021
39848
  function readIfExists(path) {
39022
39849
  try {
39023
- if (existsSync12(path)) {
39850
+ if (existsSync11(path)) {
39024
39851
  return readFileSync11(path, "utf-8");
39025
39852
  }
39026
39853
  } catch {}
@@ -39062,7 +39889,7 @@ function getServerSkillMd(slug) {
39062
39889
  const path = resolve(skillsDir, name, "SKILL.md");
39063
39890
  if (!isInsideDir(skillsDir, path))
39064
39891
  return null;
39065
- return existsSync13(path) ? readFileSync12(path, "utf8") : null;
39892
+ return existsSync12(path) ? readFileSync12(path, "utf8") : null;
39066
39893
  }
39067
39894
 
39068
39895
  // src/server/skills-api.ts
@@ -39072,6 +39899,17 @@ var MAX_SLUG_LENGTH = 128;
39072
39899
  var MAX_SKILL_MD_BYTES = 512000;
39073
39900
  var MAX_MANIFEST_BYTES = MAX_SKILL_MD_BYTES + 64000;
39074
39901
  var ALLOWED_PUBLISH_PARTS = new Set(["manifest", "bundle"]);
39902
+ function pinPayload(pin) {
39903
+ return { slug: pin.slug, pinnedAt: pin.pinnedAt, metadata: pin.metadata };
39904
+ }
39905
+ function pinMetadataField(body) {
39906
+ if (body.metadata === undefined)
39907
+ return {};
39908
+ if (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata)) {
39909
+ throw new SkillRequestError(400, "INVALID_METADATA", "`metadata` must be a JSON object");
39910
+ }
39911
+ return body.metadata;
39912
+ }
39075
39913
 
39076
39914
  class SkillRequestError extends Error {
39077
39915
  status;
@@ -39100,33 +39938,130 @@ function publishedPayload(record) {
39100
39938
  ...publishedSkillMeta(record),
39101
39939
  slug: record.slug,
39102
39940
  publishedSource: record.source,
39941
+ ...record.skillMd ? { skillMd: record.skillMd } : {},
39103
39942
  ...record.bundleSha256 ? { bundleSha256: record.bundleSha256, bundleByteSize: record.bundleByteSize } : {},
39104
39943
  publishedAt: record.createdAt,
39105
- updatedAt: record.updatedAt
39944
+ updatedAt: record.updatedAt,
39945
+ revisionId: record.revisionId,
39946
+ revisionNumber: record.revisionNumber
39947
+ };
39948
+ }
39949
+ function revisionEtag(revisionId) {
39950
+ return `"${revisionId}"`;
39951
+ }
39952
+ function parseIfMatch(value) {
39953
+ if (value === null || value.trim() === "")
39954
+ return;
39955
+ const trimmed = value.trim();
39956
+ if (trimmed === "*") {
39957
+ throw new SkillRequestError(400, "INVALID_IF_MATCH", "If-Match must name the exact revision id (the ETag of the current revision); '*' is not accepted");
39958
+ }
39959
+ const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
39960
+ if (!REVISION_ID_PATTERN.test(unquoted)) {
39961
+ 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");
39962
+ }
39963
+ return unquoted;
39964
+ }
39965
+ async function tombstoneStatus(store, artifactStorage, principal, record) {
39966
+ if (!record.tombstonedAt)
39967
+ return "live";
39968
+ if (record.tombstonePurgeAfter && record.tombstonePurgeAfter <= new Date().toISOString()) {
39969
+ const purged = await store.purgeExpiredTombstones(principal);
39970
+ for (const removed of purged) {
39971
+ if (removed.bundleSha256)
39972
+ await discardCollectedObject(store, artifactStorage, principal, removed.bundleSha256);
39973
+ }
39974
+ return "purged";
39975
+ }
39976
+ return {
39977
+ slug: record.slug,
39978
+ deleted: true,
39979
+ code: "TOMBSTONED",
39980
+ tombstonedAt: record.tombstonedAt,
39981
+ tombstonePurgeAfter: record.tombstonePurgeAfter,
39982
+ revisionId: record.revisionId
39106
39983
  };
39107
39984
  }
39108
39985
  async function listMergedSkills(store, principal) {
39109
39986
  const published = await store.listSkills(principal);
39987
+ return mergedSkillPayloads(published, listServerSkills());
39988
+ }
39989
+ function mergedSkillPayloads(published, bundled) {
39110
39990
  const publishedBySlug = new Map(published.map((record) => [record.slug, record]));
39111
- const merged = mergeSkillRegistryLists(listServerSkills(), published.map(publishedSkillMeta));
39991
+ const merged = mergeSkillRegistryLists(bundled, published.map(publishedSkillMeta));
39112
39992
  return merged.map((skill) => {
39113
39993
  const record = publishedBySlug.get(skill.name);
39114
39994
  return record ? publishedPayload(record) : skill;
39115
39995
  });
39116
39996
  }
39117
- async function getMergedSkill(store, principal, slug) {
39997
+ async function resolvePublishedSkill(store, artifactStorage, principal, slug) {
39118
39998
  const record = await store.getSkill(principal, slug);
39119
- if (record)
39120
- return publishedPayload(record);
39999
+ if (!record)
40000
+ return { kind: "absent" };
40001
+ const status = await tombstoneStatus(store, artifactStorage, principal, record);
40002
+ if (status === "purged")
40003
+ return { kind: "absent" };
40004
+ if (status !== "live")
40005
+ return { kind: "tombstone", payload: status };
40006
+ return { kind: "published", record };
40007
+ }
40008
+ async function listOrgTags(store, principal) {
40009
+ const publishedSlugs = await store.listPublishedSlugs(principal);
40010
+ const tags = new Set;
40011
+ for (const tag of await store.listTags(principal)) {
40012
+ if (tag.trim())
40013
+ tags.add(tag);
40014
+ }
40015
+ for (const skill of listServerSkills()) {
40016
+ if (publishedSlugs.includes(skill.name))
40017
+ continue;
40018
+ for (const tag of skill.tags) {
40019
+ if (tag.trim())
40020
+ tags.add(tag);
40021
+ }
40022
+ }
40023
+ return [...tags].sort();
40024
+ }
40025
+ async function listMergedSkillsByTag(store, principal, tag) {
40026
+ const published = await store.listSkillsByTag(principal, tag);
40027
+ const publishedSlugs = await store.listPublishedSlugs(principal);
40028
+ const bundled = listServerSkills().filter((skill) => skill.tags.includes(tag) && !publishedSlugs.includes(skill.name));
40029
+ return mergedSkillPayloads(published, bundled);
40030
+ }
40031
+ function skillSummary(skill) {
40032
+ return {
40033
+ slug: String(skill.slug ?? skill.name),
40034
+ ...typeof skill.name === "string" ? { name: skill.name } : {},
40035
+ ...typeof skill.version === "string" ? { version: skill.version } : {},
40036
+ ...typeof skill.updatedAt === "string" ? { updatedAt: skill.updatedAt } : {}
40037
+ };
40038
+ }
40039
+ async function listPinsByTag(store, principal, tag) {
40040
+ const publishedSlugs = await store.listPublishedSlugs(principal);
40041
+ const bundledTaggedSlugs = new Set;
40042
+ for (const skill of listServerSkills()) {
40043
+ if (skill.tags.includes(tag) && !publishedSlugs.includes(skill.name))
40044
+ bundledTaggedSlugs.add(skill.name);
40045
+ }
40046
+ const publishedPins = await store.listPinsByTag(principal, tag);
40047
+ const bundledPins = bundledTaggedSlugs.size ? (await store.listPins(principal)).filter((pin) => bundledTaggedSlugs.has(pin.slug)) : [];
40048
+ return [...publishedPins, ...bundledPins].sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map(pinPayload);
40049
+ }
40050
+ async function getMergedSkill(store, artifactStorage, principal, slug) {
40051
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
40052
+ if (resolved.kind === "tombstone")
40053
+ return resolved.payload;
40054
+ if (resolved.kind === "published")
40055
+ return publishedPayload(resolved.record);
39121
40056
  const bundled = getServerSkill(slug);
39122
40057
  return bundled ? bundled : null;
39123
40058
  }
39124
- async function getMergedSkillMd(store, principal, slug) {
39125
- const record = await store.getSkill(principal, slug);
39126
- if (record?.skillMd)
39127
- return record.skillMd;
39128
- if (record)
40059
+ async function getMergedSkillMd(store, artifactStorage, principal, slug) {
40060
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
40061
+ if (resolved.kind === "tombstone")
39129
40062
  return null;
40063
+ if (resolved.kind === "published")
40064
+ return resolved.record.skillMd ?? null;
39130
40065
  return getServerSkillMd(slug);
39131
40066
  }
39132
40067
  async function parsePublishRequest(request, config) {
@@ -39168,7 +40103,7 @@ async function parsePublishRequest(request, config) {
39168
40103
  if (bundleBytes.byteLength === 0) {
39169
40104
  throw new SkillRequestError(400, "BUNDLE_EMPTY", "the uploaded bundle is empty");
39170
40105
  }
39171
- const sha256 = createHash7("sha256").update(bundleBytes).digest("hex");
40106
+ const sha256 = createHash8("sha256").update(bundleBytes).digest("hex");
39172
40107
  const claimed = optionalString(manifest.bundleSha256);
39173
40108
  if (claimed)
39174
40109
  assertSha2562(claimed);
@@ -39196,9 +40131,13 @@ async function parsePublishRequest(request, config) {
39196
40131
  }
39197
40132
  return { input: buildPublishInput(parseManifestJson(text)) };
39198
40133
  }
39199
- async function storePublishedSkill(store, artifactStorage, principal, parsed) {
40134
+ async function storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId) {
39200
40135
  const superseded = (await store.getSkill(principal, parsed.input.slug))?.bundleSha256;
39201
- let input = { ...parsed.input, principal };
40136
+ let input = {
40137
+ ...parsed.input,
40138
+ principal,
40139
+ ...expectedRevisionId ? { expectedRevisionId } : {}
40140
+ };
39202
40141
  if (parsed.bundleBytes && input.bundle) {
39203
40142
  const placement = await artifactStorage.putBundle(principal.orgId, input.bundle.sha256, parsed.bundleBytes, input.bundle.contentType);
39204
40143
  input = { ...input, bundle: { ...input.bundle, ...placement } };
@@ -39209,15 +40148,8 @@ async function storePublishedSkill(store, artifactStorage, principal, parsed) {
39209
40148
  }
39210
40149
  return record;
39211
40150
  }
39212
- async function deletePublishedSkill(store, artifactStorage, principal, slug) {
39213
- const record = await store.getSkill(principal, slug);
39214
- if (!record)
39215
- return false;
39216
- const deleted = await store.deleteSkill(principal, slug);
39217
- if (deleted && record.bundleSha256) {
39218
- await discardCollectedObject(store, artifactStorage, principal, record.bundleSha256);
39219
- }
39220
- return deleted;
40151
+ async function deletePublishedSkill(store, artifactStorage, principal, slug, tombstoneWindowMs) {
40152
+ return store.deleteSkill(principal, slug, tombstoneWindowMs);
39221
40153
  }
39222
40154
  async function discardCollectedObject(store, artifactStorage, principal, sha256) {
39223
40155
  if (await store.getSkillBundle(principal, sha256))
@@ -39237,7 +40169,7 @@ async function readPublishedBundle(store, artifactStorage, principal, slug) {
39237
40169
  if (!bytes) {
39238
40170
  throw new SkillRequestError(503, "BUNDLE_BACKEND_UNAVAILABLE", "bundle storage backend unavailable");
39239
40171
  }
39240
- const actual = createHash7("sha256").update(bytes).digest("hex");
40172
+ const actual = createHash8("sha256").update(bytes).digest("hex");
39241
40173
  if (actual !== record.bundleSha256) {
39242
40174
  throw new SkillRequestError(500, "BUNDLE_DIGEST_DRIFT", `stored bundle for '${slug}' hashes to ${actual} but was published as ${record.bundleSha256}`);
39243
40175
  }
@@ -39353,10 +40285,10 @@ async function createSkillsFetchHandler(options = {}) {
39353
40285
  const segments = pathSegments(url.pathname);
39354
40286
  try {
39355
40287
  if (request.method === "GET" && url.pathname === "/health") {
39356
- return json({ ok: true, service: "open-skills", time: new Date().toISOString() });
40288
+ return json({ ok: true, service: "skills", time: new Date().toISOString() });
39357
40289
  }
39358
40290
  if (request.method === "GET" && url.pathname === "/ready") {
39359
- return json({ ok: true, service: "open-skills" });
40291
+ return json({ ok: true, service: "skills" });
39360
40292
  }
39361
40293
  if (url.pathname.startsWith("/api/")) {
39362
40294
  const principal = await authenticateRequest(store, request);
@@ -39374,6 +40306,14 @@ async function createSkillsFetchHandler(options = {}) {
39374
40306
  if (error instanceof SkillRequestError) {
39375
40307
  return json({ error: error.message, code: error.code }, { status: error.status });
39376
40308
  }
40309
+ if (error instanceof SkillRevisionConflictError) {
40310
+ return json({
40311
+ error: error.message,
40312
+ code: "REVISION_CONFLICT",
40313
+ slug: error.slug,
40314
+ ...error.currentRevisionId ? { currentRevisionId: error.currentRevisionId } : {}
40315
+ }, { status: 409 });
40316
+ }
39377
40317
  return json({ error: "internal server error", detail: error.message }, { status: 500 });
39378
40318
  }
39379
40319
  };
@@ -39393,24 +40333,43 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
39393
40333
  return json({ error: "invalid path segment", code: "INVALID_PATH" }, { status: 400 });
39394
40334
  }
39395
40335
  if (resource === "skills") {
39396
- if (request.method === "GET" && !id)
40336
+ if (request.method === "GET" && !id) {
40337
+ const tag = new URL(request.url).searchParams.get("tag");
40338
+ if (tag !== null && tag !== "")
40339
+ return json(await listMergedSkillsByTag(store, principal, tag));
39397
40340
  return json(await listMergedSkills(store, principal));
40341
+ }
39398
40342
  if (request.method === "POST" && !id) {
40343
+ const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
39399
40344
  const parsed = await parsePublishRequest(request, config);
39400
- const record = await storePublishedSkill(store, artifactStorage, principal, parsed);
39401
- return json(publishedPayload(record), { status: 201 });
40345
+ const record = await storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId);
40346
+ return json(publishedPayload(record), { status: 201, headers: { ETag: revisionEtag(record.revisionId) } });
39402
40347
  }
39403
40348
  if (request.method === "GET" && id && subresource === "skill.md") {
39404
- const docs = await getMergedSkillMd(store, principal, id);
40349
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
40350
+ if (resolved.kind === "tombstone") {
40351
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
40352
+ }
40353
+ const docs = await getMergedSkillMd(store, artifactStorage, principal, id);
39405
40354
  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 });
39406
40355
  }
39407
40356
  if (request.method === "GET" && id && subresource === "bundle") {
40357
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
40358
+ if (resolved.kind === "tombstone") {
40359
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
40360
+ }
40361
+ if (resolved.kind === "absent") {
40362
+ return json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
40363
+ }
39408
40364
  const { record, bytes } = await readPublishedBundle(store, artifactStorage, principal, id);
39409
40365
  const headers = {
39410
40366
  "Content-Type": "application/gzip",
39411
40367
  "Content-Length": String(bytes.byteLength),
39412
40368
  "Content-Disposition": `attachment; filename="${record.slug}.tar.gz"`,
39413
40369
  "X-Skill-Bundle-Sha256": record.bundleSha256 ?? "",
40370
+ "X-Skill-Revision-Id": record.revisionId,
40371
+ "X-Skill-Revision-Number": String(record.revisionNumber),
40372
+ ETag: revisionEtag(record.revisionId),
39414
40373
  "Cache-Control": "no-store"
39415
40374
  };
39416
40375
  if (config.bundleSigningKey) {
@@ -39419,17 +40378,56 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
39419
40378
  return new Response(bytes, { headers });
39420
40379
  }
39421
40380
  if (request.method === "GET" && id && !subresource) {
39422
- const skill = await getMergedSkill(store, principal, id);
40381
+ const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
40382
+ if (resolved.kind === "tombstone") {
40383
+ return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
40384
+ }
40385
+ if (resolved.kind === "published") {
40386
+ return json(publishedPayload(resolved.record), { headers: { ETag: revisionEtag(resolved.record.revisionId) } });
40387
+ }
40388
+ const skill = await getMergedSkill(store, artifactStorage, principal, id);
39423
40389
  return skill ? json(skill) : json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
39424
40390
  }
39425
40391
  if ((request.method === "PUT" || request.method === "PATCH") && id && !subresource) {
40392
+ const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
39426
40393
  const body = await readJson(request, config.requestBodyLimitBytes);
39427
- const updated = await store.updateSkill(principal, id, skillPatch(body));
39428
- return updated ? json(publishedPayload(updated)) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
40394
+ const updated = await store.updateSkill(principal, id, skillPatch(body), expectedRevisionId);
40395
+ return updated ? json(publishedPayload(updated), { headers: { ETag: revisionEtag(updated.revisionId) } }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
39429
40396
  }
39430
40397
  if (request.method === "DELETE" && id && !subresource) {
39431
- const removed = await deletePublishedSkill(store, artifactStorage, principal, id);
39432
- return removed ? json({ deleted: true, slug: id }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
40398
+ const removed = await deletePublishedSkill(store, artifactStorage, principal, id, config.tombstoneWindowMs);
40399
+ return removed ? json({
40400
+ deleted: true,
40401
+ slug: id,
40402
+ ...removed.tombstonedAt ? { tombstonedAt: removed.tombstonedAt, tombstonePurgeAfter: removed.tombstonePurgeAfter } : {}
40403
+ }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
40404
+ }
40405
+ }
40406
+ if (resource === "pins") {
40407
+ if (request.method === "GET" && !id) {
40408
+ const tag = new URL(request.url).searchParams.get("tag");
40409
+ if (tag !== null && tag !== "")
40410
+ return json(await listPinsByTag(store, principal, tag));
40411
+ return json((await store.listPins(principal)).map(pinPayload));
40412
+ }
40413
+ if (request.method === "PUT" && id && !subresource) {
40414
+ assertPublishableSlug(id);
40415
+ const body = await readJson(request, config.requestBodyLimitBytes);
40416
+ const pin = await store.pinSkill(principal, id, pinMetadataField(body));
40417
+ return json(pinPayload(pin));
40418
+ }
40419
+ if (request.method === "DELETE" && id && !subresource) {
40420
+ assertPublishableSlug(id);
40421
+ const removed = await store.unpinSkill(principal, id);
40422
+ return removed ? json({ deleted: true, slug: id }) : json({ error: "pin not found", code: "PIN_NOT_FOUND" }, { status: 404 });
40423
+ }
40424
+ }
40425
+ if (resource === "tags") {
40426
+ if (request.method === "GET" && !id) {
40427
+ return json(await listOrgTags(store, principal));
40428
+ }
40429
+ if (request.method === "GET" && id && subresource === "skills") {
40430
+ return json((await listMergedSkillsByTag(store, principal, id)).map(skillSummary));
39433
40431
  }
39434
40432
  }
39435
40433
  if (resource === "runs") {
@@ -44144,7 +45142,7 @@ CREATE TABLE IF NOT EXISTS execution_receipts (
44144
45142
  );
44145
45143
  `;
44146
45144
  // src/sdk/execution/admission.ts
44147
- import { createHash as createHash9 } from "crypto";
45145
+ import { createHash as createHash10 } from "crypto";
44148
45146
 
44149
45147
  // src/sdk/execution/image-profile.ts
44150
45148
  class ImageProfileResolutionError extends Error {
@@ -44269,7 +45267,7 @@ function createSubmitRunService(options) {
44269
45267
  };
44270
45268
  }
44271
45269
  function digestInput(input) {
44272
- return createHash9("sha256").update(canonicalJson(input)).digest("hex");
45270
+ return createHash10("sha256").update(canonicalJson(input)).digest("hex");
44273
45271
  }
44274
45272
  // src/sdk/execution/state-machine.ts
44275
45273
  var LEGAL_TRANSITIONS = {
@@ -44431,13 +45429,13 @@ function createReceiptService(store) {
44431
45429
  };
44432
45430
  }
44433
45431
  // src/sdk/execution/dispatchers/ecs.ts
44434
- import { createHash as createHash10 } from "crypto";
45432
+ import { createHash as createHash11 } from "crypto";
44435
45433
 
44436
45434
  // ../../node_modules/.bun/@aws-sdk+client-ecs@3.1106.0/node_modules/@aws-sdk/client-ecs/dist-es/ECSClient.js
44437
45435
  var import_client33 = __toESM(require_client2(), 1);
44438
45436
  var import_core2 = __toESM(require_dist_cjs2(), 1);
44439
45437
  var import_client34 = __toESM(require_client(), 1);
44440
- var import_config38 = __toESM(require_config(), 1);
45438
+ var import_config39 = __toESM(require_config(), 1);
44441
45439
  var import_endpoints8 = __toESM(require_endpoints(), 1);
44442
45440
  var import_protocols10 = __toESM(require_protocols(), 1);
44443
45441
  var import_retry6 = __toESM(require_retry(), 1);
@@ -44576,7 +45574,7 @@ var package_default2 = {
44576
45574
  var import_client29 = __toESM(require_client2(), 1);
44577
45575
  var import_httpAuthSchemes6 = __toESM(require_httpAuthSchemes(), 1);
44578
45576
  var import_client30 = __toESM(require_client(), 1);
44579
- var import_config37 = __toESM(require_config(), 1);
45577
+ var import_config38 = __toESM(require_config(), 1);
44580
45578
  var import_retry5 = __toESM(require_retry(), 1);
44581
45579
  var import_serde6 = __toESM(require_serde(), 1);
44582
45580
  var import_node_http_handler3 = __toESM(require_dist_cjs6(), 1);
@@ -47517,7 +48515,7 @@ var getRuntimeConfig4 = (config) => {
47517
48515
  // ../../node_modules/.bun/@aws-sdk+client-ecs@3.1106.0/node_modules/@aws-sdk/client-ecs/dist-es/runtimeConfig.js
47518
48516
  var getRuntimeConfig5 = (config) => {
47519
48517
  import_client30.emitWarningIfUnsupportedVersion(process.version);
47520
- const defaultsMode = import_config37.resolveDefaultsModeConfig(config);
48518
+ const defaultsMode = import_config38.resolveDefaultsModeConfig(config);
47521
48519
  const defaultConfigProvider = () => defaultsMode().then(import_client30.loadConfigsForDefaultMode);
47522
48520
  const clientSharedValues = getRuntimeConfig4(config);
47523
48521
  import_client29.emitWarningIfUnsupportedVersion(process.version);
@@ -47530,21 +48528,21 @@ var getRuntimeConfig5 = (config) => {
47530
48528
  ...config,
47531
48529
  runtime: "node",
47532
48530
  defaultsMode,
47533
- authSchemePreference: config?.authSchemePreference ?? import_config37.loadConfig(import_httpAuthSchemes6.NODE_AUTH_SCHEME_PREFERENCE_OPTIONS, loaderConfig),
48531
+ authSchemePreference: config?.authSchemePreference ?? import_config38.loadConfig(import_httpAuthSchemes6.NODE_AUTH_SCHEME_PREFERENCE_OPTIONS, loaderConfig),
47534
48532
  bodyLengthChecker: config?.bodyLengthChecker ?? import_serde6.calculateBodyLength,
47535
48533
  credentialDefaultProvider: config?.credentialDefaultProvider ?? defaultProvider,
47536
48534
  defaultUserAgentProvider: config?.defaultUserAgentProvider ?? import_client29.createDefaultUserAgentProvider({ serviceId: clientSharedValues.serviceId, clientVersion: package_default2.version }),
47537
- maxAttempts: config?.maxAttempts ?? import_config37.loadConfig(import_retry5.NODE_MAX_ATTEMPT_CONFIG_OPTIONS, config),
47538
- region: config?.region ?? import_config37.loadConfig(import_config37.NODE_REGION_CONFIG_OPTIONS, { ...import_config37.NODE_REGION_CONFIG_FILE_OPTIONS, ...loaderConfig }),
48535
+ maxAttempts: config?.maxAttempts ?? import_config38.loadConfig(import_retry5.NODE_MAX_ATTEMPT_CONFIG_OPTIONS, config),
48536
+ region: config?.region ?? import_config38.loadConfig(import_config38.NODE_REGION_CONFIG_OPTIONS, { ...import_config38.NODE_REGION_CONFIG_FILE_OPTIONS, ...loaderConfig }),
47539
48537
  requestHandler: import_node_http_handler3.NodeHttpHandler.create(config?.requestHandler ?? defaultConfigProvider),
47540
- retryMode: config?.retryMode ?? import_config37.loadConfig({
48538
+ retryMode: config?.retryMode ?? import_config38.loadConfig({
47541
48539
  ...import_retry5.NODE_RETRY_MODE_CONFIG_OPTIONS,
47542
48540
  default: async () => (await defaultConfigProvider()).retryMode || import_retry5.DEFAULT_RETRY_MODE
47543
48541
  }, config),
47544
48542
  streamCollector: config?.streamCollector ?? import_node_http_handler3.streamCollector,
47545
- useDualstackEndpoint: config?.useDualstackEndpoint ?? import_config37.loadConfig(import_config37.NODE_USE_DUALSTACK_ENDPOINT_CONFIG_OPTIONS, loaderConfig),
47546
- useFipsEndpoint: config?.useFipsEndpoint ?? import_config37.loadConfig(import_config37.NODE_USE_FIPS_ENDPOINT_CONFIG_OPTIONS, loaderConfig),
47547
- userAgentAppId: config?.userAgentAppId ?? import_config37.loadConfig(import_client29.NODE_APP_ID_CONFIG_OPTIONS, loaderConfig)
48543
+ useDualstackEndpoint: config?.useDualstackEndpoint ?? import_config38.loadConfig(import_config38.NODE_USE_DUALSTACK_ENDPOINT_CONFIG_OPTIONS, loaderConfig),
48544
+ useFipsEndpoint: config?.useFipsEndpoint ?? import_config38.loadConfig(import_config38.NODE_USE_FIPS_ENDPOINT_CONFIG_OPTIONS, loaderConfig),
48545
+ userAgentAppId: config?.userAgentAppId ?? import_config38.loadConfig(import_client29.NODE_APP_ID_CONFIG_OPTIONS, loaderConfig)
47548
48546
  };
47549
48547
  };
47550
48548
 
@@ -47609,7 +48607,7 @@ class ECSClient extends import_client34.Client {
47609
48607
  const _config_1 = resolveClientEndpointParameters3(_config_0);
47610
48608
  const _config_2 = import_client33.resolveUserAgentConfig(_config_1);
47611
48609
  const _config_3 = import_retry6.resolveRetryConfig(_config_2);
47612
- const _config_4 = import_config38.resolveRegionConfig(_config_3);
48610
+ const _config_4 = import_config39.resolveRegionConfig(_config_3);
47613
48611
  const _config_5 = import_client33.resolveHostHeaderConfig(_config_4);
47614
48612
  const _config_6 = import_endpoints8.resolveEndpointConfig(_config_5);
47615
48613
  const _config_7 = resolveHttpAuthSchemeConfig3(_config_6);
@@ -47662,13 +48660,13 @@ class StopTaskCommand extends command3(_ep03, _mw03, "StopTask", StopTask$) {
47662
48660
  var CLIENT_TOKEN_BYTES = 16;
47663
48661
  var TERMINAL_TASK_STATUSES = new Set(["STOPPED"]);
47664
48662
  function clientTokenFor(runId2, attemptId) {
47665
- return createHash10("sha256").update(`${runId2}\x00${attemptId}`).digest("hex").slice(0, CLIENT_TOKEN_BYTES * 2);
48663
+ return createHash11("sha256").update(`${runId2}\x00${attemptId}`).digest("hex").slice(0, CLIENT_TOKEN_BYTES * 2);
47666
48664
  }
47667
48665
  function startedByFor(runId2, attemptNumber) {
47668
48666
  return `skills-exec/${runId2}/a${attemptNumber}`;
47669
48667
  }
47670
48668
  function requestDigestFor(admission, attemptId) {
47671
- return createHash10("sha256").update(canonicalJson({ admission, attemptId })).digest("hex");
48669
+ return createHash11("sha256").update(canonicalJson({ admission, attemptId })).digest("hex");
47672
48670
  }
47673
48671
 
47674
48672
  class EcsDispatcher {
@@ -48043,7 +49041,7 @@ function runPointersOf(run) {
48043
49041
  // src/sdk/governance-store.ts
48044
49042
  import { Database as Database3 } from "bun:sqlite";
48045
49043
  import { randomUUID as randomUUID4 } from "crypto";
48046
- import { mkdirSync as mkdirSync8 } from "fs";
49044
+ import { mkdirSync as mkdirSync7 } from "fs";
48047
49045
  import { dirname as dirname9 } from "path";
48048
49046
  function receiptId() {
48049
49047
  return `rcpt_${Date.now().toString(36)}_${randomUUID4().replace(/-/g, "").slice(0, 10)}`;
@@ -48133,7 +49131,7 @@ class SqliteGovernanceStore {
48133
49131
  closed = false;
48134
49132
  constructor(path = SQLITE_MEMORY_PATH, options = {}) {
48135
49133
  if (path !== SQLITE_MEMORY_PATH)
48136
- mkdirSync8(dirname9(path), { recursive: true });
49134
+ mkdirSync7(dirname9(path), { recursive: true });
48137
49135
  this.db = new Database3(path, { create: true, readwrite: true });
48138
49136
  this.db.exec("PRAGMA busy_timeout = 5000");
48139
49137
  this.db.exec("PRAGMA foreign_keys = ON");