@vellumai/assistant 0.8.11 → 0.8.12-staging.1

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 (219) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +126 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-usage-store.test.ts +11 -0
  37. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  38. package/src/__tests__/model-intents.test.ts +1 -1
  39. package/src/__tests__/oauth-cli.test.ts +19 -8
  40. package/src/__tests__/openai-provider.test.ts +34 -0
  41. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  42. package/src/__tests__/recurrence-engine.test.ts +45 -0
  43. package/src/__tests__/schedule-routes.test.ts +34 -0
  44. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  45. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  46. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  47. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  48. package/src/__tests__/subagent-tools.test.ts +1 -0
  49. package/src/__tests__/system-prompt.test.ts +109 -11
  50. package/src/__tests__/tool-error-hook.test.ts +1 -0
  51. package/src/__tests__/tool-result-spool.test.ts +337 -0
  52. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  53. package/src/__tests__/validate-input.test.ts +95 -1
  54. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  55. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  56. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  57. package/src/agent/loop.ts +81 -24
  58. package/src/api/events/usage-progress.ts +28 -0
  59. package/src/api/index.ts +6 -0
  60. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  61. package/src/bundler/app-bundler.ts +25 -42
  62. package/src/calls/call-controller.ts +1 -1
  63. package/src/cli/commands/plugins.ts +248 -15
  64. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  66. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  68. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  69. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
  70. package/src/cli/lib/inspect-plugin.ts +252 -0
  71. package/src/cli/lib/install-from-github.ts +214 -21
  72. package/src/cli/lib/list-installed-plugins.ts +17 -6
  73. package/src/cli/lib/plugin-artifact.ts +103 -0
  74. package/src/cli/lib/plugin-details.ts +18 -1
  75. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  76. package/src/cli/lib/upgrade-plugin.ts +219 -0
  77. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  78. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  79. package/src/config/call-site-defaults.ts +13 -2
  80. package/src/config/feature-flag-registry.json +8 -16
  81. package/src/config/loader.ts +52 -59
  82. package/src/config/schema.ts +0 -2
  83. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  84. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  85. package/src/config/schemas/llm.ts +10 -0
  86. package/src/config/schemas/memory-v2.ts +13 -0
  87. package/src/config/schemas/memory-v3.ts +92 -0
  88. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  89. package/src/context/tool-result-spool.ts +104 -0
  90. package/src/credential-execution/feature-gates.ts +0 -1
  91. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  92. package/src/daemon/conversation-error.ts +6 -15
  93. package/src/daemon/conversation.ts +9 -0
  94. package/src/daemon/disk-pressure-policy.ts +0 -1
  95. package/src/daemon/lifecycle.ts +1 -20
  96. package/src/daemon/message-types/conversations.ts +2 -15
  97. package/src/daemon/trust-context.ts +1 -1
  98. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  99. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  100. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  101. package/src/home/home-content-refresh.ts +43 -31
  102. package/src/home/home-greeting-cache.ts +8 -1
  103. package/src/home/home-greeting.ts +13 -9
  104. package/src/home/suggested-prompts.ts +77 -24
  105. package/src/ipc/routes/trust-rules.test.ts +66 -72
  106. package/src/media/image-credentials.ts +2 -2
  107. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  108. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  109. package/src/memory/conversation-attention-store.ts +1 -0
  110. package/src/memory/conversation-bootstrap.ts +18 -9
  111. package/src/memory/conversation-crud.ts +12 -2
  112. package/src/memory/conversation-title-service.ts +53 -9
  113. package/src/memory/delivery-channels.ts +0 -69
  114. package/src/memory/graph/extraction-job.ts +0 -15
  115. package/src/memory/guardian-action-store.ts +1 -376
  116. package/src/memory/llm-usage-store.ts +5 -1
  117. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  119. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  120. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  121. package/src/memory/v2/consolidation-job.ts +93 -9
  122. package/src/memory/v2/injection.ts +53 -0
  123. package/src/memory/v2/prompts/consolidation.ts +1 -0
  124. package/src/memory/v2/static-context.ts +13 -1
  125. package/src/memory/v2/sweep-job.ts +1 -1
  126. package/src/memory/v2/types.ts +5 -0
  127. package/src/plugin-api/types.ts +7 -0
  128. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  129. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  130. package/src/plugins/defaults/index.ts +25 -0
  131. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  132. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  133. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  134. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  135. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  136. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  137. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  138. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  139. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  140. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  141. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  142. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  143. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  144. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  145. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  146. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  147. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  148. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  149. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  150. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  151. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  152. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  153. package/src/prompts/cache-boundary.ts +17 -0
  154. package/src/prompts/sections.ts +50 -17
  155. package/src/prompts/system-prompt.ts +12 -4
  156. package/src/prompts/templates/system-sections.ts +22 -0
  157. package/src/providers/anthropic/client.ts +74 -28
  158. package/src/providers/gemini/client.ts +5 -1
  159. package/src/providers/minimax/client.ts +9 -0
  160. package/src/providers/model-intents.ts +2 -2
  161. package/src/providers/openai/chat-completions-provider.ts +2 -1
  162. package/src/providers/openai/responses-provider.ts +5 -1
  163. package/src/providers/retry.ts +8 -0
  164. package/src/providers/types.ts +11 -0
  165. package/src/runtime/AGENTS.md +6 -0
  166. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  167. package/src/runtime/agent-wake.ts +5 -5
  168. package/src/runtime/background-job-runner.ts +2 -2
  169. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  170. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  171. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  172. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  173. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  174. package/src/runtime/pre-first-message-gate.ts +1 -1
  175. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  176. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  177. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  178. package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
  179. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  180. package/src/runtime/routes/btw-routes.ts +0 -14
  181. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  182. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  183. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  184. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  185. package/src/runtime/routes/home-feed-routes.ts +10 -0
  186. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  187. package/src/runtime/routes/identity-routes.ts +76 -20
  188. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  189. package/src/runtime/routes/plugins-routes.ts +21 -0
  190. package/src/runtime/routes/schedule-routes.ts +19 -2
  191. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  192. package/src/schedule/recurrence-engine.ts +34 -0
  193. package/src/schedule/scheduler.ts +1 -0
  194. package/src/skills/validate-input.ts +41 -1
  195. package/src/subagent/types.ts +26 -1
  196. package/src/telemetry/types.ts +15 -1
  197. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  198. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  199. package/src/tools/apps/executors.ts +1 -1
  200. package/src/tools/skills/skill-tool-factory.ts +19 -8
  201. package/src/usage/types.ts +8 -1
  202. package/src/util/platform.ts +16 -0
  203. package/src/watcher/engine.ts +1 -0
  204. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  205. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  206. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  207. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  210. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  211. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  212. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  213. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  214. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  215. package/src/config/schemas/updates.ts +0 -14
  216. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  217. package/src/memory/conversation-starters-cadence.ts +0 -78
  218. package/src/prompts/update-bulletin-job.ts +0 -180
  219. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -10,6 +10,11 @@
10
10
  import type { Command } from "commander";
11
11
 
12
12
  import { confirmPrompt } from "../lib/confirm-prompt.js";
13
+ import {
14
+ inspectPlugin,
15
+ type PluginInspection,
16
+ PluginInspectNotFoundError,
17
+ } from "../lib/inspect-plugin.js";
13
18
  import {
14
19
  DEFAULT_PLUGIN_REF,
15
20
  installPlugin,
@@ -18,6 +23,7 @@ import {
18
23
  PluginNotFoundError,
19
24
  } from "../lib/install-from-github.js";
20
25
  import { listInstalledPlugins } from "../lib/list-installed-plugins.js";
26
+ import type { FingerprintComparison } from "../lib/plugin-fingerprint.js";
21
27
  import { registerCommand } from "../lib/register-command.js";
22
28
  import {
23
29
  InvalidSearchPatternError,
@@ -27,6 +33,11 @@ import {
27
33
  PluginNotInstalledError,
28
34
  uninstallPlugin,
29
35
  } from "../lib/uninstall-plugin.js";
36
+ import {
37
+ PluginNotUpgradableError,
38
+ type PluginUpgradeResult,
39
+ upgradePlugin,
40
+ } from "../lib/upgrade-plugin.js";
30
41
  import { getCliLogger } from "../logger.js";
31
42
 
32
43
  const log = getCliLogger("plugins");
@@ -41,15 +52,19 @@ export function registerPluginsCommand(program: Command): void {
41
52
  "after",
42
53
  `
43
54
  Examples:
44
- $ assistant plugins install simple-memory
45
- $ assistant plugins install simple-memory --force
46
- $ assistant plugins install simple-memory --ref my-feature-branch
55
+ $ assistant plugins install example
56
+ $ assistant plugins install example --force
57
+ $ assistant plugins install example --ref my-feature-branch
47
58
  $ assistant plugins list
48
59
  $ assistant plugins list --json
49
- $ assistant plugins search memory
50
- $ assistant plugins search "^simple"
51
- $ assistant plugins search memory --json
52
- $ assistant plugins uninstall simple-memory`,
60
+ $ assistant plugins inspect example
61
+ $ assistant plugins inspect example --json
62
+ $ assistant plugins upgrade example
63
+ $ assistant plugins upgrade example --dry-run
64
+ $ assistant plugins search example
65
+ $ assistant plugins search "^example"
66
+ $ assistant plugins search example --json
67
+ $ assistant plugins uninstall example`,
53
68
  );
54
69
 
55
70
  plugins
@@ -147,6 +162,55 @@ Examples:
147
162
  );
148
163
  });
149
164
 
165
+ plugins
166
+ .command("inspect <name>")
167
+ .description(
168
+ "Show a plugin's local install metadata and the marketplace pin, and whether an update is available",
169
+ )
170
+ .option("--json", "Emit machine-readable JSON instead of a summary")
171
+ .action(async (name: string, opts: { json?: boolean }) => {
172
+ try {
173
+ const inspection = await inspectPlugin(
174
+ { name },
175
+ { fetch: globalThis.fetch.bind(globalThis) },
176
+ );
177
+
178
+ if (opts.json) {
179
+ process.stdout.write(JSON.stringify(inspection, null, 2) + "\n");
180
+ return;
181
+ }
182
+
183
+ // Logged after the JSON early-return: the CLI logger writes
184
+ // info to stdout, which would otherwise corrupt --json output.
185
+ log.info(
186
+ {
187
+ name: inspection.name,
188
+ installed: inspection.installed,
189
+ status: inspection.status,
190
+ },
191
+ "plugin inspect",
192
+ );
193
+
194
+ for (const line of formatInspection(inspection)) {
195
+ console.log(line);
196
+ }
197
+ } catch (err) {
198
+ if (err instanceof PluginInspectNotFoundError) {
199
+ console.error(err.message);
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ if (err instanceof InvalidPluginNameError) {
204
+ console.error(err.message);
205
+ process.exitCode = 1;
206
+ return;
207
+ }
208
+ const message = err instanceof Error ? err.message : String(err);
209
+ console.error(`Plugin inspect failed: ${message}`);
210
+ process.exitCode = 1;
211
+ }
212
+ });
213
+
150
214
  plugins
151
215
  .command("search <query>")
152
216
  .description(
@@ -160,9 +224,13 @@ Examples:
160
224
  { fetch: globalThis.fetch.bind(globalThis) },
161
225
  );
162
226
 
163
- // Log on every success path — JSON output, empty results, and
164
- // populated tables alike — so observability doesn't depend on
165
- // which formatting branch the caller landed in.
227
+ if (opts.json) {
228
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
229
+ return;
230
+ }
231
+
232
+ // Logged after the JSON early-return: the CLI logger writes info
233
+ // to stdout, which would otherwise corrupt --json output.
166
234
  log.info(
167
235
  {
168
236
  query: result.query,
@@ -172,11 +240,6 @@ Examples:
172
240
  "external plugin search",
173
241
  );
174
242
 
175
- if (opts.json) {
176
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
177
- return;
178
- }
179
-
180
243
  if (result.matches.length === 0) {
181
244
  console.log(`No plugins matched "${result.query}".`);
182
245
  return;
@@ -253,6 +316,176 @@ Examples:
253
316
  process.exitCode = 1;
254
317
  }
255
318
  });
319
+
320
+ plugins
321
+ .command("upgrade <name>")
322
+ .description(
323
+ "Upgrade an installed plugin to the marketplace's current pin",
324
+ )
325
+ .option(
326
+ "--dry-run",
327
+ "Show what would change without modifying the install",
328
+ )
329
+ .option("--json", "Emit machine-readable JSON instead of a summary")
330
+ .action(
331
+ async (name: string, opts: { dryRun?: boolean; json?: boolean }) => {
332
+ try {
333
+ const result = await upgradePlugin(
334
+ { name, dryRun: opts.dryRun },
335
+ { fetch: globalThis.fetch.bind(globalThis) },
336
+ );
337
+
338
+ if (opts.json) {
339
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
340
+ return;
341
+ }
342
+
343
+ // Logged after the JSON early-return: the CLI logger writes
344
+ // info to stdout, which would otherwise corrupt --json output.
345
+ log.info(
346
+ {
347
+ name: result.name,
348
+ outcome: result.outcome,
349
+ from: result.fromCommit,
350
+ to: result.toCommit,
351
+ },
352
+ "plugin upgrade",
353
+ );
354
+
355
+ for (const line of formatUpgrade(result)) {
356
+ console.log(line);
357
+ }
358
+ } catch (err) {
359
+ if (
360
+ err instanceof PluginNotInstalledError ||
361
+ err instanceof PluginNotUpgradableError ||
362
+ err instanceof InvalidPluginNameError
363
+ ) {
364
+ console.error(err.message);
365
+ process.exitCode = 1;
366
+ return;
367
+ }
368
+ const message = err instanceof Error ? err.message : String(err);
369
+ console.error(`Plugin upgrade failed: ${message}`);
370
+ process.exitCode = 1;
371
+ }
372
+ },
373
+ );
256
374
  },
257
375
  });
258
376
  }
377
+
378
+ /** Abbreviate a commit SHA for display; passes through non-SHA / null values. */
379
+ function shortSha(sha: string | null): string {
380
+ return sha ? sha.slice(0, 7) : "—";
381
+ }
382
+
383
+ /** Human-readable status line for an inspection result. */
384
+ function statusLine(inspection: PluginInspection): string {
385
+ const { status, local, remote } = inspection;
386
+ switch (status) {
387
+ case "up-to-date":
388
+ return "up to date";
389
+ case "update-available":
390
+ return `update available (${shortSha(local?.commit ?? null)} → ${shortSha(
391
+ remote?.commit ?? null,
392
+ )})`;
393
+ case "not-installed":
394
+ return "not installed";
395
+ case "not-in-marketplace":
396
+ return "installed (not in marketplace)";
397
+ case "unknown-provenance":
398
+ return "unknown — reinstall to record provenance";
399
+ case "remote-unavailable":
400
+ return "installed (marketplace unavailable)";
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Summarize local edits relative to the install-time fingerprint. Reports
406
+ * "unknown" when no baseline was recorded (an older or manually-copied
407
+ * install), "none" when the on-disk tree matches, or a per-category count.
408
+ */
409
+ function localEditsLine(changes: FingerprintComparison | null): string {
410
+ if (!changes)
411
+ return "unknown (no recorded baseline; reinstall to record one)";
412
+ if (changes.clean) return "none";
413
+ const parts = [
414
+ `${changes.modified.length} modified`,
415
+ `${changes.added.length} added`,
416
+ `${changes.removed.length} removed`,
417
+ ];
418
+ return parts.join(", ");
419
+ }
420
+
421
+ /** Render an inspection as aligned, human-readable summary lines. */
422
+ function formatInspection(inspection: PluginInspection): string[] {
423
+ const { name, local, remote, remoteError } = inspection;
424
+ const lines: string[] = [`${name} plugin`];
425
+ const row = (label: string, value: string) =>
426
+ lines.push(` ${label.padEnd(11)} ${value}`);
427
+
428
+ row("status", statusLine(inspection));
429
+
430
+ if (local) {
431
+ const installedAt = local.installedAt
432
+ ? ` (${local.installedAt.slice(0, 10)})`
433
+ : "";
434
+ row("installed", `${shortSha(local.commit)}${installedAt}`);
435
+ row("local edits", localEditsLine(local.localChanges));
436
+ }
437
+
438
+ if (remote) {
439
+ const where = remote.path ? `${remote.repo}/${remote.path}` : remote.repo;
440
+ row(
441
+ "remote pin",
442
+ `${shortSha(remote.commit)} (${where}, ${remote.marketplaceRef})`,
443
+ );
444
+ } else if (remoteError) {
445
+ row("remote", `unavailable — ${remoteError}`);
446
+ }
447
+
448
+ const version = local?.version ?? null;
449
+ if (version) row("version", version);
450
+
451
+ const license = remote?.license ?? null;
452
+ const homepage = remote?.homepage ?? null;
453
+ if (license && homepage) row("license", `${license} homepage ${homepage}`);
454
+ else if (license) row("license", license);
455
+ else if (homepage) row("homepage", homepage);
456
+
457
+ const description = remote?.description ?? local?.description ?? null;
458
+ if (description) row("description", description);
459
+
460
+ for (const issue of local?.issues ?? []) row("issue", issue);
461
+
462
+ return lines;
463
+ }
464
+
465
+ /** Render an upgrade result as human-readable summary lines. */
466
+ function formatUpgrade(result: PluginUpgradeResult): string[] {
467
+ const { name, fromCommit, toCommit } = result;
468
+ const move = `${shortSha(fromCommit)} → ${shortSha(toCommit)}`;
469
+ const provenanceNote = result.provenanceWasUnknown
470
+ ? " (previous install had no recorded provenance)"
471
+ : "";
472
+
473
+ switch (result.outcome) {
474
+ case "already-up-to-date":
475
+ return [`"${name}" is already up to date (${shortSha(toCommit)}).`];
476
+ case "would-upgrade":
477
+ return [
478
+ `"${name}" would upgrade ${move}${provenanceNote} (dry run; no changes made).`,
479
+ ];
480
+ case "upgraded": {
481
+ const count =
482
+ result.fileCount === null
483
+ ? ""
484
+ : ` (${result.fileCount} file${result.fileCount === 1 ? "" : "s"})`;
485
+ return [
486
+ `Upgraded "${name}" ${move}${count}${provenanceNote} → ${result.target}`,
487
+ "Restart the assistant to pick up the upgrade.",
488
+ ];
489
+ }
490
+ }
491
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Tests for {@link inspectPlugin}.
3
+ *
4
+ * The marketplace fetch is replaced with an in-memory fixture passed via the
5
+ * `fetch` dependency, and the installed-copy path is exercised against a real
6
+ * temp directory passed via `workspacePluginsDir` — no globals are monkeypatched.
7
+ * Drift is the exact comparison of the provenance sidecar's commit against the
8
+ * marketplace pin, so the fixtures vary those two SHAs.
9
+ */
10
+
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+
16
+ import {
17
+ inspectPlugin,
18
+ PluginInspectNotFoundError,
19
+ } from "../inspect-plugin.js";
20
+ import type { FetchLike } from "../install-from-github.js";
21
+ import { computeFingerprint } from "../plugin-fingerprint.js";
22
+
23
+ const SHA_A = "a".repeat(40);
24
+ const SHA_B = "b".repeat(40);
25
+
26
+ /** A marketplace manifest object with a single entry for `name`. */
27
+ function manifestWith(name: string, ref: string): unknown {
28
+ return {
29
+ name: "vellum",
30
+ plugins: [
31
+ {
32
+ name,
33
+ source: { source: "github", repo: `example-org/${name}`, ref },
34
+ description: "A test plugin.",
35
+ category: "developer",
36
+ homepage: `https://github.com/example-org/${name}`,
37
+ license: "MIT",
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Build a `fetch` that serves the marketplace manifest. `marketplace:
45
+ * undefined` answers 404 (empty catalog); `fail: true` rejects with a network
46
+ * error. Anything else surfaces a 500 so test bugs are loud.
47
+ */
48
+ function makeFetch(opts: { marketplace?: unknown; fail?: boolean }): FetchLike {
49
+ return (async (input: RequestInfo | URL) => {
50
+ const url = typeof input === "string" ? input : input.toString();
51
+ if (url.includes("marketplace.json")) {
52
+ if (opts.fail) throw new Error("network down");
53
+ if (opts.marketplace === undefined) {
54
+ return new Response("not found", { status: 404 });
55
+ }
56
+ return new Response(JSON.stringify(opts.marketplace), { status: 200 });
57
+ }
58
+ return new Response("unexpected url: " + url, { status: 500 });
59
+ }) as FetchLike;
60
+ }
61
+
62
+ /** Materialize an installed plugin copy with an optional provenance sidecar. */
63
+ function installPlugin(
64
+ workspace: string,
65
+ name: string,
66
+ opts: {
67
+ version?: string;
68
+ sidecar?: { commit?: string | null; ref?: string } | null;
69
+ /** Embed a content fingerprint of the materialized tree in the sidecar. */
70
+ fingerprint?: boolean;
71
+ } = {},
72
+ ): void {
73
+ const dir = join(workspace, name);
74
+ mkdirSync(dir, { recursive: true });
75
+ writeFileSync(
76
+ join(dir, "package.json"),
77
+ JSON.stringify({
78
+ name,
79
+ version: opts.version ?? "0.1.0",
80
+ description: "Installed copy.",
81
+ }),
82
+ );
83
+ if (opts.sidecar !== null) {
84
+ const sidecar = opts.sidecar ?? { commit: SHA_A };
85
+ // Mirror install: fingerprint the tree before the sidecar is written so it
86
+ // is not part of its own baseline.
87
+ const fingerprint = opts.fingerprint
88
+ ? computeFingerprint(dir, ["install-meta.json"])
89
+ : undefined;
90
+ writeFileSync(
91
+ join(dir, "install-meta.json"),
92
+ JSON.stringify({
93
+ origin: "vellum",
94
+ name,
95
+ source: {
96
+ kind: "github",
97
+ owner: "example-org",
98
+ repo: name,
99
+ ref: sidecar.ref ?? sidecar.commit ?? SHA_A,
100
+ },
101
+ commit: sidecar.commit ?? undefined,
102
+ installedAt: "2026-06-10T12:00:00.000Z",
103
+ fingerprint,
104
+ }),
105
+ );
106
+ }
107
+ }
108
+
109
+ let workspace: string;
110
+
111
+ beforeEach(() => {
112
+ workspace = mkdtempSync(join(tmpdir(), "inspect-plugin-"));
113
+ });
114
+
115
+ afterEach(() => {
116
+ rmSync(workspace, { recursive: true, force: true });
117
+ });
118
+
119
+ describe("inspectPlugin", () => {
120
+ test("reports up-to-date when the installed commit equals the marketplace pin", async () => {
121
+ // GIVEN an installed plugin pinned to the same commit the marketplace pins
122
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
123
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
124
+
125
+ // WHEN it is inspected
126
+ const result = await inspectPlugin(
127
+ { name: "level-up" },
128
+ { fetch, workspacePluginsDir: workspace },
129
+ );
130
+
131
+ // THEN the status is up-to-date and both sides resolve the same commit
132
+ expect(result.status).toBe("up-to-date");
133
+ expect(result.installed).toBe(true);
134
+ expect(result.local?.commit).toBe(SHA_A);
135
+ expect(result.remote?.commit).toBe(SHA_A);
136
+ // AND local + remote metadata are surfaced
137
+ expect(result.local?.version).toBe("0.1.0");
138
+ expect(result.remote?.repo).toBe("example-org/level-up");
139
+ expect(result.remote?.license).toBe("MIT");
140
+ });
141
+
142
+ test("reports update-available when the marketplace pin has advanced", async () => {
143
+ // GIVEN an installed plugin whose commit differs from the marketplace pin
144
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
145
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_B) });
146
+
147
+ // WHEN it is inspected
148
+ const result = await inspectPlugin(
149
+ { name: "level-up" },
150
+ { fetch, workspacePluginsDir: workspace },
151
+ );
152
+
153
+ // THEN drift is reported with both commits
154
+ expect(result.status).toBe("update-available");
155
+ expect(result.local?.commit).toBe(SHA_A);
156
+ expect(result.remote?.commit).toBe(SHA_B);
157
+ });
158
+
159
+ test("falls back to the sidecar ref when no commit was recorded", async () => {
160
+ // GIVEN a sidecar with no commit but a full-SHA ref equal to the pin
161
+ installPlugin(workspace, "level-up", {
162
+ sidecar: { commit: null, ref: SHA_A },
163
+ });
164
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
165
+
166
+ // WHEN it is inspected
167
+ const result = await inspectPlugin(
168
+ { name: "level-up" },
169
+ { fetch, workspacePluginsDir: workspace },
170
+ );
171
+
172
+ // THEN the ref stands in for the commit and the status is up-to-date
173
+ expect(result.status).toBe("up-to-date");
174
+ expect(result.local?.commit).toBe(SHA_A);
175
+ });
176
+
177
+ test("reports unknown-provenance when installed without a resolvable commit", async () => {
178
+ // GIVEN an installed copy with no provenance sidecar at all
179
+ installPlugin(workspace, "level-up", { sidecar: null });
180
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
181
+
182
+ // WHEN it is inspected
183
+ const result = await inspectPlugin(
184
+ { name: "level-up" },
185
+ { fetch, workspacePluginsDir: workspace },
186
+ );
187
+
188
+ // THEN the commit cannot be compared
189
+ expect(result.status).toBe("unknown-provenance");
190
+ expect(result.local?.commit).toBeNull();
191
+ expect(result.remote?.commit).toBe(SHA_A);
192
+ });
193
+
194
+ test("reports not-in-marketplace when installed but no entry claims the name", async () => {
195
+ // GIVEN an installed plugin and an empty catalog
196
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
197
+ const fetch = makeFetch({ marketplace: undefined });
198
+
199
+ // WHEN it is inspected
200
+ const result = await inspectPlugin(
201
+ { name: "level-up" },
202
+ { fetch, workspacePluginsDir: workspace },
203
+ );
204
+
205
+ // THEN there is no remote to compare against
206
+ expect(result.status).toBe("not-in-marketplace");
207
+ expect(result.installed).toBe(true);
208
+ expect(result.remote).toBeNull();
209
+ });
210
+
211
+ test("reports not-installed but previews the marketplace entry", async () => {
212
+ // GIVEN a marketplace entry with no local copy installed
213
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_B) });
214
+
215
+ // WHEN it is inspected
216
+ const result = await inspectPlugin(
217
+ { name: "level-up" },
218
+ { fetch, workspacePluginsDir: workspace },
219
+ );
220
+
221
+ // THEN the remote metadata previews what would be installed
222
+ expect(result.status).toBe("not-installed");
223
+ expect(result.installed).toBe(false);
224
+ expect(result.local).toBeNull();
225
+ expect(result.remote?.commit).toBe(SHA_B);
226
+ });
227
+
228
+ test("reports remote-unavailable when the marketplace cannot be reached", async () => {
229
+ // GIVEN an installed plugin and a failing marketplace fetch
230
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
231
+ const fetch = makeFetch({ fail: true });
232
+
233
+ // WHEN it is inspected
234
+ const result = await inspectPlugin(
235
+ { name: "level-up" },
236
+ { fetch, workspacePluginsDir: workspace },
237
+ );
238
+
239
+ // THEN local info is still reported alongside the fetch error
240
+ expect(result.status).toBe("remote-unavailable");
241
+ expect(result.local?.commit).toBe(SHA_A);
242
+ expect(result.remote).toBeNull();
243
+ expect(result.remoteError).toContain("network down");
244
+ });
245
+
246
+ test("reports no local changes when the on-disk tree matches the fingerprint", async () => {
247
+ // GIVEN an installed plugin with a recorded content fingerprint, untouched
248
+ installPlugin(workspace, "level-up", {
249
+ sidecar: { commit: SHA_A },
250
+ fingerprint: true,
251
+ });
252
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
253
+
254
+ // WHEN it is inspected
255
+ const result = await inspectPlugin(
256
+ { name: "level-up" },
257
+ { fetch, workspacePluginsDir: workspace },
258
+ );
259
+
260
+ // THEN the local copy is reported clean against its baseline
261
+ expect(result.local?.localChanges?.clean).toBe(true);
262
+ expect(result.local?.localChanges?.modified).toEqual([]);
263
+ });
264
+
265
+ test("detects a locally modified file against the fingerprint", async () => {
266
+ // GIVEN an installed plugin with a recorded fingerprint
267
+ installPlugin(workspace, "level-up", {
268
+ sidecar: { commit: SHA_A },
269
+ fingerprint: true,
270
+ });
271
+
272
+ // AND a tracked file is edited after install
273
+ writeFileSync(
274
+ join(workspace, "level-up", "package.json"),
275
+ JSON.stringify({ name: "level-up", version: "9.9.9" }),
276
+ );
277
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
278
+
279
+ // WHEN it is inspected
280
+ const result = await inspectPlugin(
281
+ { name: "level-up" },
282
+ { fetch, workspacePluginsDir: workspace },
283
+ );
284
+
285
+ // THEN the edited file surfaces as a local modification
286
+ expect(result.local?.localChanges?.clean).toBe(false);
287
+ expect(result.local?.localChanges?.modified).toEqual(["package.json"]);
288
+ });
289
+
290
+ test("reports unknown local changes when no fingerprint was recorded", async () => {
291
+ // GIVEN an install whose sidecar predates fingerprinting
292
+ installPlugin(workspace, "level-up", { sidecar: { commit: SHA_A } });
293
+ const fetch = makeFetch({ marketplace: manifestWith("level-up", SHA_A) });
294
+
295
+ // WHEN it is inspected
296
+ const result = await inspectPlugin(
297
+ { name: "level-up" },
298
+ { fetch, workspacePluginsDir: workspace },
299
+ );
300
+
301
+ // THEN modification cannot be determined
302
+ expect(result.local).not.toBeNull();
303
+ expect(result.local?.localChanges).toBeNull();
304
+ });
305
+
306
+ test("throws when the plugin is neither installed nor in the marketplace", async () => {
307
+ // GIVEN no local copy and an empty catalog
308
+ const fetch = makeFetch({ marketplace: undefined });
309
+
310
+ // WHEN/THEN inspecting a missing plugin rejects with a not-found error
311
+ await expect(
312
+ inspectPlugin(
313
+ { name: "ghost" },
314
+ { fetch, workspacePluginsDir: workspace },
315
+ ),
316
+ ).rejects.toBeInstanceOf(PluginInspectNotFoundError);
317
+ });
318
+ });
@@ -371,16 +371,23 @@ describe("installPlugin — marketplace resolution", () => {
371
371
  "https://github.com/JuliusBrussee/caveman.git",
372
372
  );
373
373
 
374
- // AND a provenance manifest records the github source + commit.
375
- const manifest = JSON.parse(
376
- readFileSync(join(target, ".vellum-plugin.json"), "utf-8"),
374
+ // AND an install-meta sidecar records origin + github source + commit.
375
+ const meta = JSON.parse(
376
+ readFileSync(join(target, "install-meta.json"), "utf-8"),
377
377
  );
378
- expect(manifest.source.kind).toBe("github");
379
- expect(manifest.source.owner).toBe("JuliusBrussee");
380
- expect(manifest.source.ref).toBe(
381
- "63a91ecadbf4c4719a4602a5abb00883f9966034",
382
- );
383
- expect(manifest.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
378
+ expect(meta.origin).toBe("vellum");
379
+ expect(meta.sourceRepo).toBe("JuliusBrussee/caveman");
380
+ expect(meta.source.kind).toBe("github");
381
+ expect(meta.source.owner).toBe("JuliusBrussee");
382
+ expect(meta.source.ref).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
383
+ expect(meta.commit).toBe("63a91ecadbf4c4719a4602a5abb00883f9966034");
384
+
385
+ // AND both content digests baseline the materialized tree (excluding the
386
+ // sidecar) so later local edits are detectable.
387
+ expect(meta.fingerprint.algorithm).toBe("sha256");
388
+ expect(meta.fingerprint.files["package.json"]).toMatch(/^[0-9a-f]{64}$/);
389
+ expect(meta.fingerprint.files["install-meta.json"]).toBeUndefined();
390
+ expect(meta.contentHash).toMatch(/^v2:[0-9a-f]{64}$/);
384
391
  });
385
392
 
386
393
  test("refuses to install when the checked-out commit differs from the pinned SHA", async () => {