@vellumai/assistant 0.10.3-dev.202606291709.2b4d83d → 0.10.3-dev.202606291850.7e0a3f7

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 (67) hide show
  1. package/openapi.yaml +246 -6
  2. package/package.json +1 -1
  3. package/src/__tests__/clawhub.test.ts +4 -0
  4. package/src/__tests__/conversation-app-control-instantiation.test.ts +1 -0
  5. package/src/__tests__/conversation-skill-tools.test.ts +153 -0
  6. package/src/__tests__/injector-v3-suppression.test.ts +5 -1
  7. package/src/__tests__/install-meta.test.ts +104 -0
  8. package/src/__tests__/managed-store.test.ts +307 -0
  9. package/src/__tests__/plugin-import-boundary-guard.test.ts +8 -3
  10. package/src/__tests__/scaffold-managed-skill-tool.test.ts +489 -2
  11. package/src/__tests__/skill-projection-feature-flag.test.ts +1 -0
  12. package/src/__tests__/skill-projection.benchmark.test.ts +1 -0
  13. package/src/__tests__/skill-version-hash.test.ts +32 -0
  14. package/src/__tests__/slim-skill-category.test.ts +33 -1
  15. package/src/__tests__/tool-executor.test.ts +22 -0
  16. package/src/cli/lib/__tests__/install-from-github.test.ts +1 -0
  17. package/src/cli/lib/install-from-github.ts +11 -0
  18. package/src/config/__tests__/proc-to-skills.test.ts +21 -0
  19. package/src/config/bundled-skills/skill-management/TOOLS.json +45 -0
  20. package/src/config/bundled-skills/skill-management/tools/find-similar.ts +12 -0
  21. package/src/config/bundled-tool-registry.ts +2 -0
  22. package/src/config/memory-v3-gate.ts +16 -0
  23. package/src/config/schemas/memory-lifecycle.ts +9 -0
  24. package/src/daemon/conversation-agent-loop.ts +17 -0
  25. package/src/daemon/conversation-skill-tools.ts +25 -1
  26. package/src/daemon/conversation-tool-setup.ts +1 -0
  27. package/src/daemon/conversation.ts +1 -0
  28. package/src/daemon/daemon-status.ts +31 -0
  29. package/src/daemon/handlers/skills.ts +8 -0
  30. package/src/daemon/lifecycle.ts +17 -14
  31. package/src/daemon/message-types/skills.ts +20 -2
  32. package/src/daemon/process-message.ts +12 -0
  33. package/src/daemon/shutdown-handlers.ts +9 -9
  34. package/src/daemon/tool-setup-types.ts +17 -0
  35. package/src/daemon/wake-conversation-ops.ts +8 -1
  36. package/src/memory/__tests__/memory-retrospective-job.test.ts +114 -2
  37. package/src/memory/memory-retrospective-constants.ts +10 -0
  38. package/src/memory/memory-retrospective-job.ts +71 -9
  39. package/src/memory/v2/__tests__/consolidation-prompt-flag-gating-guard.test.ts +17 -1
  40. package/src/memory/v2/__tests__/prompts-consolidation.test.ts +20 -1
  41. package/src/memory/v2/prompts/consolidation.ts +19 -8
  42. package/src/permissions/checker.test.ts +319 -1
  43. package/src/permissions/checker.ts +52 -0
  44. package/src/permissions/types.ts +21 -0
  45. package/src/persistence/embeddings/embed.ts +19 -5
  46. package/src/persistence/jobs-worker.ts +1 -0
  47. package/src/platform/sync-identity.ts +24 -0
  48. package/src/plugins/defaults/memory/v3/__tests__/candidate-match.test.ts +316 -0
  49. package/src/plugins/defaults/memory/v3/__tests__/maintain-job.test.ts +263 -3
  50. package/src/plugins/defaults/memory/v3/candidate-match.ts +197 -0
  51. package/src/plugins/defaults/memory/v3/maintain-job.ts +205 -3
  52. package/src/runtime/__tests__/background-job-runner.test.ts +17 -0
  53. package/src/runtime/background-job-runner.ts +11 -0
  54. package/src/runtime/routes/channel-readiness-routes.ts +7 -3
  55. package/src/runtime/routes/skills-routes.ts +11 -1
  56. package/src/skills/catalog-install.ts +1 -0
  57. package/src/skills/clawhub.ts +1 -0
  58. package/src/skills/install-meta.ts +44 -0
  59. package/src/skills/managed-store.ts +100 -2
  60. package/src/skills/skillssh-registry.ts +1 -0
  61. package/src/skills/version-hash.ts +20 -0
  62. package/src/tools/policy-context.ts +21 -0
  63. package/src/tools/skills/find-similar-skills.test.ts +277 -0
  64. package/src/tools/skills/find-similar-skills.ts +120 -0
  65. package/src/tools/skills/scaffold-managed.ts +123 -4
  66. package/src/tools/types.ts +11 -0
  67. package/src/daemon/server.ts +0 -89
package/openapi.yaml CHANGED
@@ -4152,6 +4152,15 @@ paths:
4152
4152
  properties:
4153
4153
  channel:
4154
4154
  type: string
4155
+ enum:
4156
+ - telegram
4157
+ - phone
4158
+ - vellum
4159
+ - whatsapp
4160
+ - slack
4161
+ - email
4162
+ - platform
4163
+ - a2a
4155
4164
  ready:
4156
4165
  type: boolean
4157
4166
  setupStatus:
@@ -4230,14 +4239,20 @@ paths:
4230
4239
  type: object
4231
4240
  properties:
4232
4241
  channel:
4233
- type: string
4234
4242
  description: Optional channel ID to refresh
4243
+ type: string
4244
+ enum:
4245
+ - telegram
4246
+ - phone
4247
+ - vellum
4248
+ - whatsapp
4249
+ - slack
4250
+ - email
4251
+ - platform
4252
+ - a2a
4235
4253
  includeRemote:
4236
- type: boolean
4237
4254
  description: Include remote checks (default true)
4238
- required:
4239
- - channel
4240
- - includeRemote
4255
+ type: boolean
4241
4256
  responses:
4242
4257
  "200":
4243
4258
  description: Successful response
@@ -4255,6 +4270,15 @@ paths:
4255
4270
  properties:
4256
4271
  channel:
4257
4272
  type: string
4273
+ enum:
4274
+ - telegram
4275
+ - phone
4276
+ - vellum
4277
+ - whatsapp
4278
+ - slack
4279
+ - email
4280
+ - platform
4281
+ - a2a
4258
4282
  ready:
4259
4283
  type: boolean
4260
4284
  setupStatus:
@@ -24289,7 +24313,7 @@ paths:
24289
24313
  required: false
24290
24314
  schema:
24291
24315
  type: string
24292
- description: Filter by skill origin (e.g. 'vellum', 'clawhub', 'skillssh', 'custom').
24316
+ description: Filter by skill origin (e.g. 'vellum', 'clawhub', 'skillssh', 'custom', 'assistant-memory').
24293
24317
  - name: kind
24294
24318
  in: query
24295
24319
  required: false
@@ -24591,6 +24615,60 @@ paths:
24591
24615
  - category
24592
24616
  - origin
24593
24617
  additionalProperties: false
24618
+ - type: object
24619
+ properties:
24620
+ id:
24621
+ type: string
24622
+ name:
24623
+ type: string
24624
+ description:
24625
+ type: string
24626
+ icon:
24627
+ type: string
24628
+ emoji:
24629
+ type: string
24630
+ kind:
24631
+ type: string
24632
+ enum:
24633
+ - bundled
24634
+ - installed
24635
+ - catalog
24636
+ status:
24637
+ type: string
24638
+ enum:
24639
+ - enabled
24640
+ - disabled
24641
+ - available
24642
+ category:
24643
+ type: string
24644
+ owner:
24645
+ type: object
24646
+ properties:
24647
+ kind:
24648
+ type: string
24649
+ enum:
24650
+ - skill
24651
+ - mcp
24652
+ - plugin
24653
+ - workspace
24654
+ id:
24655
+ type: string
24656
+ required:
24657
+ - kind
24658
+ - id
24659
+ additionalProperties: false
24660
+ origin:
24661
+ type: string
24662
+ const: assistant-memory
24663
+ required:
24664
+ - id
24665
+ - name
24666
+ - description
24667
+ - kind
24668
+ - status
24669
+ - category
24670
+ - origin
24671
+ additionalProperties: false
24594
24672
  type: object
24595
24673
  description: Skill objects
24596
24674
  categoryCounts:
@@ -24976,6 +25054,60 @@ paths:
24976
25054
  - category
24977
25055
  - origin
24978
25056
  additionalProperties: false
25057
+ - type: object
25058
+ properties:
25059
+ id:
25060
+ type: string
25061
+ name:
25062
+ type: string
25063
+ description:
25064
+ type: string
25065
+ icon:
25066
+ type: string
25067
+ emoji:
25068
+ type: string
25069
+ kind:
25070
+ type: string
25071
+ enum:
25072
+ - bundled
25073
+ - installed
25074
+ - catalog
25075
+ status:
25076
+ type: string
25077
+ enum:
25078
+ - enabled
25079
+ - disabled
25080
+ - available
25081
+ category:
25082
+ type: string
25083
+ owner:
25084
+ type: object
25085
+ properties:
25086
+ kind:
25087
+ type: string
25088
+ enum:
25089
+ - skill
25090
+ - mcp
25091
+ - plugin
25092
+ - workspace
25093
+ id:
25094
+ type: string
25095
+ required:
25096
+ - kind
25097
+ - id
25098
+ additionalProperties: false
25099
+ origin:
25100
+ type: string
25101
+ const: assistant-memory
25102
+ required:
25103
+ - id
25104
+ - name
25105
+ - description
25106
+ - kind
25107
+ - status
25108
+ - category
25109
+ - origin
25110
+ additionalProperties: false
24979
25111
  description: Skill detail object
24980
25112
  type: object
24981
25113
  required:
@@ -25376,6 +25508,60 @@ paths:
25376
25508
  - category
25377
25509
  - origin
25378
25510
  additionalProperties: false
25511
+ - type: object
25512
+ properties:
25513
+ id:
25514
+ type: string
25515
+ name:
25516
+ type: string
25517
+ description:
25518
+ type: string
25519
+ icon:
25520
+ type: string
25521
+ emoji:
25522
+ type: string
25523
+ kind:
25524
+ type: string
25525
+ enum:
25526
+ - bundled
25527
+ - installed
25528
+ - catalog
25529
+ status:
25530
+ type: string
25531
+ enum:
25532
+ - enabled
25533
+ - disabled
25534
+ - available
25535
+ category:
25536
+ type: string
25537
+ owner:
25538
+ type: object
25539
+ properties:
25540
+ kind:
25541
+ type: string
25542
+ enum:
25543
+ - skill
25544
+ - mcp
25545
+ - plugin
25546
+ - workspace
25547
+ id:
25548
+ type: string
25549
+ required:
25550
+ - kind
25551
+ - id
25552
+ additionalProperties: false
25553
+ origin:
25554
+ type: string
25555
+ const: assistant-memory
25556
+ required:
25557
+ - id
25558
+ - name
25559
+ - description
25560
+ - kind
25561
+ - status
25562
+ - category
25563
+ - origin
25564
+ additionalProperties: false
25379
25565
  description: Skill metadata
25380
25566
  type: object
25381
25567
  files:
@@ -25963,6 +26149,60 @@ paths:
25963
26149
  - category
25964
26150
  - origin
25965
26151
  additionalProperties: false
26152
+ - type: object
26153
+ properties:
26154
+ id:
26155
+ type: string
26156
+ name:
26157
+ type: string
26158
+ description:
26159
+ type: string
26160
+ icon:
26161
+ type: string
26162
+ emoji:
26163
+ type: string
26164
+ kind:
26165
+ type: string
26166
+ enum:
26167
+ - bundled
26168
+ - installed
26169
+ - catalog
26170
+ status:
26171
+ type: string
26172
+ enum:
26173
+ - enabled
26174
+ - disabled
26175
+ - available
26176
+ category:
26177
+ type: string
26178
+ owner:
26179
+ type: object
26180
+ properties:
26181
+ kind:
26182
+ type: string
26183
+ enum:
26184
+ - skill
26185
+ - mcp
26186
+ - plugin
26187
+ - workspace
26188
+ id:
26189
+ type: string
26190
+ required:
26191
+ - kind
26192
+ - id
26193
+ additionalProperties: false
26194
+ origin:
26195
+ type: string
26196
+ const: assistant-memory
26197
+ required:
26198
+ - id
26199
+ - name
26200
+ - description
26201
+ - kind
26202
+ - status
26203
+ - category
26204
+ - origin
26205
+ additionalProperties: false
25966
26206
  type: object
25967
26207
  description: Skill objects matching the search query
25968
26208
  required:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606291709.2b4d83d",
3
+ "version": "0.10.3-dev.202606291850.7e0a3f7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -130,6 +130,10 @@ describe("clawhubInstall staging", () => {
130
130
  "# Staged Skill\n",
131
131
  );
132
132
  expect(existsSync(join(stagedSkillDir, "install-meta.json"))).toBe(true);
133
+ const meta = JSON.parse(
134
+ readFileSync(join(stagedSkillDir, "install-meta.json"), "utf-8"),
135
+ ) as SkillInstallMeta;
136
+ expect(meta.author).toBe("user");
133
137
  } finally {
134
138
  Bun.spawn = originalSpawn;
135
139
  rmSync(projectRoot, { recursive: true, force: true });
@@ -201,6 +201,7 @@ mock.module("../skills/catalog-cache.js", () => ({
201
201
  }));
202
202
  mock.module("../skills/install-meta.js", () => ({
203
203
  readInstallMeta: () => null,
204
+ touchSkillLastUsed: () => false,
204
205
  }));
205
206
  mock.module("../telemetry/skill-loaded-events-store.js", () => ({
206
207
  recordSkillLoadedEvent: () => {},
@@ -38,6 +38,8 @@ let recordedSkillLoadedEvents: SkillLoadedEventRecord[] = [];
38
38
  let mockRecordSkillLoadedFailure = false;
39
39
  /** Skill IDs for which registerSkillTools throws (simulates a different-owner tool name collision). */
40
40
  let mockRegisterFailures: Set<string> = new Set();
41
+ /** `(skillDir, today)` pairs captured by the mocked touchSkillLastUsed. */
42
+ let recordedLastUsedTouches: Array<{ skillDir: string; today: string }> = [];
41
43
 
42
44
  // ---------------------------------------------------------------------------
43
45
  // Mocks — must be set up before importing the module under test
@@ -249,6 +251,10 @@ mock.module("../skills/install-meta.js", () => ({
249
251
  const skillId = parts[parts.length - 1];
250
252
  return mockInstallMetas[skillId] ?? null;
251
253
  },
254
+ touchSkillLastUsed: (skillDir: string, today: string) => {
255
+ recordedLastUsedTouches.push({ skillDir, today });
256
+ return true;
257
+ },
252
258
  }));
253
259
 
254
260
  mock.module("../skills/catalog-cache.js", () => ({
@@ -1174,6 +1180,153 @@ describe("skill_loaded telemetry recording", () => {
1174
1180
  });
1175
1181
  });
1176
1182
 
1183
+ // ---------------------------------------------------------------------------
1184
+ // lastUsedAt stamping on managed-skill load
1185
+ // ---------------------------------------------------------------------------
1186
+
1187
+ describe("lastUsedAt stamping on managed-skill load", () => {
1188
+ let sessionState: Map<string, string>;
1189
+
1190
+ // The projection layer computes `today` from the local clock; mirror that
1191
+ // expression so the assertion stays correct regardless of the run date.
1192
+ const expectedToday = new Date().toLocaleDateString("en-CA");
1193
+
1194
+ beforeEach(() => {
1195
+ mockCatalog = [];
1196
+ mockManifests = {};
1197
+ mockRegisteredTools = new Map();
1198
+ mockUnregisteredSkillIds = [];
1199
+ mockSkillRefCount = new Map();
1200
+ mockVersionHashes = {};
1201
+ mockVersionHashErrors = new Set();
1202
+ mockInstallMetas = {};
1203
+ recordedSkillLoadedEvents = [];
1204
+ recordedLastUsedTouches = [];
1205
+ sessionState = new Map<string, string>();
1206
+ });
1207
+
1208
+ test("managed skill load stamps lastUsedAt with today's date and its directory", () => {
1209
+ mockCatalog = [makeSkill("homemade")];
1210
+ mockManifests = { homemade: makeManifest(["hm_run"]) };
1211
+ mockInstallMetas = {
1212
+ homemade: { origin: "custom", installedAt: "2026-01-01T00:00:00Z" },
1213
+ };
1214
+
1215
+ const history: Message[] = [
1216
+ ...skillLoadMessages('<loaded_skill id="homemade" />'),
1217
+ ];
1218
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1219
+
1220
+ expect(recordedLastUsedTouches).toEqual([
1221
+ { skillDir: "/skills/homemade", today: expectedToday },
1222
+ ]);
1223
+ });
1224
+
1225
+ test("stamps managed skills even when telemetry is off (ungated)", () => {
1226
+ // origin: "custom" is suppressed for telemetry but must still be stamped.
1227
+ mockCatalog = [makeSkill("homemade")];
1228
+ mockManifests = { homemade: makeManifest(["hm_run"]) };
1229
+ mockInstallMetas = {
1230
+ homemade: { origin: "custom", installedAt: "2026-01-01T00:00:00Z" },
1231
+ };
1232
+
1233
+ const history: Message[] = [
1234
+ ...skillLoadMessages('<loaded_skill id="homemade" />'),
1235
+ ];
1236
+ // No telemetry option passed.
1237
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1238
+
1239
+ expect(recordedSkillLoadedEvents).toHaveLength(0);
1240
+ expect(recordedLastUsedTouches).toHaveLength(1);
1241
+ expect(recordedLastUsedTouches[0].skillDir).toBe("/skills/homemade");
1242
+ });
1243
+
1244
+ test("instruction-only managed skill (no TOOLS.json) is stamped", () => {
1245
+ mockCatalog = [makeSkill("catalog-skill")];
1246
+ // No manifest — instruction-only managed skill.
1247
+ mockInstallMetas = {
1248
+ "catalog-skill": {
1249
+ origin: "vellum",
1250
+ installedAt: "2026-01-01T00:00:00Z",
1251
+ },
1252
+ };
1253
+
1254
+ const history: Message[] = [
1255
+ ...skillLoadMessages('<loaded_skill id="catalog-skill" />'),
1256
+ ];
1257
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1258
+
1259
+ expect(recordedLastUsedTouches).toEqual([
1260
+ { skillDir: "/skills/catalog-skill", today: expectedToday },
1261
+ ]);
1262
+ });
1263
+
1264
+ test("bundled skills are NOT stamped", () => {
1265
+ mockCatalog = [makeSkill("notes", undefined, "bundled")];
1266
+ mockManifests = { notes: makeManifest(["notes_add"]) };
1267
+
1268
+ const history: Message[] = [
1269
+ ...skillLoadMessages('<loaded_skill id="notes" />'),
1270
+ ];
1271
+ projectSkillTools(history, {
1272
+ previouslyActiveSkillIds: sessionState,
1273
+ telemetry: { conversationId: "conv-xyz", attribution: null },
1274
+ });
1275
+
1276
+ // Telemetry still fires for the bundled skill, but no usage stamp.
1277
+ expect(recordedSkillLoadedEvents).toHaveLength(1);
1278
+ expect(recordedLastUsedTouches).toHaveLength(0);
1279
+ });
1280
+
1281
+ test("workspace skills are NOT stamped", () => {
1282
+ mockCatalog = [makeSkill("local-helper", undefined, "workspace")];
1283
+ mockManifests = { "local-helper": makeManifest(["lh_run"]) };
1284
+
1285
+ const history: Message[] = [
1286
+ ...skillLoadMessages('<loaded_skill id="local-helper" />'),
1287
+ ];
1288
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1289
+
1290
+ expect(recordedLastUsedTouches).toHaveLength(0);
1291
+ });
1292
+
1293
+ test("a still-active skill on a later turn does not re-stamp", () => {
1294
+ mockCatalog = [makeSkill("homemade")];
1295
+ mockManifests = { homemade: makeManifest(["hm_run"]) };
1296
+ mockInstallMetas = {
1297
+ homemade: { origin: "custom", installedAt: "2026-01-01T00:00:00Z" },
1298
+ };
1299
+
1300
+ const history: Message[] = [
1301
+ ...skillLoadMessages('<loaded_skill id="homemade" />'),
1302
+ ];
1303
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1304
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1305
+
1306
+ // Stamping rides the same load events as telemetry — once per activation.
1307
+ expect(recordedLastUsedTouches).toHaveLength(1);
1308
+ });
1309
+
1310
+ test("version-hash change re-registers and re-stamps the managed skill", () => {
1311
+ mockCatalog = [makeSkill("homemade")];
1312
+ mockManifests = { homemade: makeManifest(["hm_run"]) };
1313
+ mockInstallMetas = {
1314
+ homemade: { origin: "custom", installedAt: "2026-01-01T00:00:00Z" },
1315
+ };
1316
+ mockVersionHashes = { homemade: "v1:hash-aaa" };
1317
+
1318
+ const history: Message[] = [
1319
+ ...skillLoadMessages('<loaded_skill id="homemade" />'),
1320
+ ];
1321
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1322
+
1323
+ mockVersionHashes = { homemade: "v1:hash-bbb" };
1324
+ projectSkillTools(history, { previouslyActiveSkillIds: sessionState });
1325
+
1326
+ expect(recordedLastUsedTouches).toHaveLength(2);
1327
+ });
1328
+ });
1329
+
1177
1330
  // ---------------------------------------------------------------------------
1178
1331
  // resolveTools callback integration tests
1179
1332
  // ---------------------------------------------------------------------------
@@ -55,10 +55,14 @@ mock.module("../plugins/injector-registry.js", () => ({
55
55
  }));
56
56
 
57
57
  // `applyRuntimeInjections` reads the v3-live gate (`config.memory.v3.live`)
58
- // via `isMemoryV3Live`; drive it directly through this slot per-test.
58
+ // via `isMemoryV3Live`; drive it directly through this slot per-test. The
59
+ // import graph also pulls `isProcToSkillsActive` (the permission policy-context
60
+ // precomputes the proc-to-skills gate), so the wholesale mock must expose it —
61
+ // keyed off the same v3-live slot.
59
62
  let memoryV3LiveSlot = false;
60
63
  mock.module("../config/memory-v3-gate.js", () => ({
61
64
  isMemoryV3Live: () => memoryV3LiveSlot,
65
+ isProcToSkillsActive: () => false,
62
66
  }));
63
67
 
64
68
  const { applyRuntimeInjections } =
@@ -15,6 +15,7 @@ import {
15
15
  computeSkillHash,
16
16
  readInstallMeta,
17
17
  type SkillInstallMeta,
18
+ touchSkillLastUsed,
18
19
  writeInstallMeta,
19
20
  } from "../skills/install-meta.js";
20
21
 
@@ -108,6 +109,36 @@ describe("writeInstallMeta", () => {
108
109
  expect(parsed.contentHash).toBe("v2:abc123def456");
109
110
  });
110
111
 
112
+ test("round-trips optional author and lastUsedAt fields", () => {
113
+ const dir = makeTempDir();
114
+ const meta: SkillInstallMeta = {
115
+ origin: "custom",
116
+ installedAt: "2026-01-15T10:30:00.000Z",
117
+ author: "user",
118
+ lastUsedAt: "2026-02-01",
119
+ };
120
+
121
+ writeInstallMeta(dir, meta);
122
+
123
+ const result = readInstallMeta(dir);
124
+ expect(result).not.toBeNull();
125
+ expect(result!.author).toBe("user");
126
+ expect(result!.lastUsedAt).toBe("2026-02-01");
127
+ });
128
+
129
+ test("leaves author and lastUsedAt unset when omitted", () => {
130
+ const dir = makeTempDir();
131
+ writeInstallMeta(dir, {
132
+ origin: "custom",
133
+ installedAt: "2026-01-15T10:30:00.000Z",
134
+ });
135
+
136
+ const result = readInstallMeta(dir);
137
+ expect(result).not.toBeNull();
138
+ expect(result!.author).toBeUndefined();
139
+ expect(result!.lastUsedAt).toBeUndefined();
140
+ });
141
+
111
142
  test("overwrites existing install-meta.json", () => {
112
143
  const dir = makeTempDir();
113
144
  writeInstallMeta(dir, {
@@ -327,6 +358,79 @@ describe("readInstallMeta", () => {
327
358
  });
328
359
  });
329
360
 
361
+ // ─── touchSkillLastUsed ─────────────────────────────────────────────────────
362
+
363
+ describe("touchSkillLastUsed", () => {
364
+ function lastUsedDate(dir: string): string | undefined {
365
+ return readInstallMeta(dir)?.lastUsedAt?.slice(0, 10);
366
+ }
367
+
368
+ test("returns false and writes nothing when install metadata is absent", () => {
369
+ const dir = makeTempDir();
370
+ expect(touchSkillLastUsed(dir, "2026-06-22")).toBe(false);
371
+ expect(existsSync(join(dir, "install-meta.json"))).toBe(false);
372
+ });
373
+
374
+ test("first stamp writes today's date and returns true", () => {
375
+ const dir = makeTempDir();
376
+ writeInstallMeta(dir, {
377
+ origin: "custom",
378
+ installedAt: "2026-01-15T10:30:00.000Z",
379
+ author: "assistant",
380
+ });
381
+
382
+ expect(touchSkillLastUsed(dir, "2026-06-22")).toBe(true);
383
+ expect(lastUsedDate(dir)).toBe("2026-06-22");
384
+ // Existing fields are preserved.
385
+ const meta = readInstallMeta(dir);
386
+ expect(meta!.origin).toBe("custom");
387
+ expect(meta!.author).toBe("assistant");
388
+ expect(meta!.installedAt).toBe("2026-01-15T10:30:00.000Z");
389
+ // The stamp is a full ISO timestamp, not a bare date.
390
+ expect(meta!.lastUsedAt).toBe("2026-06-22T00:00:00.000Z");
391
+ });
392
+
393
+ test("second stamp on the same day is a no-op (debounced)", () => {
394
+ const dir = makeTempDir();
395
+ writeInstallMeta(dir, {
396
+ origin: "custom",
397
+ installedAt: "2026-01-15T10:30:00.000Z",
398
+ lastUsedAt: "2026-06-22T08:00:00.000Z",
399
+ });
400
+
401
+ expect(touchSkillLastUsed(dir, "2026-06-22")).toBe(false);
402
+ // The existing timestamp is untouched.
403
+ expect(readInstallMeta(dir)!.lastUsedAt).toBe("2026-06-22T08:00:00.000Z");
404
+ });
405
+
406
+ test("stamp on a later day rewrites lastUsedAt", () => {
407
+ const dir = makeTempDir();
408
+ writeInstallMeta(dir, {
409
+ origin: "custom",
410
+ installedAt: "2026-01-15T10:30:00.000Z",
411
+ lastUsedAt: "2026-06-22T08:00:00.000Z",
412
+ });
413
+
414
+ expect(touchSkillLastUsed(dir, "2026-06-23")).toBe(true);
415
+ expect(lastUsedDate(dir)).toBe("2026-06-23");
416
+ });
417
+
418
+ test("stamps over a malformed install-meta.json via legacy version.json fallback", () => {
419
+ const dir = makeTempDir();
420
+ writeFileSync(join(dir, "install-meta.json"), "{bad json", "utf-8");
421
+ writeFileSync(
422
+ join(dir, "version.json"),
423
+ JSON.stringify({ version: "1.0.0" }),
424
+ "utf-8",
425
+ );
426
+
427
+ expect(touchSkillLastUsed(dir, "2026-06-22")).toBe(true);
428
+ const meta = readInstallMeta(dir);
429
+ expect(meta!.origin).toBe("vellum");
430
+ expect(meta!.lastUsedAt).toBe("2026-06-22T00:00:00.000Z");
431
+ });
432
+ });
433
+
330
434
  // ─── computeSkillHash ───────────────────────────────────────────────────────
331
435
 
332
436
  describe("computeSkillHash", () => {