@theronap/agnoclast-mcp 0.9.156 → 0.9.157

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 +95 -0
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -2741,6 +2741,53 @@ function renderNudge(payload) {
2741
2741
  },
2742
2742
  )
2743
2743
 
2744
+ server.registerTool(
2745
+ 'my_routing_claims',
2746
+ {
2747
+ title: 'What you have claimed — and therefore where your mail goes',
2748
+ description: 'Show the routing claims you can see: which pages claim which identifiers, in which brains, and WHAT THE ROUTER WILL DO with a message carrying each one. ⚠ THE COUNT IS THE WHOLE POINT: a message routes only when EXACTLY ONE brain claims something on it — zero or several both STAGE, which is why mail piles up in the staged queue while every individual claim looks correct. Pass `identifier` to ask about one exact key ("who claims notifications@github.com?"); a zero answer is a verdict, not an empty result. ⚠ NOTHING ELSE SHOWS THIS. read_page on an identifier greps page BODIES for the literal stamp, and a body mention does not drive routing (ADR-0026) — so the surface that looks like the answer reads a different table from the one the router reads. ⚠ CHECK BEFORE YOU CLAIM: adding a claim to a second brain turns "routes" into "stages", and replacing a stale one turns a silent misroute into a different silent misroute (ADR-0066 §5b). `email:`/`thread:` claims are private to whoever made them and attach only that person\'s own units — so for YOUR records, your own claims are exactly the set the router consults, not a redacted view of a larger truth.',
2749
+ inputSchema: {
2750
+ identifier: z.string().optional().describe('one canonical identifier to ask about, e.g. email:support@npmjs.com or repo:owner/name. Omit to list everything you can see, ambiguous identifiers first.'),
2751
+ limit: z.number().optional().describe('max claim rows (default 200, capped at 500)'),
2752
+ },
2753
+ },
2754
+ async ({ identifier, limit }) => {
2755
+ const qs = new URLSearchParams()
2756
+ if (identifier) qs.set('identifier', identifier)
2757
+ if (limit) qs.set('limit', String(limit))
2758
+ let res
2759
+ try {
2760
+ res = await fetchCortex(`${BASE}/api/routing-claims${qs.toString() ? `?${qs}` : ''}`, {
2761
+ headers: { Authorization: `Bearer ${TOKEN}` },
2762
+ })
2763
+ } catch (e) {
2764
+ return toolError(`Could not read routing claims: ${e.message}`)
2765
+ }
2766
+ const out = await res.json().catch(() => null)
2767
+ if (!res.ok) return toolError(`Could not read routing claims: ${out?.error ?? res.status}${out?.hint ? `\n${out.hint}` : ''}`)
2768
+
2769
+ const groups = Array.isArray(out?.identifiers) ? out.identifiers : []
2770
+ if (groups.length === 0) {
2771
+ 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.' }] }
2772
+ }
2773
+ const lines = []
2774
+ for (const g of groups) {
2775
+ const mark = g.brains > 1 ? '⚠ ' : g.brains === 0 ? '· ' : '✓ '
2776
+ lines.push(`${mark}${g.identifier} — ${g.brains} brain(s) → ${g.verdict}`)
2777
+ for (const c of g.claims ?? []) {
2778
+ // A page the caller cannot open still claims the identifier and still decides routing, so it
2779
+ // is listed; only its title is withheld. Dropping the row would under-report the count.
2780
+ const page = c.page ?? '(a page you cannot open)'
2781
+ lines.push(` ${c.brain} · ${page}${c.mine ? '' : ' · claimed by someone else'} · ${c.source} · ${String(c.createdAt).slice(0, 10)}`)
2782
+ }
2783
+ }
2784
+ lines.push('')
2785
+ 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.')
2786
+ 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.')
2787
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
2788
+ },
2789
+ )
2790
+
2744
2791
  server.registerTool(
2745
2792
  'set_summary',
2746
2793
  {
@@ -4004,6 +4051,54 @@ function renderNudge(payload) {
4004
4051
  },
4005
4052
  )
4006
4053
 
4054
+ // THE CUSTODIAL HALF OF OWNERSHIP. `transferPageOwnership` and its route have existed since
4055
+ // 2026-08-24 and had NEVER run in production (0 of 4,280 page revisions as of 2026-09-18) for the
4056
+ // simple reason that no agent could reach them — the endpoint was HTTP-only, and ownership is a
4057
+ // thing people ask their agent about ("put Dana in charge of this"), not a thing they open a
4058
+ // console for. Added 2026-09-18 on Theron's ask, as the first piece of the member-removal design
4059
+ // on [[Member removal and org shape]]: rehoming a departing person's pages is impossible without it.
4060
+ server.registerTool(
4061
+ 'transfer_page_ownership',
4062
+ {
4063
+ title: 'Hand a page over to someone else',
4064
+ description: 'Make another org member the owner of a wiki page — "put Dana in charge of the Northwind page". Ownership decides who may re-tier it, who may set its edit policy, and (for scoped/confidential pages) who can read it, so this is a real handover, not a label. Allowed for the page\'s owner, or for a manager-ancestor who can already read it; pass yourself as `to` to TAKE ownership, which is the documented path for a manager who then needs to change policy. Recorded in page history with you as the actor. The page keeps its tier — transferring does not re-tier, so a scoped page handed to someone outside the new owner\'s chain can become unreadable to its previous audience; re-tier separately if that matters.',
4065
+ inputSchema: {
4066
+ kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
4067
+ name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
4068
+ ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. PREFER THIS over name when you have it: a ref is unique across brains, so it addresses exactly one page and never needs a brain to disambiguate it.'),
4069
+ to: z.string().describe('who becomes the owner — an org member\'s email, or their user id. Must be an ACTIVE member of the same brain as the page. Pass your own address to take ownership yourself.'),
4070
+ },
4071
+ },
4072
+ async ({ kind, name, ref, to }) => {
4073
+ let res
4074
+ try {
4075
+ res = await fetchCortex(`${BASE}/api/brain/node-policy`, {
4076
+ method: 'POST',
4077
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4078
+ // No `tier`: a node has exactly one page (ADR-0064 §4), and the route resolves it. Sending a
4079
+ // guessed tier is how this used to 404 on precisely the scoped/confidential pages it matters for.
4080
+ body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), transfer_to: to }),
4081
+ })
4082
+ } catch (e) {
4083
+ return toolError(`Could not transfer ownership: ${e.message}`)
4084
+ }
4085
+ const out = await res.json().catch(() => null)
4086
+ if (!res.ok) {
4087
+ // The route's refusals name their own remedy (not permitted / not an active member / no page
4088
+ // yet) — surface them verbatim rather than flattening them to a status code.
4089
+ if (out?.error) return toolError(`Could not transfer ownership: ${out.error}`)
4090
+ const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
4091
+ return toolError(`Could not transfer ownership: ${d.message}`)
4092
+ }
4093
+ return {
4094
+ content: [{
4095
+ type: 'text',
4096
+ text: `Done — ${name ?? out.ref} now belongs to ${to}. The page is still ${out.tier}; transferring never re-tiers, so check that its audience is still right.`,
4097
+ }],
4098
+ }
4099
+ },
4100
+ )
4101
+
4007
4102
  server.registerTool(
4008
4103
  'my_retier_notices',
4009
4104
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.156",
3
+ "version": "0.9.157",
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": {