blun-king-cli 9.1.563 → 9.1.565

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 (85) hide show
  1. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  2. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  3. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  4. package/agent-spine-plugin/.mcp.json +8 -8
  5. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  6. package/agent-spine-plugin/LICENSE +186 -186
  7. package/agent-spine-plugin/README.md +394 -0
  8. package/agent-spine-plugin/SECURITY.md +47 -0
  9. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  10. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  11. package/agent-spine-plugin/bin/agentspine.js +7 -7
  12. package/agent-spine-plugin/blun.plugin.json +33 -33
  13. package/agent-spine-plugin/hooks/codex.json +47 -47
  14. package/agent-spine-plugin/hooks/hooks.json +106 -106
  15. package/agent-spine-plugin/hooks/version.json +5 -5
  16. package/agent-spine-plugin/package.json +69 -69
  17. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  18. package/agent-spine-plugin/skill/SKILL.md +76 -76
  19. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  20. package/agent-spine-plugin/src/cli.js +1488 -1442
  21. package/agent-spine-plugin/src/hook.js +886 -812
  22. package/agent-spine-plugin/src/index.js +93 -93
  23. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  24. package/agent-spine-plugin/src/lib/attention.js +755 -755
  25. package/agent-spine-plugin/src/lib/audit.js +351 -342
  26. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  27. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  28. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  29. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  30. package/agent-spine-plugin/src/lib/context.js +154 -154
  31. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  32. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  33. package/agent-spine-plugin/src/lib/documents.js +217 -217
  34. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  35. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  36. package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
  37. package/agent-spine-plugin/src/lib/graph.js +337 -337
  38. package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
  39. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  40. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  41. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  42. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  43. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  44. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  45. package/agent-spine-plugin/src/lib/paths.js +109 -109
  46. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  47. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  48. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  49. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  50. package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
  51. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  52. package/agent-spine-plugin/src/lib/source-roots.js +529 -482
  53. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  54. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  55. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  56. package/agent-spine-plugin/src/mcp.js +597 -572
  57. package/agent-spine-plugin/src/version.js +1 -1
  58. package/agent-spine-plugin/src/worker.js +202 -195
  59. package/bin/active-steer-priority-policy.cjs +24 -0
  60. package/bin/launcher-runtime.js +8 -1
  61. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  62. package/bin/thinking-activity-status-policy.cjs +132 -0
  63. package/bin/thinking-only-guard.cjs +75 -0
  64. package/bin/tool-call-loop-policy.cjs +53 -0
  65. package/bin/turn-thinking-policy.cjs +25 -1
  66. package/blun.mjs +554 -64
  67. package/package.json +4 -1
  68. package/standard-skills/translate-native/LICENSE +21 -21
  69. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  70. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  71. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  72. package/standard-skills/translate-native/references/structured-content.md +72 -72
  73. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  74. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  75. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  76. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  77. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  78. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  79. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  80. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  81. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  82. package/standard-tools/language-guard/check_diacritics.py +353 -353
  83. package/standard-tools/language-guard/guard_service_client.py +264 -264
  84. package/standard-tools/language-guard/language_quality.py +377 -377
  85. package/standard-tools/language-guard/translation_guard.py +916 -916
@@ -1,482 +1,529 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { homedir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { lstat, mkdir, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
5
- import { ancestorsBetween, canonicalPath, isInside, stateRoot } from "./paths.js";
6
- import { indexExplicitDocuments } from "./documents.js";
7
- import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
8
- import { purgeIndexedMemoryCache, resolveIndexedMemory } from "./indexed-memory.js";
9
- import { catalogScanPolicy } from "./catalog.js";
10
-
11
- export const SOURCE_REGISTRY_SCHEMA = "agentspine.source-roots/v1";
12
- const MAX_REGISTRY_BYTES = 1024 * 1024;
13
- const MAX_SOURCES = 256;
14
- const MAX_RULE_FILES = 128;
15
- const MAX_PROJECT_FILES = 240;
16
- const MAX_TOTAL_SOURCE_BYTES = 8 * 1024 * 1024;
17
- const MAX_DIRECTORY_ENTRIES = 4096;
18
- const MAX_PROJECT_DIRECTORY_ENTRIES = 8192;
19
- const SOURCE_RESOLUTION_MS = 2000;
20
- const SAFE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
21
- const SKIP_EXTRA_DIRS = new Set([".git", ".hg", ".svn", ".claude", ".codex", "node_modules", "vendor", "dist", "build", "coverage"]);
22
-
23
- function digest(value) { return createHash("sha256").update(value).digest("hex"); }
24
- function registryPath(env = process.env) { return join(stateRoot(env), "source-roots.json"); }
25
- function emptyRegistry() { return { schema: SOURCE_REGISTRY_SCHEMA, revision: 0, bindings: [], history: [] }; }
26
-
27
- function normalizeRegistry(value) {
28
- if (!value || value.schema !== SOURCE_REGISTRY_SCHEMA || !Number.isInteger(value.revision)
29
- || !Array.isArray(value.bindings) || !Array.isArray(value.history)) {
30
- throw new Error("source-root registry is corrupt; host-native recall is disabled until repaired");
31
- }
32
- for (const binding of value.bindings) {
33
- if (!binding || typeof binding.id !== "string" || !["all", "claude", "codex"].includes(binding.host)
34
- || !["project-memory", "state-user"].includes(binding.scope) || typeof binding.profileKey !== "string"
35
- || typeof binding.projectRoot !== "string" || typeof binding.sourceRoot !== "string"
36
- || typeof binding.provenance !== "string" || typeof binding.active !== "boolean"
37
- || binding.authority !== "context-only") throw new Error("source-root registry contains an unsafe binding");
38
- }
39
- if (value.history.some((item) => !item || item.authority !== "context-only")) {
40
- throw new Error("source-root registry history contains an authority violation");
41
- }
42
- return value;
43
- }
44
-
45
- async function readRegistry(env = process.env) {
46
- const path = registryPath(env);
47
- try {
48
- const metadata = await stat(path);
49
- if (metadata.size > MAX_REGISTRY_BYTES) throw new Error("source-root registry exceeds 1 MiB");
50
- return { registry: normalizeRegistry(JSON.parse(await readFile(path, "utf8"))), path };
51
- } catch (error) {
52
- if (error.code !== "ENOENT") throw error;
53
- return { registry: emptyRegistry(), path };
54
- }
55
- }
56
-
57
- async function mutateRegistry(task, env = process.env) {
58
- const path = registryPath(env);
59
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
60
- const lock = `${path}.lock`;
61
- let handle;
62
- for (let attempt = 0; attempt < 80; attempt += 1) {
63
- try { handle = await open(lock, "wx", 0o600); break; } catch (error) {
64
- if (!isFileLockContention(error)) throw error;
65
- await new Promise((done) => setTimeout(done, 25));
66
- }
67
- }
68
- if (!handle) throw new Error("source-root registry is busy; retry shortly");
69
- try {
70
- const { registry } = await readRegistry(env);
71
- const result = await task(registry);
72
- registry.revision += 1;
73
- const content = `${JSON.stringify(registry, null, 2)}\n`;
74
- if (Buffer.byteLength(content) > MAX_REGISTRY_BYTES) throw new Error("source-root registry exceeds 1 MiB");
75
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
76
- await writeFile(temporary, content, { mode: 0o600 });
77
- await replaceFileWithRetry(temporary, path);
78
- return { ...result, registryPath: path, revision: registry.revision };
79
- } finally {
80
- await handle.close();
81
- await unlink(lock).catch((error) => { if (error.code !== "ENOENT") throw error; });
82
- }
83
- }
84
-
85
- async function existingRegular(path) {
86
- try {
87
- const metadata = await lstat(path);
88
- if (metadata.isSymbolicLink() || !metadata.isFile()) return null;
89
- return realpath(path);
90
- } catch (error) {
91
- if (error.code === "ENOENT") return null;
92
- throw error;
93
- }
94
- }
95
-
96
- async function existingDirectory(path) {
97
- try {
98
- const metadata = await lstat(path);
99
- if (metadata.isSymbolicLink() || !metadata.isDirectory()) return null;
100
- return realpath(path);
101
- } catch (error) {
102
- if (error.code === "ENOENT") return null;
103
- throw error;
104
- }
105
- }
106
-
107
- function expandHome(value, home) {
108
- if (value === "~") return home;
109
- if (value.startsWith("~/") || value.startsWith("~\\")) return join(home, value.slice(2));
110
- return value;
111
- }
112
-
113
- function samePath(left, right) {
114
- const normalize = (value) => resolve(value).replace(/[\\/]+$/, "");
115
- const a = normalize(left);
116
- const b = normalize(right);
117
- return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
118
- }
119
-
120
- async function homeRoots(env) {
121
- const candidates = [homedir(), env.HOME, env.USERPROFILE,
122
- env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : null]
123
- .filter((value) => typeof value === "string" && value && isAbsolute(value));
124
- return [...new Set(await Promise.all(candidates.map(async (value) =>
125
- await existingDirectory(value) || resolve(value))))];
126
- }
127
-
128
- async function readJsonObject(path) {
129
- const file = await existingRegular(path);
130
- if (!file) return null;
131
- const metadata = await stat(file);
132
- if (metadata.size > 1024 * 1024) throw new Error(`host settings exceed 1 MiB: ${basename(path)}`);
133
- const value = JSON.parse(await readFile(file, "utf8"));
134
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`host settings are not an object: ${basename(path)}`);
135
- return value;
136
- }
137
-
138
- function parseTomlArray(text, key) {
139
- const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m"));
140
- if (!match) return null;
141
- const values = [];
142
- for (const item of match[1].matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)) {
143
- const value = item[1] === undefined ? item[2] : JSON.parse(`"${item[1]}"`);
144
- if (!SAFE_NAME.test(value) || value.includes("/") || value.includes("\\")) throw new Error(`unsafe ${key} entry`);
145
- values.push(value);
146
- }
147
- return values;
148
- }
149
-
150
- async function codexConfig(home) {
151
- const path = join(home, "config.toml");
152
- const file = await existingRegular(path);
153
- if (!file) return { fallbackNames: [], rootMarkers: [".git"], maxBytes: 32768 };
154
- const metadata = await stat(file);
155
- if (metadata.size > 1024 * 1024) throw new Error("Codex config exceeds 1 MiB");
156
- const text = await readFile(file, "utf8");
157
- const bytes = Number(text.match(/^\s*project_doc_max_bytes\s*=\s*(\d+)/m)?.[1] || 32768);
158
- return {
159
- fallbackNames: parseTomlArray(text, "project_doc_fallback_filenames") || [],
160
- rootMarkers: parseTomlArray(text, "project_root_markers") || [".git"],
161
- maxBytes: Number.isInteger(bytes) && bytes >= 1024 && bytes <= 4 * 1024 * 1024 ? bytes : 32768
162
- };
163
- }
164
-
165
- async function findRoot(cwd, markers) {
166
- let cursor = cwd;
167
- while (true) {
168
- for (const marker of markers) {
169
- try { await lstat(join(cursor, marker)); return { root: cursor, resolution: "project-marker" }; } catch (error) {
170
- if (error.code !== "ENOENT") throw error;
171
- }
172
- }
173
- const parent = dirname(cursor);
174
- if (parent === cursor) return { root: cwd, resolution: "cwd-fallback" };
175
- cursor = parent;
176
- }
177
- }
178
-
179
- function skippedExtraDirectory(name) {
180
- const lower = name.toLowerCase();
181
- return SKIP_EXTRA_DIRS.has(name) || SKIP_EXTRA_DIRS.has(lower)
182
- || lower.includes("dropbox") || lower === "onedrive" || lower.startsWith("onedrive - ");
183
- }
184
-
185
- async function containsProjectMarker(directory) {
186
- try {
187
- await lstat(join(directory, ".git"));
188
- return true;
189
- } catch (error) {
190
- if (error.code === "ENOENT") return false;
191
- throw error;
192
- }
193
- }
194
-
195
- async function containsEmbeddedHostProfile(directory) {
196
- return Boolean(
197
- await existingDirectory(join(directory, ".runtime-private"))
198
- && await existingRegular(join(directory, "config.toml"))
199
- );
200
- }
201
-
202
- async function boundedMarkdownTree(directory, prefix, host, scope, precedenceStart, deadline, {
203
- projectBoundary = false,
204
- maxFiles = MAX_RULE_FILES,
205
- maxDirectoryEntries = MAX_DIRECTORY_ENTRIES
206
- } = {}) {
207
- const root = await existingDirectory(directory);
208
- if (!root) return [];
209
- const output = [];
210
- let visitedEntries = 0;
211
- async function walk(current) {
212
- if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
213
- if (projectBoundary && current !== root
214
- && (await containsProjectMarker(current) || await containsEmbeddedHostProfile(current))) return;
215
- const entries = [];
216
- for await (const entry of await opendir(current)) {
217
- visitedEntries += 1;
218
- if (visitedEntries > maxDirectoryEntries) throw new Error(`host-native source tree exceeds ${maxDirectoryEntries} entries`);
219
- entries.push(entry);
220
- }
221
- entries.sort((a, b) => a.name.localeCompare(b.name));
222
- for (const entry of entries) {
223
- if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
224
- if (entry.isSymbolicLink()) continue;
225
- const path = join(current, entry.name);
226
- if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
227
- else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
228
- output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
229
- binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
230
- }
231
- }
232
- }
233
- await walk(root);
234
- return output;
235
- }
236
-
237
- async function addFile(output, path, metadata) {
238
- const file = await existingRegular(path);
239
- if (file) output.push({ path: file, ...metadata });
240
- }
241
-
242
- function profileKey(host, hostHome) { return `${host}:${digest(hostHome).slice(0, 24)}`; }
243
-
244
- function activeBinding(registry, host, hostHome, projectRoot, scope) {
245
- const targetRoot = scope === "state-user" ? "*" : projectRoot;
246
- return registry.bindings.find((item) => item.active && (item.host === host || item.host === "all")
247
- && (item.host === "all" || item.profileKey === profileKey(host, hostHome))
248
- && item.projectRoot === targetRoot && item.scope === scope) || null;
249
- }
250
-
251
- async function rememberRuntimeBinding({ host, hostHome, projectRoot, sourceRoot, scope, provenance, env }) {
252
- const canonicalSource = await canonicalPath(sourceRoot);
253
- return mutateRegistry((registry) => {
254
- const key = host === "all" ? "all:local-user" : profileKey(host, hostHome);
255
- const targetRoot = scope === "state-user" ? "*" : projectRoot;
256
- const current = registry.bindings.find((item) => item.active && item.host === host
257
- && item.profileKey === key && item.projectRoot === targetRoot && item.scope === scope);
258
- if (current?.sourceRoot === canonicalSource) return { binding: current, unchanged: true };
259
- if (current && current.sourceRoot !== canonicalSource) throw new Error(`conflicting ${host} ${scope} source binding; inspect or roll back the registry`);
260
- const binding = {
261
- id: `binding:${randomUUID()}`, host, profileKey: key, projectRoot: targetRoot, sourceRoot: canonicalSource,
262
- scope, provenance, active: true, createdAt: new Date().toISOString(), authority: "context-only"
263
- };
264
- registry.bindings.push(binding);
265
- registry.history.push({ kind: "bound", bindingId: binding.id, at: binding.createdAt, provenance, authority: "context-only" });
266
- return { binding, unchanged: false };
267
- }, env);
268
- }
269
-
270
- export async function bindSourceRoot({ host, hostHome, projectRoot, sourceRoot, scope = "state-user", confirmation, env = process.env }) {
271
- if (confirmation !== "local-user-confirmed") throw new Error("source binding requires explicit local user confirmation");
272
- if (!["claude", "codex", "all"].includes(host) || !["project-memory", "state-user"].includes(scope)
273
- || (host === "all" && scope !== "state-user")) throw new Error("invalid source binding scope");
274
- return rememberRuntimeBinding({ host, hostHome: host === "all" ? "local-user" : await canonicalPath(hostHome), projectRoot: await canonicalPath(projectRoot),
275
- sourceRoot, scope, provenance: "local-user-confirmed", env });
276
- }
277
-
278
- export async function rollbackSourceBinding({ id, confirmation, env = process.env }) {
279
- if (confirmation !== "local-user-confirmed") throw new Error("source binding rollback requires explicit local user confirmation");
280
- const result = await mutateRegistry((registry) => {
281
- const binding = registry.bindings.find((item) => item.id === id && item.active);
282
- if (!binding) throw new Error(`active source binding not found: ${id}`);
283
- binding.active = false;
284
- binding.rolledBackAt = new Date().toISOString();
285
- registry.history.push({ kind: "rolled-back", bindingId: id, at: binding.rolledBackAt, authority: "context-only" });
286
- return { binding };
287
- }, env);
288
- if (result.binding.scope === "project-memory") await purgeIndexedMemoryCache(result.binding.sourceRoot, env);
289
- return result;
290
- }
291
-
292
- export async function purgeSourceBinding({ id, confirmation, env = process.env }) {
293
- if (confirmation !== "local-user-confirmed") throw new Error("source binding purge requires explicit local user confirmation");
294
- const result = await mutateRegistry((registry) => {
295
- const removed = registry.bindings.find((item) => item.id === id);
296
- const before = registry.bindings.length;
297
- registry.bindings = registry.bindings.filter((item) => item.id !== id);
298
- if (registry.bindings.length === before) throw new Error(`source binding not found: ${id}`);
299
- registry.history.push({ kind: "purged", bindingDigest: digest(id), at: new Date().toISOString(), authority: "context-only" });
300
- return { purged: true, removed };
301
- }, env);
302
- if (result.removed?.scope === "project-memory") await purgeIndexedMemoryCache(result.removed.sourceRoot, env);
303
- return { ...result, removed: undefined };
304
- }
305
-
306
- export async function inspectSourceRegistry(env = process.env) {
307
- const { registry, path } = await readRegistry(env);
308
- return { registry, registryPath: path };
309
- }
310
-
311
- async function claudeSources({ cwd, projectRoot, configDir, input, env, registry, deadline, memoryHooks }) {
312
- const sources = [];
313
- await addFile(sources, join(configDir, "CLAUDE.md"), { id: "claude:user/CLAUDE.md", host: "claude", scope: "user", binding: "CLAUDE_CONFIG_DIR", precedence: 100 });
314
- sources.push(...await boundedMarkdownTree(join(configDir, "rules"), "claude:user/rules", "claude", "user", 110, deadline));
315
- let precedence = 1000;
316
- for (const directory of ancestorsBetween(projectRoot, cwd)) {
317
- for (const name of ["CLAUDE.md", "CLAUDE.local.md"]) {
318
- await addFile(sources, join(directory, name), { id: `claude:project/${relative(projectRoot, join(directory, name)).replaceAll("\\", "/")}`,
319
- host: "claude", scope: "project", binding: "native-project-chain", precedence: precedence++ });
320
- }
321
- await addFile(sources, join(directory, ".claude", "CLAUDE.md"), { id: `claude:project/${relative(projectRoot, join(directory, ".claude", "CLAUDE.md")).replaceAll("\\", "/")}`,
322
- host: "claude", scope: "project", binding: "native-project-chain", precedence: precedence++ });
323
- }
324
- sources.push(...await boundedMarkdownTree(join(projectRoot, ".claude", "rules"), "claude:project/.claude/rules", "claude", "project", precedence, deadline));
325
-
326
- let memoryRoot = null;
327
- let memoryProvenance = null;
328
- for (const path of [join(configDir, "settings.json"), join(projectRoot, ".claude", "settings.json"), join(projectRoot, ".claude", "settings.local.json")]) {
329
- const value = await readJsonObject(path);
330
- if (typeof value?.autoMemoryDirectory === "string") {
331
- const expanded = expandHome(value.autoMemoryDirectory, homedir());
332
- if (!isAbsolute(expanded)) throw new Error("Claude autoMemoryDirectory must be absolute or home-relative");
333
- memoryRoot = await existingDirectory(expanded);
334
- memoryProvenance = "autoMemoryDirectory";
335
- }
336
- }
337
- if (!memoryRoot && typeof env.CLAUDE_CODE_PROJECT_DIR_NAME === "string" && SAFE_NAME.test(env.CLAUDE_CODE_PROJECT_DIR_NAME)) {
338
- memoryRoot = await existingDirectory(join(configDir, "projects", env.CLAUDE_CODE_PROJECT_DIR_NAME, "memory"));
339
- memoryProvenance = "CLAUDE_CODE_PROJECT_DIR_NAME";
340
- }
341
- const transcript = input.transcript_path || input.transcriptPath;
342
- if (!memoryRoot && typeof transcript === "string" && isAbsolute(transcript)) {
343
- const transcriptFile = await existingRegular(transcript);
344
- const projectsRoot = await existingDirectory(join(configDir, "projects"));
345
- if (transcriptFile && projectsRoot && isInside(projectsRoot, transcriptFile)) {
346
- memoryRoot = await existingDirectory(join(dirname(transcriptFile), "memory"));
347
- memoryProvenance = "host-hook-transcript";
348
- }
349
- }
350
- if (memoryRoot) {
351
- await rememberRuntimeBinding({ host: "claude", hostHome: configDir, projectRoot, sourceRoot: memoryRoot,
352
- scope: "project-memory", provenance: memoryProvenance, env });
353
- } else {
354
- memoryRoot = activeBinding(registry, "claude", configDir, projectRoot, "project-memory")?.sourceRoot || null;
355
- memoryProvenance = memoryRoot ? "source-root-registry" : null;
356
- }
357
- let memoryDiagnostics = null;
358
- if (memoryRoot) {
359
- const supplied = input.agent_spine_scope && typeof input.agent_spine_scope === "object"
360
- ? input.agent_spine_scope : input;
361
- const resolved = await resolveIndexedMemory({
362
- root: memoryRoot, env, hooks: memoryHooks, deadline,
363
- scope: {
364
- entityId: supplied.entity_id ?? supplied.entityId ?? null,
365
- groupId: supplied.group_id ?? supplied.groupId ?? null,
366
- projectId: supplied.project_id ?? supplied.projectId ?? null,
367
- currentTaskId: supplied.task_id ?? supplied.currentTaskId ?? null,
368
- prompt: input.prompt ?? input.user_prompt ?? input.message ?? input.input ?? null
369
- }
370
- });
371
- memoryDiagnostics = resolved.diagnostics;
372
- for (const item of resolved.sources) {
373
- sources.push({
374
- path: item.path, id: `claude:memory/${item.relativePath}`, host: "claude", scope: "project-memory",
375
- binding: "host-native-memory-index", precedence: 2000 + sources.length,
376
- snapshot: item.snapshot, relevance: item.relevance
377
- });
378
- }
379
- }
380
- return { sources, memoryRoot, memoryProvenance, memoryDiagnostics };
381
- }
382
-
383
- async function codexSources({ cwd, projectRoot, codexHome, config }) {
384
- const sources = [];
385
- for (const name of ["AGENTS.override.md", "AGENTS.md"]) {
386
- const file = await existingRegular(join(codexHome, name));
387
- if (file && (await stat(file)).size > 0) {
388
- sources.push({ path: file, id: `codex:user/${name}`, host: "codex", scope: "user", binding: "CODEX_HOME", precedence: 100, maxBytes: config.maxBytes });
389
- break;
390
- }
391
- }
392
- let precedence = 1000;
393
- for (const directory of ancestorsBetween(projectRoot, cwd)) {
394
- for (const name of ["AGENTS.override.md", "AGENTS.md", ...config.fallbackNames]) {
395
- const file = await existingRegular(join(directory, name));
396
- if (file && (await stat(file)).size > 0) {
397
- sources.push({ path: file, id: `codex:project/${relative(projectRoot, file).replaceAll("\\", "/")}`,
398
- host: "codex", scope: "project", binding: "native-project-chain", precedence: precedence++, maxBytes: config.maxBytes });
399
- break;
400
- }
401
- }
402
- }
403
- return sources;
404
- }
405
-
406
- export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), input = {}, env = process.env, memoryHooks = {} } = {}) {
407
- if (!["claude", "codex"].includes(host)) throw new Error(`host-native source resolution requires claude or codex, received ${host}`);
408
- const canonicalCwd = await canonicalPath(cwd);
409
- const deadline = Date.now() + SOURCE_RESOLUTION_MS;
410
- const { registry } = await readRegistry(env);
411
- let hostHome;
412
- let projectRoot;
413
- let sources;
414
- let hostDetails = {};
415
- let rootResolution = "explicit-root";
416
- if (host === "codex") {
417
- const codexHome = env.CODEX_HOME || env.BLUN_HOME || join(homedir(), ".codex");
418
- hostHome = await existingDirectory(resolve(codexHome)) || resolve(codexHome);
419
- const config = await codexConfig(hostHome);
420
- if (env.AGENTSPINE_ROOT) projectRoot = await canonicalPath(env.AGENTSPINE_ROOT);
421
- else ({ root: projectRoot, resolution: rootResolution } = await findRoot(canonicalCwd, config.rootMarkers));
422
- sources = await codexSources({ cwd: canonicalCwd, projectRoot, codexHome: hostHome, config });
423
- hostDetails = { rootMarkers: config.rootMarkers, fallbackNames: config.fallbackNames };
424
- } else {
425
- hostHome = await existingDirectory(resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")))
426
- || resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
427
- if (env.AGENTSPINE_ROOT) projectRoot = await canonicalPath(env.AGENTSPINE_ROOT);
428
- else ({ root: projectRoot, resolution: rootResolution } = await findRoot(canonicalCwd, [".git"]));
429
- const result = await claudeSources({ cwd: canonicalCwd, projectRoot, configDir: hostHome, input, env, registry, deadline, memoryHooks });
430
- sources = result.sources;
431
- hostDetails = { memoryRoot: result.memoryRoot, memoryProvenance: result.memoryProvenance,
432
- memoryDiagnostics: result.memoryDiagnostics };
433
- }
434
- const knownHomeRoots = await homeRoots(env);
435
- const skippedHomeTree = knownHomeRoots.some((root) => samePath(root, projectRoot));
436
- const skippedFallbackHomeTree = skippedHomeTree && rootResolution === "cwd-fallback";
437
- if (!skippedHomeTree) {
438
- sources.push(...await boundedMarkdownTree(projectRoot, "agentspine:project", host, "project", 3000, deadline,
439
- { projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES }));
440
- }
441
- const nativeNames = new Set(host === "codex"
442
- ? ["AGENTS.override.md", "AGENTS.md", ...(hostDetails.fallbackNames || [])]
443
- : ["CLAUDE.md", "CLAUDE.local.md"]);
444
- sources = sources.filter((item) => item.binding !== "host-native-rule-tree"
445
- || !item.id.startsWith("agentspine:project/") || !nativeNames.has(basename(item.path)));
446
- sources = [...new Map(sources.map((item) => [item.path, item])).values()];
447
- if (sources.length > MAX_SOURCES) throw new Error(`host-native source set exceeds ${MAX_SOURCES} files`);
448
- if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
449
- const documents = await indexExplicitDocuments(sources);
450
- if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
451
- const totalBytes = documents.reduce((sum, document) => sum + document.bytes, 0);
452
- if (totalBytes > MAX_TOTAL_SOURCE_BYTES) throw new Error("host-native source set exceeds 8 MiB");
453
- const activeUserState = activeBinding(registry, host, hostHome, projectRoot, "state-user");
454
- const diagnostics = {
455
- schema: SOURCE_REGISTRY_SCHEMA, host, status: documents.length ? "loaded" : "empty", projectRoot,
456
- hostHomeDigest: digest(hostHome).slice(0, 16), checked: ["host-profile", "project-chain", ...(host === "claude" ? ["project-memory"] : [])],
457
- scopes: Object.fromEntries(["user", "project", "project-memory"].map((scope) => [scope, documents.filter((item) => item.sourceScope === scope).length])),
458
- reason: documents.length ? null : "No regular, non-symlink host-native Markdown source exists in the checked scope.",
459
- personalContinuityLoaded: documents.some((item) => item.sourceScope === "user") || Boolean(activeUserState),
460
- broadHomeScan: false, projectTreeScan: skippedFallbackHomeTree ? "skipped-unmarked-home"
461
- : skippedHomeTree ? "skipped-home-root" : "bounded",
462
- rootResolution, registryRevision: registry.revision,
463
- ...(host === "claude" ? {
464
- memoryBound: Boolean(hostDetails.memoryRoot),
465
- memoryRootDigest: hostDetails.memoryRoot ? digest(hostDetails.memoryRoot).slice(0, 16) : null,
466
- memoryProvenance: hostDetails.memoryProvenance,
467
- memory: hostDetails.memoryDiagnostics || {
468
- indexed: 0, relevant: 0, loaded: 0, cacheHits: 0, cacheMisses: 0, missing: 0,
469
- rejected: { scope: 0, path: 0, symlink: 0, race: 0, size: 0 }, directoryEnumeration: 0
470
- }
471
- } : hostDetails)
472
- };
473
- const catalog = {
474
- schema: "agentspine.catalog/v1", generatedAt: new Date().toISOString(), root: projectRoot,
475
- scanPolicy: catalogScanPolicy(projectRoot, env),
476
- preservation: "source-files-are-read-only", documents, conflicts: [], sourceRegistry: diagnostics,
477
- summary: { total: documents.length, protected: documents.filter((item) => item.protected).length, conflicts: 0,
478
- byLayer: Object.fromEntries([...new Set(documents.map((item) => item.layer))].sort().map((layer) => [layer, documents.filter((item) => item.layer === layer).length])) }
479
- };
480
- return { host, hostHome, projectRoot, cwd: canonicalCwd, catalog, diagnostics,
481
- userStateRoot: activeUserState?.sourceRoot || null, memoryRoot: hostDetails.memoryRoot || null };
482
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { lstat, mkdir, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { ancestorsBetween, canonicalPath, isInside, stateRoot } from "./paths.js";
6
+ import { indexExplicitDocuments } from "./documents.js";
7
+ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
8
+ import { purgeIndexedMemoryCache, resolveIndexedMemory } from "./indexed-memory.js";
9
+ import { catalogScanPolicy } from "./catalog.js";
10
+
11
+ export const SOURCE_REGISTRY_SCHEMA = "agentspine.source-roots/v1";
12
+ const MAX_REGISTRY_BYTES = 1024 * 1024;
13
+ const MAX_SOURCES = 256;
14
+ const MAX_RULE_FILES = 128;
15
+ const MAX_PROJECT_FILES = 240;
16
+ const MAX_TOTAL_SOURCE_BYTES = 8 * 1024 * 1024;
17
+ const MAX_DIRECTORY_ENTRIES = 4096;
18
+ const MAX_PROJECT_DIRECTORY_ENTRIES = 8192;
19
+ const SOURCE_RESOLUTION_MS = 2000;
20
+ const SAFE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
21
+ const SKIP_EXTRA_DIRS = new Set([".git", ".hg", ".svn", ".claude", ".codex", "node_modules", "vendor", "dist", "build", "coverage"]);
22
+
23
+ function digest(value) { return createHash("sha256").update(value).digest("hex"); }
24
+ function sourceScanError(error) { error.agentSpineScan = true; return error; }
25
+ function registryPath(env = process.env) { return join(stateRoot(env), "source-roots.json"); }
26
+ function emptyRegistry() { return { schema: SOURCE_REGISTRY_SCHEMA, revision: 0, bindings: [], history: [] }; }
27
+
28
+ function normalizeRegistry(value) {
29
+ if (!value || value.schema !== SOURCE_REGISTRY_SCHEMA || !Number.isInteger(value.revision)
30
+ || !Array.isArray(value.bindings) || !Array.isArray(value.history)) {
31
+ throw new Error("source-root registry is corrupt; host-native recall is disabled until repaired");
32
+ }
33
+ for (const binding of value.bindings) {
34
+ if (!binding || typeof binding.id !== "string" || !["all", "claude", "codex"].includes(binding.host)
35
+ || !["project-memory", "state-user"].includes(binding.scope) || typeof binding.profileKey !== "string"
36
+ || typeof binding.projectRoot !== "string" || typeof binding.sourceRoot !== "string"
37
+ || typeof binding.provenance !== "string" || typeof binding.active !== "boolean"
38
+ || binding.authority !== "context-only") throw new Error("source-root registry contains an unsafe binding");
39
+ }
40
+ if (value.history.some((item) => !item || item.authority !== "context-only")) {
41
+ throw new Error("source-root registry history contains an authority violation");
42
+ }
43
+ return value;
44
+ }
45
+
46
+ async function readRegistry(env = process.env) {
47
+ const path = registryPath(env);
48
+ try {
49
+ const metadata = await stat(path);
50
+ if (metadata.size > MAX_REGISTRY_BYTES) throw new Error("source-root registry exceeds 1 MiB");
51
+ return { registry: normalizeRegistry(JSON.parse(await readFile(path, "utf8"))), path };
52
+ } catch (error) {
53
+ if (error.code !== "ENOENT") throw error;
54
+ return { registry: emptyRegistry(), path };
55
+ }
56
+ }
57
+
58
+ async function mutateRegistry(task, env = process.env) {
59
+ const path = registryPath(env);
60
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
61
+ const lock = `${path}.lock`;
62
+ let handle;
63
+ for (let attempt = 0; attempt < 80; attempt += 1) {
64
+ try { handle = await open(lock, "wx", 0o600); break; } catch (error) {
65
+ if (!isFileLockContention(error)) throw error;
66
+ await new Promise((done) => setTimeout(done, 25));
67
+ }
68
+ }
69
+ if (!handle) throw new Error("source-root registry is busy; retry shortly");
70
+ try {
71
+ const { registry } = await readRegistry(env);
72
+ const result = await task(registry);
73
+ registry.revision += 1;
74
+ const content = `${JSON.stringify(registry, null, 2)}\n`;
75
+ if (Buffer.byteLength(content) > MAX_REGISTRY_BYTES) throw new Error("source-root registry exceeds 1 MiB");
76
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
77
+ await writeFile(temporary, content, { mode: 0o600 });
78
+ await replaceFileWithRetry(temporary, path);
79
+ return { ...result, registryPath: path, revision: registry.revision };
80
+ } finally {
81
+ await handle.close();
82
+ await unlink(lock).catch((error) => { if (error.code !== "ENOENT") throw error; });
83
+ }
84
+ }
85
+
86
+ async function existingRegular(path) {
87
+ try {
88
+ const metadata = await lstat(path);
89
+ if (metadata.isSymbolicLink() || !metadata.isFile()) return null;
90
+ return realpath(path);
91
+ } catch (error) {
92
+ if (error.code === "ENOENT") return null;
93
+ throw sourceScanError(error);
94
+ }
95
+ }
96
+
97
+ async function existingDirectory(path) {
98
+ try {
99
+ const metadata = await lstat(path);
100
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) return null;
101
+ return realpath(path);
102
+ } catch (error) {
103
+ if (error.code === "ENOENT") return null;
104
+ throw sourceScanError(error);
105
+ }
106
+ }
107
+
108
+ function expandHome(value, home) {
109
+ if (value === "~") return home;
110
+ if (value.startsWith("~/") || value.startsWith("~\\")) return join(home, value.slice(2));
111
+ return value;
112
+ }
113
+
114
+ function samePath(left, right) {
115
+ const normalize = (value) => resolve(value).replace(/[\\/]+$/, "");
116
+ const a = normalize(left);
117
+ const b = normalize(right);
118
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
119
+ }
120
+
121
+ async function homeRoots(env) {
122
+ const candidates = [homedir(), env.HOME, env.USERPROFILE,
123
+ env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : null]
124
+ .filter((value) => typeof value === "string" && value && isAbsolute(value));
125
+ return [...new Set(await Promise.all(candidates.map(async (value) =>
126
+ await existingDirectory(value) || resolve(value))))];
127
+ }
128
+
129
+ async function readJsonObject(path) {
130
+ const file = await existingRegular(path);
131
+ if (!file) return null;
132
+ const metadata = await stat(file);
133
+ if (metadata.size > 1024 * 1024) throw new Error(`host settings exceed 1 MiB: ${basename(path)}`);
134
+ const value = JSON.parse(await readFile(file, "utf8"));
135
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`host settings are not an object: ${basename(path)}`);
136
+ return value;
137
+ }
138
+
139
+ function parseTomlArray(text, key) {
140
+ const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m"));
141
+ if (!match) return null;
142
+ const values = [];
143
+ for (const item of match[1].matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)) {
144
+ const value = item[1] === undefined ? item[2] : JSON.parse(`"${item[1]}"`);
145
+ if (!SAFE_NAME.test(value) || value.includes("/") || value.includes("\\")) throw new Error(`unsafe ${key} entry`);
146
+ values.push(value);
147
+ }
148
+ return values;
149
+ }
150
+
151
+ async function codexConfig(home) {
152
+ const path = join(home, "config.toml");
153
+ const file = await existingRegular(path);
154
+ if (!file) return { fallbackNames: [], rootMarkers: [".git"], maxBytes: 32768 };
155
+ const metadata = await stat(file);
156
+ if (metadata.size > 1024 * 1024) throw new Error("Codex config exceeds 1 MiB");
157
+ const text = await readFile(file, "utf8");
158
+ const bytes = Number(text.match(/^\s*project_doc_max_bytes\s*=\s*(\d+)/m)?.[1] || 32768);
159
+ return {
160
+ fallbackNames: parseTomlArray(text, "project_doc_fallback_filenames") || [],
161
+ rootMarkers: parseTomlArray(text, "project_root_markers") || [".git"],
162
+ maxBytes: Number.isInteger(bytes) && bytes >= 1024 && bytes <= 4 * 1024 * 1024 ? bytes : 32768
163
+ };
164
+ }
165
+
166
+ async function findRoot(cwd, markers) {
167
+ let cursor = cwd;
168
+ while (true) {
169
+ for (const marker of markers) {
170
+ try { await lstat(join(cursor, marker)); return { root: cursor, resolution: "project-marker" }; } catch (error) {
171
+ if (error.code !== "ENOENT") throw sourceScanError(error);
172
+ }
173
+ }
174
+ const parent = dirname(cursor);
175
+ if (parent === cursor) return { root: cwd, resolution: "cwd-fallback" };
176
+ cursor = parent;
177
+ }
178
+ }
179
+
180
+ function skippedExtraDirectory(name) {
181
+ const lower = name.toLowerCase();
182
+ return SKIP_EXTRA_DIRS.has(name) || SKIP_EXTRA_DIRS.has(lower)
183
+ || lower.includes("dropbox") || lower === "onedrive" || lower.startsWith("onedrive - ");
184
+ }
185
+
186
+ function skippableTraversalError(error) {
187
+ return ["EPERM", "EACCES", "ENOENT"].includes(error?.code);
188
+ }
189
+
190
+ function recordSkippedPath(skipped, path, error, operation) {
191
+ skipped.push({ path, code: error.code, operation });
192
+ }
193
+
194
+ async function containsProjectMarker(directory) {
195
+ try {
196
+ await lstat(join(directory, ".git"));
197
+ return true;
198
+ } catch (error) {
199
+ if (error.code === "ENOENT") return false;
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ async function containsEmbeddedHostProfile(directory) {
205
+ return Boolean(
206
+ await existingDirectory(join(directory, ".runtime-private"))
207
+ && await existingRegular(join(directory, "config.toml"))
208
+ );
209
+ }
210
+
211
+ async function boundedMarkdownTree(directory, prefix, host, scope, precedenceStart, deadline, {
212
+ projectBoundary = false,
213
+ maxFiles = MAX_RULE_FILES,
214
+ maxDirectoryEntries = MAX_DIRECTORY_ENTRIES,
215
+ skipped = []
216
+ } = {}) {
217
+ let root;
218
+ try {
219
+ root = await existingDirectory(directory);
220
+ } catch (error) {
221
+ if (!skippableTraversalError(error)) throw sourceScanError(error);
222
+ recordSkippedPath(skipped, directory, error, "inspect-directory");
223
+ return [];
224
+ }
225
+ if (!root) return [];
226
+ const output = [];
227
+ let visitedEntries = 0;
228
+ async function walk(current) {
229
+ if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
230
+ try {
231
+ if (projectBoundary && current !== root
232
+ && (await containsProjectMarker(current) || await containsEmbeddedHostProfile(current))) return;
233
+ } catch (error) {
234
+ if (!skippableTraversalError(error)) throw sourceScanError(error);
235
+ recordSkippedPath(skipped, current, error, "inspect-directory-boundary");
236
+ return;
237
+ }
238
+ const entries = [];
239
+ let stream;
240
+ try {
241
+ stream = await opendir(current);
242
+ } catch (error) {
243
+ if (!skippableTraversalError(error)) throw sourceScanError(error);
244
+ recordSkippedPath(skipped, error.path || current, error, "opendir");
245
+ return;
246
+ }
247
+ try {
248
+ for await (const entry of stream) {
249
+ visitedEntries += 1;
250
+ if (visitedEntries > maxDirectoryEntries) throw new Error(`host-native source tree exceeds ${maxDirectoryEntries} entries`);
251
+ entries.push(entry);
252
+ }
253
+ } catch (error) {
254
+ if (!skippableTraversalError(error)) throw sourceScanError(error);
255
+ recordSkippedPath(skipped, current, error, "readdir");
256
+ return;
257
+ }
258
+ entries.sort((a, b) => a.name.localeCompare(b.name));
259
+ for (const entry of entries) {
260
+ if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
261
+ if (entry.isSymbolicLink()) continue;
262
+ const path = join(current, entry.name);
263
+ if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
264
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
265
+ output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
266
+ binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
267
+ }
268
+ }
269
+ }
270
+ await walk(root);
271
+ return output;
272
+ }
273
+
274
+ async function addFile(output, path, metadata) {
275
+ const file = await existingRegular(path);
276
+ if (file) output.push({ path: file, ...metadata });
277
+ }
278
+
279
+ function profileKey(host, hostHome) { return `${host}:${digest(hostHome).slice(0, 24)}`; }
280
+
281
+ function activeBinding(registry, host, hostHome, projectRoot, scope) {
282
+ const targetRoot = scope === "state-user" ? "*" : projectRoot;
283
+ return registry.bindings.find((item) => item.active && (item.host === host || item.host === "all")
284
+ && (item.host === "all" || item.profileKey === profileKey(host, hostHome))
285
+ && item.projectRoot === targetRoot && item.scope === scope) || null;
286
+ }
287
+
288
+ async function rememberRuntimeBinding({ host, hostHome, projectRoot, sourceRoot, scope, provenance, env }) {
289
+ const canonicalSource = await canonicalPath(sourceRoot);
290
+ return mutateRegistry((registry) => {
291
+ const key = host === "all" ? "all:local-user" : profileKey(host, hostHome);
292
+ const targetRoot = scope === "state-user" ? "*" : projectRoot;
293
+ const current = registry.bindings.find((item) => item.active && item.host === host
294
+ && item.profileKey === key && item.projectRoot === targetRoot && item.scope === scope);
295
+ if (current?.sourceRoot === canonicalSource) return { binding: current, unchanged: true };
296
+ if (current && current.sourceRoot !== canonicalSource) throw new Error(`conflicting ${host} ${scope} source binding; inspect or roll back the registry`);
297
+ const binding = {
298
+ id: `binding:${randomUUID()}`, host, profileKey: key, projectRoot: targetRoot, sourceRoot: canonicalSource,
299
+ scope, provenance, active: true, createdAt: new Date().toISOString(), authority: "context-only"
300
+ };
301
+ registry.bindings.push(binding);
302
+ registry.history.push({ kind: "bound", bindingId: binding.id, at: binding.createdAt, provenance, authority: "context-only" });
303
+ return { binding, unchanged: false };
304
+ }, env);
305
+ }
306
+
307
+ export async function bindSourceRoot({ host, hostHome, projectRoot, sourceRoot, scope = "state-user", confirmation, env = process.env }) {
308
+ if (confirmation !== "local-user-confirmed") throw new Error("source binding requires explicit local user confirmation");
309
+ if (!["claude", "codex", "all"].includes(host) || !["project-memory", "state-user"].includes(scope)
310
+ || (host === "all" && scope !== "state-user")) throw new Error("invalid source binding scope");
311
+ return rememberRuntimeBinding({ host, hostHome: host === "all" ? "local-user" : await canonicalPath(hostHome), projectRoot: await canonicalPath(projectRoot),
312
+ sourceRoot, scope, provenance: "local-user-confirmed", env });
313
+ }
314
+
315
+ export async function rollbackSourceBinding({ id, confirmation, env = process.env }) {
316
+ if (confirmation !== "local-user-confirmed") throw new Error("source binding rollback requires explicit local user confirmation");
317
+ const result = await mutateRegistry((registry) => {
318
+ const binding = registry.bindings.find((item) => item.id === id && item.active);
319
+ if (!binding) throw new Error(`active source binding not found: ${id}`);
320
+ binding.active = false;
321
+ binding.rolledBackAt = new Date().toISOString();
322
+ registry.history.push({ kind: "rolled-back", bindingId: id, at: binding.rolledBackAt, authority: "context-only" });
323
+ return { binding };
324
+ }, env);
325
+ if (result.binding.scope === "project-memory") await purgeIndexedMemoryCache(result.binding.sourceRoot, env);
326
+ return result;
327
+ }
328
+
329
+ export async function purgeSourceBinding({ id, confirmation, env = process.env }) {
330
+ if (confirmation !== "local-user-confirmed") throw new Error("source binding purge requires explicit local user confirmation");
331
+ const result = await mutateRegistry((registry) => {
332
+ const removed = registry.bindings.find((item) => item.id === id);
333
+ const before = registry.bindings.length;
334
+ registry.bindings = registry.bindings.filter((item) => item.id !== id);
335
+ if (registry.bindings.length === before) throw new Error(`source binding not found: ${id}`);
336
+ registry.history.push({ kind: "purged", bindingDigest: digest(id), at: new Date().toISOString(), authority: "context-only" });
337
+ return { purged: true, removed };
338
+ }, env);
339
+ if (result.removed?.scope === "project-memory") await purgeIndexedMemoryCache(result.removed.sourceRoot, env);
340
+ return { ...result, removed: undefined };
341
+ }
342
+
343
+ export async function inspectSourceRegistry(env = process.env) {
344
+ const { registry, path } = await readRegistry(env);
345
+ return { registry, registryPath: path };
346
+ }
347
+
348
+ async function claudeSources({ cwd, projectRoot, configDir, input, env, registry, deadline, memoryHooks }) {
349
+ const sources = [];
350
+ const skipped = [];
351
+ await addFile(sources, join(configDir, "CLAUDE.md"), { id: "claude:user/CLAUDE.md", host: "claude", scope: "user", binding: "CLAUDE_CONFIG_DIR", precedence: 100 });
352
+ sources.push(...await boundedMarkdownTree(join(configDir, "rules"), "claude:user/rules", "claude", "user", 110, deadline, { skipped }));
353
+ let precedence = 1000;
354
+ for (const directory of ancestorsBetween(projectRoot, cwd)) {
355
+ for (const name of ["CLAUDE.md", "CLAUDE.local.md"]) {
356
+ await addFile(sources, join(directory, name), { id: `claude:project/${relative(projectRoot, join(directory, name)).replaceAll("\\", "/")}`,
357
+ host: "claude", scope: "project", binding: "native-project-chain", precedence: precedence++ });
358
+ }
359
+ await addFile(sources, join(directory, ".claude", "CLAUDE.md"), { id: `claude:project/${relative(projectRoot, join(directory, ".claude", "CLAUDE.md")).replaceAll("\\", "/")}`,
360
+ host: "claude", scope: "project", binding: "native-project-chain", precedence: precedence++ });
361
+ }
362
+ sources.push(...await boundedMarkdownTree(join(projectRoot, ".claude", "rules"), "claude:project/.claude/rules", "claude", "project", precedence, deadline, { skipped }));
363
+
364
+ let memoryRoot = null;
365
+ let memoryProvenance = null;
366
+ for (const path of [join(configDir, "settings.json"), join(projectRoot, ".claude", "settings.json"), join(projectRoot, ".claude", "settings.local.json")]) {
367
+ const value = await readJsonObject(path);
368
+ if (typeof value?.autoMemoryDirectory === "string") {
369
+ const expanded = expandHome(value.autoMemoryDirectory, homedir());
370
+ if (!isAbsolute(expanded)) throw new Error("Claude autoMemoryDirectory must be absolute or home-relative");
371
+ memoryRoot = await existingDirectory(expanded);
372
+ memoryProvenance = "autoMemoryDirectory";
373
+ }
374
+ }
375
+ if (!memoryRoot && typeof env.CLAUDE_CODE_PROJECT_DIR_NAME === "string" && SAFE_NAME.test(env.CLAUDE_CODE_PROJECT_DIR_NAME)) {
376
+ memoryRoot = await existingDirectory(join(configDir, "projects", env.CLAUDE_CODE_PROJECT_DIR_NAME, "memory"));
377
+ memoryProvenance = "CLAUDE_CODE_PROJECT_DIR_NAME";
378
+ }
379
+ const transcript = input.transcript_path || input.transcriptPath;
380
+ if (!memoryRoot && typeof transcript === "string" && isAbsolute(transcript)) {
381
+ const transcriptFile = await existingRegular(transcript);
382
+ const projectsRoot = await existingDirectory(join(configDir, "projects"));
383
+ if (transcriptFile && projectsRoot && isInside(projectsRoot, transcriptFile)) {
384
+ memoryRoot = await existingDirectory(join(dirname(transcriptFile), "memory"));
385
+ memoryProvenance = "host-hook-transcript";
386
+ }
387
+ }
388
+ if (memoryRoot) {
389
+ await rememberRuntimeBinding({ host: "claude", hostHome: configDir, projectRoot, sourceRoot: memoryRoot,
390
+ scope: "project-memory", provenance: memoryProvenance, env });
391
+ } else {
392
+ memoryRoot = activeBinding(registry, "claude", configDir, projectRoot, "project-memory")?.sourceRoot || null;
393
+ memoryProvenance = memoryRoot ? "source-root-registry" : null;
394
+ }
395
+ let memoryDiagnostics = null;
396
+ if (memoryRoot) {
397
+ const supplied = input.agent_spine_scope && typeof input.agent_spine_scope === "object"
398
+ ? input.agent_spine_scope : input;
399
+ const resolved = await resolveIndexedMemory({
400
+ root: memoryRoot, env, hooks: memoryHooks, deadline,
401
+ scope: {
402
+ entityId: supplied.entity_id ?? supplied.entityId ?? null,
403
+ groupId: supplied.group_id ?? supplied.groupId ?? null,
404
+ projectId: supplied.project_id ?? supplied.projectId ?? null,
405
+ currentTaskId: supplied.task_id ?? supplied.currentTaskId ?? null,
406
+ prompt: input.prompt ?? input.user_prompt ?? input.message ?? input.input ?? null
407
+ }
408
+ });
409
+ memoryDiagnostics = resolved.diagnostics;
410
+ for (const item of resolved.sources) {
411
+ sources.push({
412
+ path: item.path, id: `claude:memory/${item.relativePath}`, host: "claude", scope: "project-memory",
413
+ binding: "host-native-memory-index", precedence: 2000 + sources.length,
414
+ snapshot: item.snapshot, relevance: item.relevance
415
+ });
416
+ }
417
+ }
418
+ return { sources, memoryRoot, memoryProvenance, memoryDiagnostics, skipped };
419
+ }
420
+
421
+ async function codexSources({ cwd, projectRoot, codexHome, config }) {
422
+ const sources = [];
423
+ for (const name of ["AGENTS.override.md", "AGENTS.md"]) {
424
+ const file = await existingRegular(join(codexHome, name));
425
+ if (file && (await stat(file)).size > 0) {
426
+ sources.push({ path: file, id: `codex:user/${name}`, host: "codex", scope: "user", binding: "CODEX_HOME", precedence: 100, maxBytes: config.maxBytes });
427
+ break;
428
+ }
429
+ }
430
+ let precedence = 1000;
431
+ for (const directory of ancestorsBetween(projectRoot, cwd)) {
432
+ for (const name of ["AGENTS.override.md", "AGENTS.md", ...config.fallbackNames]) {
433
+ const file = await existingRegular(join(directory, name));
434
+ if (file && (await stat(file)).size > 0) {
435
+ sources.push({ path: file, id: `codex:project/${relative(projectRoot, file).replaceAll("\\", "/")}`,
436
+ host: "codex", scope: "project", binding: "native-project-chain", precedence: precedence++, maxBytes: config.maxBytes });
437
+ break;
438
+ }
439
+ }
440
+ }
441
+ return sources;
442
+ }
443
+
444
+ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), input = {}, env = process.env, memoryHooks = {} } = {}) {
445
+ if (!["claude", "codex"].includes(host)) throw new Error(`host-native source resolution requires claude or codex, received ${host}`);
446
+ const canonicalCwd = await canonicalPath(cwd);
447
+ const deadline = Date.now() + SOURCE_RESOLUTION_MS;
448
+ const { registry } = await readRegistry(env);
449
+ let hostHome;
450
+ let projectRoot;
451
+ let sources;
452
+ let skipped = [];
453
+ let hostDetails = {};
454
+ let rootResolution = "explicit-root";
455
+ if (host === "codex") {
456
+ const codexHome = env.CODEX_HOME || env.BLUN_HOME || join(homedir(), ".codex");
457
+ hostHome = await existingDirectory(resolve(codexHome)) || resolve(codexHome);
458
+ const config = await codexConfig(hostHome);
459
+ if (env.AGENTSPINE_ROOT) projectRoot = await canonicalPath(env.AGENTSPINE_ROOT);
460
+ else ({ root: projectRoot, resolution: rootResolution } = await findRoot(canonicalCwd, config.rootMarkers));
461
+ sources = await codexSources({ cwd: canonicalCwd, projectRoot, codexHome: hostHome, config });
462
+ hostDetails = { rootMarkers: config.rootMarkers, fallbackNames: config.fallbackNames };
463
+ } else {
464
+ hostHome = await existingDirectory(resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")))
465
+ || resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
466
+ if (env.AGENTSPINE_ROOT) projectRoot = await canonicalPath(env.AGENTSPINE_ROOT);
467
+ else ({ root: projectRoot, resolution: rootResolution } = await findRoot(canonicalCwd, [".git"]));
468
+ const result = await claudeSources({ cwd: canonicalCwd, projectRoot, configDir: hostHome, input, env, registry, deadline, memoryHooks });
469
+ sources = result.sources;
470
+ skipped = result.skipped;
471
+ hostDetails = { memoryRoot: result.memoryRoot, memoryProvenance: result.memoryProvenance,
472
+ memoryDiagnostics: result.memoryDiagnostics };
473
+ }
474
+ const knownHomeRoots = await homeRoots(env);
475
+ const skippedHomeTree = knownHomeRoots.some((root) => samePath(root, projectRoot));
476
+ const skippedFallbackHomeTree = skippedHomeTree && rootResolution === "cwd-fallback";
477
+ if (!skippedHomeTree) {
478
+ sources.push(...await boundedMarkdownTree(projectRoot, "agentspine:project", host, "project", 3000, deadline,
479
+ { projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES, skipped }));
480
+ }
481
+ const nativeNames = new Set(host === "codex"
482
+ ? ["AGENTS.override.md", "AGENTS.md", ...(hostDetails.fallbackNames || [])]
483
+ : ["CLAUDE.md", "CLAUDE.local.md"]);
484
+ sources = sources.filter((item) => item.binding !== "host-native-rule-tree"
485
+ || !item.id.startsWith("agentspine:project/") || !nativeNames.has(basename(item.path)));
486
+ sources = [...new Map(sources.map((item) => [item.path, item])).values()];
487
+ if (sources.length > MAX_SOURCES) throw new Error(`host-native source set exceeds ${MAX_SOURCES} files`);
488
+ if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
489
+ let documents;
490
+ try {
491
+ documents = await indexExplicitDocuments(sources);
492
+ } catch (error) {
493
+ if (typeof error?.code === "string" && (error.path || error.syscall)) throw sourceScanError(error);
494
+ throw error;
495
+ }
496
+ if (Date.now() > deadline) throw new Error(`host-native source resolution exceeded ${SOURCE_RESOLUTION_MS} ms`);
497
+ const totalBytes = documents.reduce((sum, document) => sum + document.bytes, 0);
498
+ if (totalBytes > MAX_TOTAL_SOURCE_BYTES) throw new Error("host-native source set exceeds 8 MiB");
499
+ const activeUserState = activeBinding(registry, host, hostHome, projectRoot, "state-user");
500
+ const diagnostics = {
501
+ schema: SOURCE_REGISTRY_SCHEMA, host, status: documents.length ? "loaded" : "empty", projectRoot,
502
+ hostHomeDigest: digest(hostHome).slice(0, 16), checked: ["host-profile", "project-chain", ...(host === "claude" ? ["project-memory"] : [])],
503
+ scopes: Object.fromEntries(["user", "project", "project-memory"].map((scope) => [scope, documents.filter((item) => item.sourceScope === scope).length])),
504
+ reason: documents.length ? null : "No regular, non-symlink host-native Markdown source exists in the checked scope.",
505
+ personalContinuityLoaded: documents.some((item) => item.sourceScope === "user") || Boolean(activeUserState),
506
+ broadHomeScan: false, projectTreeScan: skippedFallbackHomeTree ? "skipped-unmarked-home"
507
+ : skippedHomeTree ? "skipped-home-root" : "bounded",
508
+ skipped: skipped.sort((a, b) => a.path.localeCompare(b.path) || a.operation.localeCompare(b.operation)),
509
+ rootResolution, registryRevision: registry.revision,
510
+ ...(host === "claude" ? {
511
+ memoryBound: Boolean(hostDetails.memoryRoot),
512
+ memoryRootDigest: hostDetails.memoryRoot ? digest(hostDetails.memoryRoot).slice(0, 16) : null,
513
+ memoryProvenance: hostDetails.memoryProvenance,
514
+ memory: hostDetails.memoryDiagnostics || {
515
+ indexed: 0, relevant: 0, loaded: 0, cacheHits: 0, cacheMisses: 0, missing: 0,
516
+ rejected: { scope: 0, path: 0, symlink: 0, race: 0, size: 0 }, directoryEnumeration: 0
517
+ }
518
+ } : hostDetails)
519
+ };
520
+ const catalog = {
521
+ schema: "agentspine.catalog/v1", generatedAt: new Date().toISOString(), root: projectRoot,
522
+ scanPolicy: catalogScanPolicy(projectRoot, env),
523
+ preservation: "source-files-are-read-only", documents, conflicts: [], sourceRegistry: diagnostics,
524
+ summary: { total: documents.length, protected: documents.filter((item) => item.protected).length, conflicts: 0,
525
+ byLayer: Object.fromEntries([...new Set(documents.map((item) => item.layer))].sort().map((layer) => [layer, documents.filter((item) => item.layer === layer).length])) }
526
+ };
527
+ return { host, hostHome, projectRoot, cwd: canonicalCwd, catalog, diagnostics,
528
+ userStateRoot: activeUserState?.sourceRoot || null, memoryRoot: hostDetails.memoryRoot || null };
529
+ }