@theronap/agnoclast-mcp 0.9.210 → 0.9.212

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}`)
@@ -1,88 +1,78 @@
1
- // send_imessage — outbound iMessage via Messages.app. This is a personal automation, NOT org
2
- // intelligence: it writes nothing to Agnoclast. Three layers of safety (eng-review D3/D6/D10):
3
- // D6 argv-safe: recipient + body are passed as osascript `on run argv` arguments, NEVER
4
- // interpolated into the script source → no AppleScript injection, no quote/newline breakage.
5
- // D3 draft-by-default: nothing sends unless the caller explicitly passes send:true.
6
- // D10 recipient gating: an MCP send tool is reachable by any agent context (prompt-injection
7
- // surface), so a boolean alone is not enough. Allowlisted recipients send on send:true;
8
- // OFF-list recipients additionally require an out-of-band confirm secret the agent context
9
- // doesn't have (CORTEX_IMESSAGE_SEND_CONFIRM), else the send is BLOCKED.
10
- import { spawn } from 'child_process'
1
+ // send_imessage — ask to send a text from the user's own Mac, the same way request_send_email asks to
2
+ // send an email (ADR-0076).
3
+ //
4
+ // WHAT CHANGED, AND WHY. This tool used to text directly: osascript → Messages.app on whatever machine
5
+ // the MCP ran on, gated by CORTEX_IMESSAGE_SEND_ALLOWLIST and an out-of-band confirm secret. No
6
+ // approval screen, no history, no "did it go out?", and nothing written to Agnoclast — while email had
7
+ // all of it. Theron, 2026-10-05: "my texts are not being handled in the same way that emails are
8
+ // handled, and that seems like a failure."
9
+ //
10
+ // Now it creates a request on agnoclast.com (POST /api/texts/send-requests). The request waits on
11
+ // /email → Waiting for the user's OK, unless their send rule — the SAME rule email uses, set only on
12
+ // the web — lets it through. Once approved, the user's Mac (the Agnoclast desktop app) sends it through
13
+ // Messages and reports back. So:
14
+ // * this process never runs osascript and never sends anything itself;
15
+ // * the env allowlist and the confirm secret are gone — the web send rule replaced both, and an agent
16
+ // cannot loosen it;
17
+ // * without send:true it is still a draft preview, exactly as before, so existing callers that rely on
18
+ // draft-by-default see no change in that path.
19
+ //
20
+ // ⚠ THE POST IS NOT IDEMPOTENT. Never retried: a 5xx may mean the request was created.
11
21
 
12
- // AppleScript reads its inputs from argv — the message text is DATA, never code.
13
- const SEND_SCRIPT = `on run argv
14
- set targetId to item 1 of argv
15
- set targetMessage to item 2 of argv
16
- tell application "Messages"
17
- set targetService to 1st account whose service type = iMessage
18
- set targetBuddy to participant targetId of targetService
19
- send targetMessage to targetBuddy
20
- end tell
21
- end run`
22
-
23
- export function phoneKey(raw) {
24
- const d = String(raw).replace(/[^0-9]/g, '')
25
- return d.length < 7 ? null : d.slice(-10)
26
- }
27
- function handleKey(h) {
28
- return h.includes('@') ? h.trim().toLowerCase() : phoneKey(h)
29
- }
30
- export function loadSendAllowlist(raw = process.env.CORTEX_IMESSAGE_SEND_ALLOWLIST ?? '') {
31
- const set = new Set()
32
- for (const item of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
33
- set.add(item.includes('@') ? item.toLowerCase() : (phoneKey(item) ?? item))
34
- }
35
- return set
22
+ /** PURE. The draft preview — unchanged in spirit from the old tool. */
23
+ export function draftText({ recipient, message }) {
24
+ return `📝 DRAFT (not sent) — to ${recipient}:\n${message}\n\n(Call send_imessage again with send:true to ask the user to approve it.)`
36
25
  }
37
26
 
38
- // Pure decision: what should happen, given inputs + config. Testable without Messages.app.
39
- // → { action: 'draft' | 'send' | 'blocked', reason }
40
- export function decideSend({ recipient, message, send, confirm, allowlist, confirmSecret }) {
41
- if (!recipient || !String(recipient).trim()) return { action: 'blocked', reason: 'no recipient' }
42
- if (!message || !String(message).trim()) return { action: 'blocked', reason: 'empty message' }
43
- if (!send) return { action: 'draft', reason: 'draft-by-default — re-call with send:true to actually send' }
44
- const key = handleKey(String(recipient))
45
- if (key && allowlist.has(key)) return { action: 'send', reason: 'allowlisted recipient' }
46
- // off-list: require the out-of-band confirm secret
47
- if (confirmSecret && confirm && confirm === confirmSecret) return { action: 'send', reason: 'off-list send authorized by confirm secret' }
48
- return {
49
- action: 'blocked',
50
- reason: confirmSecret
51
- ? 'recipient not allowlisted — pass the out-of-band confirm secret to send, or add them to CORTEX_IMESSAGE_SEND_ALLOWLIST'
52
- : 'recipient not allowlisted — add them to CORTEX_IMESSAGE_SEND_ALLOWLIST (or set CORTEX_IMESSAGE_SEND_CONFIRM for an out-of-band override)',
53
- }
54
- }
55
-
56
- export function sendViaOsascript(recipient, message) {
57
- return new Promise((resolve) => {
58
- let err = ''
59
- let proc
60
- try {
61
- proc = spawn('osascript', ['-', String(recipient), String(message)], { stdio: ['pipe', 'ignore', 'pipe'] })
62
- } catch (e) {
63
- resolve({ ok: false, error: String(e) }); return
27
+ /**
28
+ * PURE. The server's answer → what the agent is told. Mirrors request_send_email's contract exactly:
29
+ * `reviewUrl` PRESENT means pending, and the hand-off sentence is relayed verbatim; ABSENT means the
30
+ * user's send rule already approved it, and there is nothing to approve.
31
+ *
32
+ * Returns { text, isError }.
33
+ */
34
+ export function textRequestReply(status, body, recipient) {
35
+ const msg = typeof body?.error === 'string' ? body.error : null
36
+ if (status === 201 || (status >= 200 && status < 300)) {
37
+ const reviewUrl = body?.reviewUrl
38
+ if (reviewUrl) {
39
+ return { isError: false, text:
40
+ `Text request created to ${recipient}. It is NOT sent yet. ` +
41
+ `Tell the user, in these words: It's waiting for your OK: ${reviewUrl}` }
64
42
  }
65
- proc.stderr.on('data', (d) => { err += d })
66
- proc.on('error', (e) => resolve({ ok: false, error: String(e) }))
67
- proc.on('close', (code) => resolve({ ok: code === 0, error: err.trim() }))
68
- proc.stdin.write(SEND_SCRIPT)
69
- proc.stdin.end()
70
- })
43
+ const state = body?.request?.state ?? 'approved'
44
+ return { isError: false, text:
45
+ `Approved by the user's send rule — nothing to approve (state: ${state}). Their Mac sends it through ` +
46
+ `Messages the next time it checks in, usually within a minute. Do not offer an approval link, and do ` +
47
+ `not send it again; the user can see how it went on Email → History in Agnoclast.` }
48
+ }
49
+ if (body?.code === 'no_text_sender') {
50
+ return { isError: true, text: `${msg} Nothing was stored, so nothing will be sent.` }
51
+ }
52
+ if (status >= 500) {
53
+ return { isError: true, text:
54
+ `${msg ?? `Agnoclast could not finish that (${status}).`} ⚠ The request MAY have been created anyway. ` +
55
+ `Do NOT call send_imessage again — ask the user to check Email in Agnoclast.` }
56
+ }
57
+ return { isError: true, text: msg ?? `send_imessage failed (${status}).` }
71
58
  }
72
59
 
73
- // Tool entry: decide, then act. Returns MCP content text.
74
- export async function runSendImessage({ recipient, message, send, confirm }) {
75
- const decision = decideSend({
76
- recipient, message, send, confirm,
77
- allowlist: loadSendAllowlist(),
78
- confirmSecret: process.env.CORTEX_IMESSAGE_SEND_CONFIRM ?? null,
79
- })
80
- if (decision.action === 'draft') {
81
- return `📝 DRAFT (not sent) — to ${recipient}:\n${message}\n\n(${decision.reason})`
82
- }
83
- if (decision.action === 'blocked') {
84
- return `🚫 Not sent: ${decision.reason}`
60
+ /**
61
+ * Tool entry. `request` is the injected POST (fetchCortex with retries off, in server.mjs), so this
62
+ * stays testable without a network.
63
+ */
64
+ export async function runSendImessage({ recipient, message, send }, request) {
65
+ if (!recipient || !String(recipient).trim()) return { isError: true, text: 'Name who to text: a phone number or an iMessage email address.' }
66
+ if (!message || !String(message).trim()) return { isError: true, text: 'The text is empty.' }
67
+ if (send !== true) return { isError: false, text: draftText({ recipient, message }) }
68
+ let res
69
+ try {
70
+ res = await request({ to: String(recipient).trim(), text: String(message) })
71
+ } catch (e) {
72
+ return { isError: true, text:
73
+ `Could not confirm the text request: ${e?.message ?? e} ⚠ It may have been created anyway. ` +
74
+ `Do NOT call send_imessage again — ask the user to check Email in Agnoclast.` }
85
75
  }
86
- const r = await sendViaOsascript(recipient, message)
87
- return r.ok ? `✅ Sent to ${recipient}.` : `❌ Send failed: ${r.error || 'Messages.app returned an error (is it signed in to iMessage? is Automation permission granted?)'}`
76
+ const body = await res.json().catch(() => null)
77
+ return textRequestReply(res.status, body, recipient)
88
78
  }
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'
@@ -1225,7 +1226,8 @@ function renderNudge(payload) {
1225
1226
  '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
1227
  'Name the governing page with governingPage (one of documentIds, never a person page) — otherwise the ranking picks it. ' +
1227
1228
  '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.',
1229
+ '⚠ 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. ' +
1230
+ '⚠ 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
1231
  inputSchema: {
1230
1232
  intakeItemId: z.string().describe('intake item uuid'),
1231
1233
  orgId: z.string().describe('destination brain org uuid'),
@@ -1250,6 +1252,17 @@ function renderNudge(payload) {
1250
1252
  'looking, not a way past the question. Measured 2026-09-25: 30 records filed through this verb in one afternoon with no ' +
1251
1253
  'judged page on any of them.',
1252
1254
  ),
1255
+ folderDecisions: z.array(z.object({
1256
+ identifier: z.string().describe('the open question\'s identifier, or a folder above it that its answerWith lists'),
1257
+ 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'),
1258
+ 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'),
1259
+ reason: z.string().optional().describe('required with decline: why it belongs to no page — the next session asked will read it'),
1260
+ defer: z.boolean().optional().describe('cold judges only (a cleanup lease): hand the question to the next session that touches it'),
1261
+ confirmTier: z.boolean().optional().describe('required to claim onto a page narrower than accessible — its tier will govern every future commit there'),
1262
+ })).optional().describe(
1263
+ 'ADR-0077: one answer per open folder question from a refused call — exactly one of claim, decline or defer each. ' +
1264
+ 'Send only after the server has listed the questions; claims and declines are written when the unit is filed.',
1265
+ ),
1253
1266
  },
1254
1267
  },
1255
1268
  async (args) => {
@@ -1273,10 +1286,18 @@ function renderNudge(payload) {
1273
1286
  attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
1274
1287
  ...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
1275
1288
  ...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
1289
+ ...(args.folderDecisions?.length ? { folderDecisions: args.folderDecisions } : {}),
1276
1290
  }),
1277
1291
  })
1278
1292
  if (!res.ok) {
1279
1293
  const body = await res.text()
1294
+ // ADR-0077: the forced folder question. Every refusal from it carries the open questions, so
1295
+ // print them in a form the judge can answer in ONE more call.
1296
+ {
1297
+ let parsed = null
1298
+ try { parsed = JSON.parse(body) } catch { /* not JSON */ }
1299
+ if (parsed && Array.isArray(parsed.questions)) return toolError(renderFolderQuestions(parsed))
1300
+ }
1280
1301
  if (res.status === 409) {
1281
1302
  let err = null
1282
1303
  try { err = JSON.parse(body)?.error } catch { /* not JSON: fall through to the raw text */ }
@@ -1346,6 +1367,7 @@ function renderNudge(payload) {
1346
1367
  // did not happen, which is exactly the silence this whole path exists to remove.
1347
1368
  // ADR-0075 — the deepest unclaimed folder this unit sits in, offered for the feature page it was
1348
1369
  // filed onto. Never claimed by the server; the session decides.
1370
+ lines.push(...renderFolderDecisionsApplied(body?.folderDecisions))
1349
1371
  lines.push(...renderFolderClaimOffers(body?.folderClaimOffers))
1350
1372
  if (body?.containerSkipped) {
1351
1373
  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 +2449,27 @@ function renderNudge(payload) {
2427
2449
  server.registerTool(
2428
2450
  'snooze_routing_claim',
2429
2451
  {
2430
- title: 'Stop surfacing one unclaimed source',
2452
+ title: 'Decline an unclaimed source or path (never / not yet), or stop surfacing it',
2431
2453
  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
2454
  inputSchema: {
2433
2455
  identifier: z.string().describe('the identifier exactly as the context block prints it, e.g. `repo:owner/name`'),
2434
2456
  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
2457
  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.'),
2458
+ 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.'),
2459
+ 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.'),
2460
+ 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
2461
  },
2438
2462
  },
2439
- async ({ identifier, brain, days, reason }) => {
2463
+ async ({ identifier, brain, days, reason, kind, remove }) => {
2440
2464
  let res
2441
2465
  try {
2442
2466
  res = await fetchCortex(`${BASE}/api/brain/snooze-routing-claim`, {
2443
2467
  method: 'POST',
2444
2468
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2445
- body: JSON.stringify({ identifier, ...(brain ? { brain } : {}), ...(days ? { days } : {}), ...(reason ? { reason } : {}) }),
2469
+ body: JSON.stringify({
2470
+ identifier, ...(brain ? { brain } : {}), ...(days ? { days } : {}), ...(reason ? { reason } : {}),
2471
+ ...(kind ? { kind } : {}), ...(remove ? { remove: true } : {}),
2472
+ }),
2446
2473
  })
2447
2474
  } catch (e) {
2448
2475
  return toolError(`Could not snooze ${identifier}: ${e.message}`)
@@ -2453,7 +2480,10 @@ function renderNudge(payload) {
2453
2480
  const hint = out?.hint ? `\n${out.hint}` : ''
2454
2481
  return toolError(`Could not snooze ${identifier}: ${out?.error ?? res.status}${extra}${hint}`)
2455
2482
  }
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.` }] }
2483
+ if (kind && !out?.kind) {
2484
+ return toolError(`This server cannot record a ${kind} decline yet (it stored a plain snooze instead). Re-run after the server updates.`)
2485
+ }
2486
+ 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
2487
  },
2458
2488
  )
2459
2489
 
@@ -3844,7 +3874,7 @@ function renderNudge(payload) {
3844
3874
  if (!res.ok) return toolError(`Could not read routing claims: ${out?.error ?? res.status}${out?.hint ? `\n${out.hint}` : ''}`)
3845
3875
 
3846
3876
  const groups = Array.isArray(out?.identifiers) ? out.identifiers : []
3847
- if (groups.length === 0) {
3877
+ if (groups.length === 0 && !(Array.isArray(out?.declines) && out.declines.length)) {
3848
3878
  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
3879
  }
3850
3880
  const lines = []
@@ -3858,6 +3888,9 @@ function renderNudge(payload) {
3858
3888
  lines.push(` ${c.brain} · ${page}${c.mine ? '' : ' · claimed by someone else'} · ${c.source} · ${String(c.createdAt).slice(0, 10)}`)
3859
3889
  }
3860
3890
  }
3891
+ // ADR-0077: declines beside claims.
3892
+ const declineLines = renderDeclines(out?.declines)
3893
+ if (declineLines.length) lines.push('', ...declineLines)
3861
3894
  lines.push('')
3862
3895
  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
3896
  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.')
@@ -5869,23 +5902,32 @@ function renderNudge(payload) {
5869
5902
  },
5870
5903
  )
5871
5904
 
5872
- // send_imessage — local outbound texting (NOT org intelligence; writes nothing to Agnoclast). Runs on
5873
- // this machine via Messages.app. Draft-by-default + recipient allowlist + OOB confirm (D3/D6/D10).
5905
+ // send_imessage — ask to send a text from the user's own Mac (ADR-0076). It no longer texts from this
5906
+ // machine: it creates a request on Agnoclast that waits on /email → Waiting for the user's OK (or their
5907
+ // web send rule approves it), and the user's Mac — the desktop app, through Messages — sends it and
5908
+ // reports back. The same contract as request_send_email: reviewUrl PRESENT = pending, relayed verbatim.
5909
+ //
5910
+ // ⚠ NOT IDEMPOTENT: `idempotent: false, retries: 0`, exactly as request_send_email, so a widened
5911
+ // fetchCortex default can never make sending a text retriable.
5874
5912
  server.registerTool(
5875
5913
  'send_imessage',
5876
5914
  {
5877
- title: 'Send an iMessage (draft-by-default)',
5878
- description: 'Compose/send an iMessage via the local Messages app. SAFE BY DEFAULT: without send:true it only returns a draft preview. Sending requires the recipient to be on CORTEX_IMESSAGE_SEND_ALLOWLIST (or an out-of-band confirm). Use this to text someone on the user\'s behalf — always show the draft and get the user\'s OK before sending.',
5915
+ title: 'Ask to send a text (iMessage) from the user\'s Mac',
5916
+ description: 'Text someone from the USER\'S OWN Mac, through Messages. Without send:true this only returns a draft preview. With send:true it creates a request that WAITS for the user to approve it in Agnoclast (Email → Waiting), unless their own send rule allows it; once approved, their Mac sends it within about a minute. The text is frozen when the request is made, so write the final wording, and show the user what you are about to send first. Returns an approval link to relay, or confirmation that their rule approved it. ⚠ NEVER call it twice with send:true for the same text — it is not idempotent.',
5879
5917
  inputSchema: {
5880
- recipient: z.string().describe('phone number (e.g. +18015551234) or iMessage email'),
5881
- message: z.string().describe('the message body to send'),
5882
- send: z.boolean().optional().describe('must be true to actually send; omit/false returns a draft preview only'),
5883
- confirm: z.string().optional().describe('out-of-band confirm secret, required to send to a recipient not on the allowlist'),
5918
+ recipient: z.string().describe('one phone number (e.g. +18015551234, or (801) 555-1234 for a US number) or iMessage email address'),
5919
+ message: z.string().describe('the text to send'),
5920
+ send: z.boolean().optional().describe('true to ask the user to approve sending it; omit or false for a draft preview only'),
5921
+ confirm: z.string().optional().describe('NO LONGER USED — approval happens in Agnoclast now. Ignored.'),
5884
5922
  },
5885
5923
  },
5886
- async ({ recipient, message, send, confirm }) => {
5887
- const text = await runSendImessage({ recipient, message, send, confirm })
5888
- return { content: [{ type: 'text', text }] }
5924
+ async ({ recipient, message, send }) => {
5925
+ const r = await runSendImessage({ recipient, message, send }, (body) => fetchCortex(`${BASE}/api/texts/send-requests`, {
5926
+ method: 'POST',
5927
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
5928
+ body: JSON.stringify(body),
5929
+ }, { idempotent: false, retries: 0 }))
5930
+ return r.isError ? toolError(r.text) : { content: [{ type: 'text', text: r.text }] }
5889
5931
  },
5890
5932
  )
5891
5933
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.210",
3
+ "version": "0.9.212",
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": {