| // Write-time lifespan confirmation over MCP elicitation (D-072). | ||
| // | ||
| // THE FAILURE THIS EXISTS TO STOP. A memory's lifespan comes from its `type`, and `type` is | ||
| // guessed by the calling agent. When that guess is wrong in the `state` direction the fact is | ||
| // hidden from recall two days later, and every consequence after that is silent. It happened | ||
| // to the maintainer's own store: two `user-confirmed` facts — a closed €1,600 debt and a | ||
| // settled legal fee — expired unnoticed and were four weeks from permanent deletion. The real | ||
| // gate log says it is not an edge case: **18 of 23 `state` saves carried a human source**, on | ||
| // subjects like `efood-payment-structure`, `insurance-efka-registration` and `location`. | ||
| // | ||
| // WHY ASK RATHER THAN DECIDE. D-055 already refused to auto-promote these saves, and that | ||
| // refusal still holds: "jam is between apartments this week" is a legitimate `user-explicit` | ||
| // state, and a gate that silently rewrites the caller's `type` starts lying about what it | ||
| // stored. But D-055's answer — warn in the reply and move on — is advisory. It lands in the | ||
| // agent's context once and nothing surfaces it again. Elicitation is the missing middle: it | ||
| // gets the correctness of an override with the honesty of an ask, and RULES §3 and §5.4 | ||
| // prescribe exactly this ("when uncertain, do NOT auto-save — ask the user"). | ||
| // | ||
| // This is NOT asking the user to audit their store, which RULES forbids and which the | ||
| // maintainer rejected in his own words. It is one question, about one memory, at the moment | ||
| // that memory is being written, while the user is still in the conversation that produced it. | ||
| // | ||
| // FAIL-OPEN, ALWAYS. No elicitation capability, a decline, a cancel, a timeout, a malformed | ||
| // answer, a thrown transport — every one of them ends with the save proceeding exactly as the | ||
| // caller asked, plus the deterministic D-055 warning. The gate must never lose a memory | ||
| // because a question could not be delivered. | ||
| import { isMemoryType } from "../store/types.js"; | ||
| /** Lifespans at or below this are "short enough that a mis-type loses the fact within days". | ||
| * Same threshold D-055 warns on, so the two layers can never disagree about what is risky. */ | ||
| export const SHORT_LIFESPAN_MS = 7 * 24 * 60 * 60 * 1000; | ||
| /** Sources that represent a human having asked for something to be remembered. */ | ||
| const HUMAN_SOURCES = ["user-explicit", "user-confirmed"]; | ||
| /** The durable alternatives offered when a short-lived save looks like it should last. */ | ||
| export const DURABLE_CHOICES = [ | ||
| { type: "state", label: "Passing state — expires in days (keep as-is)" }, | ||
| { type: "project", label: "Project or ongoing work — keeps for months" }, | ||
| { type: "preference", label: "A lasting preference — never expires" }, | ||
| { type: "identity", label: "Who the user is — never expires" }, | ||
| ]; | ||
| /** The field name the elicitation form asks for. */ | ||
| export const LIFESPAN_FIELD = "lifespan"; | ||
| /** | ||
| * Does this save need a lifespan confirmation before it is stored? | ||
| * | ||
| * True only for the narrow, measured shape: a human-sourced save landing a lifespan of a week | ||
| * or less. Agent-inferred state notes never ask — they are the legitimate bulk of short-lived | ||
| * memory, and asking about them would train the user to dismiss the question, which is the | ||
| * failure mode that makes a prompt worse than nothing. | ||
| */ | ||
| export function needsLifespanConfirmation(input) { | ||
| if (!input.type || !isMemoryType(input.type)) | ||
| return false; | ||
| if (!HUMAN_SOURCES.includes(input.source)) | ||
| return false; | ||
| if (input.lifespanMs === null) | ||
| return false; | ||
| return Number.isFinite(input.lifespanMs) && input.lifespanMs <= SHORT_LIFESPAN_MS; | ||
| } | ||
| /** Build the question. One field, one enum, plain language — the user is not a maintainer. */ | ||
| export function buildLifespanQuestion(input) { | ||
| const preview = input.text.length > 160 ? `${input.text.slice(0, 157)}…` : input.text; | ||
| return { | ||
| message: `This is about to be remembered as "${input.type}", which means it disappears from ` + | ||
| `recall on ${input.expiresAt.slice(0, 10)}:\n\n"${preview}"\n\n` + | ||
| `How long should it last?`, | ||
| requestedSchema: { | ||
| type: "object", | ||
| properties: { | ||
| [LIFESPAN_FIELD]: { | ||
| type: "string", | ||
| title: "How long should this be remembered?", | ||
| enum: DURABLE_CHOICES.map((c) => c.type), | ||
| enumNames: DURABLE_CHOICES.map((c) => c.label), | ||
| default: input.type, | ||
| }, | ||
| }, | ||
| required: [LIFESPAN_FIELD], | ||
| }, | ||
| }; | ||
| } | ||
| /** | ||
| * Interpret an elicitation result. Anything that is not an explicit accept carrying a valid, | ||
| * DIFFERENT memory type is a `keep` — including a value the client invented, which is why the | ||
| * answer is re-validated here rather than trusted from the wire. | ||
| */ | ||
| export function readLifespanAnswer(result, askedType) { | ||
| if (!result || result.action !== "accept") | ||
| return { outcome: "keep" }; | ||
| const chosen = result.content?.[LIFESPAN_FIELD]; | ||
| if (typeof chosen !== "string" || !isMemoryType(chosen)) | ||
| return { outcome: "keep" }; | ||
| if (chosen === askedType) | ||
| return { outcome: "keep" }; | ||
| return { outcome: "corrected", type: chosen }; | ||
| } |
@@ -11,2 +11,4 @@ // The shared save pipeline (D-049). | ||
| import { triage } from "./ambiguity.js"; | ||
| import { buildLifespanQuestion, needsLifespanConfirmation, readLifespanAnswer, SHORT_LIFESPAN_MS, } from "./lifespan.js"; | ||
| import { computeExpiresAt, resolveTtlPolicy } from "../store/ttl.js"; | ||
| import { appendGateLog, resolveGateLogConfig } from "./log.js"; | ||
@@ -26,3 +28,3 @@ import { isMemorySource, isMemoryType, MEMORY_SOURCES, MEMORY_TYPES, } from "../store/types.js"; | ||
| */ | ||
| export async function saveThroughGate(store, input, gateLog = resolveGateLogConfig(), classifier) { | ||
| export async function saveThroughGate(store, input, gateLog = resolveGateLogConfig(), classifier, confirmLifespan) { | ||
| // Validate the enums BEFORE judging the content (D-037, D-054). The `type` the caller sends | ||
@@ -133,2 +135,39 @@ // decides the memory's lifespan, so an unrecognized value is not a harmless typo — it used | ||
| } | ||
| // Gate layer 4 — confirm the LIFESPAN before the clock starts (D-072). | ||
| // | ||
| // This is the only check that runs before `store.save` and can change what is written, | ||
| // because it is the only one whose mistake is unrecoverable by the caller: once a durable | ||
| // fact is filed as `state`, it is hidden two days later and nothing surfaces it again. The | ||
| // question is asked only for the narrow measured shape (human source + lifespan ≤ a week), | ||
| // and every failure path leaves the save exactly as the caller asked it. | ||
| let effectiveType = input.type; | ||
| const prospectiveExpiry = computeExpiresAt(effectiveType, new Date().toISOString(), resolveTtlPolicy()); | ||
| const lifespanMs = prospectiveExpiry ? new Date(prospectiveExpiry).getTime() - Date.now() : null; | ||
| if (confirmLifespan && | ||
| effectiveType && | ||
| prospectiveExpiry && | ||
| needsLifespanConfirmation({ type: effectiveType, source: input.source, lifespanMs })) { | ||
| let answer = null; | ||
| try { | ||
| if (confirmLifespan.isAvailable()) { | ||
| answer = await confirmLifespan.ask(buildLifespanQuestion({ | ||
| text: input.text, | ||
| type: effectiveType, | ||
| expiresAt: prospectiveExpiry, | ||
| })); | ||
| } | ||
| } | ||
| catch { | ||
| // A client that errors on elicitation, a dropped transport, a timeout — all the same | ||
| // as never having asked. Never let a question cost the caller a memory. | ||
| answer = null; | ||
| } | ||
| const decided = readLifespanAnswer(answer, effectiveType); | ||
| if (decided.outcome === "corrected") { | ||
| notices.push(`saved as "${decided.type}" rather than "${effectiveType}" — the user was asked how ` + | ||
| `long this should last and chose that. The gate does not change a lifespan on its ` + | ||
| `own; it asked because a "${effectiveType}" memory disappears from recall within days.`); | ||
| effectiveType = decided.type; | ||
| } | ||
| } | ||
| // Use the caller's subject when given; else derive one conservatively (D-027). | ||
@@ -138,3 +177,3 @@ const subject = input.subject && input.subject.trim() !== "" ? input.subject : deriveSubject(input.text); | ||
| text: input.text, | ||
| type: input.type, | ||
| type: effectiveType, | ||
| source: input.source ?? "agent-inferred", | ||
@@ -189,3 +228,2 @@ subject, | ||
| /** A human-sourced memory must never go dark silently (D-055). */ | ||
| const SHORT_LIFESPAN_MS = 7 * 24 * 60 * 60 * 1000; | ||
| /** | ||
@@ -192,0 +230,0 @@ * Warnings the transport must show the caller alongside a successful save. |
+91
-7
@@ -15,4 +15,4 @@ #!/usr/bin/env node | ||
| import { expiredCommand } from "./store/expiredCli.js"; | ||
| import { resolveGateLogConfig } from "./gate/log.js"; | ||
| import { forgetThroughGate, saveThroughGate } from "./gate/pipeline.js"; | ||
| import { appendGateLog, resolveGateLogConfig } from "./gate/log.js"; | ||
| import { forgetThroughGate, saveThroughGate, } from "./gate/pipeline.js"; | ||
| import { classifierDisabled, createSamplingClassifier, } from "./gate/classifier.js"; | ||
@@ -37,2 +37,5 @@ /** | ||
| const classifier = classifierFromServer(server); | ||
| // The write-time lifespan question (D-072). Like the classifier, its absence is a supported | ||
| // first-class mode: a client without `elicitation` falls back to the D-055 warning. | ||
| const lifespanConfirmer = lifespanConfirmerFromServer(server); | ||
| server.setRequestHandler(ListToolsRequestSchema, async () => ({ | ||
@@ -189,3 +192,3 @@ tools: [ | ||
| client, | ||
| }, gateLog, classifier); | ||
| }, gateLog, classifier, lifespanConfirmer); | ||
| if (!outcome.ok) { | ||
@@ -359,3 +362,50 @@ // An unknown `type`/`source` is a usage error, not a verdict (D-037, D-054): answer | ||
| } | ||
| /** `JAMGATE_LIFESPAN_PROMPT=off` switches the write-time lifespan question off entirely, for | ||
| * a user who would rather never be interrupted mid-save than be asked about a durable fact. */ | ||
| function lifespanPromptDisabled() { | ||
| return (process.env.JAMGATE_LIFESPAN_PROMPT ?? "").trim().toLowerCase() === "off"; | ||
| } | ||
| /** | ||
| * Wire the write-time lifespan question (D-072) to the CALLING CLIENT via MCP elicitation. | ||
| * | ||
| * Elicitation is the one MCP capability that can put a real question in front of the human | ||
| * who is actually in the conversation, and until now nothing in Jamgate used it — MEMORY.md | ||
| * called it "the cheapest real win left". This is the case it was made for: the gate has | ||
| * detected the exact shape that silently ate the maintainer's own memories, it must not decide | ||
| * the answer itself (D-055), and the person who can answer in one click is right there. | ||
| * | ||
| * Same two guards as the classifier, for the same reasons. The capability is checked before | ||
| * every call, because elicitation is optional in MCP and calling it on a client that does not | ||
| * declare it makes the SDK throw on every save. And it is switchable off outright. | ||
| * | ||
| * Claude Code DOES declare `elicitation` (it declares no `sampling`), so unlike the classifier | ||
| * this layer is live on the client that wrote most of the corpus. | ||
| */ | ||
| function lifespanConfirmerFromServer(server) { | ||
| if (lifespanPromptDisabled()) | ||
| return undefined; | ||
| let announced = false; | ||
| return { | ||
| isAvailable() { | ||
| const supported = Boolean(server.getClientCapabilities()?.elicitation); | ||
| if (!announced) { | ||
| announced = true; | ||
| console.error(supported | ||
| ? "jamgate: lifespan confirmation active (MCP elicitation on the calling client)" | ||
| : "jamgate: lifespan confirmation unavailable (this client does not support MCP elicitation) — short-lived saves are warned about instead"); | ||
| } | ||
| return supported; | ||
| }, | ||
| async ask(question) { | ||
| // The SDK's form-params type is a deep Zod inference; the question is built and | ||
| // validated in `lifespan.ts`, so cast at this one boundary rather than mirror it. | ||
| const result = await server.elicitInput({ | ||
| message: question.message, | ||
| requestedSchema: question.requestedSchema, | ||
| }); | ||
| return { action: result.action, content: result.content }; | ||
| }, | ||
| }; | ||
| } | ||
| /** | ||
| * A one-line footer naming how many memories in this scope have expired out of recall (D-055). | ||
@@ -376,7 +426,25 @@ * | ||
| return ""; | ||
| const soonest = expired[0].compactableAt.slice(0, 10); | ||
| // Name the human-sourced ones (D-072). A bare count is a number the agent can read and do | ||
| // nothing with; it cannot tell whether what went dark was a passing mood or the user's pay | ||
| // structure. The subjects are what make the footer actionable, and these are precisely the | ||
| // records the user personally asked to be remembered. | ||
| const human = expired.filter((e) => e.memory.source === "user-explicit" || e.memory.source === "user-confirmed"); | ||
| const named = human | ||
| .slice(0, 5) | ||
| .map((e) => `"${e.memory.subject ?? "(no subject)"}"`) | ||
| .join(", "); | ||
| const more = human.length > 5 ? `, +${human.length - 5} more` : ""; | ||
| const destructible = expired.filter((e) => e.compactableAt !== null); | ||
| const deadline = destructible.length | ||
| ? ` ${destructible.length} can be deleted by compaction from ` + | ||
| `${destructible[0].compactableAt.slice(0, 10)}.` | ||
| : ` None can be deleted by compaction — everything here was asked for by the user, and ` + | ||
| `TTL never destroys those.`; | ||
| return (`\n\n(${expired.length} memor${expired.length === 1 ? "y has" : "ies have"} expired in ` + | ||
| `this scope and ${expired.length === 1 ? "is" : "are"} hidden from recall — still on ` + | ||
| `disk, deletable by compaction from ${soonest}. Run \`jamgate expired\` to see them, ` + | ||
| `and re-save anything that should have lasted with a durable type.)`); | ||
| `this scope and ${expired.length === 1 ? "is" : "are"} hidden from recall.` + | ||
| (human.length | ||
| ? ` ${human.length} of them the user asked for directly: ${named}${more}.` | ||
| : "") + | ||
| `${deadline} Run \`jamgate expired\` to read them, and re-save anything that should ` + | ||
| `have lasted under the SAME subject with a durable type.)`); | ||
| } | ||
@@ -403,2 +471,18 @@ catch { | ||
| dupThreshold: resolveDupThreshold(), | ||
| // Compaction is the only path that destroys a memory permanently, and it used to do it | ||
| // in complete silence — no reply, no log line, nothing to reconcile against (D-072). | ||
| onCompact: (dropped) => { | ||
| for (const m of dropped) { | ||
| void appendGateLog({ | ||
| decision: "compacted", | ||
| reason: `expired ${(m.expiresAt ?? "").slice(0, 10)} and past the grace window`, | ||
| type: m.type, | ||
| subject: m.subject, | ||
| source: m.source, | ||
| scope: m.scope, | ||
| client: m.client?.name, | ||
| text: m.text, | ||
| }, resolveGateLogConfig()); | ||
| } | ||
| }, | ||
| }); | ||
@@ -405,0 +489,0 @@ } |
@@ -60,4 +60,6 @@ // Terminal front-end for `jamgate expired` (D-055). | ||
| console.log(` source ${memory.source} · saved ${memory.createdAt.slice(0, 10)}`); | ||
| console.log(` expired ${(memory.expiresAt ?? "").slice(0, 10)} · deletable by compaction from ` + | ||
| `${compactableAt.slice(0, 10)}`); | ||
| console.log(` expired ${(memory.expiresAt ?? "").slice(0, 10)} · ` + | ||
| (compactableAt === null | ||
| ? "NEVER deleted by compaction — the user asked for this one (D-072)" | ||
| : `deletable by compaction from ${compactableAt.slice(0, 10)}`)); | ||
| console.log(` id: ${memory.id}`); | ||
@@ -64,0 +66,0 @@ } |
@@ -8,3 +8,3 @@ import { promises as fs } from "node:fs"; | ||
| import { normalizeSubject } from "../gate/subject.js"; | ||
| import { computeExpiresAt, isCompactable, isExpired, resolveGraceMs, resolveTtlPolicy, } from "./ttl.js"; | ||
| import { compactableAt, computeExpiresAt, isCompactable, isExpired, resolveGraceMs, resolveTtlPolicy, } from "./ttl.js"; | ||
| import { withFileLock } from "./lock.js"; | ||
@@ -75,2 +75,3 @@ import { memoryRelevance, MIN_RELEVANCE } from "../gate/relevance.js"; | ||
| dupThreshold; | ||
| onCompact; | ||
| constructor(path = process.env.JAMGATE_STORE ?? DEFAULT_PATH, opts = {}) { | ||
@@ -85,2 +86,3 @@ this.path = path; | ||
| this.dupThreshold = opts.dupThreshold ?? DEFAULT_DUP_THRESHOLD; | ||
| this.onCompact = opts.onCompact; | ||
| } | ||
@@ -464,9 +466,18 @@ /** The resolved on-disk path of this store, for reporting in the backup CLI (D-033). */ | ||
| const wantScope = normalizeScope(scope); | ||
| return (await this.readAll()) | ||
| return ((await this.readAll()) | ||
| .filter((m) => memScope(m) === wantScope && m.status === "active" && isExpired(m.expiresAt, now)) | ||
| .map((m) => ({ | ||
| // `null` means TTL may never destroy this one — a human asked for it (D-072). | ||
| compactableAt: compactableAt(m.expiresAt, this.graceMs, m.source), | ||
| memory: m, | ||
| compactableAt: new Date(new Date(m.expiresAt).getTime() + this.graceMs).toISOString(), | ||
| })) | ||
| .sort((a, b) => a.compactableAt.localeCompare(b.compactableAt)); | ||
| // Soonest-destroyed first, because that is the real deadline; the records that can | ||
| // never be destroyed sort last, since they carry no deadline at all. | ||
| .sort((a, b) => a.compactableAt === b.compactableAt | ||
| ? 0 | ||
| : a.compactableAt === null | ||
| ? 1 | ||
| : b.compactableAt === null | ||
| ? -1 | ||
| : a.compactableAt.localeCompare(b.compactableAt))); | ||
| } | ||
@@ -490,4 +501,34 @@ /** | ||
| } | ||
| /** | ||
| * Split `memories` into what survives and what TTL may destroy (D-072). | ||
| * | ||
| * Two things changed here and both are deliberate. Compaction is now **source-aware**: a | ||
| * `user-explicit` / `user-confirmed` record is never destructible, so a human-sourced fact | ||
| * can be hidden by expiry but never deleted by it. And the destroyed records are RETURNED | ||
| * rather than silently dropped, so the caller can announce them — compaction used to be the | ||
| * one operation in the system that permanently destroyed data and wrote nothing anywhere. | ||
| */ | ||
| splitCompactable(memories, nowMs) { | ||
| const kept = []; | ||
| const dropped = []; | ||
| for (const m of memories) { | ||
| if (isCompactable(m.expiresAt, nowMs, this.graceMs, m.source)) | ||
| dropped.push(m); | ||
| else | ||
| kept.push(m); | ||
| } | ||
| return { kept, dropped }; | ||
| } | ||
| /** Compact and announce. The announcement is best-effort and must never break a write. */ | ||
| dropCompactable(memories, nowMs) { | ||
| return memories.filter((m) => !isCompactable(m.expiresAt, nowMs, this.graceMs)); | ||
| const { kept, dropped } = this.splitCompactable(memories, nowMs); | ||
| if (dropped.length > 0 && this.onCompact) { | ||
| try { | ||
| this.onCompact(dropped); | ||
| } | ||
| catch { | ||
| /* announcing a deletion must never fail the write that triggered it */ | ||
| } | ||
| } | ||
| return kept; | ||
| } | ||
@@ -494,0 +535,0 @@ /** Active memories in `scope` whose embedding is at/above `floor` similarity to `vec`, most |
+33
-3
@@ -79,8 +79,38 @@ // Type-based TTL / expiry policy (Phase 2, item 2; RULES §2.5, §4). | ||
| } | ||
| /** A memory is compactable once it has been expired for longer than the grace window. | ||
| * Soft-expired-but-within-grace records are kept (hidden from recall, still auditable). */ | ||
| export function isCompactable(expiresAt, nowMs, graceMs) { | ||
| /** | ||
| * Sources whose memories are NEVER physically destroyed by TTL (D-072). | ||
| * | ||
| * A human said "remember this". The gate is allowed to decide that such a memory has gone | ||
| * STALE — that is what expiry is for, and hiding it from recall is correct. It is not allowed | ||
| * to decide the memory should cease to exist, because the only evidence it has for destroying | ||
| * it is a `type` the calling AGENT guessed, and the real store shows that guess is wrong most | ||
| * of the time it matters: 18 of 23 `state` saves in the production gate log carried one of | ||
| * these two sources, on subjects like `efood-payment-structure` and `insurance-efka-registration`. | ||
| * | ||
| * So expiry stays reversible for anything a human asked for. These records are hidden from | ||
| * recall on schedule, remain listed by `listExpired` forever, and can always be revived by | ||
| * re-saving under the same subject. Only `agent-inferred` records are ever compacted away. | ||
| */ | ||
| export const NEVER_COMPACTED_SOURCES = ["user-explicit", "user-confirmed"]; | ||
| /** Whether TTL is allowed to physically destroy a memory from this source (D-072). */ | ||
| export function isDestructibleSource(source) { | ||
| return !NEVER_COMPACTED_SOURCES.includes(source); | ||
| } | ||
| /** | ||
| * A memory is compactable once it has been expired for longer than the grace window — | ||
| * UNLESS a human asked for it, in which case it is never compactable at all (D-072). | ||
| * Soft-expired-but-within-grace records are kept (hidden from recall, still auditable). | ||
| */ | ||
| export function isCompactable(expiresAt, nowMs, graceMs, source) { | ||
| if (!expiresAt) | ||
| return false; | ||
| if (!isDestructibleSource(source)) | ||
| return false; | ||
| return new Date(expiresAt).getTime() + graceMs <= nowMs; | ||
| } | ||
| /** When TTL may physically destroy this memory, or `null` if it never may (D-072). */ | ||
| export function compactableAt(expiresAt, graceMs, source) { | ||
| if (!expiresAt || !isDestructibleSource(source)) | ||
| return null; | ||
| return new Date(new Date(expiresAt).getTime() + graceMs).toISOString(); | ||
| } |
+1
-1
@@ -9,2 +9,2 @@ /** | ||
| */ | ||
| export const VERSION = "0.12.0"; | ||
| export const VERSION = "0.13.0"; |
+1
-1
| { | ||
| "name": "jamgate", | ||
| "version": "0.12.0", | ||
| "version": "0.13.0", | ||
| "mcpName": "io.github.amirj4m/jamgate", | ||
@@ -5,0 +5,0 @@ "description": "A neutral, cross-agent memory quality gate for AI agents, delivered as an MCP server — a gate, not a store.", |
+3
-2
@@ -91,3 +91,4 @@ # Jamgate | ||
| | **Related-memory hint** *(optional)* | Below the duplicate bar but clearly on the same topic, the memory is **stored** and the look-alike is named, so the agent can re-save with a shared `subject` if it was really an update. A hint never retires anything. | | ||
| | **Type-based expiry** | Volatile state ages out (~2 days) while identity never does, so recall stays current automatically. | | ||
| | **Type-based expiry** | Volatile state ages out (~2 days) while identity never does, so recall stays current automatically. **Expiry hides a memory; it never destroys one you asked for** — compaction skips every `user-explicit` / `user-confirmed` record permanently, and `jamgate expired` lists them with no deletion deadline. | | ||
| | **Write-time lifespan check** *(needs MCP elicitation)* | If your agent files something *you* asked it to remember as short-lived state, the gate asks **you** — once, at the moment of saving, showing the memory and the date it would disappear — and stores your answer. This exists because it happened to my own store: two facts I confirmed were filed as 2-day state, went dark unannounced, and were four weeks from deletion. Replayed on my real gate log it fires on 17.5% of saves and stays silent on every agent-inferred state note. **Claude Code declares `elicitation`, so this one actually runs.** Off with `JAMGATE_LIFESPAN_PROMPT=off`. | | ||
@@ -964,3 +965,3 @@ Every rejection comes back with a reason the calling agent can act on. This matters more than | ||
| 565 tests on Node 20 and 22, run against a real MCP handshake on both transports. The full | ||
| 582 tests on Node 20 and 22, run against a real MCP handshake on both transports. The full | ||
| history is in [`CHANGELOG.md`](./CHANGELOG.md), and every non-obvious decision, including the | ||
@@ -967,0 +968,0 @@ ones I got wrong and reversed, is written up in [`DECISIONS.md`](./DECISIONS.md). |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
391070
4.45%36
2.86%6566
4.62%998
0.1%32
3.23%