@vellumai/assistant 0.8.11 → 0.8.12-staging.2

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 (244) 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 +633 -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-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Inspect a single plugin: what is installed locally versus what the curated
3
+ * marketplace currently pins, and whether the two have drifted.
4
+ *
5
+ * The marketplace pins every plugin to a full, immutable commit SHA (see
6
+ * {@link ./plugin-marketplace}); an install records the exact commit it
7
+ * materialized in an `install-meta.json` provenance sidecar (see
8
+ * {@link ./install-from-github}). Drift detection is therefore an exact
9
+ * commit-SHA comparison — the pin only moves when a curator bumps it, so a
10
+ * mismatch means a newer pin is available. The local `package.json` version is
11
+ * surfaced as informational metadata, not the drift signal: a semver string may
12
+ * not change between pins, whereas the SHA always determines the bytes.
13
+ *
14
+ * Designed for direct programmatic use with an injected `fetch`, mirroring the
15
+ * sibling plugin libraries. The CLI command `assistant plugins inspect <name>`
16
+ * is a thin wrapper that supplies production deps and formats the result.
17
+ */
18
+
19
+ import {
20
+ DEFAULT_PLUGIN_REF,
21
+ type FetchLike,
22
+ INSTALL_META_FILENAME,
23
+ type InstallMeta,
24
+ readInstallMeta,
25
+ sanitizePluginName,
26
+ } from "./install-from-github.js";
27
+ import {
28
+ type InstalledPluginInfo,
29
+ readInstalledPlugin,
30
+ } from "./list-installed-plugins.js";
31
+ import {
32
+ compareFingerprint,
33
+ type FingerprintComparison,
34
+ } from "./plugin-fingerprint.js";
35
+ import {
36
+ fetchMarketplaceEntries,
37
+ type MarketplaceEntry,
38
+ } from "./plugin-marketplace.js";
39
+
40
+ /** Full commit SHA (40 hex SHA-1 or 64 hex SHA-256). */
41
+ const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
42
+
43
+ /**
44
+ * Drift classification between the installed copy and the marketplace pin.
45
+ *
46
+ * - `up-to-date` — installed commit equals the current marketplace pin.
47
+ * - `update-available` — installed commit differs from the pin; a newer
48
+ * reviewed revision is available via `plugins install --force`.
49
+ * - `not-installed` — no local copy; the marketplace metadata is shown as a
50
+ * preview of what would be installed.
51
+ * - `not-in-marketplace` — installed but no catalog entry claims the name, so
52
+ * there is no advertised remote to compare against.
53
+ * - `unknown-provenance` — installed and in the catalog, but no resolvable
54
+ * commit was recorded (an older or manually-copied install); reinstall to
55
+ * record provenance.
56
+ * - `remote-unavailable` — installed, but the marketplace could not be reached
57
+ * to determine the current pin (rate-limit / network); local info is shown.
58
+ */
59
+ export type PluginUpdateStatus =
60
+ | "up-to-date"
61
+ | "update-available"
62
+ | "not-installed"
63
+ | "not-in-marketplace"
64
+ | "unknown-provenance"
65
+ | "remote-unavailable";
66
+
67
+ /** Locally installed copy of a plugin. */
68
+ export interface PluginLocalInfo {
69
+ /** Absolute path to the installed plugin directory. */
70
+ readonly target: string;
71
+ /** Resolved commit the copy was installed at; `null` when no provenance was recorded. */
72
+ readonly commit: string | null;
73
+ /** `package.json` `version`, when present. */
74
+ readonly version: string | null;
75
+ /** `package.json` `description`, when present. */
76
+ readonly description: string | null;
77
+ /** ISO-8601 install timestamp from the provenance sidecar; `null` when absent. */
78
+ readonly installedAt: string | null;
79
+ /** Source coordinates recorded at install time; `null` when no sidecar exists. */
80
+ readonly source: InstallMeta["source"] | null;
81
+ /**
82
+ * Local-edit state relative to the install-time fingerprint: `null` when no
83
+ * fingerprint was recorded (an older or manually-copied install), so
84
+ * modification cannot be determined.
85
+ */
86
+ readonly localChanges: FingerprintComparison | null;
87
+ /** Non-fatal issues with the installed copy (e.g. malformed `package.json`). */
88
+ readonly issues: readonly string[];
89
+ }
90
+
91
+ /** The marketplace's current pin and advertised metadata for a plugin. */
92
+ export interface PluginRemoteInfo {
93
+ /** `owner/repo` of the external plugin repository. */
94
+ readonly repo: string;
95
+ /** Repo-relative directory holding the plugin root; `""` = repo root. */
96
+ readonly path: string;
97
+ /** Pinned commit SHA the marketplace currently resolves installs to. */
98
+ readonly commit: string;
99
+ readonly description: string | null;
100
+ readonly homepage: string | null;
101
+ readonly license: string | null;
102
+ readonly category: string | null;
103
+ /** Ref of the canonical repo the marketplace manifest was read from. */
104
+ readonly marketplaceRef: string;
105
+ }
106
+
107
+ /** Resolved inspection of a single plugin. */
108
+ export interface PluginInspection {
109
+ /** Install name. Matches `assistant plugins install <name>`. */
110
+ readonly name: string;
111
+ /** Whether a copy is materialized under the workspace plugins directory. */
112
+ readonly installed: boolean;
113
+ /** Drift classification between the installed copy and the marketplace pin. */
114
+ readonly status: PluginUpdateStatus;
115
+ /** Locally installed copy; `null` when the plugin is not installed. */
116
+ readonly local: PluginLocalInfo | null;
117
+ /** Marketplace pin + metadata; `null` when no entry claims the name or it was unreachable. */
118
+ readonly remote: PluginRemoteInfo | null;
119
+ /** Marketplace fetch error message, when the catalog could not be read. */
120
+ readonly remoteError: string | null;
121
+ }
122
+
123
+ /** Neither an installed copy nor a marketplace entry claims the name. */
124
+ export class PluginInspectNotFoundError extends Error {
125
+ constructor(readonly pluginName: string) {
126
+ super(
127
+ `Plugin "${pluginName}" is not installed and has no marketplace entry.`,
128
+ );
129
+ this.name = "PluginInspectNotFoundError";
130
+ }
131
+ }
132
+
133
+ /** Options that control which plugin to inspect. */
134
+ export interface InspectPluginOptions {
135
+ /** Install name (kebab-case directory name). */
136
+ readonly name: string;
137
+ }
138
+
139
+ /** Dependencies injected by the caller. */
140
+ export interface InspectPluginDeps {
141
+ /** HTTP client. Production callers pass `globalThis.fetch.bind(globalThis)`. */
142
+ readonly fetch: FetchLike;
143
+ /** Override the workspace plugins directory. Falls back to the live workspace. */
144
+ readonly workspacePluginsDir?: string;
145
+ }
146
+
147
+ function readLocal(
148
+ entry: InstalledPluginInfo,
149
+ manifest: InstallMeta | null,
150
+ ): PluginLocalInfo {
151
+ // The provenance commit is authoritative; fall back to the recorded ref only
152
+ // when it is itself a full SHA (marketplace installs always pin one), so a
153
+ // sidecar written before the commit could be read still yields a comparable
154
+ // revision instead of dropping to "unknown".
155
+ const commit =
156
+ manifest?.commit ??
157
+ (manifest && FULL_SHA_RE.test(manifest.source.ref)
158
+ ? manifest.source.ref
159
+ : null);
160
+ // Compare the on-disk tree against the install-time baseline, applying the
161
+ // same exclusion so the sidecar is never counted as a local addition.
162
+ const localChanges = manifest?.fingerprint
163
+ ? compareFingerprint(entry.target, manifest.fingerprint, [
164
+ INSTALL_META_FILENAME,
165
+ ])
166
+ : null;
167
+ return {
168
+ target: entry.target,
169
+ commit,
170
+ version: entry.packageJson?.version ?? null,
171
+ description: entry.packageJson?.description ?? null,
172
+ installedAt: manifest?.installedAt || null,
173
+ source: manifest?.source ?? null,
174
+ localChanges,
175
+ issues: entry.issues,
176
+ };
177
+ }
178
+
179
+ function readRemote(
180
+ entry: MarketplaceEntry,
181
+ marketplaceRef: string,
182
+ ): PluginRemoteInfo {
183
+ return {
184
+ repo: entry.source.repo,
185
+ path: entry.source.path ?? "",
186
+ commit: entry.source.ref,
187
+ description: entry.description ?? null,
188
+ homepage: entry.homepage ?? null,
189
+ license: entry.license ?? null,
190
+ category: entry.category ?? null,
191
+ marketplaceRef,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Resolve the local-vs-remote inspection for a single plugin.
197
+ *
198
+ * Throws {@link PluginInspectNotFoundError} only when the plugin is neither
199
+ * installed nor present in the marketplace — there is nothing to show. A
200
+ * marketplace fetch failure for an *installed* plugin is not fatal: the local
201
+ * copy is reported with `status: "remote-unavailable"`.
202
+ */
203
+ export async function inspectPlugin(
204
+ opts: InspectPluginOptions,
205
+ deps: InspectPluginDeps,
206
+ ): Promise<PluginInspection> {
207
+ const name = sanitizePluginName(opts.name);
208
+ const marketplaceRef = DEFAULT_PLUGIN_REF;
209
+
210
+ const entry = readInstalledPlugin(name, {
211
+ workspacePluginsDir: deps.workspacePluginsDir,
212
+ });
213
+ const installed = entry !== null;
214
+ const local = entry ? readLocal(entry, readInstallMeta(entry.target)) : null;
215
+
216
+ let remote: PluginRemoteInfo | null = null;
217
+ let remoteError: string | null = null;
218
+ try {
219
+ const entries = await fetchMarketplaceEntries(
220
+ { fetch: deps.fetch },
221
+ { ref: marketplaceRef },
222
+ );
223
+ const match = entries.find((e) => e.name === name);
224
+ if (match) remote = readRemote(match, marketplaceRef);
225
+ } catch (err) {
226
+ remoteError = err instanceof Error ? err.message : String(err);
227
+ }
228
+
229
+ if (!installed && !remote) {
230
+ // A reachable-but-empty catalog with no local copy is a genuine not-found;
231
+ // a fetch failure with no local copy leaves nothing to report either.
232
+ throw new PluginInspectNotFoundError(name);
233
+ }
234
+
235
+ const status = classify(installed, local, remote, remoteError);
236
+ return { name, installed, status, local, remote, remoteError };
237
+ }
238
+
239
+ function classify(
240
+ installed: boolean,
241
+ local: PluginLocalInfo | null,
242
+ remote: PluginRemoteInfo | null,
243
+ remoteError: string | null,
244
+ ): PluginUpdateStatus {
245
+ if (!installed) return "not-installed";
246
+ if (remoteError && !remote) return "remote-unavailable";
247
+ if (!remote) return "not-in-marketplace";
248
+ if (!local?.commit) return "unknown-provenance";
249
+ return local.commit.toLowerCase() === remote.commit.toLowerCase()
250
+ ? "up-to-date"
251
+ : "update-available";
252
+ }
@@ -40,6 +40,12 @@ import { promisify } from "node:util";
40
40
 
41
41
  import { ensureBun } from "../../util/bun-runtime.js";
42
42
  import { getWorkspacePluginsDir } from "../../util/platform.js";
43
+ import {
44
+ computeContentHash,
45
+ computeFingerprint,
46
+ type Fingerprint,
47
+ parseFingerprint,
48
+ } from "./plugin-fingerprint.js";
43
49
  import {
44
50
  fetchMarketplaceEntries,
45
51
  MarketplaceFetchError,
@@ -389,11 +395,25 @@ export async function installPlugin(
389
395
  throw new PluginNotFoundError(name, ref, sourceLabel(source));
390
396
  }
391
397
 
392
- // Record install provenance (source coordinates + resolved commit) as a
393
- // hidden sidecar before the swap so it lands atomically with the files. The
394
- // daemon loader enumerates plugin directories and reads each plugin's
395
- // `package.json`, skipping dotfiles — so this never gets mistaken for code.
396
- writeInstallManifest(stagingDir, name, source, ref, commit);
398
+ // Hash the materialized tree before the sidecar is written (so the sidecar
399
+ // never hashes itself) — the baseline `plugins inspect` uses to detect later
400
+ // local edits. The per-file fingerprint answers "which files changed"; the
401
+ // whole-tree content hash is a compact integrity signal mirroring skills.
402
+ const fingerprint = computeFingerprint(stagingDir, [INSTALL_META_FILENAME]);
403
+ const contentHash = computeContentHash(stagingDir, [INSTALL_META_FILENAME]);
404
+
405
+ // Record install provenance (source coordinates + resolved commit + content
406
+ // digests) as a sidecar before the swap so it lands atomically with the
407
+ // files. The external plugin loader only reads `package.json` and the
408
+ // `hooks/`/`tools/` dirs, so this JSON file is never mistaken for code.
409
+ writeInstallMeta(stagingDir, {
410
+ name,
411
+ source,
412
+ ref,
413
+ commit,
414
+ fingerprint,
415
+ contentHash,
416
+ });
397
417
 
398
418
  // Atomic-ish swap: rmSync + renameSync. On POSIX the rename itself is
399
419
  // atomic, so the only window where the target is absent is between the
@@ -412,8 +432,78 @@ export async function installPlugin(
412
432
  /** Cap on any single git invocation; a shallow fetch is well under this. */
413
433
  const GIT_TIMEOUT_MS = 120_000;
414
434
 
415
- /** Install-provenance sidecar written at the plugin root. */
416
- const INSTALL_MANIFEST_FILENAME = ".vellum-plugin.json";
435
+ /**
436
+ * Install-provenance sidecar written at the plugin root. Named to match the
437
+ * skills' sidecar (`install-meta.json`, see `src/skills/install-meta.ts`) so
438
+ * both subsystems share one vocabulary. The external plugin loader only reads
439
+ * `package.json` and the `hooks/`/`tools/` surface dirs, so a plain JSON file
440
+ * at the root is ignored by it.
441
+ */
442
+ export const INSTALL_META_FILENAME = "install-meta.json";
443
+
444
+ /**
445
+ * Which catalog manages an installed plugin. Mirrors the skill origin values
446
+ * (`SkillInstallMeta.origin` in `src/skills/install-meta.ts`) so the two
447
+ * systems keep a consistent vocabulary. `"vellum"` denotes the first-party
448
+ * `marketplace.json`; the union widens as new sources are supported.
449
+ */
450
+ export type InstallOrigin = "vellum";
451
+
452
+ /** Resolved source coordinates recorded in the provenance sidecar. */
453
+ export interface InstallMetaSource {
454
+ /** Source kind. Only `github` is written today. */
455
+ readonly kind: string;
456
+ readonly owner: string;
457
+ readonly repo: string;
458
+ /** Repo-relative directory holding the plugin root; absent = repo root. */
459
+ readonly path?: string;
460
+ /** Ref the install resolved through (the pinned commit SHA for marketplace installs). */
461
+ readonly ref: string;
462
+ }
463
+
464
+ /**
465
+ * Parsed contents of the `install-meta.json` provenance sidecar — what was
466
+ * installed, from where, and at exactly which commit. Read by
467
+ * {@link readInstallMeta} for provenance reporting (e.g. `plugins inspect`).
468
+ *
469
+ * The leading fields share names (and meaning) with the skills'
470
+ * `SkillInstallMeta`; everything below {@link InstallMeta.name} is the
471
+ * plugin-specific superset that the git-backed install needs.
472
+ */
473
+ export interface InstallMeta {
474
+ /** Catalog the install is managed from. */
475
+ readonly origin: InstallOrigin;
476
+ /** ISO-8601 timestamp of when the install was materialized. */
477
+ readonly installedAt: string;
478
+ /** Principal that initiated the install, when known. */
479
+ readonly installedBy?: string;
480
+ /** Set by a backfill migration when provenance was reconstructed after the fact. */
481
+ readonly backfilledBy?: string;
482
+ /** Plugin `package.json` version at install time, when present. */
483
+ readonly version?: string;
484
+ /** Registry slug, recorded when it diverges from {@link InstallMeta.name}. */
485
+ readonly slug?: string;
486
+ /** `owner/repo` the install was sourced from. */
487
+ readonly sourceRepo?: string;
488
+ /**
489
+ * Whole-tree `v2:` content hash — a compact integrity signal using the same
490
+ * scheme as the skills' `contentHash`. Complements the per-file
491
+ * {@link InstallMeta.fingerprint}.
492
+ */
493
+ readonly contentHash?: string;
494
+
495
+ /** Install name. Matches the plugins directory and `plugins install <name>`. */
496
+ readonly name: string;
497
+ readonly source: InstallMetaSource;
498
+ /** Resolved commit SHA the source was cloned at; `null` when it could not be read at install time. */
499
+ readonly commit: string | null;
500
+ /**
501
+ * Per-file content digest of the materialized tree, captured at install
502
+ * time. `null` for older installs written before fingerprinting; callers
503
+ * then report local-modification state as unknown rather than clean.
504
+ */
505
+ readonly fingerprint: Fingerprint | null;
506
+ }
417
507
 
418
508
  /**
419
509
  * Materialize an external plugin by shallow-cloning its repo at the pinned ref.
@@ -852,20 +942,59 @@ function pluginGitEnv(): NodeJS.ProcessEnv {
852
942
  return env;
853
943
  }
854
944
 
945
+ /** Inputs for {@link writeInstallMeta}, resolved during a fresh install. */
946
+ interface WriteInstallMetaParams {
947
+ readonly name: string;
948
+ readonly source: PluginFetchSource;
949
+ readonly ref: string;
950
+ readonly commit: string | null;
951
+ readonly fingerprint: Fingerprint;
952
+ readonly contentHash: string;
953
+ }
954
+
955
+ /**
956
+ * Read the `version` field from a staged plugin's `package.json`. Lenient — a
957
+ * missing or malformed manifest simply yields `undefined` so provenance is
958
+ * recorded without it rather than failing the install.
959
+ */
960
+ function readStagedPackageVersion(stagingDir: string): string | undefined {
961
+ const pkgPath = join(stagingDir, "package.json");
962
+ if (!existsSync(pkgPath)) return undefined;
963
+ try {
964
+ const parsed: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
965
+ if (typeof parsed === "object" && parsed !== null) {
966
+ const version = (parsed as Record<string, unknown>).version;
967
+ if (typeof version === "string" && version.length > 0) return version;
968
+ }
969
+ } catch {
970
+ // fall through to undefined
971
+ }
972
+ return undefined;
973
+ }
974
+
855
975
  /**
856
- * Write the install-provenance sidecar into the staged plugin root, recording
857
- * the resolved source coordinates and commit so we can later report or verify
858
- * exactly what is installed. Hidden (dot-prefixed) so the daemon loader, which
859
- * skips dotfiles, never mistakes it for plugin code.
976
+ * Write the `install-meta.json` provenance sidecar into the staged plugin root,
977
+ * recording the resolved source coordinates, commit, and content digests so we
978
+ * can later report or verify exactly what is installed. The schema is an
979
+ * equal-name superset of the skills' `SkillInstallMeta`.
860
980
  */
861
- function writeInstallManifest(
981
+ function writeInstallMeta(
862
982
  stagingDir: string,
863
- name: string,
864
- source: PluginFetchSource,
865
- ref: string,
866
- commit: string | null,
983
+ {
984
+ name,
985
+ source,
986
+ ref,
987
+ commit,
988
+ fingerprint,
989
+ contentHash,
990
+ }: WriteInstallMetaParams,
867
991
  ): void {
868
- const manifest = {
992
+ const meta: InstallMeta = {
993
+ origin: "vellum",
994
+ installedAt: new Date().toISOString(),
995
+ version: readStagedPackageVersion(stagingDir),
996
+ sourceRepo: `${source.owner}/${source.repo}`,
997
+ contentHash,
869
998
  name,
870
999
  source: {
871
1000
  kind: "github",
@@ -874,15 +1003,79 @@ function writeInstallManifest(
874
1003
  path: source.rootPath || undefined,
875
1004
  ref,
876
1005
  },
877
- commit: commit ?? undefined,
878
- installedAt: new Date().toISOString(),
1006
+ commit,
1007
+ fingerprint,
879
1008
  };
880
1009
  writeFileSync(
881
- join(stagingDir, INSTALL_MANIFEST_FILENAME),
882
- `${JSON.stringify(manifest, null, 2)}\n`,
1010
+ join(stagingDir, INSTALL_META_FILENAME),
1011
+ `${JSON.stringify(meta, null, 2)}\n`,
883
1012
  );
884
1013
  }
885
1014
 
1015
+ /**
1016
+ * Read the install-provenance sidecar from an installed plugin's root.
1017
+ *
1018
+ * Lenient by design — a missing, unreadable, or malformed sidecar yields
1019
+ * `null` rather than throwing, mirroring {@link ./list-installed-plugins}.
1020
+ * Older or manually-copied installs that predate the sidecar simply report no
1021
+ * provenance. The resolved commit is the authoritative record of which bytes
1022
+ * are installed, so callers (e.g. `plugins inspect`) can compare it against the
1023
+ * marketplace's current pin to detect drift.
1024
+ */
1025
+ export function readInstallMeta(pluginDir: string): InstallMeta | null {
1026
+ const metaPath = join(pluginDir, INSTALL_META_FILENAME);
1027
+ if (!existsSync(metaPath)) return null;
1028
+
1029
+ let parsed: unknown;
1030
+ try {
1031
+ parsed = JSON.parse(readFileSync(metaPath, "utf8"));
1032
+ } catch {
1033
+ return null;
1034
+ }
1035
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1036
+ return null;
1037
+ }
1038
+
1039
+ const obj = parsed as Record<string, unknown>;
1040
+ const src = obj.source;
1041
+ if (typeof src !== "object" || src === null || Array.isArray(src)) {
1042
+ return null;
1043
+ }
1044
+ const source = src as Record<string, unknown>;
1045
+ if (
1046
+ typeof obj.name !== "string" ||
1047
+ typeof source.owner !== "string" ||
1048
+ typeof source.repo !== "string" ||
1049
+ typeof source.ref !== "string"
1050
+ ) {
1051
+ return null;
1052
+ }
1053
+
1054
+ const optionalString = (value: unknown): string | undefined =>
1055
+ typeof value === "string" ? value : undefined;
1056
+
1057
+ return {
1058
+ origin: "vellum",
1059
+ installedAt: typeof obj.installedAt === "string" ? obj.installedAt : "",
1060
+ installedBy: optionalString(obj.installedBy),
1061
+ backfilledBy: optionalString(obj.backfilledBy),
1062
+ version: optionalString(obj.version),
1063
+ slug: optionalString(obj.slug),
1064
+ sourceRepo: optionalString(obj.sourceRepo),
1065
+ contentHash: optionalString(obj.contentHash),
1066
+ name: obj.name,
1067
+ source: {
1068
+ kind: typeof source.kind === "string" ? source.kind : "github",
1069
+ owner: source.owner,
1070
+ repo: source.repo,
1071
+ path: typeof source.path === "string" ? source.path : undefined,
1072
+ ref: source.ref,
1073
+ },
1074
+ commit: typeof obj.commit === "string" ? obj.commit : null,
1075
+ fingerprint: parseFingerprint(obj.fingerprint),
1076
+ };
1077
+ }
1078
+
886
1079
  /**
887
1080
  * Recursively copy a curated adapter stub directory via the GitHub Contents API.
888
1081
  *
@@ -12,12 +12,7 @@
12
12
  * and we want both surfaces to agree on what's present.
13
13
  */
14
14
 
15
- import {
16
- existsSync,
17
- readdirSync,
18
- readFileSync,
19
- statSync,
20
- } from "node:fs";
15
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
16
  import { join } from "node:path";
22
17
 
23
18
  import { getWorkspacePluginsDir } from "../../util/platform.js";
@@ -81,6 +76,22 @@ export function listInstalledPlugins(
81
76
  return entries.map((name) => readPluginEntry(pluginsDir, name));
82
77
  }
83
78
 
79
+ /**
80
+ * Read a single installed plugin entry by name, or `null` when no directory
81
+ * for it exists under the workspace plugins directory. Parses leniently like
82
+ * {@link listInstalledPlugins} — a malformed `package.json` surfaces as an
83
+ * `issues` entry rather than throwing.
84
+ */
85
+ export function readInstalledPlugin(
86
+ name: string,
87
+ opts: ListInstalledPluginsOptions = {},
88
+ ): InstalledPluginInfo | null {
89
+ const pluginsDir = opts.workspacePluginsDir ?? getWorkspacePluginsDir();
90
+ const target = join(pluginsDir, name);
91
+ if (!existsSync(target) || !statSync(target).isDirectory()) return null;
92
+ return readPluginEntry(pluginsDir, name);
93
+ }
94
+
84
95
  function readPluginEntry(
85
96
  pluginsDir: string,
86
97
  name: string,
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Parse a plugin's prebuilt client-artifact descriptor from its
3
+ * `package.json` `vellum.artifact` block.
4
+ *
5
+ * Some plugins ship a native companion the assistant cannot build itself —
6
+ * e.g. a macOS app whose Swift toolchain is absent from the Linux container
7
+ * the daemon runs in. Those plugins publish a prebuilt, author-signed binary
8
+ * out-of-band (a GitHub Release) and point at it from their manifest:
9
+ *
10
+ * "vellum": {
11
+ * "artifact": {
12
+ * "url": "https://github.com/owner/repo/releases/download/v1.0.0/App.dmg",
13
+ * "sha256": "<64-hex>",
14
+ * "label": "Download for macOS"
15
+ * }
16
+ * }
17
+ *
18
+ * The `url` is mutable (a Release asset can be re-uploaded); the `sha256` is
19
+ * the integrity anchor a client verifies the download against — the same
20
+ * "pin the immutable hash, not the mutable pointer" philosophy the source
21
+ * whitelist already enforces with full commit SHAs.
22
+ *
23
+ * An artifact is only surfaced when **both** fields are well-formed. A
24
+ * missing block, a non-`https:` URL, or an absent / placeholder `sha256`
25
+ * (e.g. the empty string a release workflow fills in on its first run) all
26
+ * resolve to `null` — "no downloadable artifact yet" — so a client never
27
+ * offers an unverifiable download.
28
+ */
29
+
30
+ import { z } from "zod";
31
+
32
+ /** A verified, downloadable client artifact declared by a plugin. */
33
+ export interface PluginArtifact {
34
+ /** HTTPS URL the artifact is downloaded from. */
35
+ readonly url: string;
36
+ /** Lowercase 64-char hex SHA-256 the download is verified against. */
37
+ readonly sha256: string;
38
+ /**
39
+ * Optional human label for the download affordance — useful when a plugin
40
+ * ships more than one artifact (e.g. "Download for macOS", "Apple Silicon").
41
+ * Absent (or blank) when the plugin doesn't name it; clients fall back to a
42
+ * generic label.
43
+ */
44
+ readonly label?: string;
45
+ }
46
+
47
+ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
48
+
49
+ /**
50
+ * Schema for a complete artifact descriptor. The URL must be absolute and
51
+ * `https:` (an artifact fetched over plaintext defeats the integrity story);
52
+ * the digest must be canonical lowercase hex so client-side comparison is a
53
+ * plain string equality with no normalization step.
54
+ */
55
+ const PluginArtifactSchema = z.object({
56
+ url: z
57
+ .string()
58
+ .url()
59
+ .refine((u) => u.startsWith("https://"), {
60
+ message: "artifact url must be an https:// URL",
61
+ }),
62
+ sha256: z
63
+ .string()
64
+ .regex(SHA256_HEX_RE, "artifact sha256 must be 64 lowercase hex chars"),
65
+ // Optional, non-critical metadata: a malformed label must never nullify an
66
+ // otherwise-valid `url` + `sha256`, so a wrong-typed value falls back to
67
+ // `undefined` rather than failing the whole descriptor.
68
+ label: z.string().optional().catch(undefined),
69
+ });
70
+
71
+ /**
72
+ * Read `vellum.artifact` from an already-parsed `package.json` value and
73
+ * return it only when it is a complete, well-formed descriptor. Any shape
74
+ * problem — missing block, wrong types, non-https URL, placeholder/empty
75
+ * `sha256` — yields `null` rather than throwing, so callers can union this
76
+ * across sources without per-source error handling.
77
+ */
78
+ export function parsePluginArtifact(
79
+ packageJson: unknown,
80
+ ): PluginArtifact | null {
81
+ if (
82
+ typeof packageJson !== "object" ||
83
+ packageJson === null ||
84
+ Array.isArray(packageJson)
85
+ ) {
86
+ return null;
87
+ }
88
+ const vellum = (packageJson as Record<string, unknown>).vellum;
89
+ if (typeof vellum !== "object" || vellum === null || Array.isArray(vellum)) {
90
+ return null;
91
+ }
92
+ const artifact = (vellum as Record<string, unknown>).artifact;
93
+ const parsed = PluginArtifactSchema.safeParse(artifact);
94
+ if (!parsed.success) return null;
95
+ // A blank or whitespace-only label is treated as absent so it never
96
+ // invalidates an otherwise well-formed `url` + `sha256` descriptor.
97
+ const label = parsed.data.label?.trim();
98
+ return {
99
+ url: parsed.data.url,
100
+ sha256: parsed.data.sha256,
101
+ ...(label ? { label } : {}),
102
+ };
103
+ }