opencode-muse 0.1.1 → 0.2.0

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.
package/README.md CHANGED
@@ -16,7 +16,7 @@ Add the published package to your project-level `opencode.jsonc` (or
16
16
  ```jsonc
17
17
  {
18
18
  "$schema": "https://opencode.ai/config.json",
19
- "plugins": ["opencode-muse@0.1.1"],
19
+ "plugins": ["opencode-muse@0.2.0"],
20
20
  }
21
21
  ```
22
22
 
@@ -96,14 +96,25 @@ setup placeholder rather than advertising a model that will fail on use.
96
96
  been tested, but recovery from a real Muse event gap has only been tested
97
97
  with a simulated gap. See
98
98
  [#23](https://github.com/pgquiles/opencode-muse/issues/23).
99
- - **Muse CLI compatibility:** The plugin is pinned and verified with Muse CLI
100
- and SDK 1.4.12. A protocol/schema mismatch fails closed rather than risking
101
- an incompatible session; broader version-compatibility policy is tracked in
99
+ - **Muse CLI compatibility:** The plugin is pinned to SDK 1.4.12 and speaks
100
+ envelope schema version 1. A newer CLI fingerprint starts with a recorded
101
+ warning instead of failing closed; a missing/malformed fingerprint or an
102
+ unsupported envelope version still fails closed. See
102
103
  [#21](https://github.com/pgquiles/opencode-muse/issues/21).
103
104
  - **Billing and simultaneous work:** Pay-as-you-go behavior has not yet been
104
105
  tested. The plugin also allows only one active writer per workspace, so a
105
106
  new turn may be reported as busy while another is still finishing.
106
107
 
108
+ ## Troubleshooting
109
+
110
+ - **"Muse Spark 1.3 Contributor" never connects after a model switch:** that
111
+ entry is a retired OpenCode-gateway model (`opencode/muse-spark-1.3-contributor-free`),
112
+ not this plugin's model. It no longer resolves anywhere, so OpenCode keeps
113
+ retrying and the session appears stuck on "connecting". Pick the model under
114
+ provider **Muse Code** — `muse/muse-profile-tbh-model-muse-spark-1.3` —
115
+ instead of any historical "contributor-free" entry in recent-model lists. See
116
+ [#26](https://github.com/pgquiles/opencode-muse/issues/26).
117
+
107
118
  ## Credits and references
108
119
 
109
120
  - [opencode-claude](https://github.com/openchamber/opencode-claude) — a
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { MspCommandRejectedError, MuseBridgeError, MuseTranscriptDivergedError, MuseUnsupportedInputError, MuseUnsupportedRequestError, MspProtocolError, MspTransportError } from "../errors.js";
3
+ import { notificationCursorKey } from "../msp/notification.js";
3
4
  import { MUSE_CONTEXT_HEADERS, readRequestContext } from "./context.js";
4
5
  import { classifyMetaRequest, makeCompactionDigest, makeLocalTitle } from "./meta.js";
5
6
  import { promptContainsCompactionFingerprint } from "../session/fingerprint.js";
@@ -93,7 +94,7 @@ function eventTurnId(notification, itemTurnIds) {
93
94
  async function* pageGapNotifications(host, sessionId, gap) {
94
95
  let cursor = gap.after;
95
96
  const requestedCursors = new Set([gap.after]);
96
- const eventCursors = new Set();
97
+ const eventKeys = new Set();
97
98
  for (let pageNumber = 0; pageNumber < 100; pageNumber += 1) {
98
99
  const requestCursor = cursor;
99
100
  const page = await host.request("view/page", {
@@ -104,6 +105,7 @@ async function* pageGapNotifications(host, sessionId, gap) {
104
105
  });
105
106
  if (!Array.isArray(page.events))
106
107
  throw new MspProtocolError("view/page");
108
+ let boundaryReached = false;
107
109
  for (const event of page.events) {
108
110
  if (typeof event !== "object" || event === null)
109
111
  throw new MspProtocolError("view/page");
@@ -112,14 +114,20 @@ async function* pageGapNotifications(host, sessionId, gap) {
112
114
  if (typeof record.method !== "string" || !params || params.sessionId !== sessionId || typeof params.viewCursor !== "string") {
113
115
  throw new MspProtocolError("view/page");
114
116
  }
115
- if (params.viewCursor === gap.next)
116
- return;
117
- if (eventCursors.has(params.viewCursor))
118
- throw new MspProtocolError("view/page");
119
- eventCursors.add(params.viewCursor);
117
+ const atBoundary = params.viewCursor === gap.next;
118
+ if (atBoundary)
119
+ boundaryReached = true;
120
120
  cursor = params.viewCursor;
121
- yield { jsonrpc: "2.0", method: record.method, params };
121
+ const notification = { jsonrpc: "2.0", method: record.method, params };
122
+ const key = notificationCursorKey(notification);
123
+ if (key && eventKeys.has(key))
124
+ throw new MspProtocolError("view/page");
125
+ if (key)
126
+ eventKeys.add(key);
127
+ yield notification;
122
128
  }
129
+ if (boundaryReached)
130
+ return;
123
131
  if (page.nextCursor === null || typeof page.nextCursor !== "string"
124
132
  || page.nextCursor === requestCursor || requestedCursors.has(page.nextCursor)) {
125
133
  throw new MspProtocolError("view/page");
@@ -196,7 +204,7 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
196
204
  const events = {
197
205
  [Symbol.asyncIterator]: async function* () {
198
206
  const itemTurnIds = new Map();
199
- const seenCursors = new Set();
207
+ const seenNotifications = new Set();
200
208
  const processNotification = async function* (notification) {
201
209
  const params = notification.params ?? {};
202
210
  if (params.sessionId !== session.sessionId)
@@ -227,11 +235,11 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
227
235
  if (typeof params.after !== "string" || typeof params.next !== "string")
228
236
  throw new MspProtocolError("view/gap");
229
237
  for await (const replay of pageGapNotifications(session.host, session.sessionId, { after: params.after, next: params.next })) {
230
- const replayCursor = replay.params?.viewCursor;
231
- if (typeof replayCursor === "string" && seenCursors.has(replayCursor))
238
+ const replayKey = notificationCursorKey(replay);
239
+ if (replayKey && seenNotifications.has(replayKey))
232
240
  continue;
233
- if (typeof replayCursor === "string")
234
- seenCursors.add(replayCursor);
241
+ if (replayKey)
242
+ seenNotifications.add(replayKey);
235
243
  for await (const output of processNotification(replay))
236
244
  yield output;
237
245
  if (terminalSeen)
@@ -239,13 +247,13 @@ export async function runMuseTurn(context, input, options, runtime, transcriptFi
239
247
  }
240
248
  }
241
249
  else {
242
- const viewCursor = params.viewCursor;
243
- if (typeof viewCursor === "string" && seenCursors.has(viewCursor)) {
250
+ const notificationKey = notificationCursorKey(notification);
251
+ if (notificationKey && seenNotifications.has(notificationKey)) {
244
252
  next = await subscription.next();
245
253
  continue;
246
254
  }
247
- if (typeof viewCursor === "string")
248
- seenCursors.add(viewCursor);
255
+ if (notificationKey)
256
+ seenNotifications.add(notificationKey);
249
257
  for await (const output of processNotification(notification))
250
258
  yield output;
251
259
  if (terminalSeen)
@@ -14,7 +14,7 @@ export declare class MuseStreamMapper {
14
14
  private readonly reasoningStarted;
15
15
  private readonly itemDeltaCounts;
16
16
  private readonly itemText;
17
- private readonly seenUsageCursors;
17
+ private readonly seenUsageNotifications;
18
18
  private readonly textParts;
19
19
  private terminalUsage;
20
20
  private callUsage;
@@ -1,4 +1,5 @@
1
1
  import { MspProtocolError, MuseTurnFailedError, MuseTurnUnqueuedError, } from "../errors.js";
2
+ import { notificationCursorKey } from "../msp/notification.js";
2
3
  function isNumber(value) {
3
4
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
4
5
  }
@@ -17,7 +18,7 @@ export class MuseStreamMapper {
17
18
  reasoningStarted = new Set();
18
19
  itemDeltaCounts = new Map();
19
20
  itemText = new Map();
20
- seenUsageCursors = new Set();
21
+ seenUsageNotifications = new Set();
21
22
  textParts = [];
22
23
  terminalUsage;
23
24
  callUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0 };
@@ -168,9 +169,10 @@ export class MuseStreamMapper {
168
169
  tokenUsageEvent(params) {
169
170
  if (params.turnId !== this.turnId || typeof params.viewCursor !== "string")
170
171
  return;
171
- if (this.seenUsageCursors.has(params.viewCursor))
172
+ const key = notificationCursorKey({ method: "session/tokenUsage", params });
173
+ if (!key || this.seenUsageNotifications.has(key))
172
174
  return;
173
- this.seenUsageCursors.add(params.viewCursor);
175
+ this.seenUsageNotifications.add(key);
174
176
  const raw = params.usage;
175
177
  if (!raw)
176
178
  return;
@@ -1,4 +1,4 @@
1
- import { type MspHandshake, type SpawnMspConnectionOptions } from "@muse-code/sdk";
1
+ import { type FingerprintWarning, type MspHandshake, type SpawnMspConnectionOptions } from "@muse-code/sdk";
2
2
  import { MspClient, type MspConnectionLike, type MspNotification } from "./client.js";
3
3
  export interface MspHostRuntime {
4
4
  connection: MspConnectionLike;
@@ -39,6 +39,8 @@ export declare class MspHost {
39
39
  private readonly runtime;
40
40
  readonly client: MspClient;
41
41
  readonly initializeResult: Record<string, unknown>;
42
+ /** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
43
+ readonly fingerprintWarning?: FingerprintWarning;
42
44
  readonly exited: Promise<MspHostExit>;
43
45
  private state;
44
46
  private closePromise;
package/dist/msp/host.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MspError, EXPECTED_SCHEMA_FINGERPRINT, MuseHostStartError as SdkHostStartError, spawnMspConnection, } from "@muse-code/sdk";
1
+ import { MspError, checkServedFingerprint, EXPECTED_SCHEMA_FINGERPRINT, MuseHostStartError as SdkHostStartError, spawnMspConnection, } from "@muse-code/sdk";
2
2
  import { MspCommandTimeoutError, MspHostClosedError, MspHostStartError, MspInitializeRejectedError, MspSchemaMismatchError, MspStartupTimeoutError, MspTransportError, } from "../errors.js";
3
3
  import { MspClient } from "./client.js";
4
4
  function defaultHandshake(options) {
@@ -32,6 +32,17 @@ function sanitizeExit(value) {
32
32
  // Deliberately omit stderrTail, raw messages, and arbitrary child properties.
33
33
  return safe;
34
34
  }
35
+ const SCHEMA_FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
36
+ /** Envelope schema versions this plugin speaks. The SDK documents version 1 as long-lived. */
37
+ const SUPPORTED_SCHEMA_VERSION = 1;
38
+ function readSchemaInfo(value) {
39
+ if (typeof value !== "object" || value === null)
40
+ return undefined;
41
+ const record = value;
42
+ if (typeof record.fingerprint !== "string")
43
+ return undefined;
44
+ return { fingerprint: record.fingerprint, version: record.version };
45
+ }
35
46
  function withTimeout(promise, timeoutMs) {
36
47
  let timer;
37
48
  return Promise.race([
@@ -57,12 +68,15 @@ export class MspHost {
57
68
  runtime;
58
69
  client;
59
70
  initializeResult;
71
+ /** Set when the served schema fingerprint differs from the SDK pin. Warns, never blocks. */
72
+ fingerprintWarning;
60
73
  exited;
61
74
  state = "open";
62
75
  closePromise;
63
- constructor(runtime, options) {
76
+ constructor(runtime, options, fingerprintWarning) {
64
77
  this.runtime = runtime;
65
78
  this.initializeResult = runtime.initializeResult;
79
+ this.fingerprintWarning = fingerprintWarning;
66
80
  this.client = new MspClient(runtime.connection, {
67
81
  commandTimeoutMs: options.commandTimeoutMs,
68
82
  requestTimeoutMs: options.requestTimeoutMs,
@@ -107,13 +121,15 @@ export class MspHost {
107
121
  const initializing = handshake.initialize(initializeParams);
108
122
  try {
109
123
  const runtime = await withTimeout(initializing, timeoutMs);
110
- const schema = runtime.initializeResult.schema;
111
- const servedFingerprint = typeof schema?.fingerprint === "string" ? schema.fingerprint : "missing";
112
- if (servedFingerprint !== EXPECTED_SCHEMA_FINGERPRINT) {
124
+ const schema = readSchemaInfo(runtime.initializeResult.schema);
125
+ const servedFingerprint = schema?.fingerprint ?? "missing";
126
+ if (schema === undefined ||
127
+ !SCHEMA_FINGERPRINT_PATTERN.test(schema.fingerprint) ||
128
+ (schema.version !== undefined && schema.version !== SUPPORTED_SCHEMA_VERSION)) {
113
129
  await closeQuietly(runtime.close.bind(runtime));
114
130
  throw new MspSchemaMismatchError(EXPECTED_SCHEMA_FINGERPRINT, servedFingerprint);
115
131
  }
116
- return new MspHost(runtime, options);
132
+ return new MspHost(runtime, options, checkServedFingerprint(schema.fingerprint));
117
133
  }
118
134
  catch (error) {
119
135
  if (error instanceof MspSchemaMismatchError)
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stable identity for exact notification replay suppression.
3
+ * A view cursor may be shared by multiple distinct notification methods or
4
+ * payloads, so the cursor alone is not an event identity.
5
+ */
6
+ export declare function notificationCursorKey(notification: {
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ }): string | undefined;
@@ -0,0 +1,26 @@
1
+ import { createHash } from "node:crypto";
2
+ function stableJson(value) {
3
+ if (value === null || typeof value !== "object")
4
+ return JSON.stringify(value) ?? "null";
5
+ if (Array.isArray(value))
6
+ return `[${value.map(stableJson).join(",")}]`;
7
+ const entries = Object.entries(value)
8
+ .filter(([, child]) => child !== undefined)
9
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
10
+ return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`).join(",")}}`;
11
+ }
12
+ /**
13
+ * Stable identity for exact notification replay suppression.
14
+ * A view cursor may be shared by multiple distinct notification methods or
15
+ * payloads, so the cursor alone is not an event identity.
16
+ */
17
+ export function notificationCursorKey(notification) {
18
+ const params = notification.params ?? {};
19
+ const cursor = params.viewCursor;
20
+ if (typeof cursor !== "string")
21
+ return undefined;
22
+ const digest = createHash("sha256")
23
+ .update(stableJson({ method: notification.method, params }))
24
+ .digest("hex");
25
+ return `${cursor}:${digest}`;
26
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Offline schema-evolution check for policy #21.
3
+ *
4
+ * Compares MSP stable-schema snapshots and classifies the delta on the
5
+ * protocol surfaces this plugin actually uses. Additive growth passes with
6
+ * notes; removals, new required fields, dropped properties, or envelope
7
+ * version bumps on the curated surface fail closed for human review.
8
+ *
9
+ * Failing closed on uncertainty is deliberate: a false positive costs a
10
+ * human classification pass, while a false negative ships against a
11
+ * contract the bridge was never tested with.
12
+ */
13
+ export declare const SCHEMA_SECTIONS: readonly ["methods", "requests", "notifications", "errors", "capabilities"];
14
+ export type SchemaSection = (typeof SCHEMA_SECTIONS)[number];
15
+ /** Methods the bridge sends. Each name verified at a call site in src (manager, provider, language-model). */
16
+ export declare const CURATED_METHODS: readonly string[];
17
+ /** Server-to-client requests the bridge explicitly fails closed on (no fabricated answers). */
18
+ export declare const CURATED_REQUESTS: readonly string[];
19
+ /** Notifications the bridge consumes in src/model/stream.ts and src/model/language-model.ts. */
20
+ export declare const CURATED_NOTIFICATIONS: readonly string[];
21
+ export interface SchemaExportManifest {
22
+ fingerprint: string;
23
+ schemaVersion: number;
24
+ experimental: boolean;
25
+ }
26
+ export interface SchemaExport {
27
+ manifest: SchemaExportManifest;
28
+ schema: Record<string, unknown>;
29
+ }
30
+ export interface DefRecord {
31
+ digest: string;
32
+ required: string[] | null;
33
+ props: string[] | null;
34
+ }
35
+ export interface SchemaSnapshot {
36
+ cli_version: string;
37
+ fingerprint: string;
38
+ schema_version: number | null;
39
+ experimental?: boolean;
40
+ provenance?: string;
41
+ surface: Record<SchemaSection, string[] | null>;
42
+ member_digests?: Record<string, Record<string, string>>;
43
+ defs?: Record<string, DefRecord>;
44
+ /** Legacy v1 field: whole-section $defs digest only. Superseded by defs. */
45
+ defs_digests?: Record<string, string>;
46
+ sections_digest: Record<string, string>;
47
+ }
48
+ export type FindingSeverity = "breaking" | "note";
49
+ export interface DiffFinding {
50
+ severity: FindingSeverity;
51
+ kind: string;
52
+ detail: string;
53
+ }
54
+ export interface Classification {
55
+ ok: boolean;
56
+ findings: DiffFinding[];
57
+ }
58
+ /** Builds the ledger snapshot for one offline CLI schema export. */
59
+ export declare function snapshotExport(cliVersion: string, provenance: string, exp: SchemaExport): SchemaSnapshot;
60
+ /** Collects `#/$defs/<Name>` targets reachable from roots over new-side bodies. */
61
+ export declare function collectRefs(roots: Record<string, unknown>, defs: Record<string, unknown>): Set<string>;
62
+ export interface ClassifyOptions {
63
+ /**
64
+ * New-side bodies for reachability attribution: { members: name->body, defs: name->body }.
65
+ * When absent (snapshot-vs-snapshot), changed/removed defs fall back to conservative rules.
66
+ */
67
+ newBodies?: {
68
+ members: Record<string, unknown>;
69
+ defs: Record<string, unknown>;
70
+ };
71
+ }
72
+ /** Classifies old-vs-new snapshots. Breaking findings fail closed for human review. */
73
+ export declare function classifySnapshots(oldSnap: SchemaSnapshot, newSnap: SchemaSnapshot, options?: ClassifyOptions): Classification;
@@ -0,0 +1,339 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Offline schema-evolution check for policy #21.
4
+ *
5
+ * Compares MSP stable-schema snapshots and classifies the delta on the
6
+ * protocol surfaces this plugin actually uses. Additive growth passes with
7
+ * notes; removals, new required fields, dropped properties, or envelope
8
+ * version bumps on the curated surface fail closed for human review.
9
+ *
10
+ * Failing closed on uncertainty is deliberate: a false positive costs a
11
+ * human classification pass, while a false negative ships against a
12
+ * contract the bridge was never tested with.
13
+ */
14
+ export const SCHEMA_SECTIONS = ["methods", "requests", "notifications", "errors", "capabilities"];
15
+ /** Methods the bridge sends. Each name verified at a call site in src (manager, provider, language-model). */
16
+ export const CURATED_METHODS = [
17
+ "model/list", // src/provider.ts catalog fetch
18
+ "session/start", // src/session/manager.ts resolveSession
19
+ "session/resume", // src/session/manager.ts restart recovery
20
+ "session/setModel", // src/session/manager.ts + src/model/language-model.ts selection routing
21
+ "turn/start", // src/model/language-model.ts agent turns
22
+ "turn/interrupt", // src/model/language-model.ts abort path
23
+ "turn/unqueue", // src/model/language-model.ts abort path
24
+ "media/upload", // src/model/language-model.ts video uploads
25
+ "view/subscribe", // src/msp/host.ts cursor replay via MspClient
26
+ "view/page", // src/model/language-model.ts view/gap recovery
27
+ ];
28
+ /** Server-to-client requests the bridge explicitly fails closed on (no fabricated answers). */
29
+ export const CURATED_REQUESTS = [
30
+ "approval/request", // src/msp/host.ts answers method-not-found
31
+ "userInput/request", // src/msp/host.ts answers method-not-found
32
+ ];
33
+ /** Notifications the bridge consumes in src/model/stream.ts and src/model/language-model.ts. */
34
+ export const CURATED_NOTIFICATIONS = [
35
+ "item/started",
36
+ "item/delta",
37
+ "item/completed",
38
+ "turn/completed",
39
+ "turn/foregroundCompleted",
40
+ "turn/retracted",
41
+ "turn/unqueued",
42
+ "session/tokenUsage",
43
+ "view/gap",
44
+ ];
45
+ function sortKeys(value) {
46
+ if (Array.isArray(value))
47
+ return value.map(sortKeys);
48
+ if (value !== null && typeof value === "object") {
49
+ return Object.fromEntries(Object.entries(value)
50
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
51
+ .map(([k, v]) => [k, sortKeys(v)]));
52
+ }
53
+ return value;
54
+ }
55
+ function sha256Hex(canonicalJson) {
56
+ return createHash("sha256").update(canonicalJson, "utf8").digest("hex");
57
+ }
58
+ function canonical(value) {
59
+ return JSON.stringify(sortKeys(value));
60
+ }
61
+ function stringArray(value) {
62
+ if (!Array.isArray(value))
63
+ return null;
64
+ if (!value.every((v) => typeof v === "string"))
65
+ return null;
66
+ return [...value].sort();
67
+ }
68
+ /** Stable name for one section member, or null when it cannot be named. */
69
+ function memberName(section, entry) {
70
+ if (typeof entry === "string")
71
+ return entry;
72
+ if (entry !== null && typeof entry === "object") {
73
+ const record = entry;
74
+ if (section === "errors" && typeof record.kind === "string")
75
+ return record.kind;
76
+ if (typeof record.name === "string")
77
+ return record.name;
78
+ }
79
+ return null;
80
+ }
81
+ function sectionEntries(section, schema) {
82
+ const raw = schema[section];
83
+ const entries = [];
84
+ if (Array.isArray(raw)) {
85
+ for (const entry of raw) {
86
+ const name = memberName(section, entry);
87
+ if (name === null)
88
+ return null;
89
+ entries.push({ name, digest: sha256Hex(canonical(entry)) });
90
+ }
91
+ return entries;
92
+ }
93
+ if (raw !== null && typeof raw === "object") {
94
+ if (section === "capabilities") {
95
+ const record = raw;
96
+ const grantable = record.grantable;
97
+ if (!Array.isArray(grantable))
98
+ return null;
99
+ for (const entry of grantable) {
100
+ const name = memberName(section, entry);
101
+ if (name === null)
102
+ return null;
103
+ entries.push({ name, digest: sha256Hex(canonical(entry)) });
104
+ }
105
+ return entries;
106
+ }
107
+ for (const [name, body] of Object.entries(raw)) {
108
+ entries.push({ name, digest: sha256Hex(canonical(body)) });
109
+ }
110
+ return entries;
111
+ }
112
+ return null;
113
+ }
114
+ function defRecord(body) {
115
+ if (body === null || typeof body !== "object" || Array.isArray(body))
116
+ return null;
117
+ const record = body;
118
+ const properties = record.properties;
119
+ return {
120
+ digest: sha256Hex(canonical(body)),
121
+ required: stringArray(record.required),
122
+ props: properties !== null && typeof properties === "object" && !Array.isArray(properties)
123
+ ? Object.keys(properties).sort()
124
+ : null,
125
+ };
126
+ }
127
+ /** Builds the ledger snapshot for one offline CLI schema export. */
128
+ export function snapshotExport(cliVersion, provenance, exp) {
129
+ if (typeof exp?.manifest?.fingerprint !== "string" || typeof exp?.manifest?.schemaVersion !== "number") {
130
+ throw new Error("schema-diff: export manifest must carry a fingerprint string and numeric schemaVersion");
131
+ }
132
+ if (exp.schema === null || typeof exp.schema !== "object" || Array.isArray(exp.schema)) {
133
+ throw new Error("schema-diff: export schema must be an object");
134
+ }
135
+ const surface = {};
136
+ const member_digests = {};
137
+ const sections_digest = {};
138
+ for (const section of SCHEMA_SECTIONS) {
139
+ const entries = sectionEntries(section, exp.schema);
140
+ surface[section] = entries === null ? null : entries.map((e) => e.name).sort();
141
+ if (entries !== null && (section === "methods" || section === "requests" || section === "notifications")) {
142
+ member_digests[section] = Object.fromEntries(entries.map((e) => [e.name, e.digest]));
143
+ }
144
+ sections_digest[section] = sha256Hex(canonical(exp.schema[section] ?? null));
145
+ }
146
+ const rawDefs = exp.schema.$defs;
147
+ if (rawDefs === null || typeof rawDefs !== "object" || Array.isArray(rawDefs)) {
148
+ throw new Error("schema-diff: export schema must carry a $defs object");
149
+ }
150
+ const defs = {};
151
+ for (const [name, body] of Object.entries(rawDefs)) {
152
+ const record = defRecord(body);
153
+ if (record === null)
154
+ throw new Error(`schema-diff: $defs member is not an object: ${name}`);
155
+ defs[name] = record;
156
+ }
157
+ sections_digest.$defs = sha256Hex(canonical(exp.schema.$defs));
158
+ return {
159
+ cli_version: cliVersion,
160
+ fingerprint: exp.manifest.fingerprint,
161
+ schema_version: exp.manifest.schemaVersion,
162
+ experimental: exp.manifest.experimental,
163
+ provenance,
164
+ surface,
165
+ member_digests,
166
+ defs,
167
+ sections_digest,
168
+ };
169
+ }
170
+ /** Collects `#/$defs/<Name>` targets reachable from roots over new-side bodies. */
171
+ export function collectRefs(roots, defs) {
172
+ const seen = new Set();
173
+ const visit = (value) => {
174
+ if (typeof value === "string") {
175
+ const match = /^#\/\$defs\/([A-Za-z0-9_]+)$/.exec(value);
176
+ if (match && !seen.has(match[1])) {
177
+ seen.add(match[1]);
178
+ const body = defs[match[1]];
179
+ if (body !== undefined)
180
+ visit(body);
181
+ }
182
+ return;
183
+ }
184
+ if (Array.isArray(value)) {
185
+ for (const v of value)
186
+ visit(v);
187
+ return;
188
+ }
189
+ if (value !== null && typeof value === "object") {
190
+ for (const v of Object.values(value))
191
+ visit(v);
192
+ }
193
+ };
194
+ for (const root of Object.values(roots))
195
+ visit(root);
196
+ return seen;
197
+ }
198
+ function curatedFor(section) {
199
+ if (section === "methods")
200
+ return new Set(CURATED_METHODS);
201
+ if (section === "requests")
202
+ return new Set(CURATED_REQUESTS);
203
+ if (section === "notifications")
204
+ return new Set(CURATED_NOTIFICATIONS);
205
+ return new Set();
206
+ }
207
+ function requireSurface(snap, which) {
208
+ if (snap === null || typeof snap !== "object")
209
+ throw new Error(`schema-diff: ${which} snapshot is not an object`);
210
+ const surface = snap.surface;
211
+ if (surface === null || typeof surface !== "object" || Array.isArray(surface)) {
212
+ throw new Error(`schema-diff: ${which} snapshot has no surface record`);
213
+ }
214
+ for (const section of SCHEMA_SECTIONS) {
215
+ const value = surface[section];
216
+ if (!Array.isArray(value) && value !== null) {
217
+ throw new Error(`schema-diff: ${which} snapshot surface.${section} is malformed`);
218
+ }
219
+ }
220
+ }
221
+ /** Classifies old-vs-new snapshots. Breaking findings fail closed for human review. */
222
+ export function classifySnapshots(oldSnap, newSnap, options = {}) {
223
+ requireSurface(oldSnap, "old");
224
+ requireSurface(newSnap, "new");
225
+ const findings = [];
226
+ if (oldSnap.schema_version === null || oldSnap.schema_version === undefined ||
227
+ newSnap.schema_version === null || newSnap.schema_version === undefined) {
228
+ throw new Error("schema-diff: both snapshots must carry a schema_version");
229
+ }
230
+ if (oldSnap.schema_version !== newSnap.schema_version) {
231
+ findings.push({
232
+ severity: "breaking",
233
+ kind: "envelope-version-changed",
234
+ detail: `envelope schema version ${String(oldSnap.schema_version)} -> ${String(newSnap.schema_version)}`,
235
+ });
236
+ }
237
+ const oldMembers = oldSnap.member_digests;
238
+ const newMembers = newSnap.member_digests;
239
+ const memberLevel = oldMembers !== undefined && oldMembers !== null && newMembers !== undefined && newMembers !== null;
240
+ if (!memberLevel) {
241
+ findings.push({
242
+ severity: "note",
243
+ kind: "member-level-unavailable",
244
+ detail: "member-level digests absent on one side; falling back to set-level comparison",
245
+ });
246
+ }
247
+ for (const section of SCHEMA_SECTIONS) {
248
+ const oldNames = new Set(oldSnap.surface[section] ?? []);
249
+ const newNames = new Set(newSnap.surface[section] ?? []);
250
+ const curated = curatedFor(section);
251
+ for (const name of [...newNames].filter((n) => !oldNames.has(n)).sort()) {
252
+ findings.push({ severity: "note", kind: `${section}-added`, detail: `${section} added: ${name}` });
253
+ }
254
+ for (const name of [...oldNames].filter((n) => !newNames.has(n)).sort()) {
255
+ if (curated.has(name)) {
256
+ findings.push({ severity: "breaking", kind: `${section}-removed`, detail: `${section} removed curated member: ${name}` });
257
+ }
258
+ else {
259
+ findings.push({ severity: "note", kind: `${section}-removed`, detail: `${section} removed non-curated member: ${name}` });
260
+ }
261
+ }
262
+ if (memberLevel && (section === "methods" || section === "requests" || section === "notifications")) {
263
+ const oldMap = oldMembers?.[section] ?? {};
264
+ const newMap = newMembers?.[section] ?? {};
265
+ for (const name of [...oldNames].filter((n) => newNames.has(n)).sort()) {
266
+ if (oldMap[name] !== newMap[name]) {
267
+ if (curated.has(name)) {
268
+ findings.push({ severity: "breaking", kind: `${section}-changed`, detail: `${section} changed curated member shape: ${name}` });
269
+ }
270
+ else {
271
+ findings.push({ severity: "note", kind: `${section}-changed`, detail: `${section} changed non-curated member shape: ${name}` });
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+ // Reachability over new-side bodies scopes nested def changes to what the bridge can observe.
278
+ let reachable = null;
279
+ if (options.newBodies) {
280
+ const roots = {};
281
+ for (const name of [...CURATED_METHODS, ...CURATED_REQUESTS, ...CURATED_NOTIFICATIONS]) {
282
+ if (options.newBodies.members[name] !== undefined)
283
+ roots[name] = options.newBodies.members[name];
284
+ }
285
+ reachable = collectRefs(roots, options.newBodies.defs);
286
+ }
287
+ const oldDefs = oldSnap.defs ?? {};
288
+ const newDefs = newSnap.defs ?? {};
289
+ const defsDetailed = oldSnap.defs !== undefined && newSnap.defs !== undefined;
290
+ if (!defsDetailed) {
291
+ findings.push({
292
+ severity: "note",
293
+ kind: "defs-unavailable",
294
+ detail: "per-def records absent on one side; nested changes covered only by member shapes where available",
295
+ });
296
+ }
297
+ else {
298
+ const names = new Set([...Object.keys(oldDefs), ...Object.keys(newDefs)]);
299
+ for (const name of [...names].sort()) {
300
+ const oldDef = oldDefs[name];
301
+ const newDef = newDefs[name];
302
+ if (!oldDef) {
303
+ findings.push({ severity: "note", kind: "def-added", detail: `$defs added: ${name}` });
304
+ continue;
305
+ }
306
+ if (!newDef) {
307
+ if (reachable !== null && reachable.has(name)) {
308
+ findings.push({ severity: "breaking", kind: "def-removed", detail: `$defs removed reachable def: ${name}` });
309
+ }
310
+ else {
311
+ findings.push({ severity: "note", kind: "def-removed", detail: `$defs removed: ${name}${reachable === null ? " (reachability unknown: snapshot-vs-snapshot)" : " (unreachable from curated surface)"}` });
312
+ }
313
+ continue;
314
+ }
315
+ if (oldDef.digest === newDef.digest)
316
+ continue;
317
+ const requiredAdded = (newDef.required ?? []).filter((r) => !(oldDef.required ?? []).includes(r));
318
+ const propsRemoved = (oldDef.props ?? []).filter((p) => !(newDef.props ?? []).includes(p));
319
+ const breakingShape = requiredAdded.length > 0 || propsRemoved.length > 0;
320
+ const isReachable = reachable === null ? null : reachable.has(name);
321
+ if (breakingShape && (isReachable === null || isReachable)) {
322
+ findings.push({
323
+ severity: "breaking",
324
+ kind: "def-changed",
325
+ detail: `$defs changed${isReachable === null ? " (reachability unknown: snapshot-vs-snapshot)" : " (reachable from curated surface)"}: ${name}` +
326
+ `${requiredAdded.length > 0 ? ` required+${requiredAdded.join(",")}` : ""}${propsRemoved.length > 0 ? ` props-${propsRemoved.join(",")}` : ""}`,
327
+ });
328
+ }
329
+ else {
330
+ findings.push({
331
+ severity: "note",
332
+ kind: "def-changed",
333
+ detail: `$defs changed: ${name} (additive or unreachable: breaking only if a curated member shape changed, reported above)`,
334
+ });
335
+ }
336
+ }
337
+ }
338
+ return { ok: !findings.some((f) => f.severity === "breaking"), findings };
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-muse",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode plugin backed by the official Muse Code CLI and MSP",
5
5
  "license": "MIT",
6
6
  "type": "module",