blun-king-cli 9.1.564 → 9.1.566

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