@plur-ai/mcp 0.20.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-445S5QNU.js";
3
+ } from "./chunk-WA3JKTZ7.js";
4
4
 
5
5
  // src/telemetry.ts
6
6
  import { recordEvent, flushIfNeeded, registerFlushOnExit } from "@plur-ai/core";
@@ -15,9 +15,9 @@ function recordTelemetry(event) {
15
15
 
16
16
  // src/tools.ts
17
17
  import { existsSync, unlinkSync } from "fs";
18
- import { join } from "path";
18
+ import { join, dirname, resolve } from "path";
19
19
  import { homedir } from "os";
20
- import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, readProjectConfig, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation, normalizeEndpointUrl, REMOTE_STATUS_TTL_MS, PROBE_CLEARABLE_STATES, bareEngramId, summariseProvenance, renderProvenanceSummary } from "@plur-ai/core";
20
+ import { extractMetaEngrams, validateMetaEngram, confidenceBand, generateProfile, getProfileForInjection, selectModelForOperation, getCachedUpdateCheck, minorVersionsBehind, scanForTensions, CapabilityCanary, NO_SESSION, findProjectConfigPath, readProjectConfigFromPath, isSharedScope, resolveRerankerName, getReranker, classifyRerankerFailure, hfCacheDirName, SUGGEST_DISPLAY_MIN_CONFIDENCE, mcpRemoteWarningLine, doctorRemoteRemediation, normalizeEndpointUrl, REMOTE_STATUS_TTL_MS, PROBE_CLEARABLE_STATES, bareEngramId, summariseProvenance, formatLayer3, renderProvenanceSummary, describeNeedsAction, summarizeOutbox } from "@plur-ai/core";
21
21
  import { z } from "zod";
22
22
  function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
23
23
  return async (prompt) => {
@@ -79,8 +79,10 @@ var recallHandler = async (args, plur) => {
79
79
  // MCP recall remote budget (#776)
80
80
  // #243: session default scope (incl. mid-session plur_session_scope
81
81
  // changes) establishes the remote dialing org context when no explicit
82
- // scope filter is passed.
83
- session: _resolveInjectionSession(args)
82
+ // scope filter is passed. Same rule as writes (E7, formal R2): not
83
+ // exactly one open session and no id → NO_SESSION, never the process
84
+ // slot the last-started session owns.
85
+ session: _resolveWriteSession(args)
84
86
  });
85
87
  const response2 = {
86
88
  results: results.map((e) => {
@@ -124,8 +126,8 @@ var recallHandler = async (args, plur) => {
124
126
  // MCP recall remote budget (#776)
125
127
  // #243: session default scope (incl. mid-session plur_session_scope
126
128
  // changes) establishes the remote dialing org context when no explicit
127
- // scope filter is passed.
128
- session: _resolveInjectionSession(args)
129
+ // scope filter is passed. Same rule as writes (E7, formal R2).
130
+ session: _resolveWriteSession(args)
129
131
  });
130
132
  recordTelemetry("recall");
131
133
  const truncatedByCount = budget?.max_results != null && meta.engrams.length > cap;
@@ -216,10 +218,12 @@ function jsonSchemaPropToZod(prop) {
216
218
  }
217
219
  const items = prop.items;
218
220
  const itemVariants = items?.anyOf ?? items?.oneOf;
219
- const itemsAcceptString = items?.type === "string" || Array.isArray(itemVariants) && itemVariants.some((v) => v?.type === "string");
220
- if (itemsAcceptString) {
221
+ if (items?.type === "string") {
221
222
  return trimmed.length === 0 ? [] : trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
222
223
  }
224
+ if (Array.isArray(itemVariants) && itemVariants.some((v) => v?.type === "string")) {
225
+ return trimmed.length === 0 ? [] : [trimmed];
226
+ }
223
227
  return val;
224
228
  }, z.array(itemSchema));
225
229
  }
@@ -253,9 +257,9 @@ function validateToolArgs(tool, rawArgs) {
253
257
  const arrayShapedDrop = partialDrop && missingArrayParams.length > 0;
254
258
  let dropHint = "";
255
259
  if (wholePayloadDrop) {
256
- dropHint = " Known intermittent client-side issue (plur-ai/plur#772, refines #297): some MCP clients transiently drop the ENTIRE arguments payload \u2014 most often when several tool calls are batched into a single message. Nothing was stored or evaluated. Retry the IDENTICAL call with the full payload, as the ONLY tool call in that message \u2014 identical retries typically succeed. If two identical retries fail the same way, the arguments really are absent from your call \u2014 re-issue it with the intended fields." + (hasArrayParam ? ' If retries keep failing specifically on array parameters, pass them as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays.' : "");
260
+ dropHint = " Known intermittent client-side issue (plur-ai/plur#772, refines #297): some MCP clients transiently drop the ENTIRE arguments payload \u2014 most often when several tool calls are batched into a single message. Nothing was stored or evaluated. Retry the IDENTICAL call with the full payload, as the ONLY tool call in that message \u2014 identical retries typically succeed. If two identical retries fail the same way, the arguments really are absent from your call \u2014 re-issue it with the intended fields." + (hasArrayParam ? ' If retries keep failing specifically on array parameters, pass them as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays. (A list of free-text statements such as engram_suggestions is not comma-split: send it as a JSON string; a bare string is one item.)' : "");
257
261
  } else if (arrayShapedDrop) {
258
- dropHint = ` Known client-side bug (plur-ai/plur#297): some MCP clients drop array-typed parameters from a large arguments payload while keeping the earlier fields (here: ${missingArrayParams.join(", ")}). This is size-sensitive \u2014 the same call often succeeds with a shorter payload, so shrink the other fields (e.g. a briefer summary) as well. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays.`;
262
+ dropHint = ` Known client-side bug (plur-ai/plur#297): some MCP clients drop array-typed parameters from a large arguments payload while keeping the earlier fields (here: ${missingArrayParams.join(", ")}). This is size-sensitive \u2014 the same call often succeeds with a shorter payload, so shrink the other fields (e.g. a briefer summary) as well. Retry passing array parameters as a JSON string (e.g. tags: "[\\"a\\",\\"b\\"]") or a comma-separated string (tags: "a, b") \u2014 the server coerces both back into arrays. (A list of free-text statements such as engram_suggestions is not comma-split: send it as a JSON string; a bare string is one item.)`;
259
263
  }
260
264
  const receivedNote = receivedFields.length > 0 ? `Received fields: [${receivedFields.join(", ")}].` : "Received no fields (the arguments object was empty).";
261
265
  const disposition = wholePayloadDrop ? "The request reached the server but its arguments did not \u2014 do not abandon the call, and do not rewrite the payload: it was never evaluated. Retry the IDENTICAL call; it usually succeeds. If you issued several tool calls in one message, send them one per message \u2014 batching is the strongest correlate of this drop (#772)." : "The call reached the server \u2014 this is a malformed-arguments error, not a transport failure. Fix the field(s) named above and retry; do not abandon the call.";
@@ -362,16 +366,23 @@ mcpCanary.expect({
362
366
  });
363
367
  var _sessionTelemetry = /* @__PURE__ */ new Map();
364
368
  var SESSION_TTL_MS = 8 * 60 * 60 * 1e3;
369
+ var _pendingScopeEvictions = /* @__PURE__ */ new Set();
365
370
  function _cleanExpiredSessions(plur) {
366
371
  const cutoff = Date.now() - SESSION_TTL_MS;
367
372
  for (const [id, state] of _sessionTelemetry) {
368
373
  if (new Date(state.started_at).getTime() < cutoff) {
369
374
  _sessionTelemetry.delete(id);
375
+ _pendingScopeEvictions.add(id);
376
+ }
377
+ }
378
+ if (plur) {
379
+ for (const id of _pendingScopeEvictions) {
370
380
  try {
371
- plur?.clearSessionScope({ session: id });
381
+ plur.clearSessionScope({ session: id });
372
382
  } catch {
373
383
  }
374
384
  }
385
+ _pendingScopeEvictions.clear();
375
386
  }
376
387
  }
377
388
  function _implicitSessionId() {
@@ -379,11 +390,88 @@ function _implicitSessionId() {
379
390
  if (_sessionTelemetry.size !== 1) return void 0;
380
391
  return _sessionTelemetry.keys().next().value;
381
392
  }
393
+ function learnDecision(engram) {
394
+ return (engram.write_count ?? 1) > 1 ? { decision: "NOOP", existing_id: engram.id } : { decision: "ADD" };
395
+ }
396
+ var _warnedUntrustedConfigs = /* @__PURE__ */ new Set();
397
+ function _estimatePinnedCost(statement, ctx) {
398
+ const id = "ENG-0000-0000-000";
399
+ const text = String(statement ?? "");
400
+ const commitment = typeof ctx.commitment === "string" ? ctx.commitment : void 0;
401
+ let rendered = 0;
402
+ try {
403
+ rendered = formatLayer3({ id, statement: text, domain: ctx.domain, rationale: ctx.rationale, commitment, confidence_score: 0 }).length + 1;
404
+ } catch {
405
+ }
406
+ const fieldSum = id.length + 4 + text.length + (ctx.rationale ? 15 + ctx.rationale.length : 0) + (ctx.domain ? ctx.domain.length + 10 : 0) + (commitment ? commitment.length + 14 : 0) + 23;
407
+ return Math.ceil(Math.max(rendered, fieldSum) / 4);
408
+ }
409
+ function _shellWord(s, platform = process.platform) {
410
+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/.test(s)) return null;
411
+ if (/^[A-Za-z0-9_@+=:,./~-]+$/.test(s)) return s;
412
+ if (platform === "win32") {
413
+ if (/[$`%!"\u201c\u201d\u201e]/.test(s) || s.endsWith("\\")) return null;
414
+ return `"${s}"`;
415
+ }
416
+ return `'${s.replace(/'/g, `'\\''`)}'`;
417
+ }
418
+ function trustCommand(dir, storageRoot, platform = process.platform) {
419
+ const target = dir === null ? "<dir>" : _shellWord(dir, platform);
420
+ if (target === null) return null;
421
+ if (!storageRoot || resolve(storageRoot) === resolve(join(homedir(), ".plur"))) return `plur trust ${target}`;
422
+ const store = _shellWord(resolve(storageRoot), platform);
423
+ return store === null ? null : `plur --path ${store} trust ${target}`;
424
+ }
425
+ var UNTRUSTED_SCOPE_GRAMMAR = /^(?:global|[a-z][a-z0-9-]*:[A-Za-z0-9][A-Za-z0-9._@/:-]{0,199})$/;
426
+ var UNTRUSTED_DOMAIN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
427
+ var _UNSAFE_PATH_CHARS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/;
428
+ function _escapedText(s) {
429
+ return JSON.stringify(s.length > 1024 ? s.slice(0, 1024) + "\u2026" : s).replace(/[\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
430
+ }
431
+ function readTrustedProjectConfig(trust) {
432
+ const configPath = findProjectConfigPath();
433
+ const raw = readProjectConfigFromPath(configPath);
434
+ if (!raw.scope && !raw.domain) return {};
435
+ const configDir = configPath ? dirname(configPath) : null;
436
+ let trusted = false;
437
+ try {
438
+ trusted = configDir !== null && trust.isDirectoryTrusted(configDir);
439
+ } catch {
440
+ trusted = false;
441
+ }
442
+ if (trusted) return { scope: raw.scope, domain: raw.domain };
443
+ const declared = [
444
+ raw.scope ? UNTRUSTED_SCOPE_GRAMMAR.test(raw.scope) ? `scope ${_escapedText(raw.scope)}` : "an invalid scope" : null,
445
+ raw.domain ? UNTRUSTED_DOMAIN_GRAMMAR.test(raw.domain) ? `domain ${_escapedText(raw.domain)}` : "an invalid domain" : null
446
+ ].filter(Boolean).join(" / ");
447
+ const cmd = configDir !== null && _UNSAFE_PATH_CHARS.test(configDir) ? null : trustCommand(configDir, trust.storageRoot);
448
+ const warning = `${configPath ? _escapedText(configPath) : ".plur.yaml"} declares ${declared}, but ${configDir ? _escapedText(configDir) : "its directory"} is not a trusted directory \u2014 ignoring it and using the local default scope instead. If this project is yours, ` + (cmd !== null ? `run: ${cmd}` : "run `plur trust` for that directory from a terminal.");
449
+ if (configPath && !_warnedUntrustedConfigs.has(configPath)) {
450
+ _warnedUntrustedConfigs.add(configPath);
451
+ try {
452
+ process.stderr.write(`[plur] ${warning}
453
+ `);
454
+ } catch {
455
+ }
456
+ }
457
+ return { warning };
458
+ }
459
+ function describeRefusedRoute(scope) {
460
+ return isSharedScope(scope) ? { kind: "shared", what: `the shared scope "${scope}"`, rule: "unscoped writes are never auto-routed into a shared store" } : {
461
+ kind: "remote-personal",
462
+ what: `"${scope}", a personal scope on a remote store that is not verified as your own namespace (its /me identity is unknown or belongs to another user)`,
463
+ rule: "unscoped writes are never auto-routed into a remote personal namespace that is not verifiably yours"
464
+ };
465
+ }
382
466
  function _resolveInjectionSession(args) {
383
467
  const explicit = args.session_id;
384
468
  if (typeof explicit === "string" && explicit.length > 0) return explicit;
385
469
  return _implicitSessionId();
386
470
  }
471
+ var NO_SESSION_SLOT_WARNING = "No session is open, so this is the process-default slot. An id-less plur_learn / plur_inject / plur_recall uses NO session default unless exactly one session is open, so this slot governs neither writes nor the recall dialing context. Call plur_session_start (then plur_session_scope with its session_id) to scope a session.";
472
+ function _resolveWriteSession(args) {
473
+ return _resolveInjectionSession(args) ?? NO_SESSION;
474
+ }
387
475
  function _resolveScopeSession(args) {
388
476
  const explicit = args.session_id;
389
477
  if (typeof explicit === "string" && explicit.length > 0) {
@@ -415,7 +503,9 @@ var CURSOR_CORE_TOOL_NAMES = /* @__PURE__ */ new Set([
415
503
  "plur_receipt",
416
504
  "plur_doctor",
417
505
  "plur_packs_uninstall",
418
- "plur_tensions_purge"
506
+ "plur_tensions_purge",
507
+ "plur_tensions",
508
+ "plur_validate_meta"
419
509
  ]);
420
510
  function summarizeToolDescription(description) {
421
511
  const line = description.split("\n", 1)[0].trim();
@@ -493,13 +583,14 @@ function buildAdminDispatchTool(all) {
493
583
  const innerArgs = args.args ?? {};
494
584
  const validated = validateToolArgs(target, innerArgs);
495
585
  if (!validated.ok) {
496
- return { ...validated.errorPayload, error: `${action}: ${validated.errorPayload.error}` };
586
+ const inner = String(validated.errorPayload.error);
587
+ return { ...validated.errorPayload, error: inner.startsWith(`${action}:`) ? inner : `${action}: ${inner}` };
497
588
  }
498
589
  try {
499
590
  return await target.handler(validated.data, plur);
500
591
  } catch (err) {
501
592
  const message = err?.message ?? String(err);
502
- throw new Error(`${action}: ${message}`);
593
+ throw new Error(message.startsWith(`${action}:`) ? message : `${action}: ${message}`);
503
594
  }
504
595
  }
505
596
  };
@@ -567,7 +658,7 @@ function getAllToolDefinitions() {
567
658
  valid_from: { type: "string", description: "ISO date (YYYY-MM-DD) the knowledge becomes valid \u2014 inject/recall skip the engram before this date (#347)" },
568
659
  valid_until: { type: "string", description: 'ISO date (YYYY-MM-DD) the knowledge expires \u2014 inject/recall skip the engram after this date. Set this for any time-bound fact (offers, deadlines, temporary endpoints). When omitted, an explicit expiry phrase in the statement ("valid until 31 May 2026") is auto-parsed and echoed back (#347)' },
569
660
  supersedes: { type: "array", items: { type: "string" }, description: "Engram IDs this statement intentionally replaces (#240). Writes relations.supersedes on the new engram and the reverse superseded_by edge on each local target. Supersedes-linked pairs are skipped by tension scans \u2014 an intentional update is not a contradiction. Use when updating a standing fact (new version, changed rule) rather than contradicting it." },
570
- session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open; pass it when several are (#243)." },
661
+ session_id: { type: "string", description: "Session this write belongs to (from plur_session_start). Resolves the session default scope (incl. mid-session plur_session_scope changes) when no explicit scope is passed. Optional when one session is open. With none or several open and no session_id, NO session default applies: the write takes the unscoped path (#243, E7)." },
571
662
  measured_under: {
572
663
  type: "object",
573
664
  description: "Measurement context for numeric or benchmark-derived claims (#869). Records the conditions under which the asserted value was measured \u2014 model, source_type, hardware, dataset, date. When present, the tension scanner does not treat two measurements from the same store taken under different configurations as a contradiction (the skipped pair is reported in the scan result). Omit for non-numeric engrams.",
@@ -630,7 +721,7 @@ function getAllToolDefinitions() {
630
721
  visibility: {
631
722
  type: "string",
632
723
  enum: ["private", "public", "template"],
633
- description: 'Whether this memory may leave this machine. Defaults to "private", which means it is EXCLUDED from every exported pack. Set "public" only when the user has said this is shareable with others \u2014 it is their decision, not yours. Without this an agent cannot mark anything shareable at all, so every memory it writes is private forever and any pack built from them is empty.'
724
+ description: 'Who this memory is shared with beyond its scope. Defaults to "private": EXCLUDED from every exported pack and from shared git sync. The default does NOT keep a team-scope write on this machine \u2014 with visibility omitted, a write whose scope is a team store (e.g. group:acme/eng) still goes to that team store. Only an EXPLICIT "private" keeps such a write local (stored here with a warning, never sent to the team store). Set "public" only when the user has said this is shareable with others \u2014 it is their decision, not yours. Without "public" nothing an agent writes can appear in a pack.'
634
725
  }
635
726
  },
636
727
  required: ["statement"]
@@ -647,7 +738,7 @@ function getAllToolDefinitions() {
647
738
  // hierarchy segment as a FULL term hit, double the weight of a
648
739
  // statement word, so a missing domain forfeits the strongest
649
740
  // retrieval signal an author has. Explicit argument always wins.
650
- domain: args.domain ?? readProjectConfig().domain ?? void 0,
741
+ domain: args.domain ?? readTrustedProjectConfig(plur).domain ?? void 0,
651
742
  source: args.source,
652
743
  tags: args.tags,
653
744
  rationale: args.rationale,
@@ -672,7 +763,7 @@ function getAllToolDefinitions() {
672
763
  // explicit session_id first, else the lone open session. Never
673
764
  // persisted on the engram (LearnContext.session selects a scope, it
674
765
  // is not part of one).
675
- session: _resolveInjectionSession(args),
766
+ session: _resolveWriteSession(args),
676
767
  llm
677
768
  };
678
769
  const explicitScope = typeof args.scope === "string" && args.scope.length > 0;
@@ -725,9 +816,11 @@ function getAllToolDefinitions() {
725
816
  }
726
817
  try {
727
818
  const engram = await plur.learnRouted(statement, context);
819
+ const delivered = plur.deliveryOf(engram, context?.scope);
728
820
  const isOutbox = !!engram.structured_data?._outbox;
729
821
  const demoted = engram.structured_data?._demoted;
730
822
  const routed = engram.structured_data?._routed;
823
+ const routeRefused = engram.structured_data?._routeRefused;
731
824
  mcpCanary.signal("learn_activity");
732
825
  recordTelemetry("learn");
733
826
  const dedup = isOutbox ? void 0 : await plur.nearDuplicates(statement, context, engram.id);
@@ -759,7 +852,16 @@ function getAllToolDefinitions() {
759
852
  pinned: engram.pinned === true,
760
853
  // See the note on recall results: same fact, not same record.
761
854
  content_hash: engram.content_hash,
762
- decision: "ADD",
855
+ // An absorbed duplicate (content-hash or cross-scope recurrence)
856
+ // hands back the EXISTING engram with write_count bumped; reporting
857
+ // it as 'ADD' told the caller a new memory exists when none was
858
+ // written. Same vocabulary as plur_learn_batch (formal Adapters #1).
859
+ ...learnDecision(engram),
860
+ // #1264: always present. The warning sits BEFORE the others so a
861
+ // more specific `warning` below (outbox, demotion, refusal) still
862
+ // wins that key; `delivery_warning` keeps this one either way.
863
+ delivery: delivered.delivery,
864
+ ...delivered.warning ? { delivery_warning: delivered.warning, warning: delivered.warning } : {},
763
865
  ...dedup?.near_duplicates?.length ? { dedup } : {},
764
866
  ...redraft ? { redraft } : {},
765
867
  ...(() => {
@@ -771,10 +873,20 @@ function getAllToolDefinitions() {
771
873
  ...domainHint(!!routed),
772
874
  ...isOutbox ? { outbox: true, warning: "Remote write failed; engram queued locally for retry on next session start or plur_sync." } : {},
773
875
  ...demoted ? { demoted: true, requested_scope: demoted.from, warning: `Sensitive content (${demoted.patterns}) detected \u2014 stored at "${demoted.to}"/private instead of the requested shared scope "${demoted.from}". If this is a false positive, re-scope deliberately.` } : {},
774
- ...routed ? { routed: { scope: routed.scope, confidence: routed.confidence, reason: routed.reason }, info: `No scope was provided; auto-routed to "${routed.scope}" (confidence ${routed.confidence}) because its content matched that scope's covers. Pass an explicit scope to override.` } : {}
876
+ ...routed ? { routed: { scope: routed.scope, confidence: routed.confidence, reason: routed.reason }, info: `No scope was provided; auto-routed to "${routed.scope}" (confidence ${routed.confidence}) because its content matched that scope's covers. Pass an explicit scope to override.` } : {},
877
+ // #1115: a shared scope matched but was NOT adopted. Said plainly,
878
+ // as a `warning`, because the old `info` string for the opposite
879
+ // outcome proved easy to miss in a long session — and this one
880
+ // changes what the caller should do next, rather than merely
881
+ // reporting where the write went.
882
+ ...routeRefused ? (() => {
883
+ const why = describeRefusedRoute(routeRefused.scope);
884
+ return { route_refused: { scope: routeRefused.scope, confidence: routeRefused.confidence, reason: routeRefused.reason, kind: why.kind }, warning: `No scope was provided. This content matched ${why.what} (confidence ${routeRefused.confidence}), but ${why.rule} \u2014 it was stored at "${engram.scope}" instead. If it belongs there, pass scope: "${routeRefused.scope}" explicitly, or move it with plur_rescope.` };
885
+ })() : {}
775
886
  };
776
887
  } catch (err) {
777
888
  const engram = await plur.learn(statement, context);
889
+ const delivered = plur.deliveryOf(engram, context?.scope);
778
890
  const isOutbox = !!engram.structured_data?._outbox;
779
891
  const routedFallback = engram.structured_data?._routed;
780
892
  mcpCanary.signal("learn_activity");
@@ -788,19 +900,25 @@ function getAllToolDefinitions() {
788
900
  statement: engram.statement,
789
901
  scope: engram.scope,
790
902
  type: engram.type,
791
- decision: "ADD",
903
+ ...learnDecision(engram),
904
+ delivery: delivered.delivery,
905
+ ...delivered.warning ? { delivery_warning: delivered.warning } : {},
792
906
  ...temporalEcho(engram),
793
907
  ...scopeHint(engram.scope, !!routedFallback),
794
908
  ...domainHint(!!routedFallback),
795
909
  ...isOutbox ? { outbox: true } : {},
796
- warning: `Remote write failed (${err.message}); engram queued for retry.`
910
+ // The routed write can fail for reasons that have nothing to do
911
+ // with a remote (a local lock, a store error), and learn() only
912
+ // queues when the scope is remote-backed. Say what actually
913
+ // happened (formal Adapters #1).
914
+ warning: isOutbox ? `Remote write failed (${err.message}); engram queued for retry.` : `Routed write failed (${err.message}); stored through the local learn() fallback at "${engram.scope}".`
797
915
  };
798
916
  }
799
917
  }
800
918
  },
801
919
  {
802
920
  name: "plur_learn_batch",
803
- description: 'Create many engrams in one call \u2014 the batch form of plur_learn. Accepts an array of engram objects and writes them sequentially through the SAME dedup + policy pipeline as plur_learn (content-hash NOOP \u2192 semantic recall \u2192 LLM ADD/UPDATE/MERGE decision, or local cosine REPORTING when no LLM is configured). Exact-hash dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Similarity never suppresses a write \u2014 each result carries `dedup.mode` (llm | cosine | hash-only) and, when similarity ran, `dedup.near_duplicates`. Read dedup.mode before trusting an ADD: hash-only means "not identical", NOT "not a duplicate". Returns `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \u2014 a single bad item does not abort the batch. Use this when an orchestration fans out and wants to persist consolidated findings without N separate calls. LLM dedup calls are capped (default 50, override with max_llm_calls) to bound bulk-import cost. Note: unlike plur_learn, batch items take the LOCAL learn path \u2014 remote-scope auto-routing (learnRouted) is not applied per item, so for shared/remote-store writes prefer plur_learn or pass an explicit local scope. See plur-ai/plur#281.',
921
+ description: 'Create many engrams in one call \u2014 the batch form of plur_learn. Accepts an array of engram objects and writes them sequentially through the SAME dedup + policy pipeline as plur_learn (content-hash NOOP \u2192 semantic recall \u2192 LLM ADD/UPDATE/MERGE decision, or local cosine REPORTING when no LLM is configured). Exact-hash dedup also applies WITHIN the batch: a statement duplicating an earlier item in the same array resolves to NOOP against it. Similarity never suppresses a write \u2014 each result carries `dedup.mode` (llm | cosine | hash-only) and, when similarity ran, `dedup.near_duplicates`. Read dedup.mode before trusting an ADD: hash-only means "not identical", NOT "not a duplicate". Returns `ids` aligned 1:1 with the input array (ids[i] is the engram id for input i, or null if input i failed), the per-item decisions (each carrying its input_index), aggregate stats, and any per-item failures (each with its input index) \u2014 a single bad item does not abort the batch. Use this when an orchestration fans out and wants to persist consolidated findings without N separate calls. LLM dedup calls are capped (default 50, override with max_llm_calls) to bound bulk-import cost. Each item is written through the same routed path as plur_learn (learnRouted, #930), resolves the same session default scope (pass `session_id` when several sessions are open) and the same .plur.yaml domain default, and a `pinned` item is refused with pinned_quota_exceeded when the pinned set has no room \u2014 reported in `failures`, the rest of the batch still written. Items take no `visibility`: each gets the default ("private" \u2014 excluded from packs and shared git sync), and an item whose scope is a team store still goes to that team store; to keep a team-scope memory local, use plur_learn with an explicit visibility: "private". See plur-ai/plur#281.',
804
922
  annotations: { title: "Learn (batch)", destructiveHint: false, idempotentHint: false },
805
923
  inputSchema: {
806
924
  type: "object",
@@ -837,7 +955,8 @@ function getAllToolDefinitions() {
837
955
  required: ["statement"]
838
956
  }
839
957
  },
840
- max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items fall back to the local cosine path (no API cost); the dedup.mode on each result says which ran. Pass a large number to opt out." }
958
+ max_llm_calls: { type: "number", description: "Max LLM dedup calls across the whole batch (default 50). Once spent, remaining items fall back to the local cosine path (no API cost); the dedup.mode on each result says which ran. Pass a large number to opt out." },
959
+ session_id: { type: "string", description: "Session these writes belong to (from plur_session_start). Resolves the session default scope for items without an explicit scope, exactly as plur_learn does. Optional when one session is open. With none or several open and no session_id, NO session default applies: the write takes the unscoped path (#243, E7)." }
841
960
  },
842
961
  required: ["engrams"]
843
962
  },
@@ -847,12 +966,15 @@ function getAllToolDefinitions() {
847
966
  if (raw.length === 0) {
848
967
  return { ids: [], results: [], stats: { added: 0, updated: 0, merged: 0, noops: 0, failed: 0 }, failures: [], warning: "No engrams provided \u2014 pass a non-empty `engrams` array." };
849
968
  }
969
+ const batchSession = _resolveWriteSession(args);
970
+ const projectDomain = readTrustedProjectConfig(plur).domain ?? void 0;
850
971
  const items = raw.map((e) => ({
851
972
  statement: sanitizeStatement(e.statement),
852
973
  context: {
853
974
  type: e.type,
854
975
  scope: e.scope,
855
- domain: e.domain,
976
+ domain: e.domain ?? projectDomain,
977
+ session: batchSession,
856
978
  source: e.source,
857
979
  tags: e.tags,
858
980
  rationale: e.rationale,
@@ -864,11 +986,44 @@ function getAllToolDefinitions() {
864
986
  }
865
987
  }));
866
988
  const maxLlmCalls = typeof args.max_llm_calls === "number" ? args.max_llm_calls : void 0;
867
- const { results, stats, failures } = await plur.learnBatch(
868
- items,
989
+ const gateFailures = [];
990
+ let admitted = items.map((_, i) => i);
991
+ if (items.some((it) => it.context.pinned === true)) {
992
+ const q = await plur.pinnedQuota();
993
+ let free = q.free;
994
+ admitted = [];
995
+ items.forEach((it, i) => {
996
+ if (it.context.pinned !== true) {
997
+ admitted.push(i);
998
+ return;
999
+ }
1000
+ if (free <= 0) {
1001
+ const claimed = q.free - free;
1002
+ gateFailures.push({
1003
+ index: i,
1004
+ statement: String(it.statement ?? "").slice(0, 80),
1005
+ error: `pinned_quota_exceeded: the pinned set has no room (quota ${q.quota}, used ${q.used}${claimed > 0 ? `, plus ~${claimed} claimed by earlier pinned items in this batch` : ""}); this item was NOT stored \u2014 learn it unpinned or unpin something first (plur_pin {list:true}).`
1006
+ });
1007
+ return;
1008
+ }
1009
+ admitted.push(i);
1010
+ free -= _estimatePinnedCost(it.statement, it.context);
1011
+ });
1012
+ }
1013
+ const batchOut = admitted.length === 0 ? { results: [], stats: { added: 0, updated: 0, merged: 0, noops: 0, failed: 0 }, failures: [] } : await plur.learnBatch(
1014
+ admitted.map((i) => items[i]),
869
1015
  llm,
870
1016
  maxLlmCalls !== void 0 ? { maxLlmCalls } : void 0
871
1017
  );
1018
+ const results = batchOut.results.map((r) => ({
1019
+ ...r,
1020
+ input_index: r.input_index !== void 0 ? admitted[r.input_index] : void 0
1021
+ }));
1022
+ const failures = [
1023
+ ...batchOut.failures.map((f) => ({ ...f, index: admitted[f.index] ?? f.index })),
1024
+ ...gateFailures
1025
+ ].sort((a, b) => a.index - b.index);
1026
+ const stats = { ...batchOut.stats, failed: (batchOut.stats.failed ?? 0) + gateFailures.length };
872
1027
  mcpCanary.signal("learn_activity");
873
1028
  recordTelemetry("learn");
874
1029
  const ids = raw.map(() => null);
@@ -894,10 +1049,23 @@ function getAllToolDefinitions() {
894
1049
  } catch {
895
1050
  }
896
1051
  }
1052
+ const refusedScopes = [...new Set(results.map((r) => r.engram.structured_data?._routeRefused?.scope).filter((sc) => typeof sc === "string"))];
1053
+ const refusedCount = results.filter((r) => r.engram.structured_data?._routeRefused !== void 0).length;
1054
+ const warnings = [];
1055
+ if (failures.length > 0) {
1056
+ warnings.push(`${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.`);
1057
+ }
1058
+ if (refusedCount > 0) {
1059
+ warnings.push(
1060
+ `${refusedCount} of ${raw.length} engram(s) had no scope and matched ${refusedScopes.map((sc) => describeRefusedRoute(sc).what).join("; ")} \u2014 unscoped writes are never auto-routed into a shared store or into a remote personal namespace that is not verifiably yours, so they were stored at the local default instead. Pass an explicit scope on those items if they belong there, or move them with plur_rescope.`
1061
+ );
1062
+ }
897
1063
  return {
898
1064
  ids,
899
1065
  results: results.map((r) => {
900
1066
  const isOutbox = !!r.engram.structured_data?._outbox;
1067
+ const routed = r.engram.structured_data?._routed;
1068
+ const routeRefused = r.engram.structured_data?._routeRefused;
901
1069
  return {
902
1070
  input_index: r.input_index,
903
1071
  id: isOutbox ? r.engram.id : plur.readIdFor(r.engram),
@@ -909,12 +1077,15 @@ function getAllToolDefinitions() {
909
1077
  // #856 audit: `dedup` was computed and then dropped here, so the
910
1078
  // reporting it exists for reached no caller — "anything below the
911
1079
  // bar is still reported" was not observable anywhere.
912
- ...r.dedup ? { dedup: r.dedup } : {}
1080
+ ...r.dedup ? { dedup: r.dedup } : {},
1081
+ ...routed ? { routed: { scope: routed.scope, confidence: routed.confidence, reason: routed.reason } } : {},
1082
+ ...routeRefused ? { route_refused: { scope: routeRefused.scope, confidence: routeRefused.confidence, reason: routeRefused.reason } } : {}
913
1083
  };
914
1084
  }),
915
1085
  stats,
916
1086
  ...batchDomainHint,
917
- ...failures.length > 0 ? { failures, warning: `${failures.length} of ${raw.length} engram(s) failed to persist; the rest were written.` } : {}
1087
+ ...failures.length > 0 ? { failures } : {},
1088
+ ...warnings.length > 0 ? { warning: warnings.join(" ") } : {}
918
1089
  };
919
1090
  }
920
1091
  },
@@ -939,7 +1110,7 @@ function getAllToolDefinitions() {
939
1110
  budget: { type: "object", description: 'Budget constraints for sub-agents. Hybrid mode only \u2014 ignored when mode:"keyword".', properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
940
1111
  caller_session_id: { type: "string", description: 'Session ID of calling agent for budget enforcement. Hybrid mode only \u2014 ignored when mode:"keyword".' },
941
1112
  include_episodes: { type: "boolean", description: 'If true, include linked episode summaries for each engram (SP2 episodic anchoring). Hybrid mode only \u2014 ignored when mode:"keyword".' },
942
- session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope (incl. mid-session plur_session_scope changes) sets the remote dialing context when no explicit scope filter is passed. Optional when one session is open (#243)." }
1113
+ session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope (incl. mid-session plur_session_scope changes) sets the remote dialing context when no explicit scope filter is passed. Optional when one session is open (#243); with none or several open and no session_id, no session default applies." }
943
1114
  },
944
1115
  required: ["query"]
945
1116
  },
@@ -959,7 +1130,7 @@ function getAllToolDefinitions() {
959
1130
  budget: { type: "object", description: "Budget constraints for sub-agents", properties: { max_tokens: { type: "number" }, max_results: { type: "number" } } },
960
1131
  caller_session_id: { type: "string", description: "Session ID of calling agent for budget enforcement" },
961
1132
  include_episodes: { type: "boolean", description: "If true, include linked episode summaries for each engram (SP2 episodic anchoring)" },
962
- session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope sets the remote dialing context when no explicit scope filter is passed (#243)." }
1133
+ session_id: { type: "string", description: "Session this recall belongs to (from plur_session_start). Its default scope sets the remote dialing context when no explicit scope filter is passed (#243). Optional when one session is open; with none or several open and no session_id, no session default applies." }
963
1134
  },
964
1135
  required: ["query"]
965
1136
  },
@@ -981,12 +1152,12 @@ function getAllToolDefinitions() {
981
1152
  task: { type: "string", description: "The task description to inject context for" },
982
1153
  budget: { type: "number", description: "Token budget for injection (default 2000)" },
983
1154
  scope: { type: "string", description: "Scope filter for engram selection" },
984
- session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open; required for correct attribution when several are." }
1155
+ session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open. With none or several open and no session_id, no session default applies (E7) and the injection is not attributed to any session." }
985
1156
  },
986
1157
  required: ["task"]
987
1158
  },
988
1159
  handler: async (args, plur) => {
989
- const session_id = _resolveInjectionSession(args);
1160
+ const session_id = _resolveWriteSession(args);
990
1161
  const result = await plur.inject(args.task, {
991
1162
  budget: args.budget,
992
1163
  scope: args.scope,
@@ -1019,12 +1190,12 @@ function getAllToolDefinitions() {
1019
1190
  task: { type: "string", description: "The task description to inject context for" },
1020
1191
  budget: { type: "number", description: "Token budget for injection (default 2000)" },
1021
1192
  scope: { type: "string", description: "Scope filter for engram selection" },
1022
- session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open; required for correct attribution when several are." }
1193
+ session_id: { type: "string", description: "Session this injection belongs to (from plur_session_start). Optional when one session is open. With none or several open and no session_id, no session default applies (E7) and the injection is not attributed to any session." }
1023
1194
  },
1024
1195
  required: ["task"]
1025
1196
  },
1026
1197
  handler: async (args, plur) => {
1027
- const session_id = _resolveInjectionSession(args);
1198
+ const session_id = _resolveWriteSession(args);
1028
1199
  const result = await plur.injectHybrid(args.task, {
1029
1200
  budget: args.budget,
1030
1201
  scope: args.scope,
@@ -1404,6 +1575,13 @@ function getAllToolDefinitions() {
1404
1575
  // field has, so a caller reading only that cannot distinguish a
1405
1576
  // clean pack from one whose baseline was destroyed.
1406
1577
  integrity_status: p.integrity_status,
1578
+ // 'carried-from-v1' when the v2 baseline was carried from a v1 row
1579
+ // without re-verification (§5.5): `ok` then means "unchanged since
1580
+ // the migration", not "matches what was installed".
1581
+ ...p.baseline ? { baseline: p.baseline } : {},
1582
+ // true when the pack's only registry row is a legacy row another
1583
+ // same-name pack could own; integrity_status is then 'unverified'.
1584
+ ...p.registry_ambiguous ? { registry_ambiguous: true } : {},
1407
1585
  installed_at: p.installed_at,
1408
1586
  source: p.source
1409
1587
  })),
@@ -1489,7 +1667,7 @@ function getAllToolDefinitions() {
1489
1667
  },
1490
1668
  {
1491
1669
  name: "plur_outbox",
1492
- description: "Inspect the remote-write outbox \u2014 team-scoped writes queued locally because their remote store was unreachable. Read-only by default; pass flush:true to retry them now. Entries never include the target URL or token.",
1670
+ description: "Inspect the remote-write outbox \u2014 team-scoped writes queued locally because their remote store was unreachable, plus any other queued remote operation core lists (e.g. a retirement still to be applied on the remote). Read-only by default; pass flush:true to retry them now. Entries never include the target URL or token.",
1493
1671
  annotations: { title: "Outbox", readOnlyHint: false, idempotentHint: false },
1494
1672
  inputSchema: {
1495
1673
  type: "object",
@@ -1500,11 +1678,21 @@ function getAllToolDefinitions() {
1500
1678
  handler: async (args, plur) => {
1501
1679
  const before = await plur.listOutbox();
1502
1680
  if (args.flush !== true) {
1503
- return { pending: before.length, entries: before };
1681
+ const summary = summarizeOutbox(before);
1682
+ return {
1683
+ pending: before.length,
1684
+ retrying: summary.retrying,
1685
+ needs_action: summary.needs_action,
1686
+ ...summary.needs_action > 0 ? { needs_action_scopes: summary.scopes } : {},
1687
+ entries: before
1688
+ };
1504
1689
  }
1505
- const result = await plur.flushOutbox();
1690
+ const result = await plur.flushOutbox({ force: true });
1506
1691
  return {
1507
- pending: await plur.outboxCount(),
1692
+ // Counted from the same list the entries come from (formal R2): a
1693
+ // separate counter can miss an entry kind the list shows (e.g. a
1694
+ // queued remote retirement), and report 0 while one is stuck.
1695
+ pending: (await plur.listOutbox()).length,
1508
1696
  flushed: result.flushed,
1509
1697
  failed: result.failed,
1510
1698
  ...result.expired_warnings.length > 0 ? { expired_warnings: result.expired_warnings } : {},
@@ -1632,8 +1820,13 @@ function getAllToolDefinitions() {
1632
1820
  },
1633
1821
  {
1634
1822
  name: "plur_validate_meta",
1635
- description: "Test a meta-engram template against engrams from a new domain \u2014 updates confidence and domain_coverage",
1636
- annotations: { title: "Validate meta-engram", destructiveHint: false, idempotentHint: false },
1823
+ description: "Test a meta-engram template against engrams from a new domain \u2014 updates confidence and domain_coverage. A meta-engram that fails validation in a third domain is demoted (top \u2192 mop) or, below top level, RETIRED.",
1824
+ // Destructive (owner decision I_tensions_resolve, formal R2): the third
1825
+ // failed validation retires a non-top meta-engram (core
1826
+ // meta/validation.ts) and the handler persists it. A removal needs an
1827
+ // explicit, gated act, so plur_admin refuses this tool and it is a direct
1828
+ // tool in every profile (CURSOR_CORE_TOOL_NAMES).
1829
+ annotations: { title: "Validate meta-engram", destructiveHint: true, idempotentHint: false },
1637
1830
  inputSchema: {
1638
1831
  type: "object",
1639
1832
  properties: {
@@ -1658,9 +1851,15 @@ function getAllToolDefinitions() {
1658
1851
  args.llm_api_key,
1659
1852
  args.llm_model
1660
1853
  );
1854
+ const wasRetired = meta.status === "retired";
1661
1855
  const result = await validateMetaEngram(meta, testEngrams, testDomain, llm);
1856
+ const retiredNow = !wasRetired && meta.status === "retired";
1662
1857
  await plur.updateEngram(meta);
1663
1858
  return {
1859
+ ...retiredNow ? {
1860
+ retired: true,
1861
+ note: `Meta-engram ${result.meta_engram_id} was retired: its prediction failed in a third domain. It no longer injects; its history records the retirement.`
1862
+ } : {},
1664
1863
  meta_engram_id: result.meta_engram_id,
1665
1864
  test_domain: result.test_domain,
1666
1865
  prediction_held: result.prediction_held,
@@ -1704,6 +1903,9 @@ function getAllToolDefinitions() {
1704
1903
  tension_count: status.tension_count,
1705
1904
  versioned_engram_count: status.versioned_engram_count ?? 0,
1706
1905
  outbox_count: status.outbox_count ?? 0,
1906
+ // #1299: queued writes a retry cannot deliver, per scope.
1907
+ outbox_needs_action: status.outbox_needs_action ?? 0,
1908
+ ...status.outbox_attention ? { outbox_attention: status.outbox_attention } : {},
1707
1909
  // Injection-provenance event/label counts (#452) — #202's volume gate.
1708
1910
  history_events: status.history_events ?? {
1709
1911
  co_injection: 0,
@@ -2071,7 +2273,11 @@ function getAllToolDefinitions() {
2071
2273
  {
2072
2274
  name: "plur_session_start",
2073
2275
  description: "Start a session \u2014 inject relevant engrams for your task. Call at the beginning of every session.",
2074
- annotations: { title: "Session Start", readOnlyHint: true, idempotentHint: false },
2276
+ // Not read-only (formal R2, mcp-integrations#6): start registers the
2277
+ // session's scope, flushes the remote-write outbox (pushes to remote
2278
+ // stores) and writes telemetry. It only replays writes already asked for,
2279
+ // so it is not destructive.
2280
+ annotations: { title: "Session Start", readOnlyHint: false, destructiveHint: false, idempotentHint: false },
2075
2281
  inputSchema: {
2076
2282
  type: "object",
2077
2283
  properties: {
@@ -2101,6 +2307,12 @@ function getAllToolDefinitions() {
2101
2307
  } catch (err) {
2102
2308
  outbox_error = err.message;
2103
2309
  }
2310
+ let outbox_needs;
2311
+ try {
2312
+ const summary = await plur.outboxSummary();
2313
+ if (summary.needs_action > 0) outbox_needs = summary;
2314
+ } catch {
2315
+ }
2104
2316
  const remote_scopes = plur.getWritableRemoteScopes().map((s) => {
2105
2317
  const md = plur.getScopeMetadata(s.scope);
2106
2318
  return {
@@ -2109,7 +2321,7 @@ function getAllToolDefinitions() {
2109
2321
  ...md?.covers && md.covers.length > 0 ? { covers: md.covers } : {}
2110
2322
  };
2111
2323
  });
2112
- const projectConfig = readProjectConfig();
2324
+ const projectConfig = readTrustedProjectConfig(plur);
2113
2325
  const explicit_default_scope = args.default_scope ?? null;
2114
2326
  const default_scope = explicit_default_scope ?? projectConfig.scope ?? null;
2115
2327
  const scope_source = explicit_default_scope ? "caller" : projectConfig.scope ? "project-config" : "none";
@@ -2201,6 +2413,11 @@ ${guide}`;
2201
2413
  version_warning = `Update available: PLUR v${versionCheck.current} \u2192 v${versionCheck.latest}. Run: npm i -g @plur-ai/cli@latest && plur init (configs pin versions)`;
2202
2414
  }
2203
2415
  }
2416
+ if (projectConfig.warning) {
2417
+ guide = `\u26A0\uFE0F ${projectConfig.warning}
2418
+
2419
+ ${guide}`;
2420
+ }
2204
2421
  if (scope_source === "project-config") {
2205
2422
  guide += `
2206
2423
 
@@ -2261,6 +2478,11 @@ Remote store scopes available: ${scopeList}. Set scope PER ENGRAM by content: wh
2261
2478
  } catch {
2262
2479
  }
2263
2480
  }
2481
+ if (outbox_needs) {
2482
+ guide += `
2483
+
2484
+ \u26A0\uFE0F OUTBOX: ${outbox_needs.needs_action} queued team write(s) cannot be delivered by retrying. ` + describeNeedsAction(outbox_needs).join(" ") + " Tell the user; nothing is dropped automatically. `plur outbox` lists them.";
2485
+ }
2264
2486
  const session_tool_profile = activeToolProfile();
2265
2487
  if (session_tool_profile !== "full") {
2266
2488
  guide += `
@@ -2277,6 +2499,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2277
2499
  ...remote_scopes.length > 0 ? { remote_scopes } : {},
2278
2500
  ...default_scope ? { default_scope, scope_source } : {},
2279
2501
  ...default_domain ? { default_domain, domain_source: "project-config" } : {},
2502
+ ...projectConfig.warning ? { project_config_warning: projectConfig.warning } : {},
2280
2503
  // Ask LLM to check back — MCP can't push, but we can request a follow-up
2281
2504
  follow_up: store_stats.engram_count === 0 ? "This is a fresh store with 0 engrams. After your first exchange with the user, review what you learned and call plur_learn for any corrections, preferences, or patterns. Build the memory from this session." : void 0,
2282
2505
  // On fresh install, suggest hook setup for reliable injection
@@ -2293,6 +2516,10 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2293
2516
  outbox_error,
2294
2517
  outbox_warning: `The outbox flush failed \u2014 ${outbox_error}. Engrams routed to a remote store are still queued locally and were NOT pushed. They retry on the next session_start or plur_sync.`
2295
2518
  } : {},
2519
+ // #1299: writes a retry cannot deliver — count, scope, reason, next step.
2520
+ ...outbox_needs ? {
2521
+ outbox_needs_action: { count: outbox_needs.needs_action, scopes: outbox_needs.scopes }
2522
+ } : {},
2296
2523
  // Version staleness warning (issue #151)
2297
2524
  ...version_warning ? { version_warning, version: VERSION } : {}
2298
2525
  };
@@ -2302,7 +2529,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2302
2529
  },
2303
2530
  {
2304
2531
  name: "plur_session_scope",
2305
- description: `Adjust or inspect the session default write scope MID-session \u2014 narrow, expand, or switch context without restarting the session (#243). op:"set" replaces the default scope used by unscoped plur_learn calls for the rest of the session AND the org context that decides which enterprise hosts plur_recall dials; op:"show" reports the effective scope and how it was derived (project config, session_start default, or a mid-session set); op:"clear" reverts to the scope the session started with. Use when the conversation genuinely pivots \u2014 a focused bug fix surfacing a team-wide architecture insight, or switching to another org's project. Do NOT oscillate scope call-by-call: for a one-off write to a different scope, pass scope explicitly on that plur_learn instead (explicit per-call scope always beats the session default). Every change is logged as a session_scope_changed history event.`,
2532
+ description: `Adjust or inspect the session default write scope MID-session \u2014 narrow, expand, or switch context without restarting the session (#243). op:"set" replaces the default scope used by unscoped plur_learn calls for the rest of the session AND the org context that decides which enterprise hosts plur_recall dials (it needs an open session: with none open it refuses, since no id-less call reads a session-less slot); op:"show" reports the effective scope and how it was derived (project config, session_start default, or a mid-session set); op:"clear" reverts to the scope the session started with. Use when the conversation genuinely pivots \u2014 a focused bug fix surfacing a team-wide architecture insight, or switching to another org's project. Do NOT oscillate scope call-by-call: for a one-off write to a different scope, pass scope explicitly on that plur_learn instead (explicit per-call scope always beats the session default). Every change is logged as a session_scope_changed history event.`,
2306
2533
  annotations: { title: "Session scope", destructiveHint: false, idempotentHint: true },
2307
2534
  inputSchema: {
2308
2535
  type: "object",
@@ -2335,6 +2562,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2335
2562
  const reason = args.reason;
2336
2563
  const { session, ambiguous, open } = _resolveScopeSession(args);
2337
2564
  const record = session ? _sessionTelemetry.get(session) : void 0;
2565
+ const noSessionSlot = session === void 0 && open === 0;
2338
2566
  const remote_scopes = plur.getWritableRemoteScopes();
2339
2567
  const withCommon = (body) => ({
2340
2568
  op,
@@ -2350,7 +2578,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2350
2578
  source,
2351
2579
  ...ambiguous ? {
2352
2580
  warning: `${open} sessions are open \u2014 this is the process-default slot, not a specific session's scope. Pass session_id (from plur_session_start) to inspect one.`
2353
- } : {},
2581
+ } : noSessionSlot && scope != null ? { warning: NO_SESSION_SLOT_WARNING } : {},
2354
2582
  guide: scope == null ? "No session default scope is set: unscoped plur_learn writes auto-route on a confident covers match or land at the unscoped default. Explicit per-call scope always wins." : `Unscoped plur_learn calls this session default to "${scope}"; recall dialing follows the same org context. Explicit per-call scope always wins.`
2355
2583
  });
2356
2584
  }
@@ -2360,6 +2588,11 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2360
2588
  );
2361
2589
  }
2362
2590
  if (op === "set") {
2591
+ if (noSessionSlot) {
2592
+ throw new Error(
2593
+ "plur_session_scope: no session is open, so there is no session scope to set \u2014 an id-less plur_learn / plur_inject / plur_recall uses no session default unless exactly one session is open. Call plur_session_start first (then pass its session_id here), or pass scope explicitly on each plur_learn."
2594
+ );
2595
+ }
2363
2596
  const scope = args.scope;
2364
2597
  if (typeof scope !== "string" || scope.trim().length === 0) {
2365
2598
  throw new Error('plur_session_scope: op:"set" requires a non-empty string "scope" (use op:"clear" to revert to the session-start default)');
@@ -2370,7 +2603,8 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2370
2603
  const { previous: previous2, next: next2 } = plur.adjustSessionScope(scope, { session, reason, trigger: "set" });
2371
2604
  if (record) record.scope_adjusted = true;
2372
2605
  const remoteEntry = remote_scopes.find((s) => s.scope === scope);
2373
- const warning = isSharedScope(scope) ? remoteEntry ? `"${scope}" routes to the shared remote store at ${remoteEntry.url}: every unscoped plur_learn for the rest of this session defaults there, visible to everyone with read access to that scope. The per-write secrets/sensitivity guard still scans each write (offending content is demoted to local), but relevance is your call \u2014 clear or narrow the scope when the conversation leaves team context.` : `"${scope}" is a shared-family scope but matches no configured remote store scope, so writes stay on this machine under that namespace. The write-time sensitivity guard treats it as shared (scans + demotes offending content). If you expected a team store, check the remote_scopes list.` : void 0;
2606
+ const sharedWarning = isSharedScope(scope) ? remoteEntry ? `"${scope}" routes to the shared remote store at ${remoteEntry.url}: every unscoped plur_learn for the rest of this session defaults there, visible to everyone with read access to that scope. The per-write secrets/sensitivity guard still scans each write (offending content is demoted to local), but relevance is your call \u2014 clear or narrow the scope when the conversation leaves team context.` : `"${scope}" is a shared-family scope but matches no configured remote store scope, so writes stay on this machine under that namespace. The write-time sensitivity guard treats it as shared (scans + demotes offending content). If you expected a team store, check the remote_scopes list.` : void 0;
2607
+ const warning = sharedWarning;
2374
2608
  return withCommon({
2375
2609
  previous_scope: previous2,
2376
2610
  new_scope: next2,
@@ -2378,7 +2612,7 @@ Tool profile "${session_tool_profile}": most plur_* tools are not exposed by nam
2378
2612
  ...warning ? { warning } : {}
2379
2613
  });
2380
2614
  }
2381
- const restored = record !== void 0 ? record.default_scope ?? null : readProjectConfig().scope ?? null;
2615
+ const restored = record !== void 0 ? record.default_scope ?? null : readTrustedProjectConfig(plur).scope ?? null;
2382
2616
  const restored_source = record !== void 0 ? record.default_scope_source === "caller" ? "session-start" : record.default_scope_source ?? "none" : restored != null ? "project-config" : "none";
2383
2617
  const { previous, next } = plur.adjustSessionScope(restored, { session, reason, trigger: "clear" });
2384
2618
  if (record) record.scope_adjusted = false;
@@ -2424,7 +2658,7 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2424
2658
  }
2425
2659
  ]
2426
2660
  },
2427
- description: 'Learnings from this session. Preferred shape is {statement: "...", type?: "..."}; bare strings are also accepted and treated as the statement. Review the conversation for corrections, preferences, patterns, and technical facts before calling.'
2661
+ description: 'Learnings from this session. Preferred shape is {statement: "...", type?: "..."}; bare strings are also accepted and treated as the statement. If the whole parameter arrives as one plain string it is ONE suggestion (never split on commas); send several as a JSON array. Review the conversation for corrections, preferences, patterns, and technical facts before calling.'
2428
2662
  }
2429
2663
  },
2430
2664
  required: ["summary", "engram_suggestions"]
@@ -2457,13 +2691,18 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2457
2691
  session_id,
2458
2692
  channel: "mcp"
2459
2693
  });
2694
+ const endSession = _resolveInjectionSession(args);
2695
+ const projectDomain = readTrustedProjectConfig(plur).domain ?? void 0;
2460
2696
  let engrams_created = 0;
2461
2697
  const engrams_failed = [];
2462
2698
  for (let i = 0; i < items.length; i++) {
2463
2699
  const { statement, type } = items[i];
2464
2700
  try {
2465
- await plur.learn(statement, {
2701
+ await plur.learnRouted(sanitizeStatement(statement), {
2466
2702
  type,
2703
+ // E7: no resolvable session → no session default (NO_SESSION).
2704
+ session: endSession ?? NO_SESSION,
2705
+ domain: projectDomain,
2467
2706
  // Link the engram back to the session that produced it (#960).
2468
2707
  session_episode_id: episode.id,
2469
2708
  // An end-of-session summary is the model's reading of what
@@ -2475,20 +2714,23 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2475
2714
  engrams_failed.push({ index: i, statement: statement.slice(0, 80), error: err.message });
2476
2715
  }
2477
2716
  }
2478
- const telemetry = session_id ? _sessionTelemetry.get(session_id) : void 0;
2717
+ const telemetry = endSession ? _sessionTelemetry.get(endSession) : void 0;
2479
2718
  const injection_summary = telemetry && telemetry.injection_calls > 0 ? {
2480
2719
  pack_counts: { ...telemetry.pack_counts },
2481
2720
  total_injections: telemetry.injection_calls,
2482
2721
  session_duration_ms: Date.now() - new Date(telemetry.started_at).getTime()
2483
2722
  } : void 0;
2484
- if (session_id) {
2485
- _sessionTelemetry.delete(session_id);
2486
- plur.clearSessionScope({ session: session_id });
2723
+ if (endSession) {
2724
+ _sessionTelemetry.delete(endSession);
2725
+ plur.clearSessionScope({ session: endSession });
2487
2726
  }
2488
2727
  try {
2489
- const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
2728
+ const plurDir = process.env.PLUR_PATH || join(homedir(), ".plur");
2490
2729
  const sessionsDir = join(plurDir, "sessions");
2491
- const keys = [session_id, process.env.CLAUDE_SESSION_ID, String(process.ppid)].filter(Boolean).map((k) => k.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64));
2730
+ const keys = [session_id, process.env.CLAUDE_SESSION_ID, String(process.ppid)].filter(Boolean).flatMap((k) => [
2731
+ (k.replace(/[^A-Za-z0-9_-]/g, "_") || "unknown").slice(0, 64),
2732
+ k.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64)
2733
+ ]).filter(Boolean);
2492
2734
  for (const key of keys) {
2493
2735
  const cp = join(sessionsDir, `${key}.checkpoint.json`);
2494
2736
  if (existsSync(cp)) {
@@ -2595,12 +2837,24 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2595
2837
  const raw = args.min_confidence;
2596
2838
  const explicit = typeof raw === "number" && Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : void 0;
2597
2839
  const minConfidence = explicit ?? plur.getScopeRoutingConfig().min_confidence ?? SUGGEST_DISPLAY_MIN_CONFIDENCE;
2598
- const candidates = await plur.suggestScope({
2840
+ const signals = {
2599
2841
  statement: args.statement,
2600
2842
  domain: args.domain,
2601
2843
  tags: args.tags
2602
- }, { minConfidence });
2603
- return { candidates, count: candidates.length, min_confidence: minConfidence };
2844
+ };
2845
+ const candidates = await plur.suggestScope(signals, { minConfidence });
2846
+ const decision = plur.previewAutoRoute(signals);
2847
+ const would_route = decision.action === "route" && decision.scope ? { scope: decision.scope, note: "An unscoped write of these signals would be auto-routed here." } : decision.action === "refuse-shared" && decision.refusedShared ? (() => {
2848
+ const why = describeRefusedRoute(decision.refusedShared.scope);
2849
+ return {
2850
+ scope: null,
2851
+ // Field name kept for compatibility; `refused_kind` says which kind it is.
2852
+ refused_shared: decision.refusedShared.scope,
2853
+ refused_kind: why.kind,
2854
+ note: `The best match is ${why.what}, and ${why.rule}. An unscoped write would land at the local default instead. Pass that scope explicitly if the engram belongs there.`
2855
+ };
2856
+ })() : { scope: null, note: "An unscoped write of these signals would land at the local default \u2014 nothing matched confidently enough to route." };
2857
+ return { candidates, count: candidates.length, min_confidence: minConfidence, would_route };
2604
2858
  }
2605
2859
  },
2606
2860
  {
@@ -2691,6 +2945,14 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2691
2945
  {
2692
2946
  name: "plur_rescope",
2693
2947
  description: "Move existing engram(s) to a different scope (#676) \u2014 e.g. promote a personal/local engram into a team scope so it reaches the shared store. Bypasses the content-hash dedup that makes a plur_learn re-emit a silent no-op: rescope matches by id and moves the engram. Remote targets (a configured writable store scope): a copy is pushed via the routed write path (the server assigns the id, provenance is kept in the copy's source field) and the local original is soft-retired with a superseded_by link \u2014 set keep_local:true to keep it active. Local targets (local, global, project:*): the scope is rewritten in place, preserving id and activation. The target must be local/global/project:* or a scope with a configured writable store \u2014 anything else fails early (typo protection). Content is re-scanned for secrets/sensitive material before any shared/remote target and a hit blocks the move. Batch via ids; dry_run:true previews every decision without mutating anything. NOT candidate activation \u2014 that is plur_promote.",
2948
+ // NOT destructive (owner decision I_tensions_resolve, formal R2): a
2949
+ // rescope never removes content. A local target rewrites the scope in
2950
+ // place (same id, same activation); a remote target retires the local
2951
+ // original only after the copy was pushed, and links it to that copy
2952
+ // with `superseded_by`. A copy always remains, so this is a move, not a
2953
+ // removal — it stays dispatchable through plur_admin
2954
+ // (rescope-tool.test.ts). Contrast plur_tensions resolve, which retires
2955
+ // the loser with no copy and is therefore destructive.
2694
2956
  annotations: { title: "Rescope", destructiveHint: false, idempotentHint: true },
2695
2957
  inputSchema: {
2696
2958
  type: "object",
@@ -2720,7 +2982,14 @@ Include at least one engram_suggestion if ANYTHING was learned. An empty suggest
2720
2982
  {
2721
2983
  name: "plur_tensions",
2722
2984
  description: 'Tension lifecycle (#181). Default: list persisted tension records (unresolved first). scan:true runs an LLM contradiction scan, persists NEW detections as records, and skips already-recorded pairs. Lifecycle actions: action:"confirm" (real conflict), action:"dismiss" (false positive \u2014 pair suppressed from future scans), action:"resolve" + winner:<engram_id> (loser engram retired). Scan requires OPENAI_API_KEY or OPENROUTER_API_KEY env var, or explicit llm_base_url + llm_api_key args.',
2723
- annotations: { title: "Tensions", readOnlyHint: false, idempotentHint: true },
2985
+ // Not idempotent (formal R2, mcp-integrations#6): scan persists each NEW
2986
+ // detection, and an LLM judge can find new pairs on a repeat call.
2987
+ // Destructive (owner decision I_tensions_resolve, formal R2): action
2988
+ // "resolve" retires the losing engram with no copy left — exactly
2989
+ // plur_forget's effect. A removal needs an explicit, gated act, so
2990
+ // plur_admin refuses this tool and it is a direct tool in every profile
2991
+ // (CURSOR_CORE_TOOL_NAMES), where the client sees this annotation.
2992
+ annotations: { title: "Tensions", readOnlyHint: false, destructiveHint: true, idempotentHint: false },
2724
2993
  inputSchema: {
2725
2994
  type: "object",
2726
2995
  properties: {