@theronap/agnoclast-mcp 0.9.152 → 0.9.154

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
@@ -166,6 +166,7 @@ export function classify(status, contentType, bodyText, requestId) {
166
166
  let appMessage = null
167
167
  let appBrains = null
168
168
  let appCandidates = null
169
+ let appAcceptUrl = null
169
170
  if (isJson) {
170
171
  try {
171
172
  const parsed = JSON.parse(bodyText)
@@ -186,6 +187,7 @@ export function classify(status, contentType, bodyText, requestId) {
186
187
  // saw a bare code like "would_drop_sections" and had no idea what to do with it. The routes
187
188
  // were writing careful self-heal guidance that never reached anyone.
188
189
  appHint = typeof parsed?.hint === 'string' && parsed.hint.trim() ? parsed.hint.trim() : null
190
+ appAcceptUrl = typeof parsed?.acceptUrl === 'string' && /^https:\/\//.test(parsed.acceptUrl) ? parsed.acceptUrl : null
189
191
  } catch { /* not json after all */ }
190
192
  }
191
193
  const rid = requestId ? ` [request id: ${requestId}]` : ''
@@ -206,6 +208,19 @@ export function classify(status, contentType, bodyText, requestId) {
206
208
  `Vercel firewall / deployment protection on the API route. Re-running setup will not help.${rid}`,
207
209
  }
208
210
  }
211
+ // Updated Terms / Privacy Policy not yet accepted (server legal_gate.ts, review D6). The credential is
212
+ // FINE — this must never read like the 401 branch above, whose advice (rotate the token, re-run setup)
213
+ // cannot help. Only a person can clear it, in a browser, so the message says exactly that and names
214
+ // the page. Not retriable: nothing changes until they accept.
215
+ if (isJson && appError === 'legal_acceptance_required') {
216
+ const url = appAcceptUrl ?? 'https://agnoclast.com/legal/accept'
217
+ return {
218
+ kind: 'app', retriable: false,
219
+ message: `Agnoclast needs the account owner to accept its updated Terms of Service / Privacy Policy ` +
220
+ `before it can continue (HTTP ${status}). Ask them to open ${url} in a browser, sign in, and accept. ` +
221
+ `Your token is valid — rotating it or re-running setup will not help.${rid}`,
222
+ }
223
+ }
209
224
  // A brain-choice refusal is ANSWERABLE, so it must arrive as the question it is rather than as a
210
225
  // code. Deliberately narrow: only these three errors reshape the message, so every other classify()
211
226
  // output keeps its existing wording (and its tests).
@@ -23,16 +23,15 @@ export function mergeClaudeMcp(existing, spec, token) {
23
23
  * CAS-protected + snapshotted (page_revisions → page_history/rollback_page).
24
24
  * Deliberately EXCLUDED — these keep prompting: send_imessage (external side effect),
25
25
  * set_page_privacy / set_record_privacy / grant_page_access (visibility widening — the
26
- * unowned-project accessible-default sharp edge, 2026-07-02), replace_variant (destroys a page body
27
- * and deletes a variant row — the one write here that is not merely CAS-protected but genuinely
28
- * lossy at the row level, so the prompt IS the guard D1 argued for), rollback_page, decide_page_merge /
26
+ * unowned-project accessible-default sharp edge, 2026-07-02), rollback_page,
29
27
  * decide_file_request / request_file / get_file, create_brain / set_active_brain, alias_page,
30
- * set_writing_style. */
28
+ * set_writing_style. (replace_variant and decide_page_merge were here until one page per node,
29
+ * ADR-0064 §4, removed them.) */
31
30
  export const ALLOWED_TOOL_NAMES = [
32
31
  // read surface
33
32
  'grep', 'read_page', 'my_context', 'project_status', 'session_context', 'search_org',
34
33
  'list_records', 'page_history', 'page_diff', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
35
- 'code_graph_query', 'my_retier_notices', 'list_page_grants', 'file_requests', 'page_merge_requests',
34
+ 'code_graph_query', 'my_retier_notices', 'list_page_grants', 'file_requests',
36
35
  // live authoring core
37
36
  'authoring_context', 'author', 'log_session',
38
37
  // routine, reversible maintenance
package/lib/server.mjs CHANGED
@@ -1438,7 +1438,7 @@ function renderNudge(payload) {
1438
1438
  {
1439
1439
  title: 'Read a wiki page in full',
1440
1440
  description:
1441
- 'READ the full authored wiki page for one node (project/person/org/you) by its canonical name — every section, every tier you can see. This is how you READ a node; `grep` only LOCATES pages (snippets + their [[links]]), it does not read them. Navigate like a researcher: read the page you need, then FOLLOW its inline [[links]] by calling read_page on each linked name — keep following while the linked pages stay relevant, stop when they do not. You decide how deep to go. Returns only what you are permitted to see.',
1441
+ 'READ the full authored wiki page for one node (project/person/org/you) by its canonical name — every section you can see. This is how you READ a node; `grep` only LOCATES pages (snippets + their [[links]]), it does not read them. Navigate like a researcher: read the page you need, then FOLLOW its inline [[links]] by calling read_page on each linked name — keep following while the linked pages stay relevant, stop when they do not. You decide how deep to go. Returns only what you are permitted to see.',
1442
1442
  inputSchema: {
1443
1443
  name: z.string().describe('the canonical node name exactly as written (e.g. "Agnoclast", "Ben", or a [[link]] target) — identifier links ([[repo:owner/name]]) resolve to their authored HOME + a visible-event count'),
1444
1444
  kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind — a PREFERENCE, not a filter. The lookup matches every kind by title and only prefers this one when several share a name, so omitting it never hides a person/org/user page. Pass it to disambiguate a name that exists as more than one kind.'),
@@ -1598,7 +1598,7 @@ function renderNudge(payload) {
1598
1598
  // unrecoverable base_version guessing loop; say so explicitly instead.
1599
1599
  const versionLine = t.version
1600
1600
  ? `\nversion: ${t.version}`
1601
- : `\nversion: none (this variant predates content-hash tracking — base_version writes will always fail here; ask an admin about the ADR-0018 backfill)`
1601
+ : `\nversion: none (this page predates content-hash tracking — base_version writes will always fail here; ask an admin about the ADR-0018 backfill)`
1602
1602
  const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
1603
1603
  // ADR-0030 step 3b (2026-08-24): the summary header is NO LONGER RENDERED here either.
1604
1604
  // ⚠ There were TWO of these — project_status and read_page — and the first patch fixed only
@@ -1792,18 +1792,17 @@ function renderNudge(payload) {
1792
1792
  name: z.string().describe('the exact page name'),
1793
1793
  kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('node kind (default project)'),
1794
1794
  to_version: z.string().describe('which version to restore: a rev_no (e.g. "3") or a content_hash, from page_history'),
1795
- tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('which tier variant to roll back, if the node has more than one'),
1796
1795
  reason: z.string().optional().describe('optional note recorded on the new rollback version (why you rolled back)'),
1797
1796
  },
1798
1797
  },
1799
- async ({ name, kind, to_version, tier, reason }) => {
1798
+ async ({ name, kind, to_version, reason }) => {
1800
1799
  const k = kind ?? 'project'
1801
1800
  let res
1802
1801
  try {
1803
1802
  res = await fetchCortex(`${BASE}/api/brain/rollback`, {
1804
1803
  method: 'POST',
1805
1804
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1806
- body: JSON.stringify({ kind: k, name, to_version, ...(tier ? { tier } : {}), ...(reason ? { reason } : {}) }),
1805
+ body: JSON.stringify({ kind: k, name, to_version, ...(reason ? { reason } : {}) }),
1807
1806
  })
1808
1807
  } catch (e) {
1809
1808
  return toolError(`Could not roll back "${name}": ${e.message}`)
@@ -1825,19 +1824,18 @@ function renderNudge(payload) {
1825
1824
  'rename_section',
1826
1825
  {
1827
1826
  title: 'Rename one section heading on a wiki page',
1828
- description: 'Rename ONE section\'s heading on a wiki page, in place. Use this instead of re-authoring the page: `author` cannot express a rename, because a section\'s IDENTITY is its heading string — sending a new title reads as "dropped the old section, added a new one" and is rejected 409 would_drop_sections. This route changes the heading and NOTHING else: the section keeps its body, its position, and crucially its as-of date, so renaming does not reset the currency stamp on a claim nobody re-verified. It is also the ONLY way to fix a heading longer than the 80-char limit, which cannot be re-authored at all. Requires base_version (the `version` read_page prints) — that is also how it finds the right brain, so it can never rename on the wrong one. Refuses, rather than guessing, when the page exists at several tiers, when the heading repeats, or when the new heading is already taken.',
1827
+ description: 'Rename ONE section\'s heading on a wiki page, in place. Use this instead of re-authoring the page: `author` cannot express a rename, because a section\'s IDENTITY is its heading string — sending a new title reads as "dropped the old section, added a new one" and is rejected 409 would_drop_sections. This route changes the heading and NOTHING else: the section keeps its body, its position, and crucially its as-of date, so renaming does not reset the currency stamp on a claim nobody re-verified. It is also the ONLY way to fix a heading longer than the 80-char limit, which cannot be re-authored at all. Requires base_version (the `version` read_page prints) — that is also how it finds the right brain, so it can never rename on the wrong one. Refuses, rather than guessing, when the heading repeats, or when the new heading is already taken.',
1829
1828
  inputSchema: {
1830
1829
  name: z.string().describe('the exact page name, as read_page shows it'),
1831
1830
  from: z.string().describe('the CURRENT heading, exactly as stored (match is case- and whitespace-insensitive)'),
1832
1831
  to: z.string().describe('the new heading. Max 80 chars. Must not already be used by another section on this page.'),
1833
1832
  base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved. A per-SECTION hash is not valid here.'),
1834
- tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to rename in. A rename never moves content between tiers.'),
1835
1833
  ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence to rename (the error lists the ordinals).'),
1836
1834
  reason: z.string().optional().describe('why you are renaming it — recorded in page_history like any other edit'),
1837
1835
  identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier — e.g. the subject is publishing their OWN contact detail on their own page. Leave unset otherwise; the alternatives the refusal names (a confidential section, or set_routing_identifier for a private claim) are the right answer in every other case.'),
1838
1836
  },
1839
1837
  },
1840
- async ({ name, from, to, base_version, tier, ordinal, reason, identity_claim_ack }) => {
1838
+ async ({ name, from, to, base_version, ordinal, reason, identity_claim_ack }) => {
1841
1839
  let res
1842
1840
  try {
1843
1841
  res = await fetchCortex(`${BASE}/api/brain/rename-section`, {
@@ -1845,7 +1843,7 @@ function renderNudge(payload) {
1845
1843
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1846
1844
  body: JSON.stringify({
1847
1845
  name, from, to, base_version,
1848
- ...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
1846
+ ...(ordinal !== undefined ? { ordinal } : {}),
1849
1847
  ...(reason ? { reason } : {}), ...(identity_claim_ack ? { identity_claim_ack: true } : {}),
1850
1848
  }),
1851
1849
  })
@@ -1881,7 +1879,6 @@ function renderNudge(payload) {
1881
1879
  into: z.string().optional().describe('the heading of the section that SURVIVES and absorbs the content. Omit to drop `from` outright without folding it anywhere.'),
1882
1880
  new_body: z.string().optional().describe('the condensed body the surviving section should read AFTER the merge — you write it, this route does not summarize. Required with `into`; must be absent without it.'),
1883
1881
  base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved. A per-SECTION hash is not valid here.'),
1884
- tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to condense in. A merge never moves content between tiers.'),
1885
1882
  from_ordinal: z.number().optional().describe('only when `from`\'s heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
1886
1883
  into_ordinal: z.number().optional().describe('only when `into`\'s heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
1887
1884
  reason: z.string().optional().describe('WHY you are condensing, in one short phrase — recorded in page_history. Say what became redundant, not what you did.'),
@@ -1889,7 +1886,7 @@ function renderNudge(payload) {
1889
1886
  identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier.'),
1890
1887
  },
1891
1888
  },
1892
- async ({ name, from, into, new_body, base_version, tier, from_ordinal, into_ordinal, reason, drop_links_ack, identity_claim_ack }) => {
1889
+ async ({ name, from, into, new_body, base_version, from_ordinal, into_ordinal, reason, drop_links_ack, identity_claim_ack }) => {
1893
1890
  let res
1894
1891
  try {
1895
1892
  res = await fetchCortex(`${BASE}/api/brain/merge-sections`, {
@@ -1898,7 +1895,6 @@ function renderNudge(payload) {
1898
1895
  body: JSON.stringify({
1899
1896
  name, from, base_version,
1900
1897
  ...(into ? { into } : {}), ...(new_body !== undefined ? { new_body } : {}),
1901
- ...(tier ? { tier } : {}),
1902
1898
  ...(from_ordinal !== undefined ? { from_ordinal } : {}),
1903
1899
  ...(into_ordinal !== undefined ? { into_ordinal } : {}),
1904
1900
  ...(reason ? { reason } : {}),
@@ -2755,11 +2751,10 @@ function renderNudge(payload) {
2755
2751
  summary: z.string().describe('the new summary: one sentence saying what this is and where it stands. May contain [[links]]. Max 2,000 chars — detail belongs in a section.'),
2756
2752
  base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved. A per-SECTION hash is not valid here.'),
2757
2753
  reason: z.string().optional().describe('WHY the summary was wrong, in one short phrase — recorded in page_history. Say what changed ("blocker resolved 08-04; was still claiming BLOCKED"), not what you did.'),
2758
- tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to rewrite. This never moves content between tiers.'),
2759
2754
  identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier — e.g. the subject is publishing their OWN contact detail on their own page. Leave unset otherwise; the alternatives the refusal names (a confidential section, or set_routing_identifier for a private claim) are the right answer in every other case.'),
2760
2755
  },
2761
2756
  },
2762
- async ({ name, summary, base_version, reason, tier, identity_claim_ack }) => {
2757
+ async ({ name, summary, base_version, reason, identity_claim_ack }) => {
2763
2758
  let res
2764
2759
  try {
2765
2760
  res = await fetchCortex(`${BASE}/api/brain/set-summary`, {
@@ -2767,7 +2762,7 @@ function renderNudge(payload) {
2767
2762
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2768
2763
  body: JSON.stringify({
2769
2764
  name, summary, base_version,
2770
- ...(tier ? { tier } : {}), ...(reason ? { reason } : {}),
2765
+ ...(reason ? { reason } : {}),
2771
2766
  ...(identity_claim_ack ? { identity_claim_ack: true } : {}),
2772
2767
  }),
2773
2768
  })
@@ -2804,12 +2799,11 @@ function renderNudge(payload) {
2804
2799
  new_string: z.string().describe('what to replace it with. May be empty, which deletes the anchored text.'),
2805
2800
  base_version: z.string().describe('the `version` read_page prints for this page (64-hex). REQUIRED — it is the concurrency check AND how the right brain is resolved.'),
2806
2801
  reason: z.string().describe('WHY you are making this change, in one short phrase — recorded in page_history exactly like an author edit.'),
2807
- tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only when the page exists at MORE THAN ONE tier — which one to edit. An edit never moves content between tiers.'),
2808
2802
  ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
2809
2803
  identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim refusal, and only if the answer is genuinely yes: this text is meant to publish an email address or phone number as page content at this tier — e.g. the subject is publishing their OWN contact detail on their own page. Leave unset otherwise; the alternatives the refusal names (a confidential section, or set_routing_identifier for a private claim) are the right answer in every other case.'),
2810
2804
  },
2811
2805
  },
2812
- async ({ name, heading, old_string, new_string, base_version, reason, tier, ordinal, identity_claim_ack }) => {
2806
+ async ({ name, heading, old_string, new_string, base_version, reason, ordinal, identity_claim_ack }) => {
2813
2807
  let res
2814
2808
  try {
2815
2809
  res = await fetchCortex(`${BASE}/api/brain/edit-page`, {
@@ -2817,7 +2811,7 @@ function renderNudge(payload) {
2817
2811
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2818
2812
  body: JSON.stringify({
2819
2813
  name, heading, old_string, new_string, base_version, reason,
2820
- ...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
2814
+ ...(ordinal !== undefined ? { ordinal } : {}),
2821
2815
  ...(identity_claim_ack ? { identity_claim_ack: true } : {}),
2822
2816
  }),
2823
2817
  })
@@ -3149,7 +3143,7 @@ function renderNudge(payload) {
3149
3143
  org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
3150
3144
  owner: z.string().optional().describe('only pages owned by this person — a user id or an EXACT display name. A name matching no member of the brain is an error, never a silently empty list. Owner applies to scoped/confidential pages; accessible pages have no owner.'),
3151
3145
  updated_within_days: z.number().optional().describe('only pages touched in the last N days'),
3152
- tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only nodes that have a row at this tier. The row still reports every tier you can see, so a multi-tier page does not come back describing itself as single-tier.'),
3146
+ tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('only pages at this tier'),
3153
3147
  kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('only nodes of this kind'),
3154
3148
  validity: z.enum(['current', 'superseded', 'all']).optional().describe("default 'current'. Pass 'all' for the pre-2026-08 behavior, which mixed superseded pages in with nothing marking them."),
3155
3149
  name_contains: z.string().optional().describe('case-insensitive substring match on the node name or page title'),
@@ -3917,38 +3911,29 @@ function renderNudge(payload) {
3917
3911
  'set_page_privacy',
3918
3912
  {
3919
3913
  title: 'Change who can see a wiki page',
3920
- description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. If the target tier already has a page, merge your content into it via `author` FIRST, then read_page the target again to get its fresh version, then re-run this with absorb=true and target_version=<that version> — absorb will REJECT (not silently drop content) if target_version doesn\'t match what\'s actually there, so a merge that didn\'t really land can\'t destroy your source page. Org admins may demote any page, never promote.',
3914
+ description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. Org admins may demote any page, never promote.',
3921
3915
  inputSchema: {
3922
3916
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
3923
3917
  name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
3924
3918
  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.'),
3925
3919
  tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
3926
- source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
3927
- absorb: z.boolean().optional().describe('after merging your content into an existing target-tier page via author: true removes your now-absorbed source variant. Requires target_version.'),
3928
- target_version: z.string().optional().describe('REQUIRED with absorb=true — the target page\'s version, read via read_page AFTER your author() merge landed. Proves the merge actually happened before your source page is deleted; a stale or guessed value is rejected, not silently accepted.'),
3929
3920
  },
3930
3921
  },
3931
- async ({ kind, name, ref, tier, source_tier, absorb, target_version }) => {
3922
+ async ({ kind, name, ref, tier }) => {
3932
3923
  let res
3933
3924
  try {
3934
3925
  res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
3935
3926
  method: 'POST',
3936
3927
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
3937
- body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}), ...(target_version ? { target_version } : {}) }),
3928
+ body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier }),
3938
3929
  })
3939
3930
  } catch (e) {
3940
3931
  return toolError(`Could not set page privacy: ${e.message}`)
3941
3932
  }
3942
3933
  const out = await res.json().catch(() => null)
3943
3934
  if (!res.ok) {
3944
- // 409s carry the collision protocol (merge instruction, both variants when readable)
3945
- // surface the server's structured message verbatim so the agent can follow it.
3946
- if (out?.error) {
3947
- const extra = out.collision === 'readable' && out.blocking
3948
- ? `\nYour ${out.source?.tier} page: ${out.source?.summary ?? out.source?.title}\nExisting ${out.blocking.tier} page (current version: ${out.blocking.version ?? 'none — this page predates content-hash tracking and cannot be re-authored via base_version; ask an admin about a backfill'}): ${out.blocking.summary ?? out.blocking.title}`
3949
- : ''
3950
- return toolError(`Could not set page privacy: ${out.error}${extra}`)
3951
- }
3935
+ // The server's refusals carry their own remedy surface the message verbatim.
3936
+ if (out?.error) return toolError(`Could not set page privacy: ${out.error}`)
3952
3937
  const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
3953
3938
  return toolError(`Could not set page privacy: ${d.message}`)
3954
3939
  }
@@ -3959,60 +3944,6 @@ function renderNudge(payload) {
3959
3944
  },
3960
3945
  )
3961
3946
 
3962
- server.registerTool(
3963
- 'replace_variant',
3964
- {
3965
- title: 'Move one tier variant\'s body into another, collapsing the node to one page',
3966
- description: 'DESTRUCTIVE, and the only sanctioned way to fix a FORKED page. When one node exists at two tiers with different bodies, this moves the SOURCE variant\'s body into the TARGET variant\'s slot and DELETES the source, leaving the node with a single page. The source\'s body WINS — this is not `absorb`, where the target survives; in a fork repair the target is usually the damaged page, so mirroring absorb would keep the damage and delete the good copy. Sections are copied as ROWS, never re-derived from text: re-deriving a body from context is exactly what destroyed 258 sections on 2026-07-18 while sincerely reporting "copied verbatim". BEFORE CALLING: read_page the TARGET and pass its version as target_version — it proves you know which body is about to be overwritten, and a stale or guessed value is rejected rather than silently accepted. If the target tier has NO page, do not use this: the slot is free, so set_page_privacy moves the page there cheaply. Both prior bodies are retained in page history and the operation is reversible via page_history/rollback_page. Owner or editor on BOTH variants; an OWNERLESS target may be overwritten only by an org admin.',
3967
- inputSchema: {
3968
- kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
3969
- name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
3970
- 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.'),
3971
- source_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the variant whose BODY WINS and survives. This variant\'s row is then deleted.'),
3972
- target_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the OCCUPIED slot the body lands in. This variant\'s current body is DESTROYED (snapshotted to page history first). The surviving page sits at this tier.'),
3973
- target_version: z.string().describe('REQUIRED — the TARGET page\'s version, from read_page. Proves you know what is being overwritten. Do not retry a rejection blindly; re-read the target and confirm you are replacing what you think you are.'),
3974
- brain: z.string().optional().describe('only when the same page name exists in more than one of your brains'),
3975
- },
3976
- },
3977
- async ({ kind, name, ref, source_tier, target_tier, target_version, brain }) => {
3978
- let res
3979
- try {
3980
- res = await fetchCortex(`${BASE}/api/brain/replace-variant`, {
3981
- method: 'POST',
3982
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
3983
- body: JSON.stringify({
3984
- kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}),
3985
- source_tier, target_tier, target_version, ...(brain ? { brain } : {}),
3986
- }),
3987
- })
3988
- } catch (e) {
3989
- return toolError(`Could not replace variant: ${e.message}`)
3990
- }
3991
- const out = await res.json().catch(() => null)
3992
- if (!res.ok) {
3993
- // The engine's rejections carry the remedy in their text (free slot → use set_page_privacy;
3994
- // hash mismatch → re-read, do not retry blindly). Surface it verbatim rather than paraphrasing.
3995
- if (out?.error) return toolError(`Could not replace variant: ${out.error}`)
3996
- const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
3997
- return toolError(`Could not replace variant: ${d.message}`)
3998
- }
3999
- if (!out) return { content: [{ type: 'text', text: 'Replace reported success, but the server returned no body — re-read the page before assuming it landed.' }] }
4000
- // The summary rides along with the body, EXCEPT when the source has none — then the target's is
4001
- // kept rather than erased. Say so on its own line rather than at the tail of `note`: this is the
4002
- // 2026-08-04 W3 shape, where an empty summary copied over a populated one destroyed 31 of them
4003
- // under a report that read as success. A rescue the operator does not see is still a silent write.
4004
- const rescued = out.summaryRescued
4005
- ? `\n\n⚠ The ${out.moved.from} variant had NO summary. The ${out.moved.to} page's own summary was KEPT rather than overwritten with an empty one — check it still describes the body that just landed, and set_summary if not.`
4006
- : ''
4007
- return {
4008
- content: [{
4009
- type: 'text',
4010
- text: `Done — "${out.moved.title}": the ${out.moved.from} body now occupies the ${out.moved.to} page (${out.sections} section${out.sections === 1 ? '' : 's'}), and the ${out.moved.from} variant was removed. The node now has ONE variant. Both prior bodies are retained in page history.${rescued}`,
4011
- }],
4012
- }
4013
- },
4014
- )
4015
-
4016
3947
  server.registerTool(
4017
3948
  'grant_page_access',
4018
3949
  {
@@ -4024,16 +3955,15 @@ function renderNudge(payload) {
4024
3955
  ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. Prefer this over name: unique across brains, so it addresses exactly one page.'),
4025
3956
  grantee: z.string().describe('display name or email of a member OF THE PAGE\'S BRAIN (must resolve uniquely — use email if ambiguous)'),
4026
3957
  action: z.enum(['grant', 'revoke']).describe('grant or revoke'),
4027
- tier: z.enum(['scoped', 'confidential']).optional().describe('which variant (default: the most restrictive one)'),
4028
3958
  },
4029
3959
  },
4030
- async ({ kind, name, ref, grantee, action, tier }) => {
3960
+ async ({ kind, name, ref, grantee, action }) => {
4031
3961
  let res
4032
3962
  try {
4033
3963
  res = await fetchCortex(`${BASE}/api/brain/page-grants`, {
4034
3964
  method: 'POST',
4035
3965
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4036
- body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), grantee, action, ...(tier ? { tier } : {}) }),
3966
+ body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), grantee, action }),
4037
3967
  })
4038
3968
  } catch (e) {
4039
3969
  return toolError(`Could not ${action}: ${e.message}`)
@@ -4049,7 +3979,7 @@ function renderNudge(payload) {
4049
3979
  'list_page_grants',
4050
3980
  {
4051
3981
  title: 'List who has granted access to your page',
4052
- description: 'Show every explicit access grant on YOUR page\'s tier variants (owner-only). Use after re-tiering a page — grants survive tier changes and keep granting until revoked.',
3982
+ description: 'Show every explicit access grant on YOUR pages (owner-only). Use after re-tiering a page — grants survive tier changes and keep granting until revoked.',
4053
3983
  inputSchema: {
4054
3984
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
4055
3985
  name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
@@ -4069,7 +3999,7 @@ function renderNudge(payload) {
4069
3999
  const out = await res.json().catch(() => null)
4070
4000
  if (!res.ok) return toolError(`Could not list grants: ${out?.error ?? res.status}`)
4071
4001
  if (!out.grants?.length) return { content: [{ type: 'text', text: 'No grants on this page.' }] }
4072
- const lines = out.grants.map((g) => `- ${g.grantee_name ?? g.grantee_user_id} → ${g.tier} variant (since ${String(g.created_at).slice(0, 10)})`)
4002
+ const lines = out.grants.map((g) => `- ${g.grantee_name ?? g.grantee_user_id} → ${g.tier} page (since ${String(g.created_at).slice(0, 10)})`)
4073
4003
  return { content: [{ type: 'text', text: `Grants (${out.grants.length}):\n${lines.join('\n')}` }] }
4074
4004
  },
4075
4005
  )
@@ -4100,32 +4030,6 @@ function renderNudge(payload) {
4100
4030
  },
4101
4031
  )
4102
4032
 
4103
- server.registerTool(
4104
- 'page_merge_requests',
4105
- {
4106
- title: 'Merge requests on pages you own',
4107
- description: 'Someone tried to move their page into a tier slot your page occupies (they only saw "slot occupied"). Review pending requests here; read their variant, merge anything worth keeping into your page via author, then decide with decide_page_merge.',
4108
- inputSchema: {},
4109
- },
4110
- async () => {
4111
- let res
4112
- try {
4113
- res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, { headers: { Authorization: `Bearer ${TOKEN}` } })
4114
- } catch (e) {
4115
- return toolError(`Could not list merge requests: ${e.message}`)
4116
- }
4117
- if (!res.ok) {
4118
- const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
4119
- return toolError(`Could not list merge requests: ${d.message}`)
4120
- }
4121
- const { requests } = await res.json()
4122
- if (!requests?.length) return { content: [{ type: 'text', text: 'No pending page merge requests.' }] }
4123
- const lines = requests.map((q) =>
4124
- `- [${q.id}] ${q.requester_name ?? 'someone'} wants their ${q.kind} page merged into your ${q.requested_tier} "${q.page_title}". Merge via author first, then decide_page_merge.`)
4125
- return { content: [{ type: 'text', text: `Pending page merge requests (${requests.length}):\n${lines.join('\n')}` }] }
4126
- },
4127
- )
4128
-
4129
4033
  server.registerTool(
4130
4034
  'request_person_page_merge',
4131
4035
  {
@@ -4198,33 +4102,6 @@ function renderNudge(payload) {
4198
4102
  },
4199
4103
  )
4200
4104
 
4201
- server.registerTool(
4202
- 'decide_page_merge',
4203
- {
4204
- title: 'Approve or deny a page merge request',
4205
- description: 'Decide a pending page merge request you own. IMPORTANT: approve only AFTER you have merged whatever of the requester\'s content you want into your page (via author) — approving DELETES their variant of the node. Deny closes the request and nothing moves.',
4206
- inputSchema: {
4207
- id: z.string().describe('the request id from page_merge_requests'),
4208
- decision: z.enum(['approve', 'deny']).describe('approve = their variant is removed (merge first!); deny = nothing moves'),
4209
- },
4210
- },
4211
- async ({ id, decision }) => {
4212
- let res
4213
- try {
4214
- res = await fetchCortex(`${BASE}/api/brain/page-merge-requests`, {
4215
- method: 'POST',
4216
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
4217
- body: JSON.stringify({ id, decision }),
4218
- })
4219
- } catch (e) {
4220
- return toolError(`Could not decide: ${e.message}`)
4221
- }
4222
- const out = await res.json().catch(() => null)
4223
- if (!res.ok) return toolError(`Could not decide: ${out?.error ?? res.status}`)
4224
- return { content: [{ type: 'text', text: out.decision === 'denied' ? 'Denied — nothing moved.' : `Approved — removed variant(s): ${out.removed_variants.join(', ') || 'none remained'}. ${out.note}` }] }
4225
- },
4226
- )
4227
-
4228
4105
  // ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
4229
4106
  const fail = (verb, res) => async () =>
4230
4107
  toolError(`Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}`)
@@ -4484,8 +4361,7 @@ function renderNudge(payload) {
4484
4361
  // last chance to notice a page was fixed somewhere nobody reads.
4485
4362
  const corrections = Array.isArray(out?.tierCorrections) && out.tierCorrections.length
4486
4363
  ? `\n⚠ TIER: this wrote to ${out.tierCorrections.map((c) => `${c.actualTier} (you asked for ${c.requestedTier})`).join('; ')}.` +
4487
- ` If that is not the tier you meant, read_page and check which copy you just changed —` +
4488
- ` a page can exist at several tiers and they drift apart independently.`
4364
+ ` A page has one tier and author writes at it; to change who can see the page, use set_page_privacy.`
4489
4365
  : ''
4490
4366
  // KWA-26 — the advisory undated flag. The server has computed `undatedSections` since #558 and
4491
4367
  // the route has returned it ever since; NOTHING PRINTED IT, so the one consumer the item names
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.152",
3
+ "version": "0.9.154",
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": {