klypix-mcp 1.40.2 → 1.41.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.
@@ -33,6 +33,10 @@ import {
33
33
  import { mcpServerEntry } from '../src/agent-rules.mjs';
34
34
  import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
35
35
  import { spawnAutoUpdateHelper } from '../src/mcp-auto-update.mjs';
36
+ // Namespace import (already in-process via the klypix-core chain, so zero added
37
+ // load cost) so a bundle whose klypix-format predates classifyDecay degrades
38
+ // gracefully — a named import of a missing export would kill the whole server.
39
+ import * as brainFormat from '../src/klypix-format.mjs';
36
40
 
37
41
  // Real package version for the MCP handshake (was hardcoded '1.0.0', which
38
42
  // misled every client/version diagnosis — it could never reflect the true release).
@@ -118,7 +122,21 @@ const server = new McpServer(
118
122
  capabilities: { logging: {} },
119
123
  },
120
124
  );
121
- const mcpPresence = createMcpPresence({ server, initialVault: VAULT });
125
+ // Decay-aware LAST-KNOWN stamps for every MCP delivery surface (2026-07-28
126
+ // post-mortem, class B): the classifier lives ONCE in klypix-format.mjs and is
127
+ // INJECTED here so mcp-presence/agent-presence stay builtin-only. The typeof
128
+ // guards let a bundle predating the feature degrade to unstamped delivery —
129
+ // no crash, no stamp, never a throw.
130
+ const mcpPresence = createMcpPresence({
131
+ server,
132
+ initialVault: VAULT,
133
+ decay: typeof brainFormat.classifyDecay === 'function' ? {
134
+ classifyDecay: brainFormat.classifyDecay,
135
+ decayStaleMs: brainFormat.DECAY_STALE_MS,
136
+ decayMessageStamp: typeof brainFormat.decayMessageStamp === 'function' ? brainFormat.decayMessageStamp : undefined,
137
+ formatDecayAge: typeof brainFormat.formatDecayAge === 'function' ? brainFormat.formatDecayAge : undefined,
138
+ } : {},
139
+ });
122
140
 
123
141
  // Map a protocol-neutral core result → an MCP tool result.
124
142
  const toContent = (r) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.40.2",
4
- "description": "Every project gets a brain one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
3
+ "version": "1.41.0",
4
+ "description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -58,7 +58,7 @@
58
58
  "node": ">=18"
59
59
  },
60
60
  "scripts": {
61
- "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs"
61
+ "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-gate.mjs && node test/decay-status.mjs && node test/decay-hook.mjs"
62
62
  },
63
63
  "dependencies": {
64
64
  "@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -417,12 +417,62 @@ export function formatPresenceMessage(sessions, selfId, { includeSolo = false, n
417
417
  return lines.join('\n');
418
418
  }
419
419
 
420
- export function formatReceivedMessages(messages, now = Date.now()) {
420
+ // ── Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B) ──────────
421
+ // A delivered inter-session message is MEMORY, not a SENSOR: one older than 6h
422
+ // whose text asserts fast-decay build/deploy status ("no TestFlight upload
423
+ // triggered yet") must never read as CURRENT state — the ENGINE stamps it,
424
+ // never the reading model. The classifier lives ONCE in klypix-format.mjs;
425
+ // this file stays builtin-only, so consumers INJECT it (the optional third
426
+ // `decay` argument). With no injection the output is byte-identical to the
427
+ // unstamped form — an old bundle degrades to no stamp, never a throw. Each
428
+ // stamp is its OWN line appended AFTER the 400-char render slice (the v1.32.0
429
+ // law: a warning is never subject to the budget/cut it warns about) and exists
430
+ // in render output only — the lane file is never mutated.
431
+ const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
432
+ // classifyDecay is precision-first; mirror that here — only an explicit `true`
433
+ // or an explicitly fast-shaped object stamps. Anything ambiguous does NOT
434
+ // (a false stamp erodes trust in every stamp).
435
+ const isFastDecayResult = (r) => r === true || r === 'fast'
436
+ || (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
437
+ const decayAgeLabel = (ms) => {
438
+ const h = Math.floor(Math.max(0, ms) / 3_600_000);
439
+ return h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`;
440
+ };
441
+
442
+ // Decay verdict for ONE delivered message: null, or { age, stampText } when it
443
+ // is stale (ts older than the engine threshold) AND its RAW text classifies
444
+ // fast-decay (raw, not the render slice — a claim cut out of the 400 chars
445
+ // must still stamp). Threshold, wording, and age format come from the injected
446
+ // engine surface ({ classifyDecay, decayStaleMs, decayMessageStamp,
447
+ // formatDecayAge }) so the renderers can never drift apart; the local
448
+ // fallbacks mirror klypix-format verbatim for a bundle old enough to carry the
449
+ // classifier but not the helpers.
450
+ export function messageDecayInfo(message, now = Date.now(), decay = {}) {
451
+ const { classifyDecay, decayStaleMs, decayMessageStamp, formatDecayAge } = decay || {};
452
+ if (typeof classifyDecay !== 'function') return null;
453
+ try {
454
+ const ts = Number(message?.ts) || 0;
455
+ const staleMs = Number(decayStaleMs) > 0 ? Number(decayStaleMs) : MSG_DECAY_STAMP_MS;
456
+ if (!ts || now - ts < staleMs) return null;
457
+ if (!isFastDecayResult(classifyDecay(String(message?.text || '')))) return null;
458
+ const ageMs = now - ts;
459
+ return {
460
+ age: typeof formatDecayAge === 'function' ? String(formatDecayAge(ageMs)) : decayAgeLabel(ageMs),
461
+ stampText: typeof decayMessageStamp === 'function'
462
+ ? String(decayMessageStamp(ageMs))
463
+ : `⏱️ This message is ${decayAgeLabel(ageMs)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`,
464
+ };
465
+ } catch { return null; } // stamping is best-effort — a classifier bug must never break delivery
466
+ }
467
+
468
+ export function formatReceivedMessages(messages, now = Date.now(), decay = {}) {
421
469
  if (!Array.isArray(messages) || !messages.length) return '';
422
470
  const lines = ['KLYPIX message(s) from another active session:'];
423
471
  for (const message of messages) {
424
472
  const ageMin = Math.max(0, Math.round((now - Number(message.ts || now)) / 60_000));
425
473
  lines.push(`- from ${String(message.from || '?').slice(0, 12)} (${ageMin}m ago): ${String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`);
474
+ const info = messageDecayInfo(message, now, decay);
475
+ if (info) lines.push(` ${info.stampText}`);
426
476
  }
427
477
  return lines.join('\n');
428
478
  }
@@ -29,6 +29,16 @@ import { auditProject, codexGlobalInstructionsInstalled, resolveVersion } from '
29
29
  import { codexPresenceHookStatus } from './codex-hooks.mjs';
30
30
  import { inspectAutoUpdate } from './mcp-auto-update.mjs';
31
31
 
32
+ // klypix-format is the DECAY-GUARD seam (classifyDecay + the status renderer),
33
+ // loaded failure-tolerant: the doctor's doctrine is "an absent seam is a fact
34
+ // to report, not an error" — klypix-format pulls jszip, and a broken/partial
35
+ // install must degrade the layer to 'unknown', never kill the doctor. The
36
+ // sibling './' specifier resolves identically in the package (src/) and the
37
+ // flat deployed (~/.claude/project-brain) layouts; top-level await keeps
38
+ // inspect() synchronous for its existing callers.
39
+ let fmtLib = null;
40
+ try { fmtLib = await import('./klypix-format.mjs'); } catch { fmtLib = null; }
41
+
32
42
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
43
 
34
44
  const sha = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
@@ -220,6 +230,49 @@ function inspectPeers(brainDir, brainPath, now) {
220
230
  return { file, live, count: live.length };
221
231
  }
222
232
 
233
+ // ── DECAY-GUARD layer (fast-decay status protection, 2026-07-28) ─────────────
234
+ // The brain is MEMORY, not a SENSOR: completed-status claims near release nouns
235
+ // ("uploaded", "LIVE", "installed", TestFlight/build N/npm/rollout) decay in
236
+ // hours, and a bundle that renders them as current state reproduces the stale-
237
+ // "what is remaining" incident class. This layer proves the protection is
238
+ // PRESENT, end-to-end: (a) the loaded engine exports classifyDecay; (b) its
239
+ // status renderer actually stamps a synthetic 20h-old stale claim as ⏱️ LAST
240
+ // KNOWN (behavior, not just an export string); (c) the DEPLOYED bundle files
241
+ // carry the feature. A bundle that predates the feature reads as DRIFT —
242
+ // silently lacking protection is exactly the failure mode — while an
243
+ // unloadable engine reads as a reportable fact, never a doctor crash.
244
+ export function inspectDecayGuard(brainDir, lib, now = Date.now()) {
245
+ const libLoaded = !!lib;
246
+ const exported = !!(lib && typeof lib.classifyDecay === 'function');
247
+ let rendererStamps = null; // null = not testable (no lib / no renderer export)
248
+ if (exported && typeof lib.statusContextToMarkdown === 'function') {
249
+ try {
250
+ // Minimal parseKlypix-shaped struct: one 20h-old fast-decay milestone.
251
+ const struct = {
252
+ title: 'decay-probe', format: 'probe', counts: { cards: 1, connections: 0 },
253
+ cards: [{
254
+ id: 'decay-probe-1', type: 'text', title: '', links: [], tags: [],
255
+ text: 'Release: 🏁 build 26 uploaded to TestFlight — rollout LIVE',
256
+ area: 'Release', createdAt: now - 20 * 3_600_000, parentId: null, evidence: null,
257
+ }],
258
+ connections: [],
259
+ };
260
+ const md = String(lib.statusContextToMarkdown(struct, { budgetChars: 4200, now }) || '');
261
+ rendererStamps = /⏱/.test(md) && /LAST KNOWN/i.test(md);
262
+ } catch { rendererStamps = false; } // a throwing renderer is NOT protecting anything
263
+ }
264
+ // Deployed-bundle currency (inspectVersion/inspectTools idiom: read the
265
+ // DEPLOYED file text — the running package can be newer than the machine's
266
+ // bundle). Absent files are a fact (null), never drift on their own.
267
+ const deployedFmt = readText(path.join(brainDir, 'klypix-format.mjs'));
268
+ const deployedHook = readText(path.join(brainDir, 'global-brain-hook.mjs'));
269
+ return {
270
+ libLoaded, exported, rendererStamps,
271
+ deployedFmtCurrent: deployedFmt == null ? null : /export (?:function|const) classifyDecay\b/.test(deployedFmt),
272
+ deployedHookCurrent: deployedHook == null ? null : deployedHook.includes('classifyDecay'),
273
+ };
274
+ }
275
+
223
276
  /**
224
277
  * Inspect this machine's brain (+ a project's harness projection) as one report.
225
278
  * @param {{ projectDir?: string, home?: string, now?: number, npmLatest?: string|null }} [opts]
@@ -246,6 +299,8 @@ export function inspect(opts = {}) {
246
299
  };
247
300
  const tools = inspectTools(brainDir, PKG_ROOT);
248
301
  const peers = inspectPeers(brainDir, brainPath, now);
302
+ // opts.fmtLib is a test seam (stub engines); production uses the module lib.
303
+ const decayGuard = inspectDecayGuard(brainDir, opts.fmtLib !== undefined ? opts.fmtLib : fmtLib, now);
249
304
 
250
305
  // Harness drift only counts toward the verdict for a real brain project; auditProject
251
306
  // against the BAKED brain version (the deployed truth) when available.
@@ -275,6 +330,13 @@ export function inspect(opts = {}) {
275
330
  ? 'optional'
276
331
  : (codexHooks.executionStatus === 'observed' ? 'ok' : 'warning')),
277
332
  harness: hasBrain ? (harness.ok ? 'ok' : 'drift') : 'n/a',
333
+ // DECAY-GUARD drifts when the protection is provably missing: the loaded
334
+ // engine lacks classifyDecay, its renderer left the synthetic stale claim
335
+ // unstamped, or a deployed bundle file predates the feature. An unloadable
336
+ // engine is 'unknown' (a reportable fact, not a verdict flip).
337
+ decayGuard: !decayGuard.libLoaded ? 'unknown'
338
+ : (!decayGuard.exported || decayGuard.rendererStamps === false
339
+ || decayGuard.deployedFmtCurrent === false || decayGuard.deployedHookCurrent === false) ? 'drift' : 'ok',
278
340
  };
279
341
  const drifted = Object.values(layers).filter(s => s === 'drift').length;
280
342
  const verdict = !version.installed ? 'NOT-INSTALLED' : (drifted ? 'DRIFTED' : 'ALIGNED');
@@ -289,9 +351,10 @@ export function inspect(opts = {}) {
289
351
  if (version.supervisorCapable && running.known && !supervisors.active) actions.push('/mcp reconnect once # activate the zero-restart supervisor; compatible future core updates hot-swap automatically');
290
352
  if (hooks.missing.length) actions.push(`npx klypix-mcp install # half-wired: hooks not active — ${hooks.missing.join(', ')}`);
291
353
  if (hasBrain && !harness.ok) actions.push('npx klypix-mcp link # harness configs drifted — re-project managed blocks');
354
+ if (layers.decayGuard === 'drift') actions.push('npx klypix-mcp install # decay-aware status guard missing/stale — stale build/deploy claims can render as CURRENT state');
292
355
  }
293
356
 
294
- return { verdict, layers, drifted, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, tools, peers, sessions: peers, harness, npm, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
357
+ return { verdict, layers, drifted, version, running, supervisors, autoUpdate, hooks, codexSmart, codexHooks, tools, peers, sessions: peers, harness, npm, decayGuard, project: { dir: projectDir, brainPath, hasBrain }, brainDir, actions };
295
358
  }
296
359
 
297
360
  // One-line drift summary (empty when clean) — for a footer / status line.
@@ -304,6 +367,7 @@ export function driftLine(r) {
304
367
  if (r.running && r.running.matchesInstalled === false) bits.push(`live server v${r.running.version}≠installed v${r.version.baked} (/mcp reconnect)`);
305
368
  if (r.hooks.missing.length) bits.push(`${r.hooks.missing.length} hook(s) unwired`);
306
369
  if (r.project.hasBrain && !r.harness.ok) bits.push(`${r.harness.drift.length} harness file(s) drifted`);
370
+ if (r.layers?.decayGuard === 'drift') bits.push('decay-guard stale (fast-decay status claims unstamped)');
307
371
  return bits.length ? `⚠️ brain DRIFTED: ${bits.join(' · ')}` : '';
308
372
  }
309
373
 
@@ -388,6 +452,23 @@ export function render(r, opts = {}) {
388
452
  // TOOLS
389
453
  L.push(`${ok} ${c.bold}TOOLS${c.rst} ${r.tools.count} MCP verb(s)${r.tools.hash ? ` ${c.dim}[#${r.tools.hash}, ${r.tools.source}]${c.rst}` : ''}${r.tools.count ? `: ${c.dim}${r.tools.names.join(', ')}${c.rst}` : ''}`);
390
454
 
455
+ // DECAY-GUARD (fast-decay status claims must stamp as LAST KNOWN, not current)
456
+ if (r.decayGuard) {
457
+ const d = r.decayGuard;
458
+ const dmark = r.layers.decayGuard === 'ok' ? ok : warn;
459
+ if (r.layers.decayGuard === 'ok') {
460
+ L.push(`${dmark} ${c.bold}DECAY${c.rst} stale build/deploy claims stamp as ⏱️ LAST KNOWN ${c.dim}(classifyDecay + renderer self-test pass)${c.rst}`);
461
+ } else if (r.layers.decayGuard === 'unknown') {
462
+ L.push(`${dmark} ${c.bold}DECAY${c.rst} ${c.dim}engine not loadable — decay stamping unverified${c.rst}`);
463
+ } else {
464
+ const why = !d.exported ? 'engine predates classifyDecay'
465
+ : d.rendererStamps === false ? 'status renderer left a synthetic 20h-old stale claim UNSTAMPED'
466
+ : d.deployedFmtCurrent === false ? 'deployed klypix-format.mjs predates the decay guard'
467
+ : 'deployed global-brain-hook.mjs predates message stamps';
468
+ L.push(`${dmark} ${c.bold}DECAY${c.rst} ${c.red}${why} — stale status claims can render as CURRENT state${c.rst}`);
469
+ }
470
+ }
471
+
391
472
  // SESSIONS: this is an all-session count. A recent-chat row is not a heartbeat.
392
473
  if (!r.sessions.count) L.push(`${ok} ${c.bold}SESSIONS${c.rst} 0 active sessions ${c.dim}(saved/recent chats are history, not active)${c.rst}`);
393
474
  else {
@@ -82,13 +82,24 @@ function readCachedVecs(brainPath, cards) {
82
82
  // the hook keeps its exact lexical behavior). NEVER throws, NEVER embeds cards.
83
83
  export async function semanticVecs(brainPath, struct, query, { timeoutMs = 1200 } = {}) {
84
84
  try {
85
- const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), timeoutMs))]);
86
- if (!pipe) return null;
87
85
  const q = String(query || '').toLowerCase().trim();
88
86
  if (!q) return null;
87
+ // Deploy-gate, ENFORCED (2026-07-29): read the warm card-vector cache
88
+ // BEFORE any model work. This lane never embeds cards, so with no cached
89
+ // vectors there is nothing to rank against — and importing transformers
90
+ // here would spin onnxruntime worker threads inside a ONE-SHOT hook that
91
+ // process.exit(0)s the moment retrieval returns. On Windows (Node 24)
92
+ // that exit-vs-thread-teardown race aborts the whole hook process
93
+ // (libuv "!(handle->flags & UV_HANDLE_CLOSING)" in async.c), because
94
+ // `import('@huggingface/transformers')` resolves from ANY ambient
95
+ // node_modules (a dev repo's) even where the semantic install is absent.
96
+ // Empty cache → pure-lexical, zero model load, zero threads.
97
+ const vecsMap = readCachedVecs(brainPath, (struct && struct.cards) || []);
98
+ if (!vecsMap.size) return null;
99
+ const pipe = await Promise.race([getEmbedder(), new Promise(r => setTimeout(() => r(null), timeoutMs))]);
100
+ if (!pipe) return null;
89
101
  const [qv] = await embedTexts(pipe, [q]);
90
102
  if (!qv) return null;
91
- const vecsMap = readCachedVecs(brainPath, (struct && struct.cards) || []);
92
103
  return { qv, vecsMap, dot };
93
104
  } catch { return null; }
94
105
  }
@@ -15,6 +15,10 @@ import {
15
15
  } from './agent-presence.mjs';
16
16
  import { recordCodexHookExecution } from './codex-hooks.mjs';
17
17
  import { opBrainTaskContext } from './klypix-core.mjs';
18
+ // Namespace import (already in-process via the klypix-core chain, so zero added
19
+ // load cost) so a bundle whose klypix-format predates classifyDecay degrades
20
+ // gracefully — a named import of a missing export would kill the whole hook.
21
+ import * as brainFormat from './klypix-format.mjs';
18
22
  import { findPresenceConflicts } from './mcp-presence.mjs';
19
23
 
20
24
  function readInput() {
@@ -87,6 +91,77 @@ function emitSystemMessage(parts) {
87
91
  process.stdout.write(JSON.stringify({ continue: true, systemMessage }));
88
92
  }
89
93
 
94
+ // ── Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B) ──────────
95
+ // Codex-lane parity with the Claude hook's messageFooter: a delivered message
96
+ // older than 6h whose text asserts fast-decay build/deploy status must never
97
+ // read as current state — the ENGINE stamps it, never the reading model. The
98
+ // classifier lives ONCE in klypix-format (typeof-guarded: a stale bundle
99
+ // degrades to no stamp, never a throw). Stamps are appended AFTER
100
+ // formatReceivedMessages' 400-char render slice so no cut can eat them (the
101
+ // v1.32.0 law: a warning is never subject to the budget it warns about), and
102
+ // exist in render output only — the lane file, its dedup keys, and its ack
103
+ // semantics are untouched. The shared renderer (agent-presence) stamps
104
+ // internally when handed the classifier; the pass below is belt-and-braces for
105
+ // a mixed bundle whose agent-presence predates that, and its dedup is
106
+ // per-MESSAGE — a message the renderer already stamped is skipped without
107
+ // suppressing stamps the other messages still need.
108
+ const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
109
+ // Precision-first consumption (mirrors the Claude hook): only an explicit
110
+ // `true` / explicitly fast-shaped object stamps — ambiguity never does.
111
+ const isFastDecayResult = (r) => r === true || r === 'fast'
112
+ || (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
113
+ export function stampReceivedMessages(messages, now = Date.now(),
114
+ classifier = (typeof brainFormat.classifyDecay === 'function' ? brainFormat.classifyDecay : null)) {
115
+ const list = Array.isArray(messages) ? messages : [];
116
+ // Thread the full engine surface through — the shared renderer stamps each
117
+ // qualifying message internally (agent-presence stays builtin-only, so the
118
+ // engine functions ride the same injection; guarded for a stale bundle).
119
+ const base = classifier
120
+ ? formatReceivedMessages(list, now, {
121
+ classifyDecay: classifier,
122
+ decayStaleMs: brainFormat.DECAY_STALE_MS,
123
+ decayMessageStamp: typeof brainFormat.decayMessageStamp === 'function' ? brainFormat.decayMessageStamp : undefined,
124
+ formatDecayAge: typeof brainFormat.formatDecayAge === 'function' ? brainFormat.formatDecayAge : undefined,
125
+ })
126
+ : formatReceivedMessages(list, now);
127
+ if (!base || !classifier) return base;
128
+ const staleMs = Number(brainFormat.DECAY_STALE_MS) > 0 ? Number(brainFormat.DECAY_STALE_MS) : MSG_DECAY_STAMP_MS; // threshold single-sourced in the engine
129
+ const stamps = list.map((m) => {
130
+ try {
131
+ const ts = Number(m?.ts) || 0;
132
+ if (!ts || now - ts < staleMs) return '';
133
+ if (!isFastDecayResult(classifier(String(m?.text || '')))) return '';
134
+ // Wording single-sourced in the engine (decayMessageStamp) so the
135
+ // renderers can never drift apart; local fallback for a stale bundle.
136
+ if (typeof brainFormat.decayMessageStamp === 'function') return ` ${brainFormat.decayMessageStamp(now - ts)}`;
137
+ const h = Math.floor((now - ts) / 3_600_000);
138
+ const label = h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`;
139
+ return ` ⏱️ This message is ${label} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`;
140
+ } catch { return ''; } // best-effort — a classifier bug must never break delivery
141
+ });
142
+ if (!stamps.some(Boolean)) return base;
143
+ // Per-MESSAGE dedup granularity, not whole-output: pair each `- from …`
144
+ // render line with its message and add a stamp only when the line right
145
+ // after it is not already a stamp. A ⏱ inside a message's own quoted text is
146
+ // NOT a stamp and must not suppress the stamps the OTHER messages need (the
147
+ // old whole-output includes('⏱') guard silenced them all).
148
+ const isStampLine = (l) => /^\s*⏱/.test(String(l || ''));
149
+ const lines = base.split('\n');
150
+ const out = [];
151
+ let msgIdx = -1;
152
+ for (let i = 0; i < lines.length; i++) {
153
+ out.push(lines[i]);
154
+ if (!lines[i].startsWith('- from ')) continue;
155
+ msgIdx++;
156
+ if (msgIdx >= list.length || !stamps[msgIdx]) continue;
157
+ if (isStampLine(lines[i + 1])) { stamps[msgIdx] = ''; continue; } // renderer already stamped this one
158
+ out.push(stamps[msgIdx]);
159
+ stamps[msgIdx] = '';
160
+ }
161
+ const leftovers = stamps.filter(Boolean); // unknown layout → stamps still delivered at the end
162
+ return leftovers.length ? [...out, ...leftovers].join('\n') : out.join('\n');
163
+ }
164
+
90
165
  function formatConflictWarning(conflicts, event) {
91
166
  if (!conflicts.length) return '';
92
167
  const moment = event === 'PreToolUse' ? 'before this edit runs' : 'after this file operation';
@@ -187,7 +262,7 @@ async function main() {
187
262
  if (event === 'SessionStart') {
188
263
  emitSystemMessage([
189
264
  formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
190
- formatReceivedMessages(messages),
265
+ stampReceivedMessages(messages),
191
266
  ]);
192
267
  return;
193
268
  }
@@ -197,18 +272,24 @@ async function main() {
197
272
  emitSystemMessage([
198
273
  context,
199
274
  formatPresenceMessage(sessions, sessionId),
200
- formatReceivedMessages(messages),
275
+ stampReceivedMessages(messages),
201
276
  ]);
202
277
  return;
203
278
  }
204
279
  if (event === 'PreToolUse' || event === 'PostToolUse') {
205
280
  emitSystemMessage([
206
281
  formatConflictWarning(conflicts, event),
207
- formatReceivedMessages(messages),
282
+ stampReceivedMessages(messages),
208
283
  ]);
209
284
  return;
210
285
  }
211
- if (messages.length) emitSystemMessage([formatReceivedMessages(messages)]);
286
+ if (messages.length) emitSystemMessage([stampReceivedMessages(messages)]);
212
287
  }
213
288
 
214
- main().catch(() => {}).finally(() => process.exit(0));
289
+ // Run as the hook by default. The ONLY skip is the explicit opt-out flag a
290
+ // hermetic test sets before importing this module for its pure exports —
291
+ // production never sets it (mirrors global-brain-hook.mjs), so runtime
292
+ // behavior is byte-identical.
293
+ if (!process.env.KLYPIX_BRAIN_NO_MAIN) {
294
+ main().catch(() => {}).finally(() => process.exit(0));
295
+ }
@@ -40,6 +40,11 @@ const STATE = path.resolve(CWD, '.claude', 'brain-capture-state.json');
40
40
  // · ev: <path[:line]>, <path>, PR#<n> — evidence anchors; file
41
41
  // paths get their git blob OID stamped so the brief can later
42
42
  // flag the card when that code drifts.
43
+ // · verify: <command> — the exact live-probe for a fast-decay
44
+ // status claim (build/deploy/release state). Once the claim is
45
+ // stale, the status renderer re-prints it as "VERIFY: <cmd>"
46
+ // instead of asserting the claim as current. Emit PS-5.1-safe
47
+ // commands (no && chaining; use `;` or separate lines).
43
48
  const MARKER = /🧠\s*BRAIN\s*(?:\[([^\]]+)\])?\s*([?!✓~+]?)\s*:\s*(.+)$/i;
44
49
  const sha = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
45
50
  // Numeric semver compare (major.minor.patch; pre-release tags ignored). <0 if
@@ -72,20 +77,25 @@ function parseEvidence(s) {
72
77
  }
73
78
  return refs.length ? refs : null;
74
79
  }
75
- // Pull optional `closes:` / `ev:` suffixes off the END of a marker body (either
76
- // order), returning the cleaned body + parsed extras. The suffix region starts
77
- // at the first " closes:"/" ev:" key, so a decision can carry neither, either,
78
- // or both without the keywords leaking into the card text.
80
+ // Pull optional `closes:` / `ev:` / `verify:` suffixes off the END of a marker
81
+ // body (any order), returning the cleaned body + parsed extras. The suffix
82
+ // region starts at the first known key, so a decision can carry none, any, or
83
+ // all without the keywords leaking into the card text. The three value regexes
84
+ // must stay in LOCKSTEP: each value ends at the NEXT known key (or end of
85
+ // line), so omitting a key from one lookahead silently folds "verify: …" into
86
+ // that key's value — parseEvidence would mint junk file refs from it.
79
87
  function splitMarkerSuffixes(body) {
80
- const m = body.match(/\s+(?:closes|ev):/i);
81
- if (!m) return { body, closes: '', evidence: null };
88
+ const m = body.match(/\s+(?:closes|ev|verify):/i);
89
+ if (!m) return { body, closes: '', evidence: null, verify: '' };
82
90
  const suffix = body.slice(m.index);
83
- const closesM = suffix.match(/\bcloses:\s*(.+?)\s*(?=\s+\bev:|$)/i);
84
- const evM = suffix.match(/\bev:\s*(.+?)\s*(?=\s+\bcloses:|$)/i);
91
+ const closesM = suffix.match(/\bcloses:\s*(.+?)\s*(?=\s+\bev:|\s+\bverify:|$)/i);
92
+ const evM = suffix.match(/\bev:\s*(.+?)\s*(?=\s+\bcloses:|\s+\bverify:|$)/i);
93
+ const verifyM = suffix.match(/\bverify:\s*(.+?)\s*(?=\s+\bcloses:|\s+\bev:|$)/i);
85
94
  return {
86
95
  body: body.slice(0, m.index).trim(),
87
96
  closes: closesM ? closesM[1].trim() : '',
88
97
  evidence: evM ? parseEvidence(evM[1].trim()) : null,
98
+ verify: verifyM ? verifyM[1].trim().slice(0, 200) : '',
89
99
  };
90
100
  }
91
101
  // ── Self-healing brain (decision lifecycle, part 3) ──────────────────────────
@@ -421,8 +431,43 @@ function ownMcpSendTexts(tp) {
421
431
  return out;
422
432
  } catch { return new Set(); }
423
433
  }
434
+ // ── Decay-aware message stamps (2026-07-28 post-mortem, class B) ─────────────
435
+ // A delivered inter-session message is MEMORY, not a SENSOR: a peer's "no
436
+ // TestFlight upload triggered yet" relayed ~12h later read as CURRENT state and
437
+ // produced the third stale-"what is remaining" incident. The ENGINE stamps any
438
+ // delivered message older than 6h whose text asserts fast-decay build/deploy
439
+ // status — the discipline lives in the output contract, never in the reading
440
+ // model's judgment. The classifier lives ONCE in klypix-format.mjs
441
+ // (lib.classifyDecay), consumed behind a typeof guard so an older bundled lib
442
+ // degrades to no stamp — never a throw. The stamp is its OWN line appended
443
+ // AFTER the 400-char render slice (the v1.32.0 law: a warning is never subject
444
+ // to the budget/cut it warns about) and lives in render output only — the lane
445
+ // file is never mutated (dedup + ack key on the RAW message text).
446
+ const MSG_DECAY_STAMP_MS = 6 * 60 * 60 * 1000;
447
+ // classifyDecay is precision-first; mirror that here — only an explicit `true`
448
+ // or an explicitly fast-shaped object stamps. Anything ambiguous does NOT
449
+ // (a false stamp erodes trust in every stamp).
450
+ const isFastDecayResult = (r) => r === true || r === 'fast'
451
+ || (!!r && typeof r === 'object' && (r.fast === true || r.fastDecay === true || r.decay === 'fast' || r.class === 'fast' || r.kind === 'fast'));
452
+ const msgAgeLabel = (ms) => { const h = Math.floor(ms / 3_600_000); return h >= 48 ? `${Math.floor(h / 24)}d` : `${Math.max(1, h)}h`; };
453
+ function decayStampForMessage(text, ts, now, lib) {
454
+ try {
455
+ if (!lib || typeof lib.classifyDecay !== 'function') return ''; // old bundled lib → no stamp, never a throw
456
+ const t = Number(ts) || 0;
457
+ const staleMs = Number(lib.DECAY_STALE_MS) > 0 ? Number(lib.DECAY_STALE_MS) : MSG_DECAY_STAMP_MS; // threshold single-sourced in the engine
458
+ if (!t || now - t < staleMs) return '';
459
+ if (!isFastDecayResult(lib.classifyDecay(String(text || '')))) return '';
460
+ // Wording single-sourced in the engine (lib.decayMessageStamp) so the
461
+ // renderers can never drift apart; local fallback only for a lib old
462
+ // enough to have the classifier but not the stamp builder.
463
+ return typeof lib.decayMessageStamp === 'function'
464
+ ? lib.decayMessageStamp(now - t)
465
+ : `⏱️ This message is ${msgAgeLabel(now - t)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`;
466
+ } catch { return ''; } // stamping is best-effort — a classifier bug must never break delivery
467
+ }
424
468
  // Surface unseen messages addressed to me, mark them seen (delivered once) under lock.
425
- function messageFooter(sid, tp) {
469
+ // `lib` (the loaded klypix-format) is optional — absent/old lib just skips stamps.
470
+ function messageFooter(sid, tp, lib) {
426
471
  if (!sid) return '';
427
472
  let data = {}; try { data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8')); } catch { return ''; }
428
473
  const all = Array.isArray(data.messages) ? data.messages : [];
@@ -455,7 +500,12 @@ function messageFooter(sid, tp) {
455
500
  if (!showUniq.length) return '';
456
501
  const ago = (ts) => { const mm = Math.max(0, Math.round((now - (ts || now)) / 60000)); return mm <= 0 ? 'just now' : `${mm}m ago`; };
457
502
  const out = ['', '## 📨 Message(s) from another session in this project (delivered once — act on or reply to them)'];
458
- for (const m of showUniq.slice(0, 6)) out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
503
+ for (const m of showUniq.slice(0, 6)) {
504
+ out.push(`- from ${String(m.from || '?').slice(0, 8)} · ${ago(m.ts)}: ${String(m.text).replace(/\s+/g, ' ').trim().slice(0, 400)}`);
505
+ // Engine-emitted LAST-KNOWN stamp — its own line, after the 400-char slice.
506
+ const stamp = decayStampForMessage(m.text, m.ts, now, lib);
507
+ if (stamp) out.push(` ${stamp}`);
508
+ }
459
509
  out.push('Reply with `🧠 MSG [<their-id or all>]: <text>` — it reaches them on their next prompt.');
460
510
  return '\n' + out.join('\n');
461
511
  }
@@ -1149,9 +1199,10 @@ async function capture(lib) {
1149
1199
  const m = MARKER.exec(trimmed); if (!m) continue;
1150
1200
  const area = (m[1] || '').trim(), type = m[2] || '';
1151
1201
  let body = m[3].trim(); if (!body) continue;
1152
- // Strip optional `closes:` / `ev:` suffixes off the body so they don't
1153
- // leak into the card text; they drive the close-link + evidence below.
1154
- const { body: cleanBody, closes, evidence } = splitMarkerSuffixes(body);
1202
+ // Strip optional `closes:` / `ev:` / `verify:` suffixes off the body so
1203
+ // they don't leak into the card text; they drive the close-link,
1204
+ // evidence, and live-probe below.
1205
+ const { body: cleanBody, closes, evidence, verify } = splitMarkerSuffixes(body);
1155
1206
  body = cleanBody; if (!body) continue;
1156
1207
  const preview = body.slice(0, 90);
1157
1208
  // EXAMPLE/doc guard — rejects marker-SYNTAX documentation (which
@@ -1183,7 +1234,7 @@ async function capture(lib) {
1183
1234
  // ✓ resolves an EXISTING card (stamped ✅ + archived) — not a new card.
1184
1235
  if (type === '✓') { resolutions.push({ area, text: body }); ledger.push({ action: 'resolve', area, preview }); continue; }
1185
1236
  // ~ updates the matching card in place (small corrections).
1186
- if (type === '~') { updates.push({ area, text: body, createdVia: 'claude-code', ...(evidence ? { evidence } : {}) }); ledger.push({ action: 'update', area, preview, ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) }); continue; }
1237
+ if (type === '~') { updates.push({ area, text: body, createdVia: 'claude-code', ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }); ledger.push({ action: 'update', area, preview, ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) }); continue; }
1187
1238
  // Type → scannable prefix + border color: ? open question (amber),
1188
1239
  // ! milestone (blue), + skill (violet), else decision (green). A plain
1189
1240
  // decision whose text reads as a reusable RULE is AUTO-promoted to a
@@ -1199,7 +1250,7 @@ async function capture(lib) {
1199
1250
  const fileTags = recentTags.slice(-4).filter(t => t !== areaTag);
1200
1251
  const tagLine = [areaTag, ...fileTags].filter(Boolean).join(' ');
1201
1252
  const card = (area ? `${area}: ${prefix}${body}` : `${prefix}${body}`) + (tagLine ? `\n${tagLine}` : '');
1202
- cards.push({ text: card, area, borderColor, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) });
1253
+ cards.push({ text: card, area, borderColor, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) });
1203
1254
  ledger.push({ action: type === '?' ? 'add-question' : type === '!' ? 'add-milestone' : isSkill ? 'add-skill' : 'add-decision', area, preview, files: fileTags, ...(closes ? { closes } : {}), ...(evidence ? { ev: evidence.map(e => e.ref) } : {}) });
1204
1255
  }
1205
1256
  }
@@ -1286,7 +1337,7 @@ async function capture(lib) {
1286
1337
  try {
1287
1338
  const merged = readState(); for (const k of seen) merged.add(k);
1288
1339
  const res = await lib.captureIntoBrain(fs.readFileSync(BRAIN), {
1289
- cards: cards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.evidence ? { evidence: c.evidence } : {}) })),
1340
+ cards: cards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) })),
1290
1341
  resolutions,
1291
1342
  updates,
1292
1343
  });
@@ -1449,7 +1500,7 @@ async function promptRetrieve(lib) {
1449
1500
  // Other live sessions in THIS repo — surfaced even when the prompt retrieves
1450
1501
  // nothing (a peer's presence/ship is itself the signal). Empty string when solo.
1451
1502
  const peers = peerFooter(sid);
1452
- const messages = messageFooter(sid, input.transcript_path); // 📨 deliberate notes another session left for me (delivered once)
1503
+ const messages = messageFooter(sid, input.transcript_path, lib); // 📨 deliberate notes another session left for me (delivered once)
1453
1504
  let hits = [], repeats = [], struct = null;
1454
1505
  if (tokens.length) {
1455
1506
  struct = await cachedStruct(lib);
@@ -1984,7 +2035,7 @@ async function read(lib) {
1984
2035
  + ruleDraftsFooter(input.session_id, struct, { markShown: false })
1985
2036
  + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
1986
2037
  const emitFull = () => {
1987
- process.stdout.write(full + messageFooter(input.session_id || '', input.transcript_path));
2038
+ process.stdout.write(full + messageFooter(input.session_id || '', input.transcript_path, lib));
1988
2039
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
1989
2040
  };
1990
2041
  // --full = everything to stdout (manual runs); also the fallback when the
@@ -2021,7 +2072,7 @@ async function read(lib) {
2021
2072
  // 📨 messages are delivered-ONCE (acked the moment this reads them) — they
2022
2073
  // go right after the ultra brief, at the top of the visible window, never
2023
2074
  // after a stack of footers that could push them past a preview cut.
2024
- const messages = messageFooter(input.session_id || '', input.transcript_path);
2075
+ const messages = messageFooter(input.session_id || '', input.transcript_path, lib);
2025
2076
  const out = ultra + messages + healLine + draftLine
2026
2077
  + inflightFooter(input.session_id, struct)
2027
2078
  + selfCheckFooter() + doctorFooter() + versionCurrencyFooter();
@@ -2103,5 +2154,5 @@ if (!process.env.KLYPIX_BRAIN_NO_MAIN) {
2103
2154
 
2104
2155
  // Exported for hermetic unit tests only (gated by KLYPIX_BRAIN_NO_MAIN above so the
2105
2156
  // import doesn't run main()/exit the test). Not part of the runtime hook contract.
2106
- export { refreshNpmCurrency, versionCurrencyFooter, bakedBrainVersion, httpsFetchLatest, cmpSemver };
2157
+ export { refreshNpmCurrency, versionCurrencyFooter, bakedBrainVersion, httpsFetchLatest, cmpSemver, decayStampForMessage, messageFooter, splitMarkerSuffixes };
2107
2158
  // (shouldSelfUpdate is exported at its declaration above — the auto-propagation decision seam for tests)
@@ -31,6 +31,7 @@ import {
31
31
  brainLensData, lensToMarkdown, deathDateOfCard,
32
32
  statusContextToMarkdown, findFulfillmentCandidates,
33
33
  splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor,
34
+ isFastDecayCard, DECAY_STALE_MS, formatDecayAge,
34
35
  } from './klypix-format.mjs';
35
36
  import { findProjectBrain } from './agent-presence.mjs';
36
37
 
@@ -482,14 +483,26 @@ export async function opBrainTaskContext({
482
483
  const clean = flat(value);
483
484
  return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean;
484
485
  };
486
+ // Decay-aware status (2026-07-28 post-mortem): a fast-decay build/deploy
487
+ // claim >6h old is stamped ⏱️ LAST KNOWN at the ENTRY level, so every
488
+ // brain_sync host (Codex included — this is its only context surface, it
489
+ // never calls statusContextToMarkdown) gets the warning from the engine,
490
+ // not from model judgment. Best-effort: classification failure → no stamp.
491
+ const nowTs = Date.now();
485
492
  const entries = hits.map((hit) => {
486
493
  const correction = overlays.get(hit.card.id)?.by || null;
494
+ let decayAge = null;
495
+ try {
496
+ const ageMs = (hit.card.createdAt || 0) > 0 ? nowTs - hit.card.createdAt : 0;
497
+ if (ageMs > DECAY_STALE_MS && isFastDecayCard(hit.card)) decayAge = formatDecayAge(ageMs);
498
+ } catch { decayAge = null; }
487
499
  return {
488
500
  id: hit.card.id,
489
501
  area: flat(hit.card.area) || 'Notes',
490
502
  text: clip(hit.card.text, 420),
491
503
  score: Number(hit.score.toFixed(2)),
492
504
  correctedBy: correction ? clip(correction.text, 420) : null,
505
+ ...(decayAge ? { lastKnown: true, age: decayAge } : {}),
493
506
  };
494
507
  });
495
508
  const maxChars = Math.max(800, Math.min(5000, Number(budgetChars) || 2800));
@@ -501,7 +514,11 @@ export async function opBrainTaskContext({
501
514
  } else {
502
515
  for (const entry of entries) {
503
516
  if (entry.correctedBy) lines.push(`- [${entry.area}] CURRENT CORRECTION: ${entry.correctedBy}`);
504
- lines.push(`- [${entry.area}]${entry.correctedBy ? ' superseded context:' : ''} ${entry.text}`);
517
+ // The LAST KNOWN stamp is a PREFIX: the final .slice(0, maxChars) can cut
518
+ // a line's tail, and the warning must never be the part that gets cut
519
+ // (v1.32.0 law — a claim may truncate, its warning may not).
520
+ const stamp = entry.lastKnown ? ` ⏱️ LAST KNOWN (${entry.age} old — verify live before reporting):` : '';
521
+ lines.push(`- [${entry.area}]${entry.correctedBy ? ' superseded context:' : ''}${stamp} ${entry.text}`);
505
522
  if (lines.join('\n').length >= maxChars) break;
506
523
  }
507
524
  lines.push('This is a bounded start-of-task capsule, not the whole brain; use brain_ask for broad status/history questions.');
@@ -568,6 +585,9 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
568
585
  // STRONG shape only: a work request that merely mentions 'pending'/'TODO'
569
586
  // must keep its ranked answer (review fix); the phrase-shaped "what is
570
587
  // remaining?" gets the computed section.
588
+ // Decay-aware stamps (⏱️ LAST KNOWN + VERIFY probe on >6h build/deploy
589
+ // claims) ride IN the renderer — statusContextToMarkdown stamps internally,
590
+ // so this call inherits them with no options needed (now defaults inside).
571
591
  let statusMd = '';
572
592
  if (result.statusStrong && !timeTravel) {
573
593
  try { statusMd = statusContextToMarkdown(struct); mode += ' + status-mode'; } catch { statusMd = ''; }
@@ -63,6 +63,21 @@ export async function atomicWrite(filePath, buf) {
63
63
  catch (e) { try { fs.rmSync(tmp); } catch { /* */ } throw e; }
64
64
  }
65
65
 
66
+ // ── verify: suffix (decay-aware status, 2026-07-28 post-mortem) ──────────────
67
+ // A card may carry the EXACT live-probe command for its fast-decay claim as a
68
+ // `verify:` suffix ("🏁 build 26 uploaded verify: gh run list --limit 5"), the
69
+ // same marker grammar as closes:/ev: (value runs to the next known key or end
70
+ // of line). The hook strips+persists it as a machine field at capture; cards
71
+ // authored by humans/the app/brain_note keep it in prose — so parseKlypix
72
+ // falls back to a prose parse and every render surface sees ONE `verify` field.
73
+ const VERIFY_SUFFIX_RE = /(?:^|\s)verify:\s*([^\n]+)/i;
74
+ export function parseVerifySuffix(text) {
75
+ const m = VERIFY_SUFFIX_RE.exec(String(text || ''));
76
+ if (!m) return null;
77
+ const v = m[1].split(/\s+\b(?:closes|ev):/i)[0].trim();
78
+ return v || null;
79
+ }
80
+
66
81
  /**
67
82
  * Parse a .klypix/.any buffer into a structured object + the loaded zip (so
68
83
  * callers can extract binary assets). Throws on a non-canvas file.
@@ -122,6 +137,11 @@ export async function parseKlypix(buffer) {
122
137
  // Evidence anchors (file:line / PR#) with the git blob OID stamped at
123
138
  // capture-time — lets the hook flag a card whose cited code drifted.
124
139
  evidence: Array.isArray(it.evidence) && it.evidence.length ? it.evidence : null,
140
+ // Live-probe command for a fast-decay status claim (decay-aware
141
+ // status): machine field when captured via a verify: marker suffix,
142
+ // else parsed from prose so non-hook-authored cards count too.
143
+ verify: (typeof it.verify === 'string' && it.verify.trim()) ? it.verify.trim()
144
+ : (it.type === 'text' ? parseVerifySuffix(it.content) : null),
125
145
  // Machine death-date (epoch ms) written by the gardener at
126
146
  // consolidation — the prose "⤵ consolidated" stamp's reliable twin,
127
147
  // so as_of time-travel never depends on parsing prose.
@@ -518,6 +538,8 @@ export async function appendIntoContainers(buffer, addition) {
518
538
  ...(card.createdVia ? { createdVia: String(card.createdVia) } : {}),
519
539
  // Evidence anchors (file:line / PR#) — additive, ignored by older readers.
520
540
  ...(Array.isArray(card.evidence) && card.evidence.length ? { evidence: card.evidence } : {}),
541
+ // Live-probe command for a fast-decay claim — additive, ignored by older readers.
542
+ ...(typeof card.verify === 'string' && card.verify.trim() ? { verify: card.verify.trim() } : {}),
521
543
  content: wrapped, fontSize: G.FONT,
522
544
  color: card.color || '#e8e8ed', border: true, borderColor: card.borderColor || card.color || 'rgba(16,185,129,0.45)',
523
545
  fillColor: 'rgba(18,18,26,0.85)', heading: !!card.heading, fontFamily: 'Thmanyah Sans',
@@ -1182,7 +1204,15 @@ export function areaStatusDigest(struct, { activeDays = 30, maxAreas = 20, now =
1182
1204
  const miles = cs.filter(isMilestoneCard).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
1183
1205
  const opens = cs.filter(isOpenCard);
1184
1206
  const m = miles[0];
1185
- const mileTxt = m ? `last 🏁 ${day(m.createdAt)} “${cut(flat(m.text).replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, ''), 70)}”` : 'no 🏁 yet';
1207
+ // Decay-aware headline (2026-07-28 post-mortem): a fast-decay milestone
1208
+ // older than DECAY_STALE_MS never leads an area as bare current state.
1209
+ // The engine appends the age + a verify cue — the brief is the one
1210
+ // surface every session reads, and models do not compute ages from the
1211
+ // ISO date on their own (that omission was the exact incident).
1212
+ const mAge = m ? now - (m.createdAt || 0) : 0;
1213
+ const mileStale = m && mAge >= DECAY_STALE_MS && isFastDecayCard(m)
1214
+ ? ` (${formatDecayAge(mAge)} — verify live)` : '';
1215
+ const mileTxt = m ? `last 🏁 ${day(m.createdAt)}${mileStale} “${cut(flat(m.text).replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, ''), 70)}”` : 'no 🏁 yet';
1186
1216
  rows.push({ newest, line: `- ${area} — ${mileTxt} · ${opens.length} open · latest ${day(newest)}` });
1187
1217
  }
1188
1218
  rows.sort((a, b) => b.newest - a.newest);
@@ -1678,6 +1708,86 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
1678
1708
  return out.join('\n') + '\n';
1679
1709
  }
1680
1710
 
1711
+ // ── Decay-aware status assertions (2026-07-28 post-mortem, 3rd stale-status ──
1712
+ // incident). The brain is a MEMORY, not a SENSOR: for fast-decay facts (what's
1713
+ // on TestFlight, what's on npm, whether the rollout flipped) its job is to
1714
+ // carry WHERE to look, never assert WHAT is currently true. classifyDecay
1715
+ // detects texts that make such assertions so every render surface can stamp
1716
+ // them ⏱️ LAST KNOWN instead of presenting them as current state. This moves
1717
+ // the "verify before reporting" discipline from model judgment (fails on weak
1718
+ // models, occasionally on strong ones) into the engine's output contract.
1719
+ //
1720
+ // PRECISION OVER RECALL — a missed stamp is the status quo; a false stamp
1721
+ // erodes trust in every stamp. A text classifies ONLY when, inside one
1722
+ // sentence/line segment (split on .!?\n — NOT ';', the actual incident text
1723
+ // was "ready for next TestFlight build; no upload/tag triggered yet"), BOTH:
1724
+ // VERB class — completed-status forms: uploaded / published / released /
1725
+ // deployed / installed / shipped / submitted / staged / flipped /
1726
+ // triggered / rolled out|back / went|is|now live / green — or a
1727
+ // NEGATIVE-pending assertion ("no upload triggered yet", "not yet
1728
+ // published", "awaiting App Store review"), which decays just as fast.
1729
+ // NOUN class — release-shaped subjects: TestFlight / App Store / npm / CI /
1730
+ // release(s) / rollout / build N / vX.Y[.Z] and X.Y.Z version literals.
1731
+ // Or the text carries an ev: run/release id ("ev: gh run 16204339183") — a
1732
+ // machine receipt is by definition a point-in-time observation.
1733
+ // DELIBERATE NON-MATCHES (each is a live false-positive class we tested out):
1734
+ // · bare-infinitive process/architecture prose — "releases go through OIDC
1735
+ // npm publish", "the release pipeline: build → sign → upload" (no
1736
+ // completed verb form; 'publish'/'upload' ≠ 'published'/'uploaded');
1737
+ // · "staged rollout/release/deployment" AND the hyphenated "staged-rollout"
1738
+ // as adjectives (vs "draft staged") — the hyphen form was a live FP on the
1739
+ // real brain's Capabilities card ("staged-rollout auto-updater");
1740
+ // · 'released'/'shipped' with no release noun in the segment — "shipped the
1741
+ // garden fix" is a durable milestone, not a fast-decay build status;
1742
+ // · hyphenated 'live-' adjectives ("live-verified", "live-traced");
1743
+ // · questions/plans about releasing ("should we upload nightly builds?").
1744
+ export const DECAY_STALE_MS = 6 * 3_600_000; // older than this → LAST KNOWN, never current
1745
+ const DECAY_VERB_RE = /\b(?:uploaded|published|released|deployed|installed|shipped|submitted|flipped|triggered|green)\b|\bstaged\b(?![\s-]+(?:rollout|rollouts|release[sd]?|deploy\w*|migration\w*|approach))|\brolled[\s-]?(?:out|back)\b|\brolling[\s-]?out\b|\b(?:went|is|are|now|already)\s+live\b|\blive\b(?!-)/i;
1746
+ const DECAY_PENDING_RE = /\b(?:not\s+yet|hasn'?t(?:\s+been)?|haven'?t(?:\s+been)?|nothing(?:\s+has)?(?:\s+been)?|never)\s+(?:been\s+)?(?:uploaded|published|released|deployed|installed|shipped|triggered|tagged|submitted|gone\s+live)\b|\bno\s+(?:\w+[\s/-]+){0,2}?(?:upload|publish|deploy|release|install|build|tag|rollout)[\w/-]*(?:\s+\S+){0,3}?\s+(?:yet|so\s+far|triggered|started|fired|happened)\b|\b(?:awaiting|waiting\s+(?:for|on)|pending)\s+(?:apple|app\s?store|testflight|review|approval|upload|release|rollout)\b/i;
1747
+ const DECAY_NOUN_RE = /\btest\s?flight\b|\bapp\s?store\b|\bnpm\b|\bci\b|\brelease(?:s)?\b|\brollout(?:s)?\b|\bbuild\s*#?\s*\d+\b|\bv\d+(?:\.\d+)+\b|\b\d+\.\d+\.\d+\b/i;
1748
+ const DECAY_EVRUN_RE = /\bev:\s*[^\n]{0,60}?\b(?:run|workflow|release|build|rollout|deploy)[a-z-]*\s*[#:/]?\s*\d{3,}/i;
1749
+ export function classifyDecay(text) {
1750
+ const t = String(text || '');
1751
+ if (!t.trim()) return false;
1752
+ if (DECAY_EVRUN_RE.test(t)) return true;
1753
+ // A '.' only ends a segment when followed by whitespace/end — a bare-dot
1754
+ // split shredded version literals ("1.3.28 live" became "1"/"3"/"28 live"
1755
+ // and the noun never met its verb).
1756
+ for (const seg of t.split(/[\n!?]+|\.(?=\s|$)/)) {
1757
+ if (!seg.trim()) continue;
1758
+ if (DECAY_NOUN_RE.test(seg) && (DECAY_VERB_RE.test(seg) || DECAY_PENDING_RE.test(seg))) return true;
1759
+ }
1760
+ return false;
1761
+ }
1762
+ // A struct card decays if its TEXT classifies, or its machine evidence carries
1763
+ // a run/release-shaped receipt (the ev: suffix is stripped from hook-captured
1764
+ // prose, so the text alone can't see it).
1765
+ export const isFastDecayCard = (c) => classifyDecay(c?.text)
1766
+ || (Array.isArray(c?.evidence) && c.evidence.some(e => DECAY_EVRUN_RE.test('ev: ' + String(e?.ref || ''))));
1767
+ // Compact age for stamps ("20h", "3d") — both message renderers show minutes
1768
+ // only, which reads as noise at 12h+ ("720m ago").
1769
+ export const formatDecayAge = (ms) => {
1770
+ const h = Math.floor(Math.max(0, ms) / 3_600_000);
1771
+ if (h < 1) return `${Math.max(1, Math.floor(Math.max(0, ms) / 60_000))}m`;
1772
+ return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`;
1773
+ };
1774
+ // The live-probe to print after a LAST KNOWN claim: the card's own verify:
1775
+ // command wins; else a per-area default; else a generic re-verify line.
1776
+ // Emitted probes must be PS-5.1-safe: ';' separators, never '&&'.
1777
+ export function decayVerifyProbe(card) {
1778
+ const v = typeof card?.verify === 'string' && card.verify.trim() ? card.verify.trim() : null;
1779
+ if (v) return v;
1780
+ const a = String(card?.area || '').toLowerCase();
1781
+ if (/release|app\s?store|appstore|\bios\b|testflight|ship|deploy|rollout/.test(a)) return 'gh run list --limit 5; gh release list --limit 5';
1782
+ if (/drive|admin/.test(a)) return 'probe the live prod endpoint (HTTP) before reporting';
1783
+ return 're-verify live before reporting this as current';
1784
+ }
1785
+ // Engine-emitted stamp for a delivered inter-session message (class B of the
1786
+ // post-mortem taxonomy) — EXACT wording from the brief, single-sourced here so
1787
+ // the Claude-hook and agent-presence renderers can never drift apart.
1788
+ export const decayMessageStamp = (ageMs) =>
1789
+ `⏱️ This message is ${formatDecayAge(ageMs)} old and contains build/deploy status — treat as LAST KNOWN, verify live before reporting it.`;
1790
+
1681
1791
  // ── Status mode (T7, 2026-07-23) — the computed answer for status questions ──
1682
1792
  // "What is remaining?" must be answered from STATE, not lexical matching:
1683
1793
  // status vocabulary is anti-correlated with truth (cards saying "remaining"
@@ -1688,10 +1798,24 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
1688
1798
  // maxOpen defaults to NO cap: the open list is the answer to a status question,
1689
1799
  // so it is sized to fit (per-card width adapts) rather than sliced. Callers can
1690
1800
  // still pass a cap explicitly.
1691
- export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChars = 4200 } = {}) {
1801
+ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChars = 4200, now = Date.now() } = {}) {
1692
1802
  if (!struct || !Array.isArray(struct.cards)) return '';
1693
1803
  const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1694
1804
  const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
1805
+ // ⏱️ LAST KNOWN (decay-aware status, 2026-07-28): a fast-decay claim older
1806
+ // than 6h renders as last-known observation + live probe, NEVER as current
1807
+ // state. The stamp scaffolding lives OUTSIDE cut() and rides pushAlways —
1808
+ // the v1.32.0 law: a warning is never subject to the budget/width it warns
1809
+ // about, so a crowded brain can shorten the CLAIM but never the WARNING.
1810
+ // best-effort try: a malformed card must degrade to an unstamped line, not
1811
+ // take down the whole status section (opBrainAsk catch would eat it all).
1812
+ const decayStamp = (c) => {
1813
+ try {
1814
+ const age = (c.createdAt || 0) > 0 ? now - c.createdAt : 0;
1815
+ if (age <= DECAY_STALE_MS || !isFastDecayCard(c)) return null;
1816
+ return { age: formatDecayAge(age), probe: decayVerifyProbe(c) };
1817
+ } catch { return null; }
1818
+ };
1695
1819
  const isArchived = (c) => /^archive$/i.test(c.area || '');
1696
1820
  const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
1697
1821
  const out = [];
@@ -1750,7 +1874,9 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
1750
1874
  for (const c of top) {
1751
1875
  if (used > budgetChars) break;
1752
1876
  const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? ' ⏳likely-fulfilled' : ''}`;
1753
- pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
1877
+ const d = decayStamp(c);
1878
+ if (d) pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ⏱️ LAST KNOWN (${d.age}): ${cut(flat(c.text), perCard)} — VERIFY: ${d.probe}`);
1879
+ else pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
1754
1880
  shown++;
1755
1881
  }
1756
1882
  if (shown < opens.length) pushAlways(`- ⚠️ …and ${opens.length - shown} more open item(s) — this list is NOT complete; call brain_ask before reporting what remains.`);
@@ -1759,7 +1885,11 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
1759
1885
  if (miles.length) {
1760
1886
  pushAlways('');
1761
1887
  pushAlways('## Newest milestones');
1762
- for (const m of miles.slice(0, 3)) pushAlways(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ${cut(flat(m.text), 130)}`);
1888
+ for (const m of miles.slice(0, 3)) {
1889
+ const d = decayStamp(m);
1890
+ if (d) pushAlways(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ⏱️ LAST KNOWN (${d.age}): ${cut(flat(m.text), 130)} — VERIFY: ${d.probe}`);
1891
+ else pushAlways(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ${cut(flat(m.text), 130)}`);
1892
+ }
1763
1893
  }
1764
1894
  pushAlways('');
1765
1895
  return out.join('\n') + '\n';
@@ -2870,13 +3000,14 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
2870
3000
  // Self-heal: a ~ update re-stamps the evidence (fresh OID +
2871
3001
  // verifiedAt), so confirming/correcting a drifted fact marks it ✅.
2872
3002
  if (Array.isArray(u.evidence) && u.evidence.length) j.evidence = u.evidence;
3003
+ if (typeof u.verify === 'string' && u.verify.trim()) j.verify = u.verify.trim();
2873
3004
  });
2874
3005
  best.text = isTerseConfirm ? `${best.text}\n(re-affirmed ${today}: ${u.text})` : u.text;
2875
3006
  stats.updated++;
2876
3007
  } else if (!nearDupExists(u.text)) {
2877
3008
  // ~ fallback add is guarded like ✓'s: an unmatched ~ re-harvested
2878
3009
  // from the transcript tail must not stack a copy every turn.
2879
- cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}) });
3010
+ cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}), ...(typeof u.verify === 'string' && u.verify.trim() ? { verify: u.verify.trim() } : {}) });
2880
3011
  }
2881
3012
  }
2882
3013
 
@@ -2901,6 +3032,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
2901
3032
  j.createdAt = now;
2902
3033
  if (card.createdVia) j.createdVia = String(card.createdVia);
2903
3034
  if (Array.isArray(card.evidence) && card.evidence.length) j.evidence = card.evidence;
3035
+ if (typeof card.verify === 'string' && card.verify.trim()) j.verify = card.verify.trim();
2904
3036
  });
2905
3037
  best.text = String(card.text);
2906
3038
  cards.splice(i, 1);
@@ -3561,17 +3693,17 @@ export function findOverdueOpenCards(struct, { now = Date.now() } = {}) {
3561
3693
  // (milestone) | '✓' (resolve+archive a match) | '~' (update a match in place) |
3562
3694
  // '+' (skill — a REUSABLE how-to/gotcha/procedure, standing reference that always
3563
3695
  // surfaces and never ages out, distinct from a point-in-time decision).
3564
- export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, createdVia = 'mcp' } = {}) {
3696
+ export function noteToCaptureInput({ text = '', area = '', marker = '', closes = '', evidence = null, verify = null, createdVia = 'mcp' } = {}) {
3565
3697
  const body = String(text).trim();
3566
3698
  if (!body) return { cards: [], resolutions: [], updates: [] };
3567
3699
  const a = String(area || '').trim();
3568
3700
  if (marker === '✓') return { cards: [], resolutions: [{ area: a, text: body }], updates: [] };
3569
- if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}) }] };
3701
+ if (marker === '~') return { cards: [], resolutions: [], updates: [{ area: a, text: body, createdVia, ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }] };
3570
3702
  const prefix = marker === '?' ? '❓ ' : marker === '!' ? '🏁 ' : marker === '+' ? '🛠️ ' : '';
3571
3703
  const borderColor = marker === '?' ? 'rgba(245,166,35,0.8)' : marker === '!' ? 'rgba(59,130,246,0.8)' : marker === '+' ? 'rgba(139,92,246,0.85)' : 'rgba(16,185,129,0.6)';
3572
3704
  const tag = a ? `\n#${a.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
3573
3705
  const cardText = (a ? `${a}: ${prefix}${body}` : `${prefix}${body}`) + tag;
3574
- return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}) }], resolutions: [], updates: [] };
3706
+ return { cards: [{ text: cardText, area: a, color: '#e8e8ed', borderColor, createdVia, ...(closes ? { closes } : {}), ...(evidence ? { evidence } : {}), ...(verify ? { verify } : {}) }], resolutions: [], updates: [] };
3575
3707
  }
3576
3708
 
3577
3709
  /**
@@ -12,6 +12,7 @@ import {
12
12
  findProjectBrain,
13
13
  formatPresenceMessage,
14
14
  formatReceivedMessages,
15
+ messageDecayInfo,
15
16
  peekMessages,
16
17
  postPresenceMessage,
17
18
  receiveMessages,
@@ -191,6 +192,14 @@ export function createMcpPresence({
191
192
  now = () => Date.now(),
192
193
  setIntervalFn = setInterval,
193
194
  clearIntervalFn = clearInterval,
195
+ // Decay-aware LAST-KNOWN stamps (2026-07-28 post-mortem, class B): the
196
+ // injected engine surface ({ classifyDecay, decayStaleMs, decayMessageStamp,
197
+ // formatDecayAge } from klypix-format.mjs) that lets every MCP delivery
198
+ // surface — pollInbox logging, touch/decorateToolResult notices, brain_sync
199
+ // text + structured messages — stamp a stale build/deploy message as LAST
200
+ // KNOWN. Injection (not an import) keeps this file builtin-only; absent or
201
+ // partial (old bundle), delivery degrades to unstamped — never a throw.
202
+ decay = {},
194
203
  } = {}) {
195
204
  const sessionId = resolveMcpSessionId({ env });
196
205
  const effectiveInboxPollMs = Number(env?.KLYPIX_MCP_INBOX_POLL_MS)
@@ -241,7 +250,7 @@ export function createMcpPresence({
241
250
  now: now(),
242
251
  }).filter((message) => !announcedMessageIds.has(message.id));
243
252
  if (!pending.length) return [];
244
- if (sendNotice(formatReceivedMessages(pending, now()))) {
253
+ if (sendNotice(formatReceivedMessages(pending, now(), decay))) {
245
254
  for (const message of pending) announcedMessageIds.add(message.id);
246
255
  while (announcedMessageIds.size > 100) {
247
256
  announcedMessageIds.delete(announcedMessageIds.values().next().value);
@@ -296,7 +305,7 @@ export function createMcpPresence({
296
305
  : '';
297
306
  const notice = [
298
307
  presence,
299
- formatReceivedMessages(messages, stamp),
308
+ formatReceivedMessages(messages, stamp, decay),
300
309
  ].filter(Boolean).join('\n\n');
301
310
  if (notice) sendNotice(notice);
302
311
  return { sessions, messages, notice };
@@ -428,7 +437,7 @@ export function createMcpPresence({
428
437
  ].join('\n')
429
438
  : 'No exact file overlap is currently reported by another synchronized task.';
430
439
  const durationMs = Math.max(0, Date.now() - syncStartedAt);
431
- const messagesText = formatReceivedMessages(report.messages, stamp);
440
+ const messagesText = formatReceivedMessages(report.messages, stamp, decay);
432
441
  const structured = {
433
442
  schemaVersion: 1,
434
443
  status: completing ? 'complete' : 'active',
@@ -443,12 +452,19 @@ export function createMcpPresence({
443
452
  },
444
453
  peers: snapshot.peers,
445
454
  conflicts,
446
- messages: report.messages.map((message) => ({
447
- id: message.id,
448
- from: message.from,
449
- text: message.text,
450
- ts: message.ts,
451
- })),
455
+ messages: report.messages.map((message) => {
456
+ // Raw structured path gets the same decay verdict as the rendered
457
+ // text: a consumer reading structured.messages directly must see the
458
+ // LAST-KNOWN marking, not re-derive it (additive fields, schema 1).
459
+ const decayInfo = messageDecayInfo(message, stamp, decay);
460
+ return {
461
+ id: message.id,
462
+ from: message.from,
463
+ text: message.text,
464
+ ts: message.ts,
465
+ ...(decayInfo ? { lastKnown: true, age: decayInfo.age, stampText: decayInfo.stampText } : {}),
466
+ };
467
+ }),
452
468
  alertsQueued,
453
469
  delivery: {
454
470
  proactive: 'mcp-logging-best-effort',