@openviking/opencode-plugin 2026.9.9 → 2026.9.21

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,433 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * Layered workspace configuration.
4
+ *
5
+ * A workspace may carry two files under `<root>/.openviking/`: `config.json`,
6
+ * which the team commits, and `config.local.json`, which stays private. A third
7
+ * layer lives per machine in the user registry. All three share this schema and
8
+ * this merge, so there is one set of rules to learn and one to test.
9
+ *
10
+ * These files are trusted without a prompt — a hook is non-interactive, and any
11
+ * approval gate degrades into "run one command per workspace first". What is
12
+ * refused instead is structural, and costs nobody anything:
13
+ *
14
+ * - connection and credential keys are stripped, loudly. "Which server does
15
+ * my data go to" must stay answerable without reading a repository.
16
+ * - `${VAR}` is never expanded here. Variable expansion is a property of the
17
+ * layer, not of the parser: a committed file that could expand `${HOME}`
18
+ * could exfiltrate the environment.
19
+ *
20
+ * Everything else a workspace file sets takes effect. What it turns off is
21
+ * announced rather than blocked.
22
+ */
23
+
24
+ import { readFileSync, realpathSync, statSync } from "node:fs";
25
+ import { isAbsolute, join, relative } from "node:path";
26
+
27
+ import { WORKSPACE_ENUMS, WORKSPACE_KNOB_MAP, WORKSPACE_RANGES } from "./config-schema.mjs";
28
+ import { CONFIG_DIR_NAME, LOCAL_FILE, TEAM_FILE } from "./workspace-identity.mjs";
29
+
30
+ /**
31
+ * `JSON.parse` keeps `__proto__` as an own property, and assigning it walks
32
+ * into `Object.prototype`. Since `process.env` reads through the prototype
33
+ * chain and the environment outranks ovcli.conf, one such key in a committed
34
+ * file would set `OPENVIKING_URL` and `OPENVIKING_API_KEY` for the whole
35
+ * process — the exact thing every other rule here exists to prevent.
36
+ */
37
+ const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
38
+
39
+ // Deep enough for any real config; a file can nest far past the stack limit
40
+ // inside the 64 KiB cap, and an overflow here would take out sibling layers.
41
+ const MAX_DEPTH = 32;
42
+
43
+ export { CONFIG_DIR_NAME, TEAM_FILE, LOCAL_FILE };
44
+ export const CONFIG_VERSION = 1;
45
+ export const MAX_CONFIG_BYTES = 64 * 1024;
46
+
47
+ /**
48
+ * Keys no workspace file may set, at any depth. Connection and credentials
49
+ * belong to ovcli.conf and the environment, full stop — this is the invariant
50
+ * git spells `protected configuration`.
51
+ */
52
+ export const FORBIDDEN_KEYS = [
53
+ "url",
54
+ "base_url",
55
+ "mcp_url",
56
+ "api_key",
57
+ "bearer_token",
58
+ "root_api_key",
59
+ "gateway_token",
60
+ "oidc_token",
61
+ "ldap_username",
62
+ "ldap_password",
63
+ "account",
64
+ "account_id",
65
+ "user",
66
+ "user_id",
67
+ "auth_mode",
68
+ "extra_headers",
69
+ "credential_source",
70
+ "cli_config_file",
71
+ "config_file",
72
+ // The camelCase spellings the harness loaders use. The projection into
73
+ // harness knobs is an allowlist, so these could never take effect anyway —
74
+ // but someone who writes `apiKey` here deserves to be told it was ignored,
75
+ // not to have it vanish.
76
+ "baseUrl",
77
+ "mcpUrl",
78
+ "apiKey",
79
+ "bearerToken",
80
+ "rootApiKey",
81
+ "gatewayToken",
82
+ "accountId",
83
+ "userId",
84
+ "authMode",
85
+ "extraHeaders",
86
+ "credentialSource",
87
+ "credentialPath",
88
+ "configPath",
89
+ ];
90
+
91
+ /**
92
+ * Sections whose keys are the user's own vocabulary, not ours. The ban below is
93
+ * recursive so no section can smuggle a credential to a consumer that spreads
94
+ * it into a request — but a free-form map's `user` key is a label, and
95
+ * mangling it would be a bug, not a defence.
96
+ */
97
+ export const FREE_FORM_SECTIONS = ["labels"];
98
+
99
+ /**
100
+ * The workspace schema is a projection of `config-schema.mjs`: the dotted key,
101
+ * its enum members and its range all come from the one knob declaration, so a
102
+ * knob cannot be spelled one way here and another way in the loaders.
103
+ */
104
+ const ENUMS = WORKSPACE_ENUMS;
105
+ const RANGES = WORKSPACE_RANGES;
106
+ const KNOB_MAP = WORKSPACE_KNOB_MAP;
107
+
108
+ export const WORKSPACE_SCHEMA_KEYS = Object.keys(KNOB_MAP);
109
+
110
+ /** -1, 0 or 1 over dotted numeric versions; a non-numeric tail is ignored. */
111
+ function compareVersions(left, right) {
112
+ const parse = (value) => String(value || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
113
+ const a = parse(left);
114
+ const b = parse(right);
115
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
116
+ const diff = (a[i] || 0) - (b[i] || 0);
117
+ if (diff) return diff < 0 ? -1 : 1;
118
+ }
119
+ return 0;
120
+ }
121
+
122
+ /**
123
+ * `min_client_version` warns and never blocks. A committed file that could stop
124
+ * an older plugin from running would be a denial of service anyone with commit
125
+ * access could mount, so it says "this was written for a newer client" and the
126
+ * settings still apply.
127
+ */
128
+ export function checkMinClientVersion(declared, clientVersion, warnings = []) {
129
+ const required = String(declared || "").trim();
130
+ const current = String(clientVersion || "").trim();
131
+ if (!required || !current) return true;
132
+ if (compareVersions(current, required) >= 0) return true;
133
+ warnings.push(
134
+ `this workspace asks for OpenViking plugin ${required} and this one is ${current}; `
135
+ + "settings it introduced will be ignored rather than blocking the session",
136
+ );
137
+ return false;
138
+ }
139
+
140
+ function isPlainObject(value) {
141
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
142
+ }
143
+
144
+ /**
145
+ * Strip banned keys wherever they appear, collecting one warning per hit. They
146
+ * are removed rather than rejected: a file is not made unusable by carrying a
147
+ * key this layer refuses to honour.
148
+ */
149
+ function stripForbidden(value, banned, warnings, path = "", depth = 0) {
150
+ if (depth > MAX_DEPTH) throw new RangeError(`nested more than ${MAX_DEPTH} levels at '${path}'`);
151
+ if (Array.isArray(value)) {
152
+ return value.map((item, index) => stripForbidden(item, banned, warnings, `${path}[${index}]`, depth + 1));
153
+ }
154
+ if (!isPlainObject(value)) return value;
155
+
156
+ const out = {};
157
+ for (const [key, child] of Object.entries(value)) {
158
+ const here = path ? `${path}.${key}` : key;
159
+ if (UNSAFE_KEYS.includes(key)) {
160
+ warnings.push(`ignored '${here}': a config file may not reach the object prototype`);
161
+ continue;
162
+ }
163
+ if (banned.includes(key)) {
164
+ warnings.push(`ignored '${here}': connection and credential settings belong in ovcli.conf or the environment`);
165
+ continue;
166
+ }
167
+ // Matched on the key itself, not on depth: a registry entry keeps the same
168
+ // section under `settings.`, and it must not end up stricter than the
169
+ // committed file it outranks.
170
+ out[key] = FREE_FORM_SECTIONS.includes(key)
171
+ ? stripUnsafeOnly(child, warnings, here, depth + 1)
172
+ : stripForbidden(child, banned, warnings, here, depth + 1);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** A free-form section keeps its own vocabulary, minus the prototype keys. */
178
+ function stripUnsafeOnly(value, warnings, path, depth) {
179
+ return stripForbidden(value, [], warnings, path, depth);
180
+ }
181
+
182
+ /**
183
+ * Read one layer file. Every failure is a warning and an empty layer — a hook
184
+ * must not die because a config file is odd.
185
+ */
186
+ export function readWorkspaceFile(path, { root = "", layer = "" } = {}) {
187
+ const warnings = [];
188
+ const empty = { path, layer, exists: false, data: null, warnings };
189
+
190
+ let stat;
191
+ try {
192
+ stat = statSync(path);
193
+ } catch {
194
+ return empty;
195
+ }
196
+ empty.exists = true;
197
+ if (!stat.isFile()) {
198
+ warnings.push(`${path} is not a regular file`);
199
+ return empty;
200
+ }
201
+ if (stat.size > MAX_CONFIG_BYTES) {
202
+ warnings.push(`${path} is larger than ${MAX_CONFIG_BYTES} bytes`);
203
+ return empty;
204
+ }
205
+ // A symlink out of the workspace would let a repository read a file the user
206
+ // never meant to expose to it.
207
+ if (root) {
208
+ try {
209
+ const rel = relative(realpathSync(root), realpathSync(path));
210
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
211
+ warnings.push(`${path} resolves outside the workspace`);
212
+ return empty;
213
+ }
214
+ } catch {
215
+ warnings.push(`${path} could not be resolved`);
216
+ return empty;
217
+ }
218
+ }
219
+
220
+ let parsed;
221
+ try {
222
+ // Deliberately bare: no `${VAR}` expansion reaches a workspace file.
223
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
224
+ } catch (err) {
225
+ warnings.push(`${path} is not valid JSON (${err?.message || err})`);
226
+ return empty;
227
+ }
228
+ if (!isPlainObject(parsed)) {
229
+ warnings.push(`${path} must contain a JSON object`);
230
+ return empty;
231
+ }
232
+ if (parsed.version !== CONFIG_VERSION) {
233
+ warnings.push(`${path} declares version ${JSON.stringify(parsed.version)}; this client understands ${CONFIG_VERSION}`);
234
+ return empty;
235
+ }
236
+
237
+ const { version, $schema, min_client_version: minClientVersion, ...rest } = parsed;
238
+ let data;
239
+ try {
240
+ data = stripForbidden(rest, FORBIDDEN_KEYS, warnings);
241
+ } catch (err) {
242
+ warnings.push(`${path} is nested too deeply (${err?.message || err})`);
243
+ return empty;
244
+ }
245
+ if (minClientVersion) data.min_client_version = String(minClientVersion);
246
+
247
+ return { path, layer, exists: true, data, warnings };
248
+ }
249
+
250
+ function shadow(provenance, here, value, source) {
251
+ const previous = provenance[here];
252
+ const shadowed = previous ? [{ value: previous.value, source: previous.source }, ...previous.shadowed] : [];
253
+ provenance[here] = { value, source, shadowed };
254
+ return provenance[here];
255
+ }
256
+
257
+ function mergeInto(target, source, layer, provenance, path = "", depth = 0) {
258
+ if (depth > MAX_DEPTH) throw new RangeError(`nested more than ${MAX_DEPTH} levels at '${path}'`);
259
+ for (const [key, value] of Object.entries(source)) {
260
+ if (UNSAFE_KEYS.includes(key)) continue;
261
+ const here = path ? `${path}.${key}` : key;
262
+
263
+ if (isPlainObject(value)) {
264
+ // A section replacing a scalar is still a change of the effective value,
265
+ // so the scalar has to be recorded as shadowed rather than left standing
266
+ // in provenance as if it were still in force.
267
+ if (!isPlainObject(target[key])) {
268
+ if (target[key] !== undefined) shadow(provenance, here, "(section)", layer);
269
+ target[key] = {};
270
+ }
271
+ mergeInto(target[key], value, layer, provenance, here, depth + 1);
272
+ continue;
273
+ }
274
+
275
+ if (Array.isArray(value)) {
276
+ // `"!reset"` drops everything the lower layers contributed, the way
277
+ // EditorConfig's `unset` and git's empty `safe.directory` do.
278
+ const reset = value[0] === "!reset";
279
+ const incoming = reset ? value.slice(1) : value;
280
+ const inheritable = !reset && Array.isArray(target[key]);
281
+ const merged = inheritable ? [...target[key]] : [];
282
+ for (const item of incoming) if (!merged.includes(item)) merged.push(item);
283
+
284
+ // Only a genuine union credits both layers. A list landing on a scalar,
285
+ // or on nothing, belongs to this layer alone.
286
+ const previous = provenance[here];
287
+ if (inheritable && previous) {
288
+ provenance[here] = {
289
+ value: merged,
290
+ source: [previous.source, layer].filter(Boolean).join(" + "),
291
+ shadowed: previous.shadowed,
292
+ };
293
+ } else {
294
+ shadow(provenance, here, merged, reset ? `${layer} (reset)` : layer);
295
+ }
296
+ target[key] = merged;
297
+ continue;
298
+ }
299
+
300
+ shadow(provenance, here, value, layer);
301
+ target[key] = value;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Merge layers given lowest-precedence first, returning the effective value
307
+ * plus, for every key, where it came from and what it covered up — the same
308
+ * question `git config --show-origin --show-scope` answers.
309
+ */
310
+ export function mergeConfigLayers(layers, warnings = []) {
311
+ const value = {};
312
+ const provenance = {};
313
+ for (const { layer, data } of layers) {
314
+ if (!isPlainObject(data)) continue;
315
+ try {
316
+ mergeInto(value, data, layer, provenance);
317
+ } catch (err) {
318
+ // One pathological layer must not take the others down with it.
319
+ warnings.push(`skipped ${layer}: ${err?.message || err}`);
320
+ }
321
+ }
322
+ return { value, provenance };
323
+ }
324
+
325
+ function get(object, path) {
326
+ return path.split(".").reduce((node, key) => (isPlainObject(node) ? node[key] : undefined), object);
327
+ }
328
+
329
+ function set(object, path, value) {
330
+ const keys = path.split(".");
331
+ const last = keys.pop();
332
+ let node = object;
333
+ for (const key of keys) {
334
+ if (!isPlainObject(node[key])) node[key] = {};
335
+ node = node[key];
336
+ }
337
+ node[last] = value;
338
+ }
339
+
340
+ /**
341
+ * Clamp numbers and reject unknown enum values, warning once per key.
342
+ *
343
+ * A repository can raise a cost knob, so the ceiling is enforced here rather
344
+ * than trusted; an out-of-range value is clamped instead of rejected so a typo
345
+ * degrades rather than disables.
346
+ */
347
+ export function normalizeWorkspaceConfig(value, warnings = []) {
348
+ for (const [path, { min, max, integer }] of Object.entries(RANGES)) {
349
+ const raw = get(value, path);
350
+ if (raw === undefined) continue;
351
+ // `Number()` turns null, true and [] into finite numbers, which would
352
+ // silently pin a knob to a bound instead of reporting a bad value.
353
+ const number = typeof raw === "number" || typeof raw === "string" ? Number(raw) : NaN;
354
+ if (!Number.isFinite(number)) {
355
+ warnings.push(`ignored '${path}': ${JSON.stringify(raw)} is not a number`);
356
+ set(value, path, undefined);
357
+ continue;
358
+ }
359
+ const clamped = Math.min(max, Math.max(min, integer ? Math.floor(number) : number));
360
+ if (clamped !== number) warnings.push(`clamped '${path}' from ${number} to ${clamped} (allowed ${min}..${max})`);
361
+ set(value, path, clamped);
362
+ }
363
+
364
+ for (const [path, allowed] of Object.entries(ENUMS)) {
365
+ if (!allowed) continue;
366
+ const raw = get(value, path);
367
+ if (raw === undefined) continue;
368
+ if (!allowed.includes(raw)) {
369
+ warnings.push(`ignored '${path}': ${JSON.stringify(raw)} is not one of ${allowed.join(", ")}`);
370
+ set(value, path, undefined);
371
+ }
372
+ }
373
+ return value;
374
+ }
375
+
376
+ /**
377
+ * Flatten the merged workspace config into the flat knobs a harness loader
378
+ * reads, so the new layers slot in exactly where the ovcli `plugin` section
379
+ * already does and everything downstream — including the env vars that still
380
+ * win — keeps working unchanged.
381
+ */
382
+ export function projectWorkspaceSettings(value) {
383
+ const settings = {};
384
+ for (const [path, knob] of Object.entries(KNOB_MAP)) {
385
+ const raw = get(value, path);
386
+ if (raw !== undefined) settings[knob] = raw;
387
+ }
388
+ return settings;
389
+ }
390
+
391
+ export function workspaceConfigPaths(root) {
392
+ if (!root) return [];
393
+ const dir = join(root, CONFIG_DIR_NAME);
394
+ return [
395
+ { layer: `${TEAM_FILE} (workspace)`, path: join(dir, TEAM_FILE) },
396
+ { layer: `${LOCAL_FILE} (workspace)`, path: join(dir, LOCAL_FILE) },
397
+ ];
398
+ }
399
+
400
+ /**
401
+ * The two workspace-file layers, lowest precedence first. Callers stack the
402
+ * registry above these and the ovcli.conf plugin section below them.
403
+ */
404
+ export function loadWorkspaceLayers(root, { clientVersion = "" } = {}) {
405
+ const warnings = [];
406
+ const layers = [];
407
+ for (const { layer, path } of workspaceConfigPaths(root)) {
408
+ const file = readWorkspaceFile(path, { root, layer });
409
+ warnings.push(...file.warnings);
410
+ if (!file.data) continue;
411
+ const { min_client_version: declared, ...data } = file.data;
412
+ checkMinClientVersion(declared, clientVersion, warnings);
413
+ layers.push({ layer, data, path });
414
+ }
415
+ return { layers, warnings };
416
+ }
417
+
418
+ /**
419
+ * Settings a workspace file turned off. Announced at session start and in
420
+ * doctor, because trusting the file is a decision the user should be able to
421
+ * see rather than one made silently on their behalf.
422
+ */
423
+ export function announcedOverrides(provenance) {
424
+ const announced = [];
425
+ for (const [key, entry] of Object.entries(provenance)) {
426
+ const fromWorkspace = String(entry.source || "").includes("(workspace)");
427
+ if (!fromWorkspace) continue;
428
+ if (entry.value === false || key === "peer.id" || key === "peer.source" || key.startsWith("bypass.")) {
429
+ announced.push({ key, value: entry.value, source: entry.source });
430
+ }
431
+ }
432
+ return announced;
433
+ }
@@ -40,6 +40,69 @@ export const DEFAULT_PEER_SOURCE = "git";
40
40
 
41
41
  const VARIABLE_RE = /\{([a-z_]+)\}/g;
42
42
 
43
+ /**
44
+ * Layers `resolvePluginPeerId` must not read off the merged settings, because
45
+ * each has a rank of its own: the environment ranks above every file, and
46
+ * ov.conf's harness block ranks below all of them.
47
+ */
48
+ const PEER_LAYERS_RANKED_SEPARATELY = new Set(["env", "ov.conf"]);
49
+
50
+ function trimmed(value) {
51
+ return typeof value === "string" ? value.trim() : "";
52
+ }
53
+
54
+ /**
55
+ * The peer some layer named, in the one order every harness follows.
56
+ *
57
+ * Both halves of a loader's configuration carry a peer: `resolveSettings()`
58
+ * reads the workspace file, the machine registry and ovcli.conf's `plugin`
59
+ * section, while the credential chain ends in ovcli.conf's `actor_peer_id`. The
60
+ * more specific layer wins, so a `peerId` written for this harness outranks the
61
+ * account-wide actor peer — codex resolved it that way and opencode and pi
62
+ * resolved it the other way round, which meant one ovcli.conf produced two
63
+ * different peers depending on which host read it.
64
+ *
65
+ * ov.conf's harness block, where this tuning used to live, stays at the bottom.
66
+ * It gets its own rank rather than riding the credential chain, which drops it
67
+ * entirely once the credentials are pinned to ovcli.conf.
68
+ *
69
+ * `OPENVIKING_PEER_ID` still outranks every file, except when the credentials
70
+ * are pinned to ovcli.conf, where the environment is meant not to apply at all.
71
+ * A host that names a peer itself (dsh's cordis patch) outranks all of them.
72
+ *
73
+ * Returns "" when nobody named one, which is what makes
74
+ * `resolveEffectivePeerId()` derive one from `peer.source`.
75
+ */
76
+ export function resolvePluginPeerId({
77
+ settings = {},
78
+ configured = null,
79
+ sources = {},
80
+ credentials = {},
81
+ hostInput = "",
82
+ env = process.env,
83
+ credentialSource = credentials?.credentialSource || "",
84
+ } = {}) {
85
+ const host = trimmed(hostInput);
86
+ if (host) return host;
87
+
88
+ if (credentialSource !== "ovcli") {
89
+ const fromEnv = trimmed(env?.OPENVIKING_PEER_ID);
90
+ if (fromEnv) return fromEnv;
91
+ }
92
+
93
+ if (configured?.has?.("peerId") && !PEER_LAYERS_RANKED_SEPARATELY.has(sources?.peerId)) {
94
+ const named = trimmed(settings?.peerId);
95
+ if (named) return named;
96
+ }
97
+
98
+ const fromCredentials = trimmed(credentials?.peerId);
99
+ if (fromCredentials) return fromCredentials;
100
+
101
+ if (sources?.peerId === "ov.conf") return trimmed(settings?.peerId);
102
+
103
+ return "";
104
+ }
105
+
43
106
  export function deriveWorkspacePeerId(cwd) {
44
107
  return legacySanitize(cwd);
45
108
  }
@@ -0,0 +1,87 @@
1
+ // GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
2
+ /**
3
+ * The per-machine workspace registry, `~/.openviking/workspaces/`.
4
+ *
5
+ * One file per workspace rather than one file listing them all: hooks are many
6
+ * short-lived processes, and a shared JSON file loses writes whenever any two
7
+ * of them touch unrelated workspaces at once.
8
+ *
9
+ * The plugins only read this layer; nothing writes it today. A user who wants
10
+ * an entry creates the file by hand, and it then outranks both workspace files
11
+ * — so it is where they keep the last word over any repository they clone.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { homedir } from "node:os";
16
+ import { basename, join } from "node:path";
17
+
18
+ import { readWorkspaceFile } from "./workspace-config.mjs";
19
+
20
+ export function registryDir(env = process.env) {
21
+ const home = String(env.OPENVIKING_HOME || "").trim();
22
+ const base = home ? home.replace(/^~(?=$|\/)/, homedir()) : join(homedir(), ".openviking");
23
+ return join(base, "workspaces");
24
+ }
25
+
26
+ /**
27
+ * A readable name plus a hash, keyed on the workspace's identity rather than
28
+ * its path wherever git supplies one.
29
+ *
30
+ * Two linked worktrees of one repository are one workspace — the same peer and
31
+ * the same settings — and keying on the checkout path would silently split
32
+ * them in two. Outside a repository there is no
33
+ * identity but the path, so two `~/src/api` clones still get separate entries.
34
+ */
35
+ export function slotName(root, identity = null) {
36
+ const path = String(root || "");
37
+ const key = identity ? identityKey(identity) : "path";
38
+ const source = key === "path" ? path : key;
39
+ const label = key.startsWith("remote:") ? key.split("/").pop() : basename(path);
40
+ const readable = String(label || "").replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
41
+ const digest = createHash("sha256").update(source).digest("hex").slice(0, 12);
42
+ return `${readable ? `${readable}-` : ""}${digest}.json`;
43
+ }
44
+
45
+ export function entryPath(root, env = process.env, identity = null) {
46
+ return join(registryDir(env), slotName(root, identity));
47
+ }
48
+
49
+ /**
50
+ * The identity a stored entry is checked against. Path alone is not enough:
51
+ * a directory can be deleted and a different repository cloned in its place,
52
+ * and inheriting the old entry's peer would silently cross two projects.
53
+ */
54
+ export function identityKey(identity) {
55
+ // The normalized remote, so re-spelling origin (ssh ↔ https, or rotating an
56
+ // embedded token) is not mistaken for a different repository.
57
+ const remote = String(identity?.remote || "").trim();
58
+ if (remote) return `remote:${remote}`;
59
+ if (identity?.isGit) return `git:${identity.gitCommonDir || ""}`;
60
+ return "path";
61
+ }
62
+
63
+ /**
64
+ * Read this workspace's entry, or null.
65
+ *
66
+ * A stored entry whose identity contradicts the current one is treated as a
67
+ * miss — negative evidence. Nothing is inherited from it.
68
+ */
69
+ export function readEntry(root, { identity = null, env = process.env } = {}) {
70
+ const path = entryPath(root, env, identity);
71
+ const file = readWorkspaceFile(path, { layer: "registry" });
72
+ if (!file.data) return { path, entry: null, warnings: file.warnings, conflict: false };
73
+
74
+ const entry = file.data;
75
+ const warnings = [...file.warnings];
76
+ if (identity) {
77
+ const expected = identityKey(identity);
78
+ const stored = String(entry.identity || "");
79
+ if (stored && stored !== expected) {
80
+ warnings.push(
81
+ `${path} was recorded for a different repository (${stored}); starting a fresh entry for ${expected}`,
82
+ );
83
+ return { path, entry: null, warnings, conflict: true };
84
+ }
85
+ }
86
+ return { path, entry, warnings, conflict: false };
87
+ }