@brainervirus/workit-core 2.9.0 → 4.0.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.
@@ -0,0 +1,123 @@
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import { detectBranchPolicy } from "./branch-policy";
5
+ import { configDir } from "./config";
6
+ import {
7
+ readWorkspacesResult,
8
+ resolveWorkspaceFrom,
9
+ validateWorkspaceGlob,
10
+ validateWorkspacesDocument,
11
+ workspacesRevision,
12
+ type WorkspaceConfig,
13
+ } from "./workspaces";
14
+
15
+ const readFileSafe = (p: string): string | null => {
16
+ try {
17
+ return readFileSync(p, "utf8");
18
+ } catch {
19
+ return null;
20
+ }
21
+ };
22
+
23
+ // The one proposal→write path for a workspace branch policy, shared by the
24
+ // OpenCode `workit_init_apply` branch_policy action (init.ts) and the CLI
25
+ // wizard, so both produce byte-identical workspaces.json writes.
26
+ export function applyWorkspaceBranchPolicy(opts: {
27
+ workspace_root: string;
28
+ env?: NodeJS.ProcessEnv;
29
+ }): Record<string, any> {
30
+ const { workspace_root, env = process.env } = opts;
31
+ const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
32
+ const {
33
+ status,
34
+ path: wsPath,
35
+ entries,
36
+ document,
37
+ revision,
38
+ error: workspacesError,
39
+ } = readWorkspacesResult(dir);
40
+ if (status === "malformed" || status === "invalid")
41
+ return { ok: false, error: workspacesError ?? `invalid workspaces.json: ${wsPath}` };
42
+ const detection = detectBranchPolicy(workspace_root);
43
+ const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
44
+ const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
45
+ const policy = {
46
+ preset: detection.preset,
47
+ developBranch: env.WORKFLOW_BP_DEVELOP ?? detection.developBranch ?? undefined,
48
+ prefixes: detection.prefixes,
49
+ allowed: detection.allowed,
50
+ protected: detection.protected,
51
+ integration,
52
+ };
53
+ const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
54
+ if (!validateWorkspaceGlob(glob).ok)
55
+ return { ok: false, error: `invalid workspace glob: ${glob}` };
56
+ let existing: WorkspaceConfig | null;
57
+ try {
58
+ existing = resolveWorkspaceFrom(workspace_root, dir);
59
+ } catch (error) {
60
+ const selectedName = env.WORKFLOW_BP_NAME?.trim();
61
+ if (!selectedName)
62
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
63
+ try {
64
+ existing = resolveWorkspaceFrom(workspace_root, dir, selectedName);
65
+ } catch (selectionError) {
66
+ return {
67
+ ok: false,
68
+ error: selectionError instanceof Error ? selectionError.message : String(selectionError),
69
+ };
70
+ }
71
+ }
72
+ if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
73
+ return {
74
+ ok: true,
75
+ status: "already-configured",
76
+ workspace: existing,
77
+ policy,
78
+ config_path: wsPath,
79
+ };
80
+ }
81
+ const next = existing
82
+ ? entries.map((w) => (w.name === existing?.name ? { ...w, branchPolicy: policy } : w))
83
+ : [...entries, { name, glob, branchPolicy: policy }];
84
+ if (!existing && entries.some((entry) => entry.name === name))
85
+ return { ok: false, error: `workspace name ${JSON.stringify(name)} is already configured` };
86
+ const proposed = validateWorkspacesDocument({ ...document, workspaces: next }, wsPath);
87
+ if (proposed.status !== "valid") return { ok: false, error: proposed.error };
88
+ if (workspacesRevision(readFileSafe(wsPath)) !== revision)
89
+ return {
90
+ ok: false,
91
+ error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
92
+ };
93
+ mkdirSync(path.dirname(wsPath), { recursive: true });
94
+ const tmp = `${wsPath}.${process.pid}.tmp`;
95
+ try {
96
+ writeFileSync(tmp, JSON.stringify({ ...document, workspaces: next }, null, 2) + "\n", "utf8");
97
+ if (workspacesRevision(readFileSafe(wsPath)) !== revision) {
98
+ rmSync(tmp, { force: true });
99
+ return {
100
+ ok: false,
101
+ error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
102
+ };
103
+ }
104
+ renameSync(tmp, wsPath);
105
+ } catch (error) {
106
+ try {
107
+ rmSync(tmp, { force: true });
108
+ } catch {}
109
+ return {
110
+ ok: false,
111
+ error: `failed to write ${wsPath}: ${error instanceof Error ? error.message : String(error)}`,
112
+ };
113
+ }
114
+ return {
115
+ ok: true,
116
+ status: existing ? "updated" : "configured",
117
+ workspace: existing
118
+ ? { ...existing, branchPolicy: policy }
119
+ : { name, glob, branchPolicy: policy },
120
+ policy,
121
+ config_path: wsPath,
122
+ };
123
+ }
package/src/core.ts CHANGED
@@ -61,12 +61,7 @@ export type {
61
61
  Result as ContractResult,
62
62
  } from "./core/task-contract";
63
63
  export { TaskStore, runtimeVersion } from "./core/task-store";
64
- export type {
65
- MetadataLock,
66
- ProcessEvidence,
67
- RecoveryInput,
68
- TaskIndexEntry,
69
- } from "./core/task-store";
64
+ export type { TaskIndexEntry } from "./core/task-store";
70
65
  export { compactTaskContext, reconcileResume } from "./core/task-context";
71
66
  export type {
72
67
  CompactTaskContext,
@@ -6,10 +6,24 @@ import { canonicalJson } from "../core/task-contract";
6
6
  import { isObservedCheck, type Freshness } from "../core/task-evaluation";
7
7
  import { worktreeSignal } from "../git/rev";
8
8
  import { WorkitCore, type OperationContext } from "../core/task-engine";
9
- import { fileSignature, racySignature, TaskStore, type TaskIndexEntry } from "../core/task-store";
9
+ import {
10
+ fileSignature,
11
+ MIGRATION_PENDING,
12
+ racySignature,
13
+ TaskStore,
14
+ type TaskIndexEntry,
15
+ } from "../core/task-store";
10
16
  import { capabilitiesFor, type HostDescriptor } from "./descriptor";
11
17
  import type { HookInput } from "./protocol";
12
18
 
19
+ /**
20
+ * Shown on every per-turn path while the checkout's 2.x store waits for the
21
+ * CLI to migrate it (hooks never migrate), instead of silently no context.
22
+ */
23
+ export const MIGRATION_PENDING_NOTE = "workit migration pending — run `workit task status`";
24
+ const migrationPending = (result: { ok: boolean; code?: string; error?: string }): boolean =>
25
+ !result.ok && result.code === "needs_input" && (result.error ?? "").startsWith(MIGRATION_PENDING);
26
+
13
27
  /** A native host session, e.g. `{ host: "opencode", handle: sessionID }`. */
14
28
  export type SessionHandle = { host: string; handle: string };
15
29
 
@@ -50,6 +64,34 @@ export const currentTaskEntry = (
50
64
  return active.length === 1 ? active[0] : null;
51
65
  };
52
66
 
67
+ /**
68
+ * The open task bound to this checkout's branch (its implicit task, D3),
69
+ * unless a host conversation other than this one is bound to it: someone
70
+ * else's conversation is only ever offered, never injected. Sessions of the
71
+ * CLI (`workit_cli`, which creates implicit tasks) are tooling, not
72
+ * conversations.
73
+ */
74
+ const implicitTaskEntry = (
75
+ store: TaskStore,
76
+ entries: TaskIndexEntry[],
77
+ session: SessionHandle,
78
+ ): TaskIndexEntry | null => {
79
+ const open = entries.filter(
80
+ (entry) =>
81
+ entry.key !== null &&
82
+ entry.status !== "closed" &&
83
+ !entry.sessions.some(
84
+ (item) =>
85
+ item.host !== "workit_cli" &&
86
+ !(item.host === session.host && item.handle === session.handle),
87
+ ),
88
+ );
89
+ if (open.length === 0) return null;
90
+ const key = store.currentKey();
91
+ if (!key.ok) return null;
92
+ return open.filter((entry) => entry.key === key.data.key).toSorted(newestFirst)[0] ?? null;
93
+ };
94
+
53
95
  /**
54
96
  * History offer for open tasks not bound to `session` (and not `excludeTaskId`,
55
97
  * the task already shown), built from the task index. Task text is quoted and
@@ -109,7 +151,9 @@ const recentSignal = (root: string): string | null => {
109
151
  const cache = new Map<string, CachedContext>();
110
152
 
111
153
  /**
112
- * Compact context for `session`'s current task, for per-turn host injection.
154
+ * Compact context for `session`'s current task (the task bound to the
155
+ * session, else the implicit task of the checkout's branch), for per-turn
156
+ * host injection.
113
157
  *
114
158
  * Reads the task index (no full-record parse for unrelated tasks), never
115
159
  * captures a candidate, and reuses the previous result while the task and
@@ -123,8 +167,10 @@ export function sessionCompactContext(
123
167
  selection: HostDescriptor["context"]["task"] = "session-bound",
124
168
  ): string | null {
125
169
  const listed = store.listTaskIndex();
126
- if (!listed.ok) return null;
127
- const entry = currentTaskEntry(listed.data, session, selection);
170
+ if (!listed.ok) return migrationPending(listed) ? MIGRATION_PENDING_NOTE : null;
171
+ const entry =
172
+ currentTaskEntry(listed.data, session, selection) ??
173
+ implicitTaskEntry(store, listed.data, session);
128
174
  return entry ? entryCompactContext(store, entry, session, context) : null;
129
175
  }
130
176
 
@@ -208,8 +254,13 @@ export const sessionContextText = (
208
254
  try {
209
255
  const store = new TaskStore(input.cwd);
210
256
  const listed = store.listTaskIndex();
257
+ // Hooks never migrate (hot path, possibly a live 2.x writer): say how.
258
+ if (migrationPending(listed))
259
+ return `<workit-contract>\n${invariantBootstrap()}\n${MIGRATION_PENDING_NOTE}${options.addendum ? `\n${options.addendum}` : ""}\n</workit-contract>`;
211
260
  if (!listed.ok) throw new Error(listed.error);
212
- const entry = currentTaskEntry(listed.data, session, descriptor.context.task);
261
+ const entry =
262
+ currentTaskEntry(listed.data, session, descriptor.context.task) ??
263
+ implicitTaskEntry(store, listed.data, session);
213
264
  const text = entry
214
265
  ? entryCompactContext(store, entry, session, hookOperationContext(input, descriptor))
215
266
  : null;
@@ -230,6 +281,7 @@ export const turnContextText = (input: HookInput, descriptor: HostDescriptor): s
230
281
  hookOperationContext(input, descriptor),
231
282
  descriptor.context.task,
232
283
  );
284
+ if (text === MIGRATION_PENDING_NOTE) return text;
233
285
  return text ? `<workit-task-context>${text}</workit-task-context>` : null;
234
286
  } catch {
235
287
  return null;
@@ -4,8 +4,8 @@ import type { HostDescriptor } from "../descriptor";
4
4
  export const OPENCODE_DESCRIPTOR: HostDescriptor = {
5
5
  host: "opencode",
6
6
  label: "OpenCode",
7
- verifiedAgainst: "@opencode-ai/plugin 1.18.30",
8
- docs: ["https://opencode.ai/docs/plugins/"],
7
+ verifiedAgainst: "@opencode/plugin 2.0.18",
8
+ docs: ["https://opencode.ai/v2/docs/build/plugins/"],
9
9
  transport: "in-process-plugin",
10
10
  events: {
11
11
  // No separate start event: context is injected on every agent-loop call.
package/src/ledger.ts CHANGED
@@ -40,6 +40,7 @@ import { createHash, randomBytes } from "node:crypto";
40
40
  import fs from "node:fs";
41
41
  import path from "node:path";
42
42
  import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
43
+ import { resolveStore } from "./store/paths";
43
44
 
44
45
  export const LEDGER_VERSION = 1;
45
46
  /** The largest row (including its newline) that one append may carry. */
@@ -254,29 +255,22 @@ export function diffHash(cwd: string, base: string, head: string): string | null
254
255
 
255
256
  export type StoreRoot = { root: string; shared: boolean };
256
257
 
257
- const NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
258
-
259
258
  /**
260
259
  * Where workit state lives for `cwd`: `<git common dir>/workit` (shared by all
261
260
  * worktrees of the repo), or `<cwd>/.workit` only when git reports that `cwd`
262
- * is not inside a repository. A broken repository, a missing git binary or a
263
- * refused (dubious-ownership) repository is `unavailable`.
261
+ * is not inside a repository (or git is not installed). A broken repository
262
+ * or a refused (dubious-ownership) repository is `unavailable`. The task
263
+ * store resolves the same way (store/paths.ts).
264
264
  */
265
265
  export function storeRoot(cwd: string): LedgerResult<StoreRoot> {
266
- const run = gitRun(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
267
- const common = run.stdout.trim();
268
- if (run.ok && common)
269
- return {
270
- ok: true,
271
- value: { root: path.join(path.resolve(cwd, common), "workit"), shared: true },
272
- };
273
- if (!run.ok && NOT_A_REPO.test(run.stderr))
274
- return { ok: true, value: { root: path.join(path.resolve(cwd), ".workit"), shared: false } };
275
- return err(
276
- "unavailable",
277
- `cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split("\n")[0] || "no output"})`,
278
- "fix the repository (git status should work here) or run from a non-git directory",
279
- );
266
+ const location = resolveStore(path.resolve(cwd));
267
+ if (location instanceof Error)
268
+ return err(
269
+ "unavailable",
270
+ location.message.replace(/; fix the repository.*$/u, ""),
271
+ "fix the repository (git status should work here) or run from a non-git directory",
272
+ );
273
+ return { ok: true, value: { root: location.dir, shared: location.shared } };
280
274
  }
281
275
 
282
276
  export function ledgerPath(cwd: string): LedgerResult<string> {
@@ -0,0 +1,217 @@
1
+ // The append-only event log of one task: `events.jsonl`, one JSON event per
2
+ // line, plus `snapshot.json`, a rebuildable cache of the reduced state
3
+ // (design §4.1).
4
+ //
5
+ // - A writer holds the task lock, appends one complete line with O_APPEND in
6
+ // a single write, and fsyncs. A crash can therefore only leave a torn last
7
+ // line (no trailing newline); readers ignore it and the next locked append
8
+ // truncates it away first. Any other unreadable line is real damage.
9
+ // - Readers are lenient (D17): unknown event keys and unknown event types are
10
+ // ignored, unless the event says `critical: true`, in which case a reader
11
+ // that does not know the type fails closed with an upgrade message.
12
+ // - The snapshot names the event it was taken after (seq, id, byte range). A
13
+ // snapshot that does not match the log is ignored and the log is replayed.
14
+ import { randomUUID } from "node:crypto";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ export const EVENT_VERSION = 1;
19
+
20
+ export type EventActor = { host: string; session: string | null; agentId: string | null };
21
+
22
+ export type StoreEvent = {
23
+ v: number;
24
+ seq: number;
25
+ at: string;
26
+ id: string;
27
+ task: string;
28
+ actor: EventActor | null;
29
+ type: string;
30
+ data: Record<string, unknown>;
31
+ critical?: boolean;
32
+ };
33
+
34
+ export type LogRead = {
35
+ events: StoreEvent[];
36
+ /** Byte offset just past the last complete event line. */
37
+ end: number;
38
+ /** Byte offset where the last complete event line starts. */
39
+ lastStart: number;
40
+ /** File size; larger than `end` when a torn line trails the log. */
41
+ size: number;
42
+ };
43
+
44
+ export class LogDamage extends Error {
45
+ constructor(
46
+ message: string,
47
+ readonly upgrade: boolean = false,
48
+ ) {
49
+ super(message);
50
+ }
51
+ }
52
+
53
+ const isObject = (value: unknown): value is Record<string, unknown> =>
54
+ typeof value === "object" && value !== null && !Array.isArray(value);
55
+
56
+ const parseEvent = (line: string, file: string, offset: number): StoreEvent => {
57
+ let value: unknown;
58
+ try {
59
+ value = JSON.parse(line);
60
+ } catch {
61
+ throw new LogDamage(`${file}: unreadable event at byte ${offset}`);
62
+ }
63
+ if (!isObject(value) || typeof value.v !== "number")
64
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
65
+ if (value.v > EVENT_VERSION)
66
+ throw new LogDamage(
67
+ `${file}: event format v${value.v} was written by a newer Workit; upgrade Workit to read this task`,
68
+ true,
69
+ );
70
+ if (
71
+ typeof value.seq !== "number" ||
72
+ !Number.isInteger(value.seq) ||
73
+ typeof value.type !== "string" ||
74
+ typeof value.id !== "string" ||
75
+ typeof value.task !== "string" ||
76
+ !isObject(value.data)
77
+ )
78
+ throw new LogDamage(`${file}: malformed event at byte ${offset}`);
79
+ return value as StoreEvent;
80
+ };
81
+
82
+ /**
83
+ * Read the events of `file` starting at byte `from` (a line start). A torn
84
+ * trailing line is excluded; `expectSeq` is the seq the first event must have.
85
+ * Returns null when the file does not exist.
86
+ */
87
+ export function readLog(file: string, from = 0, expectSeq: number | null = null): LogRead | null {
88
+ let fd: number;
89
+ try {
90
+ fd = fs.openSync(file, "r");
91
+ } catch (error) {
92
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
93
+ throw error;
94
+ }
95
+ let bytes: Buffer;
96
+ let size: number;
97
+ try {
98
+ size = fs.fstatSync(fd).size;
99
+ if (from > size) throw new LogDamage(`${file}: shorter than its snapshot`);
100
+ bytes = Buffer.alloc(size - from);
101
+ let read = 0;
102
+ while (read < bytes.length) {
103
+ const got = fs.readSync(fd, bytes, read, bytes.length - read, from + read);
104
+ if (got === 0) break;
105
+ read += got;
106
+ }
107
+ bytes = bytes.subarray(0, read);
108
+ size = from + read;
109
+ } finally {
110
+ fs.closeSync(fd);
111
+ }
112
+ const events: StoreEvent[] = [];
113
+ let start = 0;
114
+ let lastStart = from;
115
+ let seq = expectSeq;
116
+ for (;;) {
117
+ const newline = bytes.indexOf(0x0a, start);
118
+ if (newline < 0) break;
119
+ const line = bytes.subarray(start, newline).toString("utf8");
120
+ if (line.trim() !== "") {
121
+ const event = parseEvent(line, file, from + start);
122
+ if (seq !== null && event.seq !== seq)
123
+ throw new LogDamage(`${file}: event seq ${event.seq} where ${seq} was expected`);
124
+ seq = event.seq + 1;
125
+ events.push(event);
126
+ lastStart = from + start;
127
+ }
128
+ start = newline + 1;
129
+ }
130
+ return { events, end: from + start, lastStart, size };
131
+ }
132
+
133
+ /** Durably write `dir`'s entries (no-op where directories cannot be fsynced). */
134
+ export const fsyncDirectory = (dir: string): void => {
135
+ try {
136
+ const fd = fs.openSync(dir, "r");
137
+ try {
138
+ fs.fsyncSync(fd);
139
+ } finally {
140
+ fs.closeSync(fd);
141
+ }
142
+ } catch (error) {
143
+ if (process.platform !== "win32") throw error;
144
+ }
145
+ };
146
+
147
+ /**
148
+ * Append one event line. `validEnd` is where the last complete line ends: a
149
+ * torn tail beyond it is truncated first. Returns the byte offset the line
150
+ * starts at.
151
+ */
152
+ export function appendEvent(file: string, event: StoreEvent, validEnd: number): number {
153
+ const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
154
+ const created = !fs.existsSync(file);
155
+ // Cut a torn tail first, through a writable handle: an append-only handle
156
+ // cannot truncate on Windows.
157
+ if (!created && fs.statSync(file).size !== validEnd) {
158
+ const cut = fs.openSync(file, "r+");
159
+ try {
160
+ fs.ftruncateSync(cut, validEnd);
161
+ fs.fsyncSync(cut);
162
+ } finally {
163
+ fs.closeSync(cut);
164
+ }
165
+ }
166
+ const fd = fs.openSync(file, "a", 0o600);
167
+ try {
168
+ let written = 0;
169
+ while (written < line.length) written += fs.writeSync(fd, line, written, line.length - written);
170
+ fs.fsyncSync(fd);
171
+ } finally {
172
+ fs.closeSync(fd);
173
+ }
174
+ if (created) fsyncDirectory(path.dirname(file));
175
+ return validEnd;
176
+ }
177
+
178
+ /** Replace `file` with `bytes` atomically (temp file + rename). */
179
+ export function replaceFile(file: string, bytes: string, durable: boolean): void {
180
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
181
+ try {
182
+ const fd = fs.openSync(temporary, "wx", 0o600);
183
+ try {
184
+ fs.writeSync(fd, bytes);
185
+ if (durable) fs.fsyncSync(fd);
186
+ } finally {
187
+ fs.closeSync(fd);
188
+ }
189
+ renameWithRetry(temporary, file);
190
+ if (durable) fsyncDirectory(path.dirname(file));
191
+ } catch (error) {
192
+ try {
193
+ fs.unlinkSync(temporary);
194
+ } catch {}
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
200
+ /** Windows briefly refuses to replace a file another process is reading. */
201
+ export const isTransientWindowsError = (error: unknown): boolean => {
202
+ if (process.platform !== "win32") return false;
203
+ const code = (error as { code?: unknown } | null)?.code;
204
+ return typeof code === "string" && TRANSIENT_WINDOWS_CODES.has(code);
205
+ };
206
+ export const retryTransient = <T>(run: () => T): T => {
207
+ if (process.platform !== "win32") return run();
208
+ for (let attempt = 0; ; attempt += 1) {
209
+ try {
210
+ return run();
211
+ } catch (error) {
212
+ if (attempt >= 20 || !isTransientWindowsError(error)) throw error;
213
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 * (attempt + 1));
214
+ }
215
+ }
216
+ };
217
+ const renameWithRetry = (from: string, to: string) => retryTransient(() => fs.renameSync(from, to));
@@ -0,0 +1,29 @@
1
+ // One-time notes from the store to the user. A host installs a reporter on
2
+ // the global `Symbol.for("workit.migrationReporter")` slot (the CLI router
3
+ // does; it must not import workit-core on its cold path). Without one, the
4
+ // note is silent: hooks and in-process hosts own their output channels.
5
+
6
+ export type MigrationReport = {
7
+ /** The 2.x store that was migrated. */
8
+ from: string;
9
+ /** The store it now lives in. */
10
+ to: string;
11
+ /** Where the 2.x files were backed up. */
12
+ backup: string;
13
+ tasks: number;
14
+ skipped: number;
15
+ workspace: boolean;
16
+ };
17
+
18
+ const SLOT = Symbol.for("workit.migrationReporter");
19
+
20
+ export const reportMigration = (report: MigrationReport): void => {
21
+ try {
22
+ const reporter = (globalThis as Record<symbol, unknown>)[SLOT];
23
+ if (typeof reporter === "function") reporter(report);
24
+ } catch {}
25
+ };
26
+
27
+ /** Whether a host installed a reporter (it can tell the user about a migration). */
28
+ export const migrationReporterInstalled = (): boolean =>
29
+ typeof (globalThis as Record<symbol, unknown>)[SLOT] === "function";