blun-king-cli 9.1.520 → 9.1.525

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 (58) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LIESMICH.txt +4 -2
  3. package/README.md +4 -2
  4. package/agent-spine-plugin/.claude-plugin/marketplace.json +1 -1
  5. package/agent-spine-plugin/.claude-plugin/plugin.json +1 -1
  6. package/agent-spine-plugin/.codex-plugin/plugin.json +1 -2
  7. package/agent-spine-plugin/CHANGELOG.md +81 -14
  8. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  9. package/agent-spine-plugin/README.md +6 -4
  10. package/agent-spine-plugin/SECURITY.md +47 -0
  11. package/agent-spine-plugin/blun.plugin.json +2 -2
  12. package/agent-spine-plugin/docs/acceptance.md +2 -2
  13. package/agent-spine-plugin/docs/architecture.md +1 -1
  14. package/agent-spine-plugin/docs/gateway-runtime.md +2 -2
  15. package/agent-spine-plugin/docs/host-integration.md +2 -2
  16. package/agent-spine-plugin/docs/learning.md +37 -2
  17. package/agent-spine-plugin/docs/preflight-recall.md +1 -1
  18. package/agent-spine-plugin/docs/quality-gates.md +1 -1
  19. package/agent-spine-plugin/docs/relationships.md +1 -1
  20. package/agent-spine-plugin/docs/session-briefing.md +1 -1
  21. package/agent-spine-plugin/docs/source-roots.md +1 -1
  22. package/agent-spine-plugin/hooks/codex.json +1 -1
  23. package/agent-spine-plugin/hooks/version.json +1 -1
  24. package/agent-spine-plugin/package.json +1 -1
  25. package/agent-spine-plugin/scripts/check-hosts.js +2 -2
  26. package/agent-spine-plugin/skills/agent-spine/SKILL.md +3 -3
  27. package/agent-spine-plugin/src/cli.js +69 -5
  28. package/agent-spine-plugin/src/hook.js +41 -23
  29. package/agent-spine-plugin/src/index.js +2 -1
  30. package/agent-spine-plugin/src/lib/acceptance.js +2 -3
  31. package/agent-spine-plugin/src/lib/attention.js +4 -4
  32. package/agent-spine-plugin/src/lib/audit.js +12 -3
  33. package/agent-spine-plugin/src/lib/authentication.js +4 -2
  34. package/agent-spine-plugin/src/lib/briefing.js +20 -8
  35. package/agent-spine-plugin/src/lib/catalog.js +28 -3
  36. package/agent-spine-plugin/src/lib/channel-runtime.js +2 -2
  37. package/agent-spine-plugin/src/lib/continuity.js +18 -13
  38. package/agent-spine-plugin/src/lib/documents.js +23 -5
  39. package/agent-spine-plugin/src/lib/feed-transport.js +4 -2
  40. package/agent-spine-plugin/src/lib/graph.js +47 -12
  41. package/agent-spine-plugin/src/lib/learning.js +422 -48
  42. package/agent-spine-plugin/src/lib/paths.js +50 -1
  43. package/agent-spine-plugin/src/lib/persona-runtime.js +20 -14
  44. package/agent-spine-plugin/src/lib/preflight.js +50 -26
  45. package/agent-spine-plugin/src/lib/source-roots.js +38 -7
  46. package/agent-spine-plugin/src/mcp.js +50 -4
  47. package/agent-spine-plugin/src/version.js +1 -1
  48. package/bin/telegram-approval-relay.cjs +2 -0
  49. package/blun.mjs +111 -8
  50. package/package.json +8 -2
  51. package/scripts/check-approval-observability-regression.js +111 -0
  52. package/scripts/check-bundled-agent-spine-regression.js +48 -0
  53. package/scripts/check-session-picker-resume-metrics-regression.js +97 -0
  54. package/scripts/check-session-start-hook-context-regression.js +54 -4
  55. package/scripts/check-shell-terminal-isolation-regression.js +81 -0
  56. package/scripts/check-slash-escape-regression.js +89 -0
  57. package/scripts/check-telegram-loop-exactly-once-regression.js +71 -0
  58. package/telegram-plugin/bin/telegram-approval-relay.cjs +2 -1
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { lstat, mkdir, realpath } from "node:fs/promises";
5
6
 
6
7
  export async function canonicalPath(input = process.cwd()) {
@@ -49,6 +50,54 @@ export function stateRoot(env = process.env) {
49
50
  return join(env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "agentspine");
50
51
  }
51
52
 
53
+ export function comparablePath(value) {
54
+ let cursor = resolve(value);
55
+ const missing = [];
56
+ let canonical = cursor;
57
+ while (true) {
58
+ try {
59
+ canonical = realpathSync.native(cursor);
60
+ break;
61
+ } catch (error) {
62
+ if (error.code !== "ENOENT") break;
63
+ const parent = dirname(cursor);
64
+ if (parent === cursor) break;
65
+ missing.unshift(basename(cursor));
66
+ cursor = parent;
67
+ }
68
+ }
69
+ canonical = join(canonical, ...missing);
70
+ return canonical.replace(/[\\/]+$/, "");
71
+ }
72
+
73
+ function samePath(left, right) {
74
+ const normalize = comparablePath;
75
+ const a = normalize(left);
76
+ const b = normalize(right);
77
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
78
+ }
79
+
80
+ export function isUserHomeRoot(root, env = process.env) {
81
+ const candidates = [homedir(), env.HOME, env.USERPROFILE,
82
+ env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : null]
83
+ .filter((value) => typeof value === "string" && value && isAbsolute(value));
84
+ return candidates.some((candidate) => samePath(root, candidate));
85
+ }
86
+
87
+ export function statePathIsScanExcluded(catalog, path, env = process.env) {
88
+ if (!catalog || catalog.schema !== "agentspine.catalog/v1" || typeof path !== "string") return false;
89
+ const localStateRoot = stateRoot(env);
90
+ const pathBelongsToState = isInside(localStateRoot, path);
91
+ const canonicalRoot = comparablePath(catalog.root);
92
+ const canonicalStateRoot = comparablePath(localStateRoot);
93
+ if (!pathBelongsToState && !isInside(canonicalRoot, comparablePath(path))) return true;
94
+ if (pathBelongsToState && !isInside(canonicalRoot, canonicalStateRoot)) return true;
95
+ return catalog.scanPolicy?.stateRoot === "excluded"
96
+ && isUserHomeRoot(canonicalRoot, env)
97
+ && isInside(canonicalRoot, canonicalStateRoot)
98
+ && pathBelongsToState;
99
+ }
100
+
52
101
  export function projectId(root) {
53
102
  return createHash("sha256").update(root).digest("hex").slice(0, 20);
54
103
  }
@@ -249,7 +249,7 @@ function sameRuntimeEntity(entity, persona, binding) {
249
249
  }
250
250
 
251
251
  async function reconcilePersonaGraph(paths, policy, runtime) {
252
- let { graph } = await loadGraph(paths.catalog.root);
252
+ let { graph } = await loadGraph(paths.catalog.root, paths.catalog);
253
253
  const changes = { groupsCreated: 0, entitiesUpdated: 0, membershipsAdded: 0, membershipsRemoved: 0 };
254
254
  const activeGroupIds = [...new Set(runtime.personas
255
255
  .filter((item) => item.status === "active" && item.groupId !== null)
@@ -265,9 +265,9 @@ async function reconcilePersonaGraph(paths, policy, runtime) {
265
265
  }
266
266
  if (!existing) {
267
267
  await upsertEntity({ root: paths.catalog.root, id: groupId, kind: "group", privacy: "group",
268
- attributes: { identitySource: "authenticated-persona-roster" }, confidence: 1 });
268
+ attributes: { identitySource: "authenticated-persona-roster" }, confidence: 1, catalog: paths.catalog });
269
269
  changes.groupsCreated += 1;
270
- graph = (await loadGraph(paths.catalog.root)).graph;
270
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
271
271
  }
272
272
  }
273
273
 
@@ -284,36 +284,40 @@ async function reconcilePersonaGraph(paths, policy, runtime) {
284
284
  sourceDocument: existing?.sourceDocument
285
285
  && paths.catalog.documents.some((item) => item.relativePath === existing.sourceDocument)
286
286
  ? existing.sourceDocument : null,
287
- privacy: persona.groupId ? "group" : "shared", confidence: 1 });
287
+ privacy: persona.groupId ? "group" : "shared", confidence: 1, catalog: paths.catalog });
288
288
  changes.entitiesUpdated += 1;
289
- graph = (await loadGraph(paths.catalog.root)).graph;
289
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
290
290
  existing = graph.entities.find((item) => item.id === persona.personaId);
291
291
  }
292
292
 
293
293
  const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
294
294
  for (const edge of memberships.filter((item) => persona.status !== "active" || item.to !== persona.groupId)) {
295
- await unlinkEntities({ root: paths.catalog.root, from: edge.from, to: edge.to, relation: edge.relation });
295
+ await unlinkEntities({ root: paths.catalog.root, from: edge.from, to: edge.to, relation: edge.relation,
296
+ catalog: paths.catalog });
296
297
  changes.membershipsRemoved += 1;
297
- graph = (await loadGraph(paths.catalog.root)).graph;
298
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
298
299
  }
299
300
  if (persona.status === "active" && persona.groupId !== null
300
301
  && !graph.entityEdges.some((edge) => edge.from === persona.personaId && edge.to === persona.groupId
301
302
  && edge.relation === "member-of" && edge.privacy === "group")) {
302
303
  await linkEntities({ root: paths.catalog.root, from: persona.personaId, to: persona.groupId,
303
- relation: "member-of", reason: "Authenticated roster membership; context only.", confidence: 1, privacy: "group" });
304
+ relation: "member-of", reason: "Authenticated roster membership; context only.", confidence: 1, privacy: "group",
305
+ catalog: paths.catalog });
304
306
  changes.membershipsAdded += 1;
305
- graph = (await loadGraph(paths.catalog.root)).graph;
307
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
306
308
  }
307
309
  }
308
310
  return changes;
309
311
  }
310
312
 
311
- export async function applyPersonaRoster({ root = process.cwd(), bindings, rosterScopes = [], confirmation, now = new Date() }) {
313
+ export async function applyPersonaRoster({
314
+ root = process.cwd(), bindings, rosterScopes = [], confirmation, now = new Date(), catalog: providedCatalog = null
315
+ }) {
312
316
  if (confirmation !== CONFIRMATION) throw new Error("persona roster changes require explicit local owner confirmation");
313
317
  if (!Array.isArray(bindings) || bindings.length > 256 || (!bindings.length && !rosterScopes.length)) {
314
318
  throw new Error("bindings must contain up to 256 persona bindings or one explicit roster scope");
315
319
  }
316
- const paths = await pathsFor(root);
320
+ const paths = await pathsFor(root, providedCatalog);
317
321
  return withLock(paths, async () => {
318
322
  const [policy, runtime] = await Promise.all([
319
323
  readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
@@ -471,11 +475,13 @@ export async function loadPersonaRuntime(root = process.cwd(), providedCatalog =
471
475
  return { policy, runtime, ...paths };
472
476
  }
473
477
 
474
- export async function syncPersonaRosterFromEnvironment({ root = process.cwd(), env = process.env, now = new Date() } = {}) {
478
+ export async function syncPersonaRosterFromEnvironment({
479
+ root = process.cwd(), env = process.env, now = new Date(), catalog: providedCatalog = null
480
+ } = {}) {
475
481
  const configured = env.AGENTSPINE_PERSONA_ROSTER_FILE;
476
482
  if (!configured) return { configured: false, changed: false };
477
483
  if (!isAbsolute(configured)) throw new Error("AGENTSPINE_PERSONA_ROSTER_FILE must be an absolute path");
478
- const catalog = await buildCatalog(root);
484
+ const catalog = providedCatalog || await buildCatalog(root);
479
485
  const supplied = await lstat(configured);
480
486
  if (supplied.isSymbolicLink() || !supplied.isFile()) throw new Error("persona roster must be a regular non-symlink file");
481
487
  const canonical = await realpath(configured);
@@ -497,7 +503,7 @@ export async function syncPersonaRosterFromEnvironment({ root = process.cwd(), e
497
503
  const rosterScopes = nativeScopes.map((scope) => ({ authenticator: "host-manifest", issuer: scope.issuer,
498
504
  tenantId: scope.tenantId, host: scope.host, profileId: scope.profileId }));
499
505
  const result = await applyPersonaRoster({ root: catalog.root, bindings, rosterScopes,
500
- confirmation: CONFIRMATION, now: value.observedAt || now });
506
+ confirmation: CONFIRMATION, now: value.observedAt || now, catalog });
501
507
  return { configured: true, changed: !result.duplicate || result.graphReconciled,
502
508
  rosterChanged: !result.duplicate, graphReconciled: result.graphReconciled, graphChanges: result.graphChanges,
503
509
  revision: value.revision,
@@ -21,8 +21,8 @@ const ENV_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
21
21
  const MAX_POLICY_BYTES = 1024 * 1024;
22
22
  const MAX_STATE_BYTES = 8 * 1024 * 1024;
23
23
  const MAX_PROVIDER_BYTES = 1024 * 1024;
24
- const MAX_REQUIRED_INSTRUCTION_FILE_BYTES = 4 * 1024 * 1024;
25
- const MAX_REQUIRED_INSTRUCTION_METADATA_BYTES = 4 * 1024;
24
+ const STANDARD_REQUIRED_INSTRUCTIONS_BYTES = 8 * 1024;
25
+ const MAX_CLAUDE_REQUIRED_INSTRUCTIONS_BYTES = 16 * 1024;
26
26
  const MAX_REQUIRED_MEMORY_BYTES = 6 * 1024;
27
27
  const RECEIPT_TTL_MS = 60_000;
28
28
  const FORBIDDEN_MEMORY = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\b(?:password|passwort|secret|token|api[-_ ]?key|credential|permission|rights?|roles?|delegat|authoriz|berechtig|freigabe|approval|tool access|file access|network|production|payment|zahlung|policy)\b/i;
@@ -326,13 +326,16 @@ async function assertNoSymlinkParents(path, allowedRoot) {
326
326
  }
327
327
  return { root, target };
328
328
  }
329
- async function safeReadRequired(path, allowedRoot, maximum = MAX_REQUIRED_INSTRUCTION_FILE_BYTES, fileHooks = null) {
329
+ async function safeReadRequired(path, allowedRoot, maximum = STANDARD_REQUIRED_INSTRUCTIONS_BYTES, fileHooks = null) {
330
330
  const checked = await assertNoSymlinkParents(path, allowedRoot);
331
331
  const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
332
332
  const handle = await open(checked.target, flags);
333
333
  try {
334
334
  const before = await handle.stat();
335
- if (!before.isFile() || before.size > maximum) throw new Error(`required instruction ${basename(path)} cannot fit the mandatory context budget`);
335
+ if (!before.isFile()) throw new Error(`required instruction ${basename(path)} is not a regular file`);
336
+ if (before.size > maximum) {
337
+ throw new Error(`required instruction ${basename(path)} is ${before.size} bytes; mandatory limit is ${maximum} bytes`);
338
+ }
336
339
  const buffer = Buffer.alloc(Number(before.size));
337
340
  let offset = 0;
338
341
  while (offset < buffer.length) {
@@ -350,8 +353,8 @@ async function safeReadRequired(path, allowedRoot, maximum = MAX_REQUIRED_INSTRU
350
353
  || current.size !== before.size || current.mtimeMs !== before.mtimeMs || current.ctimeMs !== before.ctimeMs) {
351
354
  throw new Error(`required instruction ${basename(path)} was replaced during preflight`);
352
355
  }
353
- return { bytes: buffer.length, sha256: sha256(buffer),
354
- identity: sha256(`${before.dev}\0${before.ino}\0${before.size}\0${before.mtimeMs}\0${before.ctimeMs}`) };
356
+ return { content: buffer.toString("utf8"), bytes: buffer.length, sha256: sha256(buffer),
357
+ identity: sha256(`${before.dev}\0${before.ino}\0${before.size}\0${before.mtimeMs}\0${before.ctimeMs}`) };
355
358
  } finally { await handle.close(); }
356
359
  }
357
360
 
@@ -374,6 +377,19 @@ function instructionDocuments(catalog, host) {
374
377
  return catalog.documents.filter((item) => item.layer === "constitution" && pattern.test(item.relativePath))
375
378
  .sort((left, right) => left.precedence - right.precedence || left.relativePath.localeCompare(right.relativePath));
376
379
  }
380
+ function instructionBudget(host, usedBytes = 0) {
381
+ const hardLimitBytes = host === "claude"
382
+ ? MAX_CLAUDE_REQUIRED_INSTRUCTIONS_BYTES
383
+ : STANDARD_REQUIRED_INSTRUCTIONS_BYTES;
384
+ const overflowBytes = Math.max(0, usedBytes - STANDARD_REQUIRED_INSTRUCTIONS_BYTES);
385
+ return {
386
+ mode: overflowBytes ? "claude-required-overflow" : "standard",
387
+ standardBytes: STANDARD_REQUIRED_INSTRUCTIONS_BYTES,
388
+ hardLimitBytes,
389
+ usedBytes,
390
+ overflowBytes
391
+ };
392
+ }
377
393
  async function rejectKnownInstructionSymlinks(resolvedSources, host) {
378
394
  const candidates = host === "claude"
379
395
  ? [join(resolvedSources.hostHome, "CLAUDE.md"), ...ancestorsBetween(resolvedSources.projectRoot, resolvedSources.cwd)
@@ -485,21 +501,22 @@ export async function runPreflight({ input, scope, resolvedSources, prompt, now
485
501
  && item.profileId === exactScope.profileId && item.tenantId === exactScope.tenantId) || null;
486
502
  const instructionHost = resolvedSources.host;
487
503
  await rejectKnownInstructionSymlinks(resolvedSources, instructionHost);
488
- const documents = instructionDocuments(resolvedSources.catalog, instructionHost);
489
- const requiredInstructions = [];
490
- for (const document of documents) {
491
- const allowedRoot = document.sourceScope === "user" ? resolvedSources.hostHome : resolvedSources.projectRoot;
492
- const snapshot = await safeReadRequired(document.path, allowedRoot, MAX_REQUIRED_INSTRUCTION_FILE_BYTES, fileHooks);
493
- if (snapshot.sha256 !== document.sha256 || snapshot.bytes !== document.bytes) throw new Error(`required instruction changed after source resolution: ${document.relativePath}`);
494
- requiredInstructions.push({ path: document.path, displayPath: document.relativePath, scope: document.sourceScope,
495
- bytes: snapshot.bytes, sha256: snapshot.sha256, identity: snapshot.identity });
496
- }
497
- const instructionBriefing = requiredInstructions.map(({ path: _path, identity: _identity, ...item }) => ({
498
- ...item, delivery: "host-native", authority: "host-instruction-verified"
499
- }));
500
- if (Buffer.byteLength(JSON.stringify(instructionBriefing)) > MAX_REQUIRED_INSTRUCTION_METADATA_BYTES) {
501
- throw new Error("required host instruction descriptors exceed the mandatory preflight metadata budget");
502
- }
504
+ const documents = instructionDocuments(resolvedSources.catalog, instructionHost);
505
+ const requiredInstructions = [];
506
+ let instructionBytes = 0;
507
+ const maximumInstructionBytes = instructionBudget(instructionHost).hardLimitBytes;
508
+ for (const document of documents) {
509
+ const allowedRoot = document.sourceScope === "user" ? resolvedSources.hostHome : resolvedSources.projectRoot;
510
+ const snapshot = await safeReadRequired(document.path, allowedRoot, maximumInstructionBytes, fileHooks);
511
+ if (snapshot.sha256 !== document.sha256 || snapshot.bytes !== document.bytes) throw new Error(`required instruction changed after source resolution: ${document.relativePath}`);
512
+ instructionBytes += snapshot.bytes;
513
+ if (instructionBytes > maximumInstructionBytes) {
514
+ throw new Error(`required host instructions total ${instructionBytes} bytes; mandatory limit is ${maximumInstructionBytes} bytes`);
515
+ }
516
+ requiredInstructions.push({ path: document.path, displayPath: document.relativePath, scope: document.sourceScope,
517
+ bytes: snapshot.bytes, sha256: snapshot.sha256, identity: snapshot.identity, content: snapshot.content });
518
+ }
519
+ const appliedInstructionBudget = instructionBudget(instructionHost, instructionBytes);
503
520
  const memoryState = validateMemories(await readJson(paths.memories, MAX_STATE_BYTES, emptyMemories));
504
521
  const mustRemember = memoryState.entries.filter((item) => memoryMatches(item, exactScope));
505
522
  if (Buffer.byteLength(JSON.stringify(mustRemember.map((item) => item.claim))) > MAX_REQUIRED_MEMORY_BYTES) {
@@ -519,13 +536,14 @@ export async function runPreflight({ input, scope, resolvedSources, prompt, now
519
536
  }
520
537
  const requiredProviderIds = (profile?.providers || []).filter((item) => item.required).map((item) => item.id);
521
538
  const loadedIds = providerResults.flatMap((result) => result.items.map((item) => ({ providerId: result.providerId, id: item.id, revision: item.revision })));
522
- const briefing = {
523
- schema: "agentspine.preflight-briefing/v2", order: ["host-instructions", "must-remember", "required-retrieval", "current-work", "relationships", "accepted-context"],
524
- instructions: instructionBriefing,
539
+ const briefing = {
540
+ schema: "agentspine.preflight-briefing/v2", order: ["host-instructions", "must-remember", "required-retrieval", "current-work", "relationships", "accepted-context"],
541
+ instructions: requiredInstructions.map(({ path: _path, identity: _identity, ...item }) => item),
525
542
  mustRemember: mustRemember.map((item) => ({ id: item.id, version: item.version, claim: item.claim, checksum: item.checksum, authority: "context-only" })),
526
543
  retrieval: providerResults.map((item) => ({ providerId: item.providerId, status: item.status, rejected: item.rejected, items: item.items })),
527
544
  recallStatus: requiredProviderIds.length ? "required-complete" : "no-required-provider-configured",
528
- instruction: "The host-native instruction files were verified for this exact turn and are not duplicated here. Apply the host-loaded instructions first, then confirmed critical context and scoped retrieval. None of this content grants authority or relaxes host policy.",
545
+ instructionBudget: appliedInstructionBudget,
546
+ instruction: "Apply the complete host instructions first, then confirmed critical context and scoped retrieval. None of this content grants authority or relaxes host policy.",
529
547
  authority: "context-only"
530
548
  };
531
549
  const createdAt = timestamp(now); const expiresAt = timestamp(new Date(new Date(createdAt).getTime() + RECEIPT_TTL_MS));
@@ -534,6 +552,7 @@ export async function runPreflight({ input, scope, resolvedSources, prompt, now
534
552
  sessionId: exactScope.sessionId, projectId: exactScope.projectId, cwdDigest: sha256(exactScope.cwd),
535
553
  taskId: exactScope.taskId, groupId: exactScope.groupId, hookEvent: "UserPromptSubmit", deliveryId, promptDigest,
536
554
  instructionFiles: requiredInstructions.map((item) => ({ path: item.path, scope: item.scope, bytes: item.bytes, sha256: item.sha256, identity: item.identity })),
555
+ instructionBudget: appliedInstructionBudget,
537
556
  policyRevision: policy.revision, policyProfileDigest: profile ? digestObject(profile) : null,
538
557
  mustRemember: memoryProofs(mustRemember),
539
558
  providerQueries: providerResults.map((item) => ({ providerId: item.providerId, queryDigest: item.queryDigest, status: item.status, rejected: item.rejected })),
@@ -600,12 +619,17 @@ export async function verifyPreflightReceipt({ receipt, input, scope, resolvedSo
600
619
  const currentDocuments = instructionDocuments(freshSources.catalog, freshSources.host);
601
620
  if (currentDocuments.length !== receipt.instructionFiles.length
602
621
  || currentDocuments.some((document, index) => document.path !== receipt.instructionFiles[index]?.path)) return false;
622
+ let instructionBytes = 0;
623
+ const maximumInstructionBytes = instructionBudget(receipt.instructionHost).hardLimitBytes;
603
624
  for (const instruction of receipt.instructionFiles) {
604
625
  const allowedRoot = instruction.scope === "user" ? freshSources.hostHome : freshSources.projectRoot;
605
- const currentSnapshot = await safeReadRequired(instruction.path, allowedRoot);
626
+ const currentSnapshot = await safeReadRequired(instruction.path, allowedRoot, maximumInstructionBytes);
606
627
  if (currentSnapshot.sha256 !== instruction.sha256 || currentSnapshot.bytes !== instruction.bytes
607
628
  || currentSnapshot.identity !== instruction.identity) return false;
629
+ instructionBytes += currentSnapshot.bytes;
608
630
  }
631
+ if (instructionBytes > maximumInstructionBytes
632
+ || canonical(receipt.instructionBudget) !== canonical(instructionBudget(receipt.instructionHost, instructionBytes))) return false;
609
633
  } catch { return false; }
610
634
  if (consume) {
611
635
  const paths = storagePaths(env);
@@ -6,6 +6,7 @@ import { ancestorsBetween, canonicalPath, isInside, stateRoot } from "./paths.js
6
6
  import { indexExplicitDocuments } from "./documents.js";
7
7
  import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
8
8
  import { purgeIndexedMemoryCache, resolveIndexedMemory } from "./indexed-memory.js";
9
+ import { catalogScanPolicy } from "./catalog.js";
9
10
 
10
11
  export const SOURCE_REGISTRY_SCHEMA = "agentspine.source-roots/v1";
11
12
  const MAX_REGISTRY_BYTES = 1024 * 1024;
@@ -109,6 +110,21 @@ function expandHome(value, home) {
109
110
  return value;
110
111
  }
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
+
112
128
  async function readJsonObject(path) {
113
129
  const file = await existingRegular(path);
114
130
  if (!file) return null;
@@ -150,16 +166,22 @@ async function findRoot(cwd, markers) {
150
166
  let cursor = cwd;
151
167
  while (true) {
152
168
  for (const marker of markers) {
153
- try { await lstat(join(cursor, marker)); return cursor; } catch (error) {
169
+ try { await lstat(join(cursor, marker)); return { root: cursor, resolution: "project-marker" }; } catch (error) {
154
170
  if (error.code !== "ENOENT") throw error;
155
171
  }
156
172
  }
157
173
  const parent = dirname(cursor);
158
- if (parent === cursor) return cwd;
174
+ if (parent === cursor) return { root: cwd, resolution: "cwd-fallback" };
159
175
  cursor = parent;
160
176
  }
161
177
  }
162
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
+
163
185
  async function containsProjectMarker(directory) {
164
186
  try {
165
187
  await lstat(join(directory, ".git"));
@@ -201,7 +223,7 @@ async function boundedMarkdownTree(directory, prefix, host, scope, precedenceSta
201
223
  if (output.length >= maxFiles) throw new Error(`host-native rule tree exceeds ${maxFiles} files`);
202
224
  if (entry.isSymbolicLink()) continue;
203
225
  const path = join(current, entry.name);
204
- if (entry.isDirectory() && !entry.name.startsWith(".") && !SKIP_EXTRA_DIRS.has(entry.name)) await walk(path);
226
+ if (entry.isDirectory() && !entry.name.startsWith(".") && !skippedExtraDirectory(entry.name)) await walk(path);
205
227
  else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
206
228
  output.push({ path, id: `${prefix}/${relative(root, path).replaceAll("\\", "/")}`, host, scope,
207
229
  binding: "host-native-rule-tree", precedence: precedenceStart + output.length });
@@ -390,23 +412,29 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
390
412
  let projectRoot;
391
413
  let sources;
392
414
  let hostDetails = {};
415
+ let rootResolution = "explicit-root";
393
416
  if (host === "codex") {
394
417
  const codexHome = env.CODEX_HOME || env.BLUN_HOME || join(homedir(), ".codex");
395
418
  hostHome = await existingDirectory(resolve(codexHome)) || resolve(codexHome);
396
419
  const config = await codexConfig(hostHome);
397
- projectRoot = env.AGENTSPINE_ROOT ? await canonicalPath(env.AGENTSPINE_ROOT) : await findRoot(canonicalCwd, config.rootMarkers);
420
+ if (env.AGENTSPINE_ROOT) projectRoot = await canonicalPath(env.AGENTSPINE_ROOT);
421
+ else ({ root: projectRoot, resolution: rootResolution } = await findRoot(canonicalCwd, config.rootMarkers));
398
422
  sources = await codexSources({ cwd: canonicalCwd, projectRoot, codexHome: hostHome, config });
399
423
  hostDetails = { rootMarkers: config.rootMarkers, fallbackNames: config.fallbackNames };
400
424
  } else {
401
425
  hostHome = await existingDirectory(resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")))
402
426
  || resolve(env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"));
403
- projectRoot = env.AGENTSPINE_ROOT ? await canonicalPath(env.AGENTSPINE_ROOT) : await findRoot(canonicalCwd, [".git"]);
427
+ if (env.AGENTSPINE_ROOT) projectRoot = await canonicalPath(env.AGENTSPINE_ROOT);
428
+ else ({ root: projectRoot, resolution: rootResolution } = await findRoot(canonicalCwd, [".git"]));
404
429
  const result = await claudeSources({ cwd: canonicalCwd, projectRoot, configDir: hostHome, input, env, registry, deadline, memoryHooks });
405
430
  sources = result.sources;
406
431
  hostDetails = { memoryRoot: result.memoryRoot, memoryProvenance: result.memoryProvenance,
407
432
  memoryDiagnostics: result.memoryDiagnostics };
408
433
  }
409
- if (projectRoot !== homedir() && projectRoot !== dirname(hostHome)) {
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) {
410
438
  sources.push(...await boundedMarkdownTree(projectRoot, "agentspine:project", host, "project", 3000, deadline,
411
439
  { projectBoundary: true, maxFiles: MAX_PROJECT_FILES, maxDirectoryEntries: MAX_PROJECT_DIRECTORY_ENTRIES }));
412
440
  }
@@ -429,7 +457,9 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
429
457
  scopes: Object.fromEntries(["user", "project", "project-memory"].map((scope) => [scope, documents.filter((item) => item.sourceScope === scope).length])),
430
458
  reason: documents.length ? null : "No regular, non-symlink host-native Markdown source exists in the checked scope.",
431
459
  personalContinuityLoaded: documents.some((item) => item.sourceScope === "user") || Boolean(activeUserState),
432
- broadHomeScan: false, registryRevision: registry.revision,
460
+ broadHomeScan: false, projectTreeScan: skippedFallbackHomeTree ? "skipped-unmarked-home"
461
+ : skippedHomeTree ? "skipped-home-root" : "bounded",
462
+ rootResolution, registryRevision: registry.revision,
433
463
  ...(host === "claude" ? {
434
464
  memoryBound: Boolean(hostDetails.memoryRoot),
435
465
  memoryRootDigest: hostDetails.memoryRoot ? digest(hostDetails.memoryRoot).slice(0, 16) : null,
@@ -442,6 +472,7 @@ export async function resolveHostSourceCatalog({ host, cwd = process.cwd(), inpu
442
472
  };
443
473
  const catalog = {
444
474
  schema: "agentspine.catalog/v1", generatedAt: new Date().toISOString(), root: projectRoot,
475
+ scanPolicy: catalogScanPolicy(projectRoot, env),
445
476
  preservation: "source-files-are-read-only", documents, conflicts: [], sourceRegistry: diagnostics,
446
477
  summary: { total: documents.length, protected: documents.filter((item) => item.protected).length, conflicts: 0,
447
478
  byLayer: Object.fromEntries([...new Set(documents.map((item) => item.layer))].sort().map((layer) => [layer, documents.filter((item) => item.layer === layer).length])) }
@@ -10,7 +10,7 @@ import {
10
10
  } from "./lib/attention.js";
11
11
  import {
12
12
  addLearningEvidence, configureLearning, deleteLearning, evaluateLearning,
13
- learningContext, proposeLearning, reviewLearning, rollbackLearning
13
+ learningContext, learningOutcomeStatus, proposeLearning, reviewLearning, rollbackLearning
14
14
  } from "./lib/learning.js";
15
15
  import { checkDelegation, createTask, taskContext, updateTask } from "./lib/coordination.js";
16
16
  import { sharedContext } from "./lib/sharing.js";
@@ -46,6 +46,8 @@ const tools = [
46
46
  root: { type: "string" }, cwd: { type: "string" },
47
47
  host: { type: "string", enum: ["codex", "claude", "generic"] },
48
48
  entityId: { anyOf: [{ type: "string" }, { type: "null" }] },
49
+ userId: { anyOf: [{ type: "string" }, { type: "null" }] },
50
+ tenantId: { anyOf: [{ type: "string" }, { type: "null" }] },
49
51
  groupId: { anyOf: [{ type: "string" }, { type: "null" }] },
50
52
  projectId: { anyOf: [{ type: "string" }, { type: "null" }] },
51
53
  currentTaskId: { anyOf: [{ type: "string" }, { type: "null" }] },
@@ -240,10 +242,18 @@ const tools = [
240
242
  type: "object", required: ["kind", "claim", "evidence"],
241
243
  properties: {
242
244
  root: { type: "string" }, id: { type: "string" },
243
- kind: { type: "string", enum: ["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference"] },
245
+ kind: { type: "string", enum: ["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference", "behavior"] },
244
246
  claim: { type: "string", maxLength: 1000 }, subjectId: { anyOf: [{ type: "string" }, { type: "null" }] },
245
247
  privacy: { type: "string", enum: ["private", "shared", "group"] },
246
248
  groupId: { anyOf: [{ type: "string" }, { type: "null" }] },
249
+ scope: {
250
+ type: "object", additionalProperties: false,
251
+ properties: {
252
+ personaId: { anyOf: [{ type: "string" }, { type: "null" }] }, userId: { anyOf: [{ type: "string" }, { type: "null" }] },
253
+ tenantId: { anyOf: [{ type: "string" }, { type: "null" }] }, projectId: { anyOf: [{ type: "string" }, { type: "null" }] },
254
+ groupId: { anyOf: [{ type: "string" }, { type: "null" }] }, taskId: { anyOf: [{ type: "string" }, { type: "null" }] }
255
+ }
256
+ },
247
257
  supersedesId: { anyOf: [{ type: "string" }, { type: "null" }] },
248
258
  evidence: { "$ref": "#/$defs/evidence" }
249
259
  },
@@ -297,11 +307,40 @@ const tools = [
297
307
  properties: {
298
308
  root: { type: "string" }, includePrivate: { type: "boolean" },
299
309
  groupId: { anyOf: [{ type: "string" }, { type: "null" }] },
300
- kinds: { type: "array", items: { type: "string", enum: ["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference"] } },
310
+ scope: {
311
+ type: "object", additionalProperties: false,
312
+ properties: {
313
+ personaId: { anyOf: [{ type: "string" }, { type: "null" }] }, userId: { anyOf: [{ type: "string" }, { type: "null" }] },
314
+ tenantId: { anyOf: [{ type: "string" }, { type: "null" }] }, projectId: { anyOf: [{ type: "string" }, { type: "null" }] },
315
+ groupId: { anyOf: [{ type: "string" }, { type: "null" }] }, taskId: { anyOf: [{ type: "string" }, { type: "null" }] }
316
+ }
317
+ },
318
+ kinds: { type: "array", items: { type: "string", enum: ["preference", "no-go", "goal", "correction", "personal-fact", "project-fact", "reference", "behavior"] } },
301
319
  subjectIds: { type: "array", items: { type: "string" } }, maxItems: { type: "integer", minimum: 0, maximum: 50 }
302
320
  }
303
321
  }
304
322
  },
323
+ {
324
+ name: "learning_outcome_status",
325
+ description: "Read outcome receipt counts, contradiction state, and canary health for learned context. This read-only view is context-only and cannot record evidence, promote learning, or grant authority.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ root: { type: "string" },
330
+ scope: {
331
+ type: "object", additionalProperties: false,
332
+ properties: {
333
+ personaId: { anyOf: [{ type: "string" }, { type: "null" }] },
334
+ userId: { anyOf: [{ type: "string" }, { type: "null" }] },
335
+ tenantId: { anyOf: [{ type: "string" }, { type: "null" }] },
336
+ projectId: { anyOf: [{ type: "string" }, { type: "null" }] },
337
+ groupId: { anyOf: [{ type: "string" }, { type: "null" }] },
338
+ taskId: { anyOf: [{ type: "string" }, { type: "null" }] }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ },
305
344
  {
306
345
  name: "evaluate_learning",
307
346
  description: "Evaluate pending candidates against the opt-in low-risk auto-promotion policy. Auto-promotion is disabled by default and limited to project facts and references.",
@@ -326,7 +365,13 @@ const tools = [
326
365
  type: "object", additionalProperties: false,
327
366
  properties: {
328
367
  autoPromote: { type: "boolean" }, minConfidence: { type: "number", minimum: 0.5, maximum: 1 },
329
- minEvidence: { type: "integer", minimum: 1, maximum: 10 }, maxContextItems: { type: "integer", minimum: 1, maximum: 50 }
368
+ minEvidence: { type: "integer", minimum: 1, maximum: 10 }, maxContextItems: { type: "integer", minimum: 1, maximum: 50 },
369
+ minOutcomeReceipts: { type: "integer", minimum: 2, maximum: 10 },
370
+ minImprovement: { type: "number", minimum: 0, maximum: 1 },
371
+ regressionTolerance: { type: "number", minimum: 0, maximum: 1 },
372
+ outcomeMaxAgeDays: { type: "integer", minimum: 1, maximum: 365 },
373
+ canaryReceipts: { type: "integer", minimum: 1, maximum: 10 },
374
+ canaryTtlDays: { type: "integer", minimum: 1, maximum: 90 }
330
375
  }
331
376
  }
332
377
  }
@@ -459,6 +504,7 @@ async function callTool(name, args = {}) {
459
504
  if (name === "add_learning_evidence") return textResult(await addLearningEvidence({ ...args, root }));
460
505
  if (name === "review_learning") return textResult(await reviewLearning({ ...args, root }));
461
506
  if (name === "learning_context") return textResult(await learningContext({ ...args, root }));
507
+ if (name === "learning_outcome_status") return textResult(await learningOutcomeStatus({ ...args, root }));
462
508
  if (name === "evaluate_learning") return textResult(await evaluateLearning({ ...args, root }));
463
509
  if (name === "rollback_learning") return textResult(await rollbackLearning({ ...args, root }));
464
510
  if (name === "configure_learning") return textResult(await configureLearning({ ...args, root }));
@@ -1 +1 @@
1
- export const VERSION = "0.10.1";
1
+ export const VERSION = "0.11.4";
@@ -68,6 +68,8 @@ function resolveTelegramApprovalTarget(allowFrom, configuredChatId) {
68
68
  : [];
69
69
  const configured = String(configuredChatId ?? '').trim();
70
70
  if (configured.length > 0) return allowed.includes(configured) ? configured : null;
71
+ const privateChats = allowed.filter((value) => /^[1-9]\d*$/u.test(value));
72
+ if (privateChats.length === 1) return privateChats[0];
71
73
  return allowed.length === 1 ? allowed[0] : null;
72
74
  }
73
75