@theronap/agnoclast-mcp 0.9.152 → 0.9.153

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
@@ -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}`)
@@ -3917,38 +3916,29 @@ function renderNudge(payload) {
3917
3916
  'set_page_privacy',
3918
3917
  {
3919
3918
  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.',
3919
+ 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
3920
  inputSchema: {
3922
3921
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
3923
3922
  name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
3924
3923
  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
3924
  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
3925
  },
3930
3926
  },
3931
- async ({ kind, name, ref, tier, source_tier, absorb, target_version }) => {
3927
+ async ({ kind, name, ref, tier }) => {
3932
3928
  let res
3933
3929
  try {
3934
3930
  res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
3935
3931
  method: 'POST',
3936
3932
  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 } : {}) }),
3933
+ body: JSON.stringify({ kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}), tier }),
3938
3934
  })
3939
3935
  } catch (e) {
3940
3936
  return toolError(`Could not set page privacy: ${e.message}`)
3941
3937
  }
3942
3938
  const out = await res.json().catch(() => null)
3943
3939
  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
- }
3940
+ // The server's refusals carry their own remedy surface the message verbatim.
3941
+ if (out?.error) return toolError(`Could not set page privacy: ${out.error}`)
3952
3942
  const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
3953
3943
  return toolError(`Could not set page privacy: ${d.message}`)
3954
3944
  }
@@ -3959,60 +3949,6 @@ function renderNudge(payload) {
3959
3949
  },
3960
3950
  )
3961
3951
 
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
3952
  server.registerTool(
4017
3953
  'grant_page_access',
4018
3954
  {
@@ -4100,32 +4036,6 @@ function renderNudge(payload) {
4100
4036
  },
4101
4037
  )
4102
4038
 
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
4039
  server.registerTool(
4130
4040
  'request_person_page_merge',
4131
4041
  {
@@ -4198,33 +4108,6 @@ function renderNudge(payload) {
4198
4108
  },
4199
4109
  )
4200
4110
 
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
4111
  // ── File requests: ask the owner for a record's FULL original (lives on their machine) ──
4229
4112
  const fail = (verb, res) => async () =>
4230
4113
  toolError(`Could not ${verb}: ${classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id')).message}`)
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.153",
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": {