@theronap/agnoclast-mcp 0.9.211 → 0.9.213

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.
package/lib/diagnose.mjs CHANGED
@@ -414,7 +414,9 @@ async function fetchCortexOnce(url, opts = {}, policy = {}) {
414
414
  sleep: sleepFn = sleep, random = Math.random, now = Date.now,
415
415
  } = policy
416
416
  const { timeoutMs: optTimeout, signal: callerSignal, ...rest } = opts
417
- const baseHeaders = { ...(rest.headers ?? {}), 'x-cortex-client': CLIENT_ID }
417
+ // x-cortex-tool: the tool this request is for (the running one, else the path map) — the server
418
+ // counts calls per tool for staff usage stats (api_requests). A name only; arguments never travel.
419
+ const baseHeaders = { ...(rest.headers ?? {}), 'x-cortex-client': CLIENT_ID, 'x-cortex-tool': toolForUrl(url) }
418
420
  const fetchOpts = SESSION_KEY
419
421
  ? { ...rest, headers: { ...baseHeaders, 'x-cortex-session-key': SESSION_KEY } }
420
422
  : { ...rest, headers: baseHeaders }
@@ -0,0 +1,80 @@
1
+ // THE FORCED FOLDER QUESTION AND THE DECLINE LIST — pure renderers (ADR-0077, 2026-10-05).
2
+ //
3
+ // The server refuses to file a GitHub unit until every changed path has an answer. The refusal is only
4
+ // useful if the judge can answer it in ONE more call, so this prints every open question with what it
5
+ // accepts, the candidate pages (and their tiers), any earlier decision being re-asked, and a
6
+ // ready-to-edit `folderDecisions` value. Pure so CI can execute exactly what each refusal prints.
7
+
8
+ const q = (s) => `"${s}"`
9
+
10
+ /** The refusal text for a 409/400/403 from intake_materialize carrying ADR-0077 questions. */
11
+ export function renderFolderQuestions(body) {
12
+ const lines = []
13
+ const questions = Array.isArray(body?.questions) ? body.questions : []
14
+ const pages = Array.isArray(body?.candidatePages) ? body.candidatePages : []
15
+ const head = {
16
+ folder_decisions_required: 'Not filed yet — these paths have no page and no decision. Answer each, then call again with folderDecisions.',
17
+ claim_tier_unconfirmed: 'Not filed yet — that page is narrower than accessible.',
18
+ folder_defer_not_allowed: 'Not filed yet — only a cold judge may defer.',
19
+ decline_never_needs_working_session: 'Not filed yet — a cold judge may not decline "never".',
20
+ folder_decision_unmatched: 'Not filed yet — an answer matched no open question.',
21
+ folder_claim_on_mother_page: 'Not filed yet — that is the repo\'s mother page.',
22
+ folder_claim_not_a_project_page: 'Not filed yet — code belongs on a project page.',
23
+ folder_claim_page_unresolved: 'Not filed yet — that page could not be resolved.',
24
+ }[body?.error] ?? `Not filed yet — ${body?.error ?? 'folder decision refused'}.`
25
+ lines.push(head)
26
+ if (typeof body?.detail === 'string') lines.push(body.detail)
27
+ if (body?.page?.tier) lines.push(`Page: ${q(body.page.title)} (${body.page.tier}) — ref ${body.page.ref}`)
28
+ const open = questions.filter((x) => !x.answered)
29
+ if (open.length) {
30
+ lines.push('', `Open (${open.length}):`)
31
+ for (const x of open) {
32
+ const what = x.kind === 'file' ? `file in grab-bag \`${x.grabBag ?? '?'}\`` : `folder (${x.files?.length ?? 0} changed file${x.files?.length === 1 ? '' : 's'})`
33
+ lines.push(`- \`${x.identifier}\` — ${what}`)
34
+ if (Array.isArray(x.answerWith) && x.answerWith.length > 1) {
35
+ lines.push(` may also be answered at: ${x.answerWith.slice(1).map((a) => `\`${a}\``).join(', ')}`)
36
+ }
37
+ if (x.earlier) {
38
+ lines.push(` asked before: declined ${x.earlier.kind} on \`${x.earlier.identifier}\`${x.earlier.decidedBy ? ` by ${x.earlier.decidedBy}` : ''} ${String(x.earlier.decidedAt).slice(0, 10)} — "${x.earlier.reason ?? ''}". That decision has lapsed.`)
39
+ }
40
+ }
41
+ }
42
+ if (pages.length) {
43
+ lines.push('', 'Pages this unit is being filed onto (candidates — only if a page is ABOUT the folder):')
44
+ for (const p of pages) lines.push(`- ${q(p.title)} (${p.tier}) — ref ${p.ref}${p.tier !== 'accessible' ? ' — needs confirmTier: true' : ''}`)
45
+ }
46
+ if (open.length) {
47
+ const example = open.slice(0, 3).map((x) => (pages[0]
48
+ ? { identifier: x.identifier, claim: pages[0].ref }
49
+ : { identifier: x.identifier, decline: 'not_yet', reason: 'why it belongs to no page' }))
50
+ lines.push('', `Answer shape: folderDecisions: ${JSON.stringify(example)}`)
51
+ lines.push('No page fits? Author a small area page about the folder (`author`), then claim onto its ref. Generated/vendored/lockfiles/fixtures: decline "never".')
52
+ if (body?.cold) lines.push('You are a cold judge here: {identifier, defer: true} hands a question to the next session that touches it.')
53
+ }
54
+ return lines.join('\n')
55
+ }
56
+
57
+ /** intake_materialize success: what the answers did. [] when nothing was asked. */
58
+ export function renderFolderDecisionsApplied(fd) {
59
+ const applied = Array.isArray(fd?.applied) ? fd.applied : []
60
+ if (!applied.length) return []
61
+ const lines = [`Folder decisions applied (${applied.length}):`]
62
+ for (const op of applied) {
63
+ if (op.action === 'claim') lines.push(` - claimed \`${op.identifier}\` onto ${q(op.page)} (${op.tier})`)
64
+ else if (op.action === 'decline') lines.push(` - declined ${op.kind} \`${op.identifier}\` — "${op.reason}"`)
65
+ else lines.push(` - deferred \`${op.identifier}\` to the next session that touches it`)
66
+ }
67
+ return lines
68
+ }
69
+
70
+ /** my_routing_claims / read_page: the declines, as lines. [] when there are none (or the server is older). */
71
+ export function renderDeclines(declines, { heading = 'Declines' } = {}) {
72
+ const list = Array.isArray(declines) ? declines : []
73
+ if (!list.length) return []
74
+ const lines = [`${heading} (${list.length}) — a decline covers the identifier and everything under it:`]
75
+ for (const d of list) {
76
+ const state = d.active ? (d.kind === 'never' ? 'never' : d.kind === 'not_yet' ? 'not yet' : 'snoozed') : `${d.kind}, lapsed — will be asked again`
77
+ lines.push(` ${d.active ? '⊘' : '·'} \`${d.identifier}\`${d.brain ? ` · ${d.brain}` : ''} — ${state}${d.reason ? ` — "${d.reason}"` : ''}${d.decidedBy ? ` · ${d.decidedBy}` : ''}${d.cold ? ' (cold judge)' : ''} · ${String(d.decidedAt).slice(0, 10)}`)
78
+ }
79
+ return lines
80
+ }
@@ -10,6 +10,8 @@
10
10
  //
11
11
  // Pure so CI can execute what each state prints.
12
12
 
13
+ import { renderDeclines } from './folder_decisions.mjs'
14
+
13
15
  const q = (s) => `"${s}"`
14
16
  const pageLabel = (h) => `${q(h.title)} (${h.kind}, ${h.tier})`
15
17
 
@@ -56,6 +58,8 @@ export function renderIdentifierResolution(out, { expand = false } = {}) {
56
58
  if (mentions.length) {
57
59
  lines.push(`Mentioned on (does not route): ${mentions.map((h) => q(h.title)).join(', ')}`)
58
60
  }
61
+ // ADR-0077: a decline on it or a folder above it decides whether a commit is ASKED about it.
62
+ lines.push(...renderDeclines(out.declines, { heading: 'Declined' }))
59
63
  const ev = out.events ?? { visibleCount: 0, recent: [] }
60
64
  lines.push(`· ${ev.visibleCount} tagged timeline event${ev.visibleCount === 1 ? '' : 's'} visible to you${ev.recent?.length ? ':' : expand ? '.' : ' — pass expand: true to list recent ones.'}`)
61
65
  for (const e of ev.recent ?? []) lines.push(` - ${String(e.occurredAt).slice(0, 10)} · ${e.source} · ${e.summary}`)
@@ -0,0 +1,26 @@
1
+ // Rendering for ADR-0076 §4.5 — a conversation's texts, decided together and filed one record each.
2
+ // Kept out of server.mjs so it can be tested on its output, not on its source text.
3
+
4
+ /**
5
+ * A batch materialization, per id. ⚠ PARTIAL FAILURE IS REPORTED PER ID, never summarised as success — a
6
+ * batch that half landed is the one outcome an agent must not mistake for done (same rule as
7
+ * place_staged_record's batch).
8
+ */
9
+ export function renderMaterializeBatch(results) {
10
+ const good = results.filter((r) => r.ok)
11
+ const bad = results.filter((r) => !r.ok)
12
+ return `Batch of ${results.length}: ${good.length} filed, ${bad.length} failed — one record each.\n` +
13
+ results.map((r) => ` ${r.ok ? '✓' : '✗'} ${r.id} — ${r.detail}`).join('\n') +
14
+ (bad.length ? `\n\n⚠ ${bad.length} did NOT land. They are still in the pile; fix the cause and file those ids again (a unit must be claimed first).` : '')
15
+ }
16
+
17
+ /**
18
+ * One settling offer that stands for a whole text conversation (the server collapsed its waiting texts into
19
+ * it), or null when the offer is a single arrival and renders the ordinary way.
20
+ */
21
+ export function renderConversationOffer(x, why = '', looked = '') {
22
+ if (!x?.conversation || !(x.conversation.count > 1)) return null
23
+ const ids = x.conversation.ids.slice(0, 12).join(', ') + (x.conversation.ids.length > 12 ? ', …' : '')
24
+ return ` - 📱 ${x.conversation.count} texts in one conversation · newest ${x.hoursAgo}h ago · ${ids}${why}${looked}\n` +
25
+ ' decide them together: `intake_look` the newest, `intake_claim` them all, then file them in one `intake_materialize` (intakeItemIds) — one record each'
26
+ }
package/lib/server.mjs CHANGED
@@ -10,6 +10,7 @@ import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, set
10
10
  import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
11
11
  import { folderClaimLine } from './folder_claims.mjs'
12
12
  import { renderFolderClaimOffers, renderIdentifierResolution, renderUnroutedPathStamps } from './identifier_routes.mjs'
13
+ import { renderDeclines, renderFolderDecisionsApplied, renderFolderQuestions } from './folder_decisions.mjs'
13
14
  import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
14
15
  import { runSendImessage } from './imessage_send.mjs'
15
16
  import { formatGrepHits } from './grep_cli.mjs'
@@ -23,6 +24,7 @@ import { spawnObligationWorker } from './obligations_worker.mjs'
23
24
  import { renderObligations, renderPageObligations, tierReads, who } from './obligations_render.mjs'
24
25
  import { renderRemovalQuestion, renderRemoved } from './member_remove_render.mjs'
25
26
  import { renderSettings } from './settings_render.mjs'
27
+ import { renderConversationOffer, renderMaterializeBatch } from './intake_batch_render.mjs'
26
28
  import { formatCreated, formatDryRun, formatSources, homeFromArgs, pickSource, renderHomeProblem } from './custom_sources.mjs'
27
29
 
28
30
  // PAGE tiers (ADR-0071). Six for a PAGE; a RECORD keeps three (`privacy` on log_session and
@@ -427,6 +429,9 @@ function renderSettling(payload) {
427
429
  ? ' — ⚠ NO session of yours matches this; it is here because it reached nobody'
428
430
  : ' — every session that matched it declined, so it is back to everyone'
429
431
  const looked = x.looks > 0 ? ` [looked at ${x.looks}x, still unclaimed]` : ''
432
+ // ADR-0076 §4.5: a conversation's waiting texts arrive one per message and are offered as ONE line.
433
+ const conversationLine = renderConversationOffer(x, why, looked)
434
+ if (conversationLine) return conversationLine
430
435
  return ` - ${x.source} · ${x.hoursAgo}h ago · ${x.intakeItemId}${why}${looked}`
431
436
  })
432
437
 
@@ -1225,9 +1230,16 @@ function renderNudge(payload) {
1225
1230
  'Atomically publish a claimed intake item into one brain — the disclosure moment (ADR-0062). Deterministic identifier homes in that brain are always attached; documentIds may add further pages. The record is born at the strictest of its source baseline, what the producer asked for, and every attached page; a source floor (iMessage, claude-code) pins it. ' +
1226
1231
  'Name the governing page with governingPage (one of documentIds, never a person page) — otherwise the ranking picks it. ' +
1227
1232
  'Pass mode "preview" first when your choice differs from the claim\'s proposal: it returns the pages, the governing page and the tier this exact call would produce, and writes nothing. Never writes private intake into search/history before this call. ' +
1228
- '⚠ ADR-0051: a unit does not finish until you looked for its OTHER homes. A call whose documentIds add nothing beyond the deterministic homes it gets anyway is REFUSED once — name the pages this unit is about (the design it implements, the project, the person), or go look, or say you looked with searchedForOtherHomes.',
1233
+ '⚠ ADR-0051: a unit does not finish until you looked for its OTHER homes. A call whose documentIds add nothing beyond the deterministic homes it gets anyway is REFUSED once — name the pages this unit is about (the design it implements, the project, the person), or go look, or say you looked with searchedForOtherHomes. ' +
1234
+ '⚠ ADR-0077: a GitHub commit or PR does not finish until every changed path has an answer. If a changed file\'s deepest folder (or, in a grab-bag folder like web/lib/engine/ or db/supabase/, the file itself) has no claim and no decline, the call is REFUSED with the open questions; answer each in folderDecisions and call again.',
1229
1235
  inputSchema: {
1230
1236
  intakeItemId: z.string().describe('intake item uuid'),
1237
+ intakeItemIds: z.array(z.string()).optional().describe(
1238
+ 'MORE units to file exactly the same way, in one call — one record EACH, never merged (ADR-0076 §4.5). ' +
1239
+ 'For a conversation\'s waiting texts that you decided together: claim them all with `intake_claim`, then pass ' +
1240
+ 'the first as intakeItemId and the rest here. title/summary/dedupeKey are ignored in a batch (each unit keeps its ' +
1241
+ 'own), and a preview covers intakeItemId only. Each id is reported separately; a failure never hides behind a success.',
1242
+ ),
1231
1243
  orgId: z.string().describe('destination brain org uuid'),
1232
1244
  documentIds: z.array(z.string()).optional().describe('additional pages in orgId — each a page `ref:` exactly as read_page shows it, or a brain_documents id (deterministic homes are merged automatically)'),
1233
1245
  title: z.string().optional(),
@@ -1250,9 +1262,52 @@ function renderNudge(payload) {
1250
1262
  'looking, not a way past the question. Measured 2026-09-25: 30 records filed through this verb in one afternoon with no ' +
1251
1263
  'judged page on any of them.',
1252
1264
  ),
1265
+ folderDecisions: z.array(z.object({
1266
+ identifier: z.string().describe('the open question\'s identifier, or a folder above it that its answerWith lists'),
1267
+ claim: z.string().optional().describe('page `ref:` (or document id) of a project page ABOUT this path — never the repo\'s mother page, a person or a user page'),
1268
+ decline: z.enum(['never', 'not_yet']).optional().describe('never = generated/build output, lockfiles, fixtures, vendored code; not_yet = asked again after 10 more commits or 90 days'),
1269
+ reason: z.string().optional().describe('required with decline: why it belongs to no page — the next session asked will read it'),
1270
+ defer: z.boolean().optional().describe('cold judges only (a cleanup lease): hand the question to the next session that touches it'),
1271
+ confirmTier: z.boolean().optional().describe('required to claim onto a page narrower than accessible — its tier will govern every future commit there'),
1272
+ })).optional().describe(
1273
+ 'ADR-0077: one answer per open folder question from a refused call — exactly one of claim, decline or defer each. ' +
1274
+ 'Send only after the server has listed the questions; claims and declines are written when the unit is filed.',
1275
+ ),
1253
1276
  },
1254
1277
  },
1255
1278
  async (args) => {
1279
+ // ADR-0076 §4.5 — A BATCH: one decision, N records. Theron, 2026-10-05: a conversation's waiting texts
1280
+ // are looked up once and handled together, but "it shouldn't file all of them as one record". The
1281
+ // loop lives here, as it does for place_staged_record's batch: the route stays one unit per call.
1282
+ const batch = [...new Set([args.intakeItemId, ...(args.intakeItemIds ?? [])])].slice(0, 50)
1283
+ if (batch.length > 1 && args.mode !== 'preview') {
1284
+ const results = []
1285
+ for (const id of batch) {
1286
+ try {
1287
+ const r = await fetchCortex(`${BASE}${intakeMaterializePath(undefined)}`, {
1288
+ method: 'POST',
1289
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1290
+ body: JSON.stringify({
1291
+ intakeItemId: id,
1292
+ orgId: args.orgId,
1293
+ documentIds: args.documentIds ?? [],
1294
+ record: { source: args.source, record_type: args.recordType },
1295
+ attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
1296
+ ...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
1297
+ ...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
1298
+ // One placement is a decision about ONE conversation: the server refuses any id whose
1299
+ // batch differs from the first id's, so a stray id is never filed onto these pages.
1300
+ sameBatchAs: batch[0],
1301
+ }),
1302
+ })
1303
+ const o = await r.json().catch(() => null)
1304
+ results.push({ id, ok: r.ok, detail: r.ok ? (o?.recordId ?? 'filed') : (o?.error ?? `HTTP ${r.status}`) })
1305
+ } catch (e) {
1306
+ results.push({ id, ok: false, detail: e.message })
1307
+ }
1308
+ }
1309
+ return { content: [{ type: 'text', text: renderMaterializeBatch(results) }] }
1310
+ }
1256
1311
  // A preview goes to its OWN route: a server too old to know it answers 404, where the materialize
1257
1312
  // route would ignore a `mode` it does not know and publish.
1258
1313
  const preview = args.mode === 'preview'
@@ -1273,10 +1328,18 @@ function renderNudge(payload) {
1273
1328
  attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
1274
1329
  ...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
1275
1330
  ...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
1331
+ ...(args.folderDecisions?.length ? { folderDecisions: args.folderDecisions } : {}),
1276
1332
  }),
1277
1333
  })
1278
1334
  if (!res.ok) {
1279
1335
  const body = await res.text()
1336
+ // ADR-0077: the forced folder question. Every refusal from it carries the open questions, so
1337
+ // print them in a form the judge can answer in ONE more call.
1338
+ {
1339
+ let parsed = null
1340
+ try { parsed = JSON.parse(body) } catch { /* not JSON */ }
1341
+ if (parsed && Array.isArray(parsed.questions)) return toolError(renderFolderQuestions(parsed))
1342
+ }
1280
1343
  if (res.status === 409) {
1281
1344
  let err = null
1282
1345
  try { err = JSON.parse(body)?.error } catch { /* not JSON: fall through to the raw text */ }
@@ -1291,8 +1354,10 @@ function renderNudge(payload) {
1291
1354
  }
1292
1355
  // Same redirect-not-dispatch rule as place_staged_record. A staged arrival needs no claim and
1293
1356
  // no unseal, so the fix is a different verb rather than a retry of this one.
1294
- if (res.status === 404 && (await pileHolding(intakeItemId)) === 'staged') {
1295
- return toolError(`${intakeItemId} is a STAGED arrival, not an intake unit — \`intake_materialize\` cannot act on it. Use \`place_staged_record\` with the pages it belongs to; staged arrivals take no lease, so there is nothing to claim or release. (\`staged_look\` reads it first, and carries the brain-ladder proposal.)`)
1357
+ // ⚠ `args.intakeItemId`, not a bare `intakeItemId`: this handler never declared one, so a 404 here
1358
+ // threw a ReferenceError instead of saying which verb to use (found 2026-10-05 adding batches).
1359
+ if (res.status === 404 && (await pileHolding(args.intakeItemId)) === 'staged') {
1360
+ return toolError(`${args.intakeItemId} is a STAGED arrival, not an intake unit — \`intake_materialize\` cannot act on it. Use \`place_staged_record\` with the pages it belongs to; staged arrivals take no lease, so there is nothing to claim or release. (\`staged_look\` reads it first, and carries the brain-ladder proposal.)`)
1296
1361
  }
1297
1362
  return toolError(body)
1298
1363
  }
@@ -1346,6 +1411,7 @@ function renderNudge(payload) {
1346
1411
  // did not happen, which is exactly the silence this whole path exists to remove.
1347
1412
  // ADR-0075 — the deepest unclaimed folder this unit sits in, offered for the feature page it was
1348
1413
  // filed onto. Never claimed by the server; the session decides.
1414
+ lines.push(...renderFolderDecisionsApplied(body?.folderDecisions))
1349
1415
  lines.push(...renderFolderClaimOffers(body?.folderClaimOffers))
1350
1416
  if (body?.containerSkipped) {
1351
1417
  lines.push(`⚠ CONTAINER NOT APPLIED — ${body.containerSkipped}. The record exists but is not inside the event you named; place it with contain_record or leave it out.`)
@@ -2427,22 +2493,27 @@ function renderNudge(payload) {
2427
2493
  server.registerTool(
2428
2494
  'snooze_routing_claim',
2429
2495
  {
2430
- title: 'Stop surfacing one unclaimed source',
2496
+ title: 'Decline an unclaimed source or path (never / not yet), or stop surfacing it',
2431
2497
  description: "Silence one identifier in the `Sources nothing has claimed` block at session start. Use it when a recurring source genuinely belongs nowhere — a scratch repo, a bot channel, someone else's project that happens to cc you. NOT for something you simply have not got to yet: that is what leaving it alone does. A snooze silences the NOTICE and routes nothing; the events keep landing exactly where they land today. The block already has a volume floor that keeps one-off identifiers out, so anything you see there is recurring by construction — which is precisely why no threshold can tell 'not yet decided' from 'decided: nowhere', and why this exists. Bounded to at most a year and defaulting to 90 days, because a repo that was noise in August may be the centre of the work by November. Identity namespaces (`email:`, `thread:`) are refused: they are never surfaced as claimable in the first place, so there is nothing to snooze.",
2432
2498
  inputSchema: {
2433
2499
  identifier: z.string().describe('the identifier exactly as the context block prints it, e.g. `repo:owner/name`'),
2434
2500
  brain: z.string().optional().describe('which brain to stop surfacing it in. Required when you belong to more than one — the call refuses rather than picking.'),
2435
2501
  days: z.number().optional().describe('how long to stay quiet (1-365, default 90).'),
2436
- reason: z.string().optional().describe('why it belongs nowhere, in one phrase — the next person to see it resurface will want this.'),
2502
+ reason: z.string().optional().describe('why it belongs nowhere, in one phrase — the next person to see it resurface will want this. REQUIRED with kind.'),
2503
+ kind: z.enum(['never', 'not_yet']).optional().describe('ADR-0077: record a DECLINE instead of silencing the notice. never = generated/build output, lockfiles, fixtures, vendored code (a cold judge — a session holding cleanup leases — may not write it); not_yet = asked again after 10 more commits touch it or 90 days. A decline covers the identifier and everything under it, and claiming the identifier clears it.'),
2504
+ remove: z.boolean().optional().describe('ADR-0077: UN-decline — delete the decline (or snooze) on this identifier, so the next commit that touches it is asked again.'),
2437
2505
  },
2438
2506
  },
2439
- async ({ identifier, brain, days, reason }) => {
2507
+ async ({ identifier, brain, days, reason, kind, remove }) => {
2440
2508
  let res
2441
2509
  try {
2442
2510
  res = await fetchCortex(`${BASE}/api/brain/snooze-routing-claim`, {
2443
2511
  method: 'POST',
2444
2512
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2445
- body: JSON.stringify({ identifier, ...(brain ? { brain } : {}), ...(days ? { days } : {}), ...(reason ? { reason } : {}) }),
2513
+ body: JSON.stringify({
2514
+ identifier, ...(brain ? { brain } : {}), ...(days ? { days } : {}), ...(reason ? { reason } : {}),
2515
+ ...(kind ? { kind } : {}), ...(remove ? { remove: true } : {}),
2516
+ }),
2446
2517
  })
2447
2518
  } catch (e) {
2448
2519
  return toolError(`Could not snooze ${identifier}: ${e.message}`)
@@ -2453,7 +2524,10 @@ function renderNudge(payload) {
2453
2524
  const hint = out?.hint ? `\n${out.hint}` : ''
2454
2525
  return toolError(`Could not snooze ${identifier}: ${out?.error ?? res.status}${extra}${hint}`)
2455
2526
  }
2456
- return { content: [{ type: 'text', text: `${out.note}\nIf it should route somewhere after all, \`set_routing_identifier\` on the owning page overrides this — a claim always beats a snooze.` }] }
2527
+ if (kind && !out?.kind) {
2528
+ return toolError(`This server cannot record a ${kind} decline yet (it stored a plain snooze instead). Re-run after the server updates.`)
2529
+ }
2530
+ return { content: [{ type: 'text', text: `${out.note}\nIf it should route somewhere after all, \`set_routing_identifier\` on the owning page overrides this — a claim always beats a decline, and clears it.` }] }
2457
2531
  },
2458
2532
  )
2459
2533
 
@@ -3844,7 +3918,7 @@ function renderNudge(payload) {
3844
3918
  if (!res.ok) return toolError(`Could not read routing claims: ${out?.error ?? res.status}${out?.hint ? `\n${out.hint}` : ''}`)
3845
3919
 
3846
3920
  const groups = Array.isArray(out?.identifiers) ? out.identifiers : []
3847
- if (groups.length === 0) {
3921
+ if (groups.length === 0 && !(Array.isArray(out?.declines) && out.declines.length)) {
3848
3922
  return { content: [{ type: 'text', text: 'No routing claims you can see. Nothing you own decides a brain for any identifier, so inbound mail resolves by nothing and stages.' }] }
3849
3923
  }
3850
3924
  const lines = []
@@ -3858,6 +3932,9 @@ function renderNudge(payload) {
3858
3932
  lines.push(` ${c.brain} · ${page}${c.mine ? '' : ' · claimed by someone else'} · ${c.source} · ${String(c.createdAt).slice(0, 10)}`)
3859
3933
  }
3860
3934
  }
3935
+ // ADR-0077: declines beside claims.
3936
+ const declineLines = renderDeclines(out?.declines)
3937
+ if (declineLines.length) lines.push('', ...declineLines)
3861
3938
  lines.push('')
3862
3939
  lines.push('— Exactly one brain routes. Zero or several STAGE, and a real message carries several identifiers, so one clean sender can still stage on another key it carries.')
3863
3940
  lines.push('— Before adding a claim: a second brain on the same identifier converts routing into staging. Claim the NARROWEST key the message carries (a repo or package), not the account it came from.')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.211",
3
+ "version": "0.9.213",
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": {