@theronap/agnoclast-mcp 0.9.100 → 0.9.103

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 +123 -9
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -662,6 +662,7 @@ export async function runServer(version) {
662
662
  inputSchema: {
663
663
  claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
664
664
  limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
665
+ leaseMinutes: z.number().optional().describe('lease length in minutes, 5-60 (default 30). Prefer intake_look for read-only inspection — a short lease is only for briefly exclusive work.'),
665
666
  intakeItemIds: z.array(z.string()).optional().describe(
666
667
  'claim these specific units instead of the head of the queue (max 50). Without it you get FIFO, ' +
667
668
  'so reaching one known unit means claiming everything ahead of it. Ids come from intake_changes ' +
@@ -673,7 +674,7 @@ export async function runServer(version) {
673
674
  ),
674
675
  },
675
676
  },
676
- async ({ claimKind, limit, intakeItemIds }) => {
677
+ async ({ claimKind, limit, intakeItemIds, leaseMinutes }) => {
677
678
  const res = await fetchCortex(`${BASE}/api/intake/claim`, {
678
679
  method: 'POST',
679
680
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
@@ -681,6 +682,7 @@ export async function runServer(version) {
681
682
  claimKind: claimKind ?? 'relevance',
682
683
  limit,
683
684
  includePayload: true,
685
+ ...(leaseMinutes ? { leaseMinutes } : {}),
684
686
  // Forwarded only when present. An empty array is a real request to claim nothing and must
685
687
  // survive as one; sending `[]` where the caller sent nothing would silently switch a FIFO
686
688
  // claim into a no-op.
@@ -883,6 +885,40 @@ export async function runServer(version) {
883
885
  },
884
886
  )
885
887
 
888
+ server.registerTool(
889
+ 'intake_look',
890
+ {
891
+ title: 'Read an intake unit without claiming it',
892
+ description:
893
+ 'Read a PENDING intake unit\'s FULL decrypted body without taking a lease — a read RECEIPT, not a claim (ADR-0035). Use it when the settling feed\'s headline + candidate identifiers cannot tell you whether the unit belongs to your current work; looking is non-exclusive (any number of sessions may inspect the same unit) and always logged, so look freely rather than guessing from the headline — diligence is subsidized here. After looking: claim it if it is yours to process, or simply move on — no release needed, you never held it. The response includes how many sessions have looked (`looks`/`distinctLookers`): several looks and no claim is a sign the unit needs the triage agent or the owner, not another look. Resolved units are refused — read those as records.',
894
+ inputSchema: {
895
+ intakeItemId: z.string().describe('intake item uuid, from the settling feed (intake_changes) or intake_claim'),
896
+ workingIdentifiers: z.array(z.string()).optional().describe('the identifiers your current work touches (repo:…, file:…, project:…) — stored on the receipt; feeds look→claim conversion analysis'),
897
+ },
898
+ },
899
+ async ({ intakeItemId, workingIdentifiers }) => {
900
+ let res
901
+ try {
902
+ res = await fetchCortex(`${BASE}/api/intake/look`, {
903
+ method: 'POST',
904
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
905
+ body: JSON.stringify({ intakeItemId, workingIdentifiers: workingIdentifiers ?? [] }),
906
+ })
907
+ } catch (e) {
908
+ return toolError(`Could not look: ${e.message}`)
909
+ }
910
+ const out = await res.json().catch(() => null)
911
+ if (!res.ok) {
912
+ if (out?.error === 'not_lookable') {
913
+ return toolError(`Not lookable: the unit is ${out?.state ?? 'resolved'} — resolved units are read as records, not looks.`)
914
+ }
915
+ if (out?.error === 'not_found') return toolError('No such intake unit in your account.')
916
+ return toolError(`Could not look: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
917
+ }
918
+ return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }
919
+ },
920
+ )
921
+
886
922
  server.registerTool(
887
923
  'intake_cleanup_status',
888
924
  {
@@ -1400,9 +1436,10 @@ export async function runServer(version) {
1400
1436
  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.'),
1401
1437
  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).'),
1402
1438
  reason: z.string().optional().describe('why you are renaming it — recorded in page_history like any other edit'),
1439
+ 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.'),
1403
1440
  },
1404
1441
  },
1405
- async ({ name, from, to, base_version, tier, ordinal, reason }) => {
1442
+ async ({ name, from, to, base_version, tier, ordinal, reason, identity_claim_ack }) => {
1406
1443
  let res
1407
1444
  try {
1408
1445
  res = await fetchCortex(`${BASE}/api/brain/rename-section`, {
@@ -1411,7 +1448,7 @@ export async function runServer(version) {
1411
1448
  body: JSON.stringify({
1412
1449
  name, from, to, base_version,
1413
1450
  ...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
1414
- ...(reason ? { reason } : {}),
1451
+ ...(reason ? { reason } : {}), ...(identity_claim_ack ? { identity_claim_ack: true } : {}),
1415
1452
  }),
1416
1453
  })
1417
1454
  } catch (e) {
@@ -1423,6 +1460,7 @@ export async function runServer(version) {
1423
1460
  // rather than something to retry blindly.
1424
1461
  const extra = [
1425
1462
  out?.detail ? `existing: ${out.detail}` : '',
1463
+ Array.isArray(out?.identityClaims) ? `identity identifiers this would publish: ${out.identityClaims.join(', ')}` : '',
1426
1464
  Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
1427
1465
  Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
1428
1466
  out?.currentVersion ? `current version: ${out.currentVersion}` : '',
@@ -1445,9 +1483,10 @@ export async function runServer(version) {
1445
1483
  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.'),
1446
1484
  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.'),
1447
1485
  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.'),
1486
+ 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.'),
1448
1487
  },
1449
1488
  },
1450
- async ({ name, summary, base_version, reason, tier }) => {
1489
+ async ({ name, summary, base_version, reason, tier, identity_claim_ack }) => {
1451
1490
  let res
1452
1491
  try {
1453
1492
  res = await fetchCortex(`${BASE}/api/brain/set-summary`, {
@@ -1456,6 +1495,7 @@ export async function runServer(version) {
1456
1495
  body: JSON.stringify({
1457
1496
  name, summary, base_version,
1458
1497
  ...(tier ? { tier } : {}), ...(reason ? { reason } : {}),
1498
+ ...(identity_claim_ack ? { identity_claim_ack: true } : {}),
1459
1499
  }),
1460
1500
  })
1461
1501
  } catch (e) {
@@ -1467,6 +1507,7 @@ export async function runServer(version) {
1467
1507
  // rather than something to retry blindly.
1468
1508
  const extra = [
1469
1509
  out?.detail ? `detail: ${out.detail}` : '',
1510
+ Array.isArray(out?.identityClaims) ? `identity identifiers this would publish: ${out.identityClaims.join(', ')}` : '',
1470
1511
  Array.isArray(out?.tiers) ? `tiers: ${out.tiers.join(', ')}` : '',
1471
1512
  out?.currentVersion ? `current version: ${out.currentVersion}` : '',
1472
1513
  ].filter(Boolean).join(' · ')
@@ -1492,9 +1533,10 @@ export async function runServer(version) {
1492
1533
  reason: z.string().describe('WHY you are making this change, in one short phrase — recorded in page_history exactly like an author edit.'),
1493
1534
  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.'),
1494
1535
  ordinal: z.number().optional().describe('only when the same heading appears more than once on the page — which occurrence (the error lists the ordinals).'),
1536
+ 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.'),
1495
1537
  },
1496
1538
  },
1497
- async ({ name, heading, old_string, new_string, base_version, reason, tier, ordinal }) => {
1539
+ async ({ name, heading, old_string, new_string, base_version, reason, tier, ordinal, identity_claim_ack }) => {
1498
1540
  let res
1499
1541
  try {
1500
1542
  res = await fetchCortex(`${BASE}/api/brain/edit-page`, {
@@ -1503,6 +1545,7 @@ export async function runServer(version) {
1503
1545
  body: JSON.stringify({
1504
1546
  name, heading, old_string, new_string, base_version, reason,
1505
1547
  ...(tier ? { tier } : {}), ...(ordinal !== undefined ? { ordinal } : {}),
1548
+ ...(identity_claim_ack ? { identity_claim_ack: true } : {}),
1506
1549
  }),
1507
1550
  })
1508
1551
  } catch (e) {
@@ -1518,6 +1561,7 @@ export async function runServer(version) {
1518
1561
  Array.isArray(out?.ordinals) ? `ordinals: ${out.ordinals.join(', ')}` : '',
1519
1562
  out?.count ? `matches: ${out.count}` : '',
1520
1563
  out?.whitespaceNear === true ? 'YOUR TEXT IS PRESENT but the whitespace differs — re-copy it from the stored body' : '',
1564
+ Array.isArray(out?.identityClaims) ? `identity identifiers this edit would publish: ${out.identityClaims.join(', ')}` : '',
1521
1565
  out?.currentVersion ? `current version: ${out.currentVersion}` : '',
1522
1566
  ].filter(Boolean).join('\n')
1523
1567
  const cur = out?.currentSectionBody
@@ -2084,7 +2128,48 @@ export async function runServer(version) {
2084
2128
  const out = await res.json().catch(() => null)
2085
2129
  if (!res.ok) return toolError(`Could not alias "${name}": ${out?.error ?? res.status}`)
2086
2130
  if (!out) return { content: [{ type: 'text', text: `Aliased "${name}", but the server returned no body — re-read the page to confirm.` }] }
2087
- return { content: [{ type: 'text', text: `Done [[${out.alias}]] now resolves to "${out.target}" (${out.target_kind}). It's out of the wanted-page backlog.` }] }
2131
+ // Render what the server actually OBSERVED, never a fixed sentence. The old copy asserted both
2132
+ // "now resolves to X" and "out of the wanted-page backlog" unconditionally — it said the second
2133
+ // about a probe name nothing referenced, and said the first for a month about three aliases a
2134
+ // page-less node was shadowing (#687). The route now checks both and reports them.
2135
+ const lines = [`Aliased [[${out.alias}]] -> "${out.target}" (${out.target_kind}).`]
2136
+ if (out.resolves === true) lines.push('Verified: the name resolves to that page now.')
2137
+ else if (out.resolves === false) lines.push('\u26a0 WRITTEN BUT NOT RESOLVING — the alias row is saved, yet the name still does not lead to that page, so something is shadowing it. Re-running this will not help; report it rather than retrying.')
2138
+ else lines.push('Could not verify resolution on this call — re-read the page to confirm.')
2139
+ lines.push(out.backlogCleared
2140
+ ? "It's out of the wanted-page backlog."
2141
+ : 'It was not in the wanted-page backlog, so nothing was cleared there.')
2142
+ return { content: [{ type: 'text', text: lines.join(' ') }] }
2143
+ },
2144
+ )
2145
+
2146
+ server.registerTool(
2147
+ 'unalias_page',
2148
+ {
2149
+ title: 'Withdraw an alias',
2150
+ description: 'Remove a name -> page redirect created by `alias_page`, when the alias was wrong or is no longer wanted. The name stops resolving to that page and goes BACK into the org\'s wanted-page backlog, so it can be authored or re-aliased. Aliasing used to be one-way — a mistaken redirect silently sent every future reader of that name somewhere else, with no way back. This does NOT touch the target page itself, only the redirect.',
2151
+ inputSchema: {
2152
+ name: z.string().describe('the aliased [[Name]] to stop redirecting'),
2153
+ },
2154
+ },
2155
+ async ({ name }) => {
2156
+ let res
2157
+ try {
2158
+ res = await fetchCortex(`${BASE}/api/brain/alias?name=${encodeURIComponent(name)}`, {
2159
+ method: 'DELETE',
2160
+ headers: { Authorization: `Bearer ${TOKEN}` },
2161
+ })
2162
+ } catch (e) {
2163
+ return toolError(`Could not withdraw the alias: ${e.message}`)
2164
+ }
2165
+ const out = await res.json().catch(() => null)
2166
+ if (!res.ok) return toolError(`Could not withdraw "${name}": ${out?.message ?? out?.error ?? res.status}`)
2167
+ if (!out) return { content: [{ type: 'text', text: `Withdrew the alias for "${name}", but the server returned no body — re-read the page to confirm.` }] }
2168
+ const lines = [`Withdrew the alias [[${out.alias}]] in ${out.brain}.`]
2169
+ lines.push(out.backlogReopened
2170
+ ? 'The name is back in the wanted-page backlog.'
2171
+ : 'It was not marked authored in the backlog, so nothing there changed.')
2172
+ return { content: [{ type: 'text', text: lines.join(' ') }] }
2088
2173
  },
2089
2174
  )
2090
2175
 
@@ -2893,10 +2978,11 @@ export async function runServer(version) {
2893
2978
  base_version: z.string().optional().describe('the `version` hash shown when you read this page (read_page) — REQUIRED when updating an existing page, so a concurrent edit is caught instead of clobbered. Omit only for a brand-new node. If the save returns "stale" or "read first", read_page again and retry with the fresh version.'),
2894
2979
  reason: z.string().describe('WHY you are making this edit, in one short phrase — recorded permanently in page_history so a later reader can tell a routine addition from a correction. Say what CHANGED and what prompted it ("Ben pilot abandoned per Theron 07-17", "corrected: 0069 already widened the CHECK"), not what you did ("updated page"). This is the field that makes staleness auditable.'),
2895
2980
  change_kind: z.enum(['add', 'correct', 'supersede', 'expand', 'retire']).optional().describe('what KIND of edit: "add" (new information), "correct" (the page said something FALSE — the currency-critical one), "supersede" (was true, now outdated by events), "expand" (elaborates, no claim changed), "retire" (putting the page or a section to rest). Be honest with "correct" — a page whose history shows repeated corrections is a page whose claims need checking, and that signal is the point.'),
2981
+ identity_claim_ack: z.boolean().optional().describe('ONLY after a 409 identity_claim_unacked refusal, and only if the answer is genuinely yes: this page 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.'),
2896
2982
  brain: z.string().optional().describe('which brain a genuinely NEW page is created in — a brain name or its org id. Choose by RELEVANCE to what you are writing (`my_brains` shows what each brain holds), not by the active pointer. Has top precedence, so it also disambiguates a page name you hold in several brains. Unnecessary when the brain is resolvable from the write itself (base_version, or an existing page of this name) and unnecessary when you only have one brain.'),
2897
2983
  },
2898
2984
  },
2899
- async ({ kind, name, summary, sections, tier, base_version, reason, change_kind, brain }) => {
2985
+ async ({ kind, name, summary, sections, tier, base_version, reason, change_kind, brain, identity_claim_ack }) => {
2900
2986
  // No client-side tier default — the server computes the per-kind safe default (page-privacy
2901
2987
  // T4/D10) so version-pinned installs can't bake a stale policy.
2902
2988
  const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
@@ -2908,7 +2994,7 @@ export async function runServer(version) {
2908
2994
  // `brain` is forwarded only when the caller named one. The server's resolveAuthorBrain gives
2909
2995
  // an explicit brain top precedence and 409s on an unknown one rather than falling back to
2910
2996
  // the pointer, so sending an empty value would turn "I did not choose" into "I chose wrong".
2911
- body: JSON.stringify({ kind, name, pages, reason, change_kind, ...(brain ? { brain } : {}) }),
2997
+ body: JSON.stringify({ kind, name, pages, reason, change_kind, ...(brain ? { brain } : {}), ...(identity_claim_ack ? { identity_claim_ack: true } : {}) }),
2912
2998
  })
2913
2999
  } catch (e) {
2914
3000
  return toolError(`Could not author "${name}": ${e.message}`)
@@ -2935,6 +3021,23 @@ export async function runServer(version) {
2935
3021
  `\n\nRe-run author with brain:"<name>" — choose by what each brain HOLDS, not by its name.`,
2936
3022
  )
2937
3023
  }
3024
+ // IDENTITY-CLAIM REFUSAL — carries the identifiers and the three ways out. classify() would
3025
+ // flatten this to "409", which is the one failure mode that must NOT be generic: an agent that
3026
+ // cannot see WHICH address it tried to publish, or that a private alternative exists, will
3027
+ // simply re-send with the ack. The refusal has to teach, or it just trains the bypass.
3028
+ if (err?.error === 'identity_claim_unacked') {
3029
+ const claims = Array.isArray(err.claims) ? err.claims.join(', ') : ''
3030
+ return toolError(
3031
+ `Refused — NOTHING was written to "${name}".` +
3032
+ `\n\nThis write puts ${claims ? `${claims} ` : 'an identity identifier '}into the page BODY at the ${err.tier} tier.` +
3033
+ ` A body claim is page TEXT governed by that tier — it is NOT the private, claimant-read` +
3034
+ ` routing claim — so everyone who can read this page can read the address or phone number.` +
3035
+ `\n\nPick one:` +
3036
+ `\n • the subject is publishing their OWN contact detail → re-send with identity_claim_ack: true` +
3037
+ `\n • it belongs on the page but not to everyone → put it in a confidential section` +
3038
+ `\n • you just want your own records to join here → set_routing_identifier (private to you)`,
3039
+ )
3040
+ }
2938
3041
  const d = classify(res.status, res.headers.get('content-type'), raw, res.headers.get('x-vercel-id'))
2939
3042
  return toolError(`Could not author "${name}": ${d.message}`)
2940
3043
  }
@@ -2975,8 +3078,19 @@ export async function runServer(version) {
2975
3078
  ` STATUS ("X is live", "Y is not merged"), add the date inline with edit_page while you still` +
2976
3079
  ` hold the context. The write already landed; this is advisory.`
2977
3080
  : ''
3081
+ // PARTIAL-WRITE REFUSAL. A multi-tier call where one tier landed and another was gated returns
3082
+ // 200 (per the route's mixed-outcome rule), so the 409 branch never runs and the refusal would
3083
+ // ride silently in `skipped` — which this success path does not print. That is the exact
3084
+ // computed-but-never-printed failure the KWA-26 note below is about, and a privacy refusal is
3085
+ // the worst thing to lose it on: the agent would read "Authored" and believe the claim landed.
3086
+ const identityRefused = Array.isArray(out?.identityClaimTiers) && out.identityClaimTiers.length
3087
+ ? `\n⚠ REFUSED at ${out.identityClaimTiers.map((t) => `${t.tier} (${t.claims.join(', ')})`).join('; ')}` +
3088
+ ` — that tier was NOT written. A body claim is page text at that tier, readable by everyone who` +
3089
+ ` can read the page. Re-send with identity_claim_ack: true only if the subject is publishing` +
3090
+ ` their own contact detail; otherwise use a confidential section or set_routing_identifier.`
3091
+ : ''
2978
3092
  const verb = out?.created ? 'Created + authored' : 'Authored'
2979
- const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}${undated}`
3093
+ const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}${undated}${identityRefused}`
2980
3094
  : `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.${corrections}`
2981
3095
  return { content: [{ type: 'text', text: note }] }
2982
3096
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.100",
3
+ "version": "0.9.103",
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": {