@theronap/cortex-mcp 0.9.89 → 0.9.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/server.mjs +334 -63
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -40,6 +40,38 @@ async function redLinkTriage(BASE, TOKEN, name) {
40
40
  // file-request tools, and a module-level `fail` would be silently shadowed by it from that point on.
41
41
  const toolError = (text) => ({ content: [{ type: 'text', text }], isError: true })
42
42
 
43
+ // SECTION CURRENCY (gate 3) — ONE renderer, used by BOTH read_page and project_status.
44
+ //
45
+ // Extracted as a PURE function on the pickBrainMatch precedent: that fix pulled a shared rule out of two
46
+ // resolvers specifically so they could not drift, after they drifted. This is the same situation found
47
+ // 2026-08-15. THREE surfaces render authored sections from the same server-computed fields:
48
+ // • renderAuthoredNodeBody (web/lib/engine/authored_page_tiers.ts) — console/web
49
+ // • read_page here — printed a date and "⚠ Undated section" as two adjacent lines
50
+ // • project_status here — printed NO currency at all: no as-of, no warning, nothing
51
+ // PR #558 fixed only the first. project_status is the tool the routing docs reach for FIRST, and gate 3
52
+ // reads "a reader can date any claim without a second query" — a reader there could date nothing.
53
+ //
54
+ // The two dates are DIFFERENT facts and both true: `asOf` is when the section's text last CHANGED; an
55
+ // explicit date in the prose is when the CLAIM was true. Stated as one sentence they inform; stacked as
56
+ // two lines they read as the page contradicting itself.
57
+ //
58
+ // ⚠ Do NOT "simplify" this by keying on `asOf` — it is set on every section always, so the detector
59
+ // would go silent everywhere. The branch must key on `hasExplicitDate`, and on `=== false` rather than
60
+ // falsy: `undefined` is an older server mid-rolling-deploy that has computed no verdict, and inventing
61
+ // one there is the 0093 don't-impute violation.
62
+ export const sectionCurrencyStamp = (s, day) => {
63
+ const on = s.asOf ? day(s.asOf) : ''
64
+ return s.hasExplicitDate === false
65
+ ? `${on ? ` · text last written ${on} —` : ' —'} ⚠ the claim itself carries no date; verify before relying on it`
66
+ : (on ? ` · as of ${on}` : '')
67
+ }
68
+
69
+ // ⚠ The newline is a FIX, not cosmetics. read_page's old form was `${asOf}${currency}${s.body}`, where
70
+ // only the UNDATED branch contributed a trailing \n — so every DATED section ran its body straight onto
71
+ // the header line ("· as of 2026-08-14Identifiers are candidates, never publication."). The defect was
72
+ // invisible on exactly the sections that were healthy.
73
+ export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
74
+
43
75
  // The Agnoclast MCP server (stdio). Serves the signed-in employee's scoped org
44
76
  // context to their AI assistant. CORTEX_TOKEN identifies the user + org.
45
77
 
@@ -192,62 +224,179 @@ export async function runServer(version) {
192
224
  'log_session',
193
225
  {
194
226
  title: 'Log this session to Agnoclast',
195
- description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. Pass sessionId (the Claude Code session id) if you have it so this dedupes with the auto-capture of the same session. If you belong to more than one brain, pass `brain` — without it a session log has no route and is STAGED rather than recorded.',
227
+ description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. Pass sessionId (the Claude Code session id) so this dedupes with the auto-capture of the same session. If you belong to more than one brain you MUST name one — without a brain a session log has no route and is STAGED rather than recorded. **If the session touched work belonging to different brains, pass `segments` instead of `summary` and split it** — one segment per brain, each summary standing on its own and never alluding to the others. Reports every segment individually; a partial result is reported as PARTIAL, never as success.',
196
228
  inputSchema: {
197
- summary: z.string().describe('the curated session summary (what was done, decided, left open) becomes the durable record'),
229
+ summary: z.string().optional().describe('the curated session summary the single-brain form. Omit when passing `segments`'),
230
+ segments: z.array(z.object({
231
+ brain: z.string().describe('destination brain for this half of the session (name or org id)'),
232
+ summary: z.string().describe('a summary that stands ON ITS OWN. It must not mention, allude to, or imply that other segments exist — "the rest of this session covered personal projects" leaks in prose exactly what splitting was meant to contain'),
233
+ title: z.string().optional(),
234
+ project: z.string().optional(),
235
+ })).optional().describe('SPLIT the log, one entry per brain. Use whenever a session touched work belonging to different brains: a session is a container of time, not a topic, and every single-brain answer is wrong — filing it all in the org brain exposes personal work to colleagues, filing it all in the personal one denies the org its record, and summarizing half silently drops the other half. Max ONE segment per brain (they share this session\'s dedupe key, so two aimed at the same brain would overwrite each other). When a chunk is ambiguous, put it in the MORE PRIVATE brain — a misfile there is private, a misfile the other way is visible to everyone in the org.'),
198
236
  project: z.string().optional().describe('project key/name this session worked in'),
199
237
  title: z.string().optional().describe('short title for the session'),
200
- sessionId: z.string().optional().describe('the Claude Code session id (dedupes with the auto-capture hook of the same session)'),
238
+ sessionId: z.string().optional().describe('the Claude Code session id shared by every segment, and the only thing pairing them. Deliberately NOT a link: segments never reference each other, so a reader cleared for one brain cannot tell the others exist, while you can join on it across brains'),
201
239
  brain: z.string().optional().describe('which brain to record this session in (name or org id, one of your own). REQUIRED IN EFFECT for a multi-brain member: session-class sources route only by an explicit brain or a sole membership, so omitting it stages the log instead of recording it.'),
202
240
  privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME. Use when the summary names confidential work (a candidate evaluation, a security finding) — safer than letting it land org-visible and re-tiering after, which leaves it readable in between.'),
203
241
  },
204
242
  },
205
- async ({ summary, project, title, sessionId, brain, privacy }) => {
206
- const res = await fetchCortex(`${BASE}/api/ingest`, {
207
- method: 'POST',
208
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
209
- body: JSON.stringify({
210
- source: 'claude-code',
211
- captureSource: 'skill',
212
- summary,
213
- ...(project ? { project } : {}),
214
- ...(title ? { title } : {}),
215
- ...(sessionId ? { sessionId } : {}),
216
- // ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an explicit
217
- // brain or a sole membershipanything else STAGES. This tool never sent one, so every
218
- // close-out from a multi-brain member landed in staged_records instead of the org. Measured
219
- // 2026-08-09: 86 staged rows, and the pile is not drainable by /api/staged/promote because
220
- // promote resolves through the same branch session-class sources skip.
221
- ...(brain ? { brain } : {}),
222
- ...(privacy ? { privacy } : {}),
223
- payload: { via: 'log_session' },
224
- }),
225
- })
226
- if (!res.ok) {
227
- const body = await res.text()
228
- throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
243
+ async ({ summary, segments, project, title, sessionId, brain, privacy }) => {
244
+ // A session is a container of TIME, not a topic (ADR-0029 step 4). Normalize to a list of
245
+ // segments; the single-brain call is just a one-segment list.
246
+ const list = Array.isArray(segments) && segments.length
247
+ ? segments.map((s) => ({ brain: s.brain, summary: s.summary, title: s.title ?? title, project: s.project ?? project }))
248
+ : (summary ? [{ brain, summary, title, project }] : null)
249
+ if (!list) {
250
+ return toolError('Pass `summary` (single brain) or a non-empty `segments` array (one entry per brain).')
251
+ }
252
+
253
+ // Records are unique on the ORG-SCOPED (org_id, dedupe_key) and every segment carries this
254
+ // session's dedupe key. Cross-brain segments therefore never collide that constraint is what
255
+ // makes the whole design workbut two aimed at the SAME brain would silently merge and lose
256
+ // one. Refuse instead of letting that happen quietly.
257
+ const seenBrain = new Set()
258
+ for (const s of list) {
259
+ const k = String(s.brain ?? '').trim().toLowerCase()
260
+ if (seenBrain.has(k)) {
261
+ return toolError(`Two segments target the same brain (${s.brain}). They share this session's dedupe key, so the second would overwrite the first — merge them into one segment.`)
262
+ }
263
+ seenBrain.add(k)
264
+ }
265
+
266
+ let brainIndex = null
267
+ const orgIdFor = async (nameOrId) => {
268
+ if (!nameOrId) return null
269
+ if (!brainIndex) {
270
+ try {
271
+ const r = await fetchCortex(`${BASE}/api/brains`, { headers: { Authorization: `Bearer ${TOKEN}` } })
272
+ brainIndex = r.ok ? ((await r.json().catch(() => ({}))).brains ?? []) : []
273
+ } catch {
274
+ brainIndex = []
275
+ }
276
+ }
277
+ const q = String(nameOrId).trim().toLowerCase()
278
+ const hit = brainIndex.find(
279
+ (b) => String(b.orgId ?? '').toLowerCase() === q || String(b.name ?? '').toLowerCase() === q,
280
+ )
281
+ return hit?.orgId ?? null
229
282
  }
230
- const j = await res.json().catch(() => ({}))
231
283
 
232
- // NEVER report a non-record as "logged". `staged` and `skipped` are 200 OK responses that wrote
233
- // no record, and this line used to print "Logged to Agnoclast (authoritative) … updated existing"
234
- // for both — `j.inserted` is merely falsy on a staged write, which reads as an UPDATE. That is
235
- // how a session close-out was reported as saved when it had not been (2026-08-09), and it is the
236
- // same failure shape the cortex-log skill warns about for `author`: a success string over a write
237
- // that did not land. The caller cannot tell the difference, so the message has to.
238
- if (j.staged) {
239
- const why = j.reason === 'no_route_for_source' && !brain
240
- ? 'no brain was named and you belong to more than one, so it had nowhere to route'
241
- : `reason: ${j.reason ?? 'unknown'}`
242
- return { content: [{ type: 'text', text: `NOT LOGGED — STAGED, not recorded (${why}). Re-run log_session with brain:"<name>" to record it. Staged session logs cannot currently be drained by /api/staged/promote.` }] }
243
- }
244
- if (j.skipped) {
245
- return { content: [{ type: 'text', text: `NOT LOGGED — the server skipped this write: ${j.skipped}` }] }
246
- }
247
- if (!j.id) {
248
- return { content: [{ type: 'text', text: `NOT LOGGED — the server returned no record id. Raw response: ${JSON.stringify(j).slice(0, 300)}` }] }
284
+ const lines = []
285
+ let failures = 0
286
+
287
+ for (const seg of list) {
288
+ const where = seg.brain ?? '(no brain named)'
289
+ let res
290
+ try {
291
+ res = await fetchCortex(`${BASE}/api/ingest`, {
292
+ method: 'POST',
293
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
294
+ body: JSON.stringify({
295
+ source: 'claude-code',
296
+ captureSource: 'skill',
297
+ summary: seg.summary,
298
+ ...(seg.project ? { project: seg.project } : {}),
299
+ ...(seg.title ? { title: seg.title } : {}),
300
+ ...(sessionId ? { sessionId } : {}),
301
+ // ADR-0022 deleted the write pointer, so a session-class source routes ONLY by an
302
+ // explicit brain or a sole membership — anything else STAGES. This tool never sent one,
303
+ // so every close-out from a multi-brain member landed in staged_records instead of the
304
+ // org. Measured 2026-08-09: 86 staged rows, not drainable by /api/staged/promote.
305
+ ...(seg.brain ? { brain: seg.brain } : {}),
306
+ ...(privacy ? { privacy } : {}),
307
+ payload: { via: 'log_session' },
308
+ }),
309
+ })
310
+ } catch (e) {
311
+ failures++
312
+ lines.push(`✗ ${where}: ${e.message}`)
313
+ continue
314
+ }
315
+ if (!res.ok) {
316
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
317
+ failures++
318
+ lines.push(`✗ ${where}: ${d.message}`)
319
+ continue
320
+ }
321
+ const j = await res.json().catch(() => ({}))
322
+
323
+ // NEVER report a non-record as "logged". `staged` and `skipped` are 200 OK responses that
324
+ // wrote no record, and this used to print "Logged … updated existing" for both, because
325
+ // `j.inserted` is merely falsy on a staged write.
326
+ if (j.staged) {
327
+ const why = j.reason === 'no_route_for_source' && !seg.brain
328
+ ? 'no brain named and you belong to more than one, so it had nowhere to route'
329
+ : `reason: ${j.reason ?? 'unknown'}`
330
+ failures++
331
+ lines.push(`✗ ${where}: NOT LOGGED — staged, not recorded (${why}). Re-run this segment with brain:"<name>" — staged session logs cannot be drained by /api/staged/promote.`)
332
+ continue
333
+ }
334
+ if (j.skipped) {
335
+ failures++
336
+ lines.push(`✗ ${where}: NOT LOGGED — server skipped the write: ${j.skipped}`)
337
+ continue
338
+ }
339
+
340
+ // Gate 4 routes a session log into PRIVATE INTAKE rather than straight to records, answering
341
+ // `via:"private_intake"` with an intakeItemId and no id. The previous check fell through to
342
+ // "no record id" and reported NOT LOGGED over a write that had landed — a false negative that
343
+ // drives retries, and retries against ingest make duplicates.
344
+ //
345
+ // Materializing here is not an optimization. Close-out is the ONLY moment the destination
346
+ // brain is known; a unit left in intake goes cleanup-due in a day and becomes an item no later
347
+ // session has the authority to route, because deciding which brain half of someone's session
348
+ // belongs in is exactly the judgment a stranger cannot make.
349
+ if (!j.id && j.via === 'private_intake' && j.intakeItemId) {
350
+ const orgId = await orgIdFor(seg.brain)
351
+ if (!orgId) {
352
+ failures++
353
+ lines.push(`✗ ${where}: captured to intake as ${j.intakeItemId}, but that brain did not resolve to an org id — materialize it by hand`)
354
+ continue
355
+ }
356
+ let m
357
+ try {
358
+ m = await fetchCortex(`${BASE}/api/intake/materialize`, {
359
+ method: 'POST',
360
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
361
+ body: JSON.stringify({
362
+ intakeItemId: j.intakeItemId,
363
+ orgId,
364
+ title: seg.title,
365
+ summary: seg.summary,
366
+ source: 'claude-code',
367
+ recordType: 'ai_session',
368
+ origin: 'session',
369
+ }),
370
+ })
371
+ } catch (e) {
372
+ failures++
373
+ lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${e.message}`)
374
+ continue
375
+ }
376
+ const mj = await m.json().catch(() => ({}))
377
+ if (!m.ok || !mj.recordId) {
378
+ failures++
379
+ lines.push(`✗ ${where}: intake ${j.intakeItemId} NOT materialized — ${mj.error ?? m.status}`)
380
+ continue
381
+ }
382
+ lines.push(`✓ ${where}: record ${mj.recordId}`)
383
+ continue
384
+ }
385
+
386
+ if (!j.id) {
387
+ failures++
388
+ lines.push(`✗ ${where}: NOT LOGGED — no record id. Raw: ${JSON.stringify(j).slice(0, 200)}`)
389
+ continue
390
+ }
391
+ lines.push(`✓ ${where}: record ${j.id}${j.inserted ? '' : ' (updated existing)'}`)
249
392
  }
250
- return { content: [{ type: 'text', text: `Logged to Agnoclast (authoritative): "${j.title ?? title ?? 'session'}" — ${j.inserted ? 'new record' : 'updated existing'} (id ${j.id}).` }] }
393
+
394
+ // Partial success is a real outcome once there is more than one segment, and "3 of 4 landed"
395
+ // must never read as done — that is the same silent-loss shape this whole path was repaired for.
396
+ const head = failures === 0
397
+ ? `Logged ${lines.length} segment${lines.length === 1 ? '' : 's'}.`
398
+ : `PARTIAL — ${lines.length - failures} of ${lines.length} segments logged, ${failures} FAILED. A partly-logged session is not a logged session; re-run the failed segments.`
399
+ return { content: [{ type: 'text', text: `${head}\n${lines.join('\n')}` }] }
251
400
  },
252
401
  )
253
402
 
@@ -422,7 +571,7 @@ export async function runServer(version) {
422
571
  {
423
572
  title: 'Check private intake changes',
424
573
  description:
425
- 'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. If cleanupDueCount > 0, call intake_claim with claimKind=cleanup before ordinary work.',
574
+ 'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. cleanupDueCount is advisory: prefer to service it with claimKind=cleanup when you reach a natural break, but never park the work you were actually asked to do in order to drain the queue first. NOTE afterSeq is this feed\'s own sequence — the changeSeq returned by ingest is a different counter, and passing it here seeks past the end and looks like a dead feed.',
426
575
  inputSchema: {
427
576
  afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
428
577
  limit: z.number().optional().describe('max change rows (default 100)'),
@@ -448,25 +597,66 @@ export async function runServer(version) {
448
597
  {
449
598
  title: 'Claim private intake items',
450
599
  description:
451
- 'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. If cleanup is due, relevance claims return 409 — process cleanup first. Requires x-cortex-session-key (set automatically by this MCP server).',
600
+ 'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. Requires x-cortex-session-key (set automatically by this MCP server). Aged work never blocks a claim: the response reports cleanupDueCount and how far behind the oldest unit is, and servicing it is expected but always your call. Claiming is a commitment to process — hand back anything you will not finish with intake_release, or intake_defer if it is the owner\'s decision to make.',
452
601
  inputSchema: {
453
602
  claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
454
- limit: z.number().optional().describe('max items (default 10)'),
603
+ limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
604
+ intakeItemIds: z.array(z.string()).optional().describe(
605
+ 'claim these specific units instead of the head of the queue (max 50). Without it you get FIFO, ' +
606
+ 'so reaching one known unit means claiming everything ahead of it. Ids come from intake_changes ' +
607
+ 'or from an ingest response. The reply adds an `outcomes` entry per requested id — claimed, ' +
608
+ 'held_by_you (you already hold a live lease on it — proceed, do not re-claim), ' +
609
+ 'held (someone else has a live lease; the holder is a stable hash, never their session key), ' +
610
+ 'ineligible (already resolved or deferred to the owner), not_found, or unavailable (a momentary ' +
611
+ 'lock — retrying is reasonable). Outcomes are best-effort, not a snapshot you can rely on.',
612
+ ),
455
613
  },
456
614
  },
457
- async ({ claimKind, limit }) => {
615
+ async ({ claimKind, limit, intakeItemIds }) => {
458
616
  const res = await fetchCortex(`${BASE}/api/intake/claim`, {
459
617
  method: 'POST',
460
618
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
461
- body: JSON.stringify({ claimKind: claimKind ?? 'relevance', limit, includePayload: true }),
619
+ body: JSON.stringify({
620
+ claimKind: claimKind ?? 'relevance',
621
+ limit,
622
+ includePayload: true,
623
+ // Forwarded only when present. An empty array is a real request to claim nothing and must
624
+ // survive as one; sending `[]` where the caller sent nothing would silently switch a FIFO
625
+ // claim into a no-op.
626
+ ...(intakeItemIds ? { intakeItemIds } : {}),
627
+ }),
462
628
  })
463
629
  if (!res.ok) {
464
630
  const body = await res.text()
465
631
  if (res.status === 403) return toolError('Private intake is not enabled for this account.')
632
+ // Kept deliberately after the server stopped sending it. A published MCP build outlives any
633
+ // one deployment, so this client will meet servers that still refuse relevance claims while
634
+ // cleanup is due. Surfacing that as its own message beats a generic HTTP failure.
466
635
  if (res.status === 409) return toolError(body)
467
636
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
468
637
  }
469
- return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
638
+
639
+ const j = await res.json()
640
+
641
+ // The nudge that replaced the 409. It has to be legible without doing arithmetic on a
642
+ // timestamp, so say how far behind rather than printing an ISO string and hoping — "6 days"
643
+ // is a reason to act and "2026-08-09T…" is a field to skim past.
644
+ let nudge = ''
645
+ if ((j?.cleanupDueCount ?? 0) > 0) {
646
+ const n = j.cleanupDueCount
647
+ let behind = ''
648
+ const oldest = j?.oldestCleanupDueAt ? Date.parse(j.oldestCleanupDueAt) : NaN
649
+ if (Number.isFinite(oldest)) {
650
+ const mins = Math.max(0, Math.floor((Date.now() - oldest) / 60000))
651
+ behind = mins >= 1440 ? `, oldest ${Math.floor(mins / 1440)}d overdue`
652
+ : mins >= 60 ? `, oldest ${Math.floor(mins / 60)}h overdue`
653
+ : `, oldest ${mins}m overdue`
654
+ }
655
+ nudge = `\n\n⏳ ${n} intake unit${n === 1 ? '' : 's'} past the cleanup deadline${behind}. `
656
+ + `Nothing is blocked — run intake_claim with claimKind:"cleanup" when you reach a natural break.`
657
+ }
658
+
659
+ return { content: [{ type: 'text', text: JSON.stringify(j, null, 2) + nudge }] }
470
660
  },
471
661
  )
472
662
 
@@ -485,7 +675,7 @@ export async function runServer(version) {
485
675
  source: z.string().optional(),
486
676
  recordType: z.string().optional(),
487
677
  dedupeKey: z.string().optional(),
488
- origin: z.enum(['deterministic', 'llm', 'user']).optional(),
678
+ origin: z.enum(['deterministic', 'llm', 'user', 'session']).optional(),
489
679
  },
490
680
  },
491
681
  async (args) => {
@@ -553,11 +743,90 @@ export async function runServer(version) {
553
743
  },
554
744
  )
555
745
 
746
+ server.registerTool(
747
+ 'intake_defer',
748
+ {
749
+ title: 'Defer a private intake item to the owner',
750
+ description:
751
+ 'The THIRD terminal outcome, and the right one whenever the honest answer is "this is not mine to decide." Use it when a claimed unit is someone else\'s private content, when the destination brain is a real judgment call rather than a lookup, or when publishing and destroying are both wrong — a stranger\'s message thread, a photo you cannot place, an email whose brain depends on context only the owner has. Destroys NOTHING: the unit stays encrypted and intact, its state becomes `awaiting_user`, and your `question` is what the owner actually sees. Prefer this over letting a lease lapse — a lapsed lease says nothing, increments `attempts`, and hands the identical dead end to the next session. Requires the claim, one item per call.',
752
+ inputSchema: {
753
+ intakeItemId: z.string().describe('intake item uuid from intake_claim'),
754
+ question: z.string().describe('what you need the owner to decide, in their words not yours — this is the entire message they get, so "which brain should this iMessage thread go to, if any?" beats "needs triage"'),
755
+ options: z.array(z.string()).optional().describe('the concrete choices, when the decision is a pick rather than an open question — e.g. ["Personal","TTO","Discard — nothing to record"]. Stored on the question and rendered by intake_cleanup_status, so the owner can answer with a choice instead of prose.'),
756
+ },
757
+ },
758
+ async ({ intakeItemId, question, options }) => {
759
+ let res
760
+ try {
761
+ res = await fetchCortex(`${BASE}/api/intake/defer`, {
762
+ method: 'POST',
763
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
764
+ body: JSON.stringify({ intakeItemId, question, ...(options?.length ? { options } : {}) }),
765
+ })
766
+ } catch (e) {
767
+ return toolError(`Could not defer: ${e.message}`)
768
+ }
769
+ const out = await res.json().catch(() => null)
770
+ if (!res.ok) {
771
+ if (out?.error === 'not_claimed') {
772
+ return toolError('You do not hold a claim on that item — intake_claim it first, so the question follows from having read it.')
773
+ }
774
+ if (out?.error === 'already_terminal') {
775
+ return toolError(`Nothing left to ask about: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
776
+ }
777
+ return toolError(`Could not defer: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
778
+ }
779
+ if (out?.alreadyDeferred) {
780
+ return { content: [{ type: 'text', text: 'Already awaiting the owner — nothing to do.' }] }
781
+ }
782
+ return { content: [{ type: 'text', text: 'Deferred to the owner. Content preserved, question queued in intake_cleanup_status.openQuestions, and the unit no longer gates cleanup.' }] }
783
+ },
784
+ )
785
+
786
+ server.registerTool(
787
+ 'intake_release',
788
+ {
789
+ title: 'Hand an intake claim back',
790
+ description:
791
+ 'Give a claimed unit back to the queue WITHOUT deciding anything. This is not a fourth outcome — materialize, discard and defer resolve a unit; release just ends your hold on it. Use it when you claimed more than you can act on, or when the unit turns out to belong to work you are not doing. Prefer it over letting the lease expire: a lapse and a crash are indistinguishable in the ledger, so silently timing out costs the pile the one signal that says a session looked and chose to pass. Use intake_defer instead when the unit needs the OWNER to decide — release puts it back in front of the next session, which will hit whatever wall you did.',
792
+ inputSchema: {
793
+ intakeItemId: z.string().describe('intake item uuid from intake_claim'),
794
+ reason: z.string().describe('why you are handing it back — "claimed too broadly", "not related to this session\'s work". Recorded on the claim, and the only thing distinguishing this from a lapsed lease'),
795
+ },
796
+ },
797
+ async ({ intakeItemId, reason }) => {
798
+ let res
799
+ try {
800
+ res = await fetchCortex(`${BASE}/api/intake/release`, {
801
+ method: 'POST',
802
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
803
+ body: JSON.stringify({ intakeItemId, reason }),
804
+ })
805
+ } catch (e) {
806
+ return toolError(`Could not release: ${e.message}`)
807
+ }
808
+ const out = await res.json().catch(() => null)
809
+ if (!res.ok) {
810
+ if (out?.error === 'not_claimed') {
811
+ return toolError('You do not hold a claim on that item — there is nothing to hand back.')
812
+ }
813
+ if (out?.error === 'awaiting_user') {
814
+ return toolError('That unit is already deferred to the owner. Releasing it would orphan the open question — leave it, or have the owner answer it.')
815
+ }
816
+ if (out?.error === 'already_terminal') {
817
+ return toolError(`Nothing to release: ${out?.detail ?? 'the unit is already materialized or discarded'}.`)
818
+ }
819
+ return toolError(`Could not release: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
820
+ }
821
+ return { content: [{ type: 'text', text: 'Released. The unit is back in the queue as pending, and your lease is gone.' }] }
822
+ },
823
+ )
824
+
556
825
  server.registerTool(
557
826
  'intake_cleanup_status',
558
827
  {
559
828
  title: 'Private intake cleanup status',
560
- description: 'Counts of pending/claimed/awaiting/cleanup-due intake items plus open clarifying questions for the owner.',
829
+ description: 'Counts of pending/claimed/awaiting/cleanup-due intake items, open clarifying questions for the owner, and `needsAttention` — units that keep coming back, with attempts / releases / deferrals / lapses each broken out. A high `lapses` means sessions claimed that unit and neither resolved it nor handed it back; a `deferrals` above 1 means the owner has already been asked more than once. Both are the OWNER\'s signal to act on, not yours — surface them rather than trying to clear them yourself.',
561
830
  inputSchema: {},
562
831
  },
563
832
  async () => {
@@ -679,7 +948,11 @@ export async function runServer(version) {
679
948
  const day = (d) => (d ? String(d).slice(0, 10) : '')
680
949
  const renderMatch = (m) => {
681
950
  const blocks = m.tiers.map((t) => {
682
- const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
951
+ // Was heading-then-body with NO currency at all — no as-of, no warning — while
952
+ // read_page showed it. project_status is what the routing docs reach for first, so a
953
+ // reader here could date nothing. Same renderer as read_page now, so the two cannot
954
+ // drift again.
955
+ const secs = (t.sections ?? []).map((s) => renderSection(s, day)).join('\n\n')
683
956
  const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
684
957
  return [head, t.summary, secs].filter(Boolean).join('\n')
685
958
  })
@@ -816,13 +1089,9 @@ export async function runServer(version) {
816
1089
  const renderMatch = (m, tagBrain) => {
817
1090
  const blocks = m.tiers.map((t) => {
818
1091
  const secs = (t.sections ?? []).map((s) => {
819
- // Undefined is an older-server response during a rolling deploy: do
820
- // not invent a currency verdict until this server has computed one.
821
- const currency = s.hasExplicitDate === false
822
- ? '\n⚠ **Undated section — verify before relying on its claims.**\n'
823
- : ''
824
- const asOf = s.asOf ? ` · as of ${day(s.asOf)}` : ''
825
- return `### ${s.heading}${asOf}${currency}${s.body}`
1092
+ // Shared with project_status via renderSection see its definition for why this is one
1093
+ // function and not two (they drifted; #558 fixed one of three surfaces).
1094
+ return renderSection(s, day)
826
1095
  }).join('\n\n')
827
1096
  // ADR-0018: a null version isn't "nothing to show" — it means this variant predates content-
828
1097
  // hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
@@ -1517,14 +1786,16 @@ export async function runServer(version) {
1517
1786
  project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
1518
1787
  since_days: z.number().optional().describe('only records from the last N days'),
1519
1788
  limit: z.number().optional().describe('max rows (1-50, default 20)'),
1789
+ session: z.string().optional().describe('a session id — returns every record that session produced, INCLUDING the separate halves of a log that was split across brains. Segmented logs share a sessionId and never link to each other, so this is the only way to reassemble one. RLS-scoped: you get the halves you are cleared for and cannot tell whether others exist'),
1520
1790
  },
1521
1791
  },
1522
- async ({ type, project, since_days, limit }) => {
1792
+ async ({ type, project, since_days, limit, session }) => {
1523
1793
  const qs = new URLSearchParams()
1524
1794
  if (type) qs.set('type', type)
1525
1795
  if (project) qs.set('project', project)
1526
1796
  if (since_days != null) qs.set('since_days', String(since_days))
1527
1797
  if (limit != null) qs.set('limit', String(limit))
1798
+ if (session) qs.set('session', session)
1528
1799
  let res
1529
1800
  try {
1530
1801
  res = await fetchCortex(`${BASE}/api/records?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.89",
3
+ "version": "0.9.91",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {