@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
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Tests for {@link upgradePlugin}.
3
+ *
4
+ * An upgrade is drift detection (the same exact SHA comparison
5
+ * {@link inspectPlugin} performs) followed by a forced re-install at the
6
+ * marketplace pin. The marketplace + GitHub Contents API are replaced with an
7
+ * in-memory fixture passed via `fetch`, the clone is replaced with a fake
8
+ * {@link GitRunner} that materializes a tree, and the install target is a real
9
+ * temp directory passed via `workspacePluginsDir` — no globals are patched.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
23
+
24
+ import type { FetchLike, GitRunner } from "../install-from-github.js";
25
+ import { PluginNotInstalledError } from "../uninstall-plugin.js";
26
+ import { PluginNotUpgradableError, upgradePlugin } from "../upgrade-plugin.js";
27
+
28
+ const SHA_A = "a".repeat(40);
29
+ const SHA_B = "b".repeat(40);
30
+ const CANON_REPO = "vellum-ai/vellum-assistant";
31
+ const MANIFEST_URL = `https://api.github.com/repos/${CANON_REPO}/contents/plugins/marketplace.json`;
32
+ const CONTENTS = `https://api.github.com/repos/${CANON_REPO}/contents/`;
33
+
34
+ /** A marketplace manifest pinning `name` to `ref`. */
35
+ function manifestWith(name: string, ref: string): unknown {
36
+ return {
37
+ name: "vellum",
38
+ plugins: [
39
+ {
40
+ name,
41
+ source: { source: "github", repo: `example-org/${name}`, ref },
42
+ description: "A test plugin.",
43
+ category: "developer",
44
+ license: "MIT",
45
+ },
46
+ ],
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Build a `fetch` that serves the marketplace manifest and answers the GitHub
52
+ * Contents API listing (used by the adapter-stub lookup) with a 404, so the
53
+ * clone is treated as a raw external tree. `manifest: undefined` answers the
54
+ * manifest with 404; `manifestStatus` overrides the manifest status to
55
+ * simulate a transient marketplace failure.
56
+ */
57
+ function makeFetch(opts: {
58
+ manifest?: unknown;
59
+ manifestStatus?: number;
60
+ }): FetchLike {
61
+ return (async (input: RequestInfo | URL) => {
62
+ const url = typeof input === "string" ? input : input.toString();
63
+ if (url.startsWith(MANIFEST_URL)) {
64
+ if (opts.manifestStatus !== undefined && opts.manifestStatus !== 200) {
65
+ return new Response("manifest unavailable", {
66
+ status: opts.manifestStatus,
67
+ });
68
+ }
69
+ if (opts.manifest === undefined) {
70
+ return new Response("not found", { status: 404 });
71
+ }
72
+ return new Response(JSON.stringify(opts.manifest), { status: 200 });
73
+ }
74
+ // No adapter stub: the Contents API listing for plugins/<name> is empty.
75
+ if (url.startsWith(CONTENTS)) {
76
+ return new Response("not found", { status: 404 });
77
+ }
78
+ return new Response(`unexpected url: ${url}`, { status: 500 });
79
+ }) as FetchLike;
80
+ }
81
+
82
+ /** A fake clone that materializes one file and reports `commit` at HEAD. */
83
+ function fakeGitRunner(commit: string, calls?: string[][]): GitRunner {
84
+ return async (args, { cwd }) => {
85
+ calls?.push([...args]);
86
+ switch (args[0]) {
87
+ case "fetch": {
88
+ mkdirSync(join(cwd, ".git"), { recursive: true });
89
+ writeFileSync(join(cwd, ".git", "config"), "[core]\n");
90
+ writeFileSync(join(cwd, "package.json"), '{"name":"level-up"}');
91
+ return { stdout: "" };
92
+ }
93
+ case "rev-parse":
94
+ return { stdout: `${commit}\n` };
95
+ default:
96
+ return { stdout: "" };
97
+ }
98
+ };
99
+ }
100
+
101
+ /** A git runner that fails the test if any git command runs. */
102
+ const unusedGitRunner: GitRunner = async (args) => {
103
+ throw new Error(`git should not run for this upgrade: ${args.join(" ")}`);
104
+ };
105
+
106
+ /** Materialize an installed plugin copy with an optional provenance sidecar. */
107
+ function installCopy(
108
+ pluginsDir: string,
109
+ name: string,
110
+ sidecar: { commit: string } | null,
111
+ ): void {
112
+ const dir = join(pluginsDir, name);
113
+ mkdirSync(dir, { recursive: true });
114
+ writeFileSync(
115
+ join(dir, "package.json"),
116
+ JSON.stringify({ name, version: "0.1.0", description: "Installed copy." }),
117
+ );
118
+ if (sidecar !== null) {
119
+ writeFileSync(
120
+ join(dir, "install-meta.json"),
121
+ JSON.stringify({
122
+ origin: "vellum",
123
+ name,
124
+ source: {
125
+ kind: "github",
126
+ owner: "example-org",
127
+ repo: name,
128
+ ref: sidecar.commit,
129
+ },
130
+ commit: sidecar.commit,
131
+ installedAt: "2026-06-10T12:00:00.000Z",
132
+ }),
133
+ );
134
+ }
135
+ }
136
+
137
+ /** Read the commit recorded in a copy's provenance sidecar, if present. */
138
+ function sidecarCommit(pluginsDir: string, name: string): string | null {
139
+ const path = join(pluginsDir, name, "install-meta.json");
140
+ if (!existsSync(path)) return null;
141
+ return JSON.parse(readFileSync(path, "utf-8")).commit ?? null;
142
+ }
143
+
144
+ let ws: string;
145
+ let pluginsDir: string;
146
+
147
+ beforeEach(() => {
148
+ ws = mkdtempSync(join(tmpdir(), "upgrade-plugin-"));
149
+ pluginsDir = join(ws, "plugins");
150
+ mkdirSync(pluginsDir, { recursive: true });
151
+ });
152
+
153
+ afterEach(() => {
154
+ rmSync(ws, { recursive: true, force: true });
155
+ });
156
+
157
+ describe("upgradePlugin", () => {
158
+ test("upgrades to the marketplace pin when it has advanced", async () => {
159
+ // GIVEN an installed copy pinned to SHA_A
160
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
161
+ // AND the marketplace now pins SHA_B
162
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
163
+ const runGit = fakeGitRunner(SHA_B);
164
+
165
+ // WHEN the plugin is upgraded
166
+ const result = await upgradePlugin(
167
+ { name: "level-up" },
168
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
169
+ );
170
+
171
+ // THEN it reports the move from the old commit to the pin
172
+ expect(result.outcome).toBe("upgraded");
173
+ expect(result.fromCommit).toBe(SHA_A);
174
+ expect(result.toCommit).toBe(SHA_B);
175
+ expect(result.fileCount).toBeGreaterThan(0);
176
+ // AND the new pin is recorded in the provenance sidecar on disk
177
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_B);
178
+ });
179
+
180
+ test("is a no-op when the installed commit already equals the pin", async () => {
181
+ // GIVEN an installed copy already pinned to SHA_A
182
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
183
+ // AND the marketplace pins the same SHA_A
184
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_A) });
185
+
186
+ // WHEN the plugin is upgraded (git must never run)
187
+ const result = await upgradePlugin(
188
+ { name: "level-up" },
189
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
190
+ );
191
+
192
+ // THEN it reports already-up-to-date and makes no changes
193
+ expect(result.outcome).toBe("already-up-to-date");
194
+ expect(result.fileCount).toBeNull();
195
+ expect(result.toCommit).toBe(SHA_A);
196
+ });
197
+
198
+ test("a dry run reports the move without modifying the install", async () => {
199
+ // GIVEN an installed copy pinned to SHA_A and a marketplace pin of SHA_B
200
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
201
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
202
+
203
+ // WHEN the plugin is upgraded with dryRun (git must never run)
204
+ const result = await upgradePlugin(
205
+ { name: "level-up", dryRun: true },
206
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
207
+ );
208
+
209
+ // THEN it reports what would change but leaves the install untouched
210
+ expect(result.outcome).toBe("would-upgrade");
211
+ expect(result.dryRun).toBe(true);
212
+ expect(result.fileCount).toBeNull();
213
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_A);
214
+ });
215
+
216
+ test("re-pins and records provenance for an install with none", async () => {
217
+ // GIVEN an installed copy with no provenance sidecar
218
+ installCopy(pluginsDir, "level-up", null);
219
+ // AND the marketplace pins SHA_B
220
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
221
+ const runGit = fakeGitRunner(SHA_B);
222
+
223
+ // WHEN the plugin is upgraded
224
+ const result = await upgradePlugin(
225
+ { name: "level-up" },
226
+ { fetch, runGit, workspacePluginsDir: pluginsDir },
227
+ );
228
+
229
+ // THEN it upgrades, flags the missing provenance, and records the new pin
230
+ expect(result.outcome).toBe("upgraded");
231
+ expect(result.fromCommit).toBeNull();
232
+ expect(result.provenanceWasUnknown).toBe(true);
233
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_B);
234
+ });
235
+
236
+ test("throws PluginNotInstalledError when nothing is installed", async () => {
237
+ // GIVEN no installed copy, though the marketplace has an entry
238
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
239
+
240
+ // WHEN an upgrade is attempted
241
+ // THEN it refuses because there is no install to advance
242
+ await expect(
243
+ upgradePlugin(
244
+ { name: "level-up" },
245
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
246
+ ),
247
+ ).rejects.toBeInstanceOf(PluginNotInstalledError);
248
+ });
249
+
250
+ test("throws PluginNotUpgradableError when not in the marketplace", async () => {
251
+ // GIVEN an installed copy but an empty marketplace catalog
252
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
253
+ const fetch = makeFetch({ manifest: undefined });
254
+
255
+ // WHEN an upgrade is attempted
256
+ // THEN there is no pin to advance to
257
+ await expect(
258
+ upgradePlugin(
259
+ { name: "level-up" },
260
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
261
+ ),
262
+ ).rejects.toBeInstanceOf(PluginNotUpgradableError);
263
+ });
264
+
265
+ test("throws PluginNotUpgradableError when the marketplace is unreachable", async () => {
266
+ // GIVEN an installed copy and a marketplace fetch that fails transiently
267
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
268
+ const fetch = makeFetch({ manifestStatus: 500 });
269
+
270
+ // WHEN an upgrade is attempted
271
+ // THEN the latest pin cannot be determined, so it refuses
272
+ await expect(
273
+ upgradePlugin(
274
+ { name: "level-up" },
275
+ { fetch, runGit: unusedGitRunner, workspacePluginsDir: pluginsDir },
276
+ ),
277
+ ).rejects.toBeInstanceOf(PluginNotUpgradableError);
278
+ });
279
+
280
+ test("preserves the existing install when the re-install clone fails", async () => {
281
+ // GIVEN an installed copy pinned to SHA_A and an advanced marketplace pin
282
+ installCopy(pluginsDir, "level-up", { commit: SHA_A });
283
+ const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
284
+ // AND a clone that fails mid-fetch
285
+ const failingGit: GitRunner = async (args) => {
286
+ if (args[0] === "fetch") throw new Error("network down");
287
+ return { stdout: "" };
288
+ };
289
+
290
+ // WHEN the upgrade is attempted
291
+ // THEN it surfaces the clone failure
292
+ await expect(
293
+ upgradePlugin(
294
+ { name: "level-up" },
295
+ { fetch, runGit: failingGit, workspacePluginsDir: pluginsDir },
296
+ ),
297
+ ).rejects.toThrow("network down");
298
+ // AND the previously installed copy is left intact at its old pin
299
+ expect(sidecarCommit(pluginsDir, "level-up")).toBe(SHA_A);
300
+ });
301
+ });
@@ -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
+ }