@vellumai/assistant 0.5.2 → 0.5.4

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 (144) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/architecture/memory.md +105 -0
  3. package/docs/skills.md +100 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/archive-recall.test.ts +560 -0
  6. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  7. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  8. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  9. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  10. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  11. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  12. package/src/__tests__/conversation-wipe.test.ts +226 -0
  13. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  14. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  15. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  16. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  17. package/src/__tests__/inline-command-runner.test.ts +311 -0
  18. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  19. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  20. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  21. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  22. package/src/__tests__/memory-brief-time.test.ts +285 -0
  23. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  24. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  25. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  26. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  27. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  28. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  29. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  30. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  31. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  32. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  33. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  34. package/src/__tests__/memory-reducer-types.test.ts +707 -0
  35. package/src/__tests__/memory-reducer.test.ts +704 -0
  36. package/src/__tests__/memory-regressions.test.ts +30 -8
  37. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  38. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  39. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  40. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  41. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  42. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  43. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  44. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  45. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  46. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  47. package/src/cli/commands/conversations.ts +18 -0
  48. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  49. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  50. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  51. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  52. package/src/config/feature-flag-registry.json +16 -0
  53. package/src/config/raw-config-utils.ts +28 -0
  54. package/src/config/schema.ts +12 -0
  55. package/src/config/schemas/memory-simplified.ts +101 -0
  56. package/src/config/schemas/memory.ts +4 -0
  57. package/src/config/skills.ts +50 -4
  58. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  59. package/src/daemon/conversation-agent-loop.ts +71 -1
  60. package/src/daemon/conversation-lifecycle.ts +11 -1
  61. package/src/daemon/conversation-memory.ts +117 -0
  62. package/src/daemon/conversation-runtime-assembly.ts +3 -1
  63. package/src/daemon/conversation-surfaces.ts +31 -8
  64. package/src/daemon/conversation.ts +40 -23
  65. package/src/daemon/handlers/config-embeddings.ts +10 -2
  66. package/src/daemon/handlers/config-model.ts +0 -9
  67. package/src/daemon/handlers/conversations.ts +11 -0
  68. package/src/daemon/handlers/identity.ts +12 -1
  69. package/src/daemon/lifecycle.ts +52 -1
  70. package/src/daemon/message-types/conversations.ts +0 -1
  71. package/src/daemon/server.ts +1 -1
  72. package/src/followups/followup-store.ts +47 -1
  73. package/src/memory/archive-recall.ts +516 -0
  74. package/src/memory/archive-store.ts +400 -0
  75. package/src/memory/brief-formatting.ts +33 -0
  76. package/src/memory/brief-open-loops.ts +266 -0
  77. package/src/memory/brief-time.ts +162 -0
  78. package/src/memory/brief.ts +75 -0
  79. package/src/memory/conversation-crud.ts +455 -101
  80. package/src/memory/conversation-key-store.ts +33 -4
  81. package/src/memory/db-init.ts +16 -0
  82. package/src/memory/indexer.ts +106 -15
  83. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  84. package/src/memory/job-handlers/conversation-starters.ts +9 -3
  85. package/src/memory/job-handlers/embedding.test.ts +1 -0
  86. package/src/memory/job-handlers/embedding.ts +83 -0
  87. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  88. package/src/memory/job-utils.ts +1 -1
  89. package/src/memory/jobs-store.ts +8 -0
  90. package/src/memory/jobs-worker.ts +20 -0
  91. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  92. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  93. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  94. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  95. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  96. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  97. package/src/memory/migrations/186-memory-archive.ts +109 -0
  98. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  99. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  100. package/src/memory/migrations/index.ts +4 -0
  101. package/src/memory/qdrant-client.ts +23 -4
  102. package/src/memory/reducer-scheduler.ts +242 -0
  103. package/src/memory/reducer-store.ts +271 -0
  104. package/src/memory/reducer-types.ts +106 -0
  105. package/src/memory/reducer.ts +467 -0
  106. package/src/memory/schema/conversations.ts +3 -0
  107. package/src/memory/schema/index.ts +2 -0
  108. package/src/memory/schema/infrastructure.ts +1 -0
  109. package/src/memory/schema/memory-archive.ts +121 -0
  110. package/src/memory/schema/memory-brief.ts +55 -0
  111. package/src/memory/search/semantic.ts +17 -4
  112. package/src/oauth/oauth-store.ts +3 -1
  113. package/src/permissions/checker.ts +89 -6
  114. package/src/permissions/defaults.ts +14 -0
  115. package/src/runtime/auth/route-policy.ts +10 -1
  116. package/src/runtime/routes/conversation-management-routes.ts +94 -2
  117. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  118. package/src/runtime/routes/conversation-routes.ts +52 -5
  119. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  120. package/src/runtime/routes/identity-routes.ts +2 -35
  121. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  122. package/src/runtime/routes/memory-item-routes.ts +90 -5
  123. package/src/runtime/routes/secret-routes.ts +3 -0
  124. package/src/runtime/routes/surface-action-routes.ts +68 -1
  125. package/src/schedule/schedule-store.ts +28 -0
  126. package/src/schedule/scheduler.ts +6 -2
  127. package/src/skills/inline-command-expansions.ts +204 -0
  128. package/src/skills/inline-command-render.ts +127 -0
  129. package/src/skills/inline-command-runner.ts +242 -0
  130. package/src/skills/transitive-version-hash.ts +88 -0
  131. package/src/tasks/task-store.ts +43 -1
  132. package/src/telemetry/usage-telemetry-reporter.ts +1 -1
  133. package/src/tools/filesystem/edit.ts +6 -1
  134. package/src/tools/filesystem/read.ts +6 -1
  135. package/src/tools/filesystem/write.ts +6 -1
  136. package/src/tools/memory/handlers.ts +129 -1
  137. package/src/tools/permission-checker.ts +8 -1
  138. package/src/tools/schedule/create.ts +3 -0
  139. package/src/tools/schedule/list.ts +5 -1
  140. package/src/tools/schedule/update.ts +6 -0
  141. package/src/tools/skills/load.ts +140 -6
  142. package/src/util/platform.ts +18 -0
  143. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  144. package/src/workspace/migrations/registry.ts +1 -1
@@ -0,0 +1,301 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseInlineCommandExpansions } from "../skills/inline-command-expansions.js";
4
+
5
+ // ─── Basic parsing ────────────────────────────────────────────────────────────
6
+
7
+ describe("parseInlineCommandExpansions", () => {
8
+ test("parses a single inline command expansion", () => {
9
+ const body = "Run this: !`gh pr diff`";
10
+ const result = parseInlineCommandExpansions(body);
11
+
12
+ expect(result.expansions).toHaveLength(1);
13
+ expect(result.errors).toHaveLength(0);
14
+
15
+ const exp = result.expansions[0];
16
+ expect(exp.command).toBe("gh pr diff");
17
+ expect(exp.placeholderId).toBe(0);
18
+ expect(exp.startOffset).toBe(10);
19
+ expect(exp.endOffset).toBe(23);
20
+ });
21
+
22
+ test("parses multiple inline command expansions in order", () => {
23
+ const body = "First: !`ls -la` and second: !`echo hello`";
24
+ const result = parseInlineCommandExpansions(body);
25
+
26
+ expect(result.expansions).toHaveLength(2);
27
+ expect(result.errors).toHaveLength(0);
28
+
29
+ expect(result.expansions[0].command).toBe("ls -la");
30
+ expect(result.expansions[0].placeholderId).toBe(0);
31
+
32
+ expect(result.expansions[1].command).toBe("echo hello");
33
+ expect(result.expansions[1].placeholderId).toBe(1);
34
+ });
35
+
36
+ test("preserves literal command text including internal spaces", () => {
37
+ const body = "!`git log --oneline -n 10`";
38
+ const result = parseInlineCommandExpansions(body);
39
+
40
+ expect(result.expansions).toHaveLength(1);
41
+ expect(result.expansions[0].command).toBe("git log --oneline -n 10");
42
+ });
43
+
44
+ test("trims whitespace from command text", () => {
45
+ const body = "!` gh pr diff `";
46
+ const result = parseInlineCommandExpansions(body);
47
+
48
+ expect(result.expansions).toHaveLength(1);
49
+ expect(result.expansions[0].command).toBe("gh pr diff");
50
+ });
51
+
52
+ test("returns empty expansions for body with no tokens", () => {
53
+ const body = "Just a normal skill body with no inline commands.";
54
+ const result = parseInlineCommandExpansions(body);
55
+
56
+ expect(result.expansions).toHaveLength(0);
57
+ expect(result.errors).toHaveLength(0);
58
+ });
59
+
60
+ test("returns empty expansions for empty body", () => {
61
+ const result = parseInlineCommandExpansions("");
62
+
63
+ expect(result.expansions).toHaveLength(0);
64
+ expect(result.errors).toHaveLength(0);
65
+ });
66
+
67
+ // ─── Byte offsets ───────────────────────────────────────────────────────────
68
+
69
+ test("startOffset and endOffset match the token positions", () => {
70
+ const body = "abc !`cmd` def";
71
+ const result = parseInlineCommandExpansions(body);
72
+
73
+ expect(result.expansions).toHaveLength(1);
74
+ const exp = result.expansions[0];
75
+ // "abc " = 4 chars, then !`cmd` = 6 chars
76
+ expect(exp.startOffset).toBe(4);
77
+ expect(exp.endOffset).toBe(10);
78
+ expect(body.slice(exp.startOffset, exp.endOffset)).toBe("!`cmd`");
79
+ });
80
+
81
+ // ─── Fenced code block handling ─────────────────────────────────────────────
82
+
83
+ test("ignores tokens inside fenced code blocks with backtick fence", () => {
84
+ const body = [
85
+ "Normal text with !`real command`",
86
+ "",
87
+ "```",
88
+ "Example: !`gh pr diff`",
89
+ "```",
90
+ "",
91
+ "More text with !`another real command`",
92
+ ].join("\n");
93
+
94
+ const result = parseInlineCommandExpansions(body);
95
+
96
+ expect(result.expansions).toHaveLength(2);
97
+ expect(result.expansions[0].command).toBe("real command");
98
+ expect(result.expansions[1].command).toBe("another real command");
99
+ });
100
+
101
+ test("ignores tokens inside fenced code blocks with tilde fence", () => {
102
+ const body = [
103
+ "~~~",
104
+ "!`should be ignored`",
105
+ "~~~",
106
+ "",
107
+ "!`should be found`",
108
+ ].join("\n");
109
+
110
+ const result = parseInlineCommandExpansions(body);
111
+
112
+ expect(result.expansions).toHaveLength(1);
113
+ expect(result.expansions[0].command).toBe("should be found");
114
+ });
115
+
116
+ test("ignores tokens inside fenced code blocks with info string", () => {
117
+ const body = [
118
+ "```markdown",
119
+ "Use !`gh pr diff` to view changes",
120
+ "```",
121
+ "",
122
+ "!`real command`",
123
+ ].join("\n");
124
+
125
+ const result = parseInlineCommandExpansions(body);
126
+
127
+ expect(result.expansions).toHaveLength(1);
128
+ expect(result.expansions[0].command).toBe("real command");
129
+ });
130
+
131
+ test("handles nested-like fence delimiters (longer opening fence)", () => {
132
+ const body = [
133
+ "````",
134
+ "```",
135
+ "!`inside nested`",
136
+ "```",
137
+ "````",
138
+ "",
139
+ "!`outside`",
140
+ ].join("\n");
141
+
142
+ const result = parseInlineCommandExpansions(body);
143
+
144
+ expect(result.expansions).toHaveLength(1);
145
+ expect(result.expansions[0].command).toBe("outside");
146
+ });
147
+
148
+ test("treats unclosed fenced code block as extending to EOF", () => {
149
+ const body = [
150
+ "```",
151
+ "!`inside unclosed fence`",
152
+ "This code block is never closed",
153
+ ].join("\n");
154
+
155
+ const result = parseInlineCommandExpansions(body);
156
+
157
+ expect(result.expansions).toHaveLength(0);
158
+ expect(result.errors).toHaveLength(0);
159
+ });
160
+
161
+ // ─── Fail-closed on malformed tokens ────────────────────────────────────────
162
+
163
+ test("rejects empty command text", () => {
164
+ const body = "!``";
165
+ const result = parseInlineCommandExpansions(body);
166
+
167
+ expect(result.expansions).toHaveLength(0);
168
+ expect(result.errors).toHaveLength(1);
169
+ expect(result.errors[0].reason).toBe("Empty command text");
170
+ expect(result.errors[0].raw).toBe("!``");
171
+ expect(result.errors[0].offset).toBe(0);
172
+ });
173
+
174
+ test("rejects whitespace-only command text", () => {
175
+ const body = "!` `";
176
+ const result = parseInlineCommandExpansions(body);
177
+
178
+ expect(result.expansions).toHaveLength(0);
179
+ expect(result.errors).toHaveLength(1);
180
+ expect(result.errors[0].reason).toBe("Empty command text");
181
+ });
182
+
183
+ test("reports unmatched opening backtick", () => {
184
+ const body = "Some text !`unmatched";
185
+ const result = parseInlineCommandExpansions(body);
186
+
187
+ expect(result.expansions).toHaveLength(0);
188
+ expect(result.errors).toHaveLength(1);
189
+ expect(result.errors[0].reason).toContain("Unmatched opening backtick");
190
+ expect(result.errors[0].offset).toBe(10);
191
+ });
192
+
193
+ test("valid and malformed tokens can coexist", () => {
194
+ const body = "!`good command` and !`` and !`another good`";
195
+ const result = parseInlineCommandExpansions(body);
196
+
197
+ expect(result.expansions).toHaveLength(2);
198
+ expect(result.expansions[0].command).toBe("good command");
199
+ expect(result.expansions[0].placeholderId).toBe(0);
200
+ expect(result.expansions[1].command).toBe("another good");
201
+ expect(result.expansions[1].placeholderId).toBe(1);
202
+
203
+ expect(result.errors).toHaveLength(1);
204
+ expect(result.errors[0].reason).toBe("Empty command text");
205
+ });
206
+
207
+ // ─── Placeholder ID stability ───────────────────────────────────────────────
208
+
209
+ test("placeholder IDs are sequential by encounter order", () => {
210
+ const body = "!`first` then !`second` and !`third`";
211
+ const result = parseInlineCommandExpansions(body);
212
+
213
+ expect(result.expansions).toHaveLength(3);
214
+ expect(result.expansions[0].placeholderId).toBe(0);
215
+ expect(result.expansions[1].placeholderId).toBe(1);
216
+ expect(result.expansions[2].placeholderId).toBe(2);
217
+ });
218
+
219
+ test("malformed tokens do not consume placeholder IDs", () => {
220
+ const body = "!`first` then !`` then !`second`";
221
+ const result = parseInlineCommandExpansions(body);
222
+
223
+ expect(result.expansions).toHaveLength(2);
224
+ expect(result.expansions[0].placeholderId).toBe(0);
225
+ expect(result.expansions[1].placeholderId).toBe(1);
226
+ });
227
+
228
+ // ─── List items and paragraphs ──────────────────────────────────────────────
229
+
230
+ test("detects tokens in list items", () => {
231
+ const body = [
232
+ "- Step 1: !`git fetch`",
233
+ "- Step 2: !`git rebase origin/main`",
234
+ ].join("\n");
235
+
236
+ const result = parseInlineCommandExpansions(body);
237
+
238
+ expect(result.expansions).toHaveLength(2);
239
+ expect(result.expansions[0].command).toBe("git fetch");
240
+ expect(result.expansions[1].command).toBe("git rebase origin/main");
241
+ });
242
+
243
+ test("detects tokens across multiple paragraphs", () => {
244
+ const body = [
245
+ "First paragraph with !`cmd1`.",
246
+ "",
247
+ "Second paragraph with !`cmd2`.",
248
+ ].join("\n");
249
+
250
+ const result = parseInlineCommandExpansions(body);
251
+
252
+ expect(result.expansions).toHaveLength(2);
253
+ expect(result.expansions[0].command).toBe("cmd1");
254
+ expect(result.expansions[1].command).toBe("cmd2");
255
+ });
256
+
257
+ // ─── Edge cases ─────────────────────────────────────────────────────────────
258
+
259
+ test("does not match regular backtick code spans", () => {
260
+ const body = "Use `gh pr diff` to view changes";
261
+ const result = parseInlineCommandExpansions(body);
262
+
263
+ expect(result.expansions).toHaveLength(0);
264
+ expect(result.errors).toHaveLength(0);
265
+ });
266
+
267
+ test("does not match bare exclamation marks", () => {
268
+ const body = "This is exciting! And `code` too!";
269
+ const result = parseInlineCommandExpansions(body);
270
+
271
+ expect(result.expansions).toHaveLength(0);
272
+ expect(result.errors).toHaveLength(0);
273
+ });
274
+
275
+ test("handles token at start of body", () => {
276
+ const body = "!`first thing`";
277
+ const result = parseInlineCommandExpansions(body);
278
+
279
+ expect(result.expansions).toHaveLength(1);
280
+ expect(result.expansions[0].command).toBe("first thing");
281
+ expect(result.expansions[0].startOffset).toBe(0);
282
+ });
283
+
284
+ test("handles token at end of body", () => {
285
+ const body = "Do this: !`last thing`";
286
+ const result = parseInlineCommandExpansions(body);
287
+
288
+ expect(result.expansions).toHaveLength(1);
289
+ expect(result.expansions[0].command).toBe("last thing");
290
+ expect(result.expansions[0].endOffset).toBe(body.length);
291
+ });
292
+
293
+ test("unmatched backtick inside fenced code block is not an error", () => {
294
+ const body = ["```", "!`unmatched inside fence", "```"].join("\n");
295
+
296
+ const result = parseInlineCommandExpansions(body);
297
+
298
+ expect(result.expansions).toHaveLength(0);
299
+ expect(result.errors).toHaveLength(0);
300
+ });
301
+ });
@@ -0,0 +1,333 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+
6
+ import type { SkillSummary } from "../config/skills.js";
7
+ import { indexCatalogById } from "../skills/include-graph.js";
8
+ import {
9
+ computeTransitiveSkillVersionHash,
10
+ TransitiveHashError,
11
+ } from "../skills/transitive-version-hash.js";
12
+
13
+ const testDirs: string[] = [];
14
+
15
+ function makeTempDir(): string {
16
+ const dir = mkdtempSync(join(tmpdir(), "transitive-hash-test-"));
17
+ testDirs.push(dir);
18
+ return dir;
19
+ }
20
+
21
+ /** Create a minimal SkillSummary with just the fields we need. */
22
+ function makeSkill(
23
+ id: string,
24
+ directoryPath: string,
25
+ includes?: string[],
26
+ ): SkillSummary {
27
+ return {
28
+ id,
29
+ name: id,
30
+ displayName: id,
31
+ description: `Test skill ${id}`,
32
+ directoryPath,
33
+ skillFilePath: join(directoryPath, "SKILL.md"),
34
+ source: "managed",
35
+ includes,
36
+ };
37
+ }
38
+
39
+ afterEach(() => {
40
+ for (const dir of testDirs.splice(0)) {
41
+ rmSync(dir, { recursive: true, force: true });
42
+ }
43
+ });
44
+
45
+ describe("computeTransitiveSkillVersionHash", () => {
46
+ test("returns a tv1: prefixed sha256 hash for a single skill", () => {
47
+ const dir = makeTempDir();
48
+ writeFileSync(join(dir, "SKILL.md"), "# Root Skill\n");
49
+
50
+ const catalog = [makeSkill("root", dir)];
51
+ const index = indexCatalogById(catalog);
52
+
53
+ const hash = computeTransitiveSkillVersionHash("root", index);
54
+ expect(hash).toMatch(/^tv1:[0-9a-f]{64}$/);
55
+ });
56
+
57
+ test("is deterministic for same content", () => {
58
+ const dir = makeTempDir();
59
+ writeFileSync(join(dir, "SKILL.md"), "# Root Skill\n");
60
+
61
+ const catalog = [makeSkill("root", dir)];
62
+ const index = indexCatalogById(catalog);
63
+
64
+ const hash1 = computeTransitiveSkillVersionHash("root", index);
65
+ const hash2 = computeTransitiveSkillVersionHash("root", index);
66
+ expect(hash1).toBe(hash2);
67
+ });
68
+
69
+ test("is stable across separate index builds with unchanged content", () => {
70
+ const dir = makeTempDir();
71
+ writeFileSync(join(dir, "SKILL.md"), "# Root Skill\n");
72
+
73
+ const catalog1 = [makeSkill("root", dir)];
74
+ const index1 = indexCatalogById(catalog1);
75
+ const hash1 = computeTransitiveSkillVersionHash("root", index1);
76
+
77
+ // Build a fresh catalog and index from the same directory
78
+ const catalog2 = [makeSkill("root", dir)];
79
+ const index2 = indexCatalogById(catalog2);
80
+ const hash2 = computeTransitiveSkillVersionHash("root", index2);
81
+
82
+ expect(hash1).toBe(hash2);
83
+ });
84
+
85
+ test("changes when root skill content changes", () => {
86
+ const dir = makeTempDir();
87
+ writeFileSync(join(dir, "SKILL.md"), "# Root v1\n");
88
+
89
+ const catalog = [makeSkill("root", dir)];
90
+ const index = indexCatalogById(catalog);
91
+ const hash1 = computeTransitiveSkillVersionHash("root", index);
92
+
93
+ writeFileSync(join(dir, "SKILL.md"), "# Root v2\n");
94
+ const hash2 = computeTransitiveSkillVersionHash("root", index);
95
+
96
+ expect(hash1).not.toBe(hash2);
97
+ });
98
+
99
+ test("changes when an included child skill content changes", () => {
100
+ const rootDir = makeTempDir();
101
+ const childDir = makeTempDir();
102
+ writeFileSync(join(rootDir, "SKILL.md"), "# Root\n");
103
+ writeFileSync(join(childDir, "SKILL.md"), "# Child v1\n");
104
+
105
+ const catalog = [
106
+ makeSkill("root", rootDir, ["child"]),
107
+ makeSkill("child", childDir),
108
+ ];
109
+ const index = indexCatalogById(catalog);
110
+
111
+ const hash1 = computeTransitiveSkillVersionHash("root", index);
112
+
113
+ // Modify child
114
+ writeFileSync(join(childDir, "SKILL.md"), "# Child v2\n");
115
+ const hash2 = computeTransitiveSkillVersionHash("root", index);
116
+
117
+ expect(hash1).not.toBe(hash2);
118
+ });
119
+
120
+ test("changes when a deeply nested child skill changes", () => {
121
+ const rootDir = makeTempDir();
122
+ const childDir = makeTempDir();
123
+ const grandchildDir = makeTempDir();
124
+ writeFileSync(join(rootDir, "SKILL.md"), "# Root\n");
125
+ writeFileSync(join(childDir, "SKILL.md"), "# Child\n");
126
+ writeFileSync(join(grandchildDir, "SKILL.md"), "# Grandchild v1\n");
127
+
128
+ const catalog = [
129
+ makeSkill("root", rootDir, ["child"]),
130
+ makeSkill("child", childDir, ["grandchild"]),
131
+ makeSkill("grandchild", grandchildDir),
132
+ ];
133
+ const index = indexCatalogById(catalog);
134
+
135
+ const hash1 = computeTransitiveSkillVersionHash("root", index);
136
+
137
+ writeFileSync(join(grandchildDir, "SKILL.md"), "# Grandchild v2\n");
138
+ const hash2 = computeTransitiveSkillVersionHash("root", index);
139
+
140
+ expect(hash1).not.toBe(hash2);
141
+ });
142
+
143
+ test("includes all children in a diamond dependency graph", () => {
144
+ const rootDir = makeTempDir();
145
+ const leftDir = makeTempDir();
146
+ const rightDir = makeTempDir();
147
+ const bottomDir = makeTempDir();
148
+ writeFileSync(join(rootDir, "SKILL.md"), "# Root\n");
149
+ writeFileSync(join(leftDir, "SKILL.md"), "# Left\n");
150
+ writeFileSync(join(rightDir, "SKILL.md"), "# Right\n");
151
+ writeFileSync(join(bottomDir, "SKILL.md"), "# Bottom v1\n");
152
+
153
+ // Diamond: root -> [left, right], left -> [bottom], right -> [bottom]
154
+ const catalog = [
155
+ makeSkill("root", rootDir, ["left", "right"]),
156
+ makeSkill("left", leftDir, ["bottom"]),
157
+ makeSkill("right", rightDir, ["bottom"]),
158
+ makeSkill("bottom", bottomDir),
159
+ ];
160
+ const index = indexCatalogById(catalog);
161
+
162
+ const hash1 = computeTransitiveSkillVersionHash("root", index);
163
+
164
+ // Changing the shared bottom skill must change the root's transitive hash
165
+ writeFileSync(join(bottomDir, "SKILL.md"), "# Bottom v2\n");
166
+ const hash2 = computeTransitiveSkillVersionHash("root", index);
167
+
168
+ expect(hash1).not.toBe(hash2);
169
+ });
170
+
171
+ test("throws TransitiveHashError with code 'missing' for missing child", () => {
172
+ const rootDir = makeTempDir();
173
+ writeFileSync(join(rootDir, "SKILL.md"), "# Root\n");
174
+
175
+ // Root references "missing-child" which is not in the catalog
176
+ const catalog = [makeSkill("root", rootDir, ["missing-child"])];
177
+ const index = indexCatalogById(catalog);
178
+
179
+ expect(() => computeTransitiveSkillVersionHash("root", index)).toThrow(
180
+ TransitiveHashError,
181
+ );
182
+
183
+ try {
184
+ computeTransitiveSkillVersionHash("root", index);
185
+ } catch (err) {
186
+ expect(err).toBeInstanceOf(TransitiveHashError);
187
+ expect((err as TransitiveHashError).code).toBe("missing");
188
+ expect((err as TransitiveHashError).message).toContain("missing-child");
189
+ }
190
+ });
191
+
192
+ test("throws TransitiveHashError with code 'cycle' for cyclic includes", () => {
193
+ const dirA = makeTempDir();
194
+ const dirB = makeTempDir();
195
+ writeFileSync(join(dirA, "SKILL.md"), "# A\n");
196
+ writeFileSync(join(dirB, "SKILL.md"), "# B\n");
197
+
198
+ // Cycle: a -> b -> a
199
+ const catalog = [makeSkill("a", dirA, ["b"]), makeSkill("b", dirB, ["a"])];
200
+ const index = indexCatalogById(catalog);
201
+
202
+ expect(() => computeTransitiveSkillVersionHash("a", index)).toThrow(
203
+ TransitiveHashError,
204
+ );
205
+
206
+ try {
207
+ computeTransitiveSkillVersionHash("a", index);
208
+ } catch (err) {
209
+ expect(err).toBeInstanceOf(TransitiveHashError);
210
+ expect((err as TransitiveHashError).code).toBe("cycle");
211
+ }
212
+ });
213
+
214
+ test("throws for a self-referencing cycle", () => {
215
+ const dir = makeTempDir();
216
+ writeFileSync(join(dir, "SKILL.md"), "# Self\n");
217
+
218
+ const catalog = [makeSkill("self-ref", dir, ["self-ref"])];
219
+ const index = indexCatalogById(catalog);
220
+
221
+ expect(() => computeTransitiveSkillVersionHash("self-ref", index)).toThrow(
222
+ TransitiveHashError,
223
+ );
224
+
225
+ try {
226
+ computeTransitiveSkillVersionHash("self-ref", index);
227
+ } catch (err) {
228
+ expect((err as TransitiveHashError).code).toBe("cycle");
229
+ }
230
+ });
231
+
232
+ test("throws for missing deeply nested child", () => {
233
+ const rootDir = makeTempDir();
234
+ const childDir = makeTempDir();
235
+ writeFileSync(join(rootDir, "SKILL.md"), "# Root\n");
236
+ writeFileSync(join(childDir, "SKILL.md"), "# Child\n");
237
+
238
+ // child references "ghost" which doesn't exist
239
+ const catalog = [
240
+ makeSkill("root", rootDir, ["child"]),
241
+ makeSkill("child", childDir, ["ghost"]),
242
+ ];
243
+ const index = indexCatalogById(catalog);
244
+
245
+ expect(() => computeTransitiveSkillVersionHash("root", index)).toThrow(
246
+ TransitiveHashError,
247
+ );
248
+
249
+ try {
250
+ computeTransitiveSkillVersionHash("root", index);
251
+ } catch (err) {
252
+ expect((err as TransitiveHashError).code).toBe("missing");
253
+ expect((err as TransitiveHashError).message).toContain("ghost");
254
+ }
255
+ });
256
+
257
+ test("skill with no includes behaves like a leaf", () => {
258
+ const dir = makeTempDir();
259
+ writeFileSync(join(dir, "SKILL.md"), "# Leaf Skill\n");
260
+
261
+ const catalog = [makeSkill("leaf", dir)];
262
+ const index = indexCatalogById(catalog);
263
+
264
+ // Should succeed with no errors
265
+ const hash = computeTransitiveSkillVersionHash("leaf", index);
266
+ expect(hash).toMatch(/^tv1:[0-9a-f]{64}$/);
267
+ });
268
+
269
+ test("skill with empty includes array behaves like a leaf", () => {
270
+ const dir = makeTempDir();
271
+ writeFileSync(join(dir, "SKILL.md"), "# Leaf Skill\n");
272
+
273
+ const catalog = [makeSkill("leaf", dir, [])];
274
+ const index = indexCatalogById(catalog);
275
+
276
+ const hash = computeTransitiveSkillVersionHash("leaf", index);
277
+ expect(hash).toMatch(/^tv1:[0-9a-f]{64}$/);
278
+ });
279
+
280
+ test("adding a new file to a child changes the parent transitive hash", () => {
281
+ const rootDir = makeTempDir();
282
+ const childDir = makeTempDir();
283
+ writeFileSync(join(rootDir, "SKILL.md"), "# Root\n");
284
+ writeFileSync(join(childDir, "SKILL.md"), "# Child\n");
285
+
286
+ const catalog = [
287
+ makeSkill("root", rootDir, ["child"]),
288
+ makeSkill("child", childDir),
289
+ ];
290
+ const index = indexCatalogById(catalog);
291
+
292
+ const hash1 = computeTransitiveSkillVersionHash("root", index);
293
+
294
+ // Add a new file to child
295
+ writeFileSync(join(childDir, "helper.ts"), "export {};");
296
+ const hash2 = computeTransitiveSkillVersionHash("root", index);
297
+
298
+ expect(hash1).not.toBe(hash2);
299
+ });
300
+
301
+ test("different include orderings produce different hashes", () => {
302
+ // The include graph structure is encoded by the DFS visit order,
303
+ // so different graphs must produce different hashes.
304
+ const rootDir1 = makeTempDir();
305
+ const rootDir2 = makeTempDir();
306
+ const childADir = makeTempDir();
307
+ const childBDir = makeTempDir();
308
+ writeFileSync(join(rootDir1, "SKILL.md"), "# Root\n");
309
+ writeFileSync(join(rootDir2, "SKILL.md"), "# Root\n");
310
+ writeFileSync(join(childADir, "SKILL.md"), "# A\n");
311
+ writeFileSync(join(childBDir, "SKILL.md"), "# B\n");
312
+
313
+ // Graph 1: root includes only A
314
+ const catalog1 = [
315
+ makeSkill("root", rootDir1, ["a"]),
316
+ makeSkill("a", childADir),
317
+ makeSkill("b", childBDir),
318
+ ];
319
+ const index1 = indexCatalogById(catalog1);
320
+ const hash1 = computeTransitiveSkillVersionHash("root", index1);
321
+
322
+ // Graph 2: root includes A and B
323
+ const catalog2 = [
324
+ makeSkill("root", rootDir2, ["a", "b"]),
325
+ makeSkill("a", childADir),
326
+ makeSkill("b", childBDir),
327
+ ];
328
+ const index2 = indexCatalogById(catalog2);
329
+ const hash2 = computeTransitiveSkillVersionHash("root", index2);
330
+
331
+ expect(hash1).not.toBe(hash2);
332
+ });
333
+ });