@theronap/agnoclast-mcp 0.9.193 → 0.9.195
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/seat_charge.mjs +36 -0
- package/lib/server.mjs +32 -9
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A brain that pays for its members' seats (ADR-0072) refuses a member-add until the NEW monthly
|
|
2
|
+
// total has been confirmed: POST /api/invite answers 409 with
|
|
3
|
+
// { code: 'seat_charge_unconfirmed', seatMonthlyTotal, seats, error }.
|
|
4
|
+
// The confirmation is a recurring charge on the brain owner's card, so the tool must be able to tell
|
|
5
|
+
// this apart from every other refusal and put the question to the person instead of reporting an
|
|
6
|
+
// error. Pure, so it can be tested without a server.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {number} status HTTP status of the refused add
|
|
10
|
+
* @param {string} raw the response body, unparsed
|
|
11
|
+
* @returns {{ total: number, seats: number | null } | null} null for any other refusal
|
|
12
|
+
*/
|
|
13
|
+
export function seatChargeRefusal(status, raw) {
|
|
14
|
+
if (status !== 409) return null
|
|
15
|
+
let body
|
|
16
|
+
try { body = JSON.parse(raw) } catch { return null }
|
|
17
|
+
if (!body || body.code !== 'seat_charge_unconfirmed') return null
|
|
18
|
+
const total = body.seatMonthlyTotal
|
|
19
|
+
// No usable number means there is nothing a person could confirm; fall through to the plain error
|
|
20
|
+
// rather than ask them to agree to "$undefined/month".
|
|
21
|
+
if (typeof total !== 'number' || !Number.isFinite(total) || total < 0) return null
|
|
22
|
+
const seats = typeof body.seats === 'number' && Number.isFinite(body.seats) ? body.seats : null
|
|
23
|
+
return { total, seats }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the caller is told. `confirmed` is the total it passed, if any — then that total was stale. */
|
|
27
|
+
export function seatChargeQuestion({ brainName, email, total, seats, confirmed }) {
|
|
28
|
+
return [
|
|
29
|
+
`${brainName} pays for its members' seats, so adding ${email} changes what its owner is charged.`,
|
|
30
|
+
`New total: $${total}/month${seats ? ` for ${seats} ${seats === 1 ? 'seat' : 'seats'}` : ''} ($10/month for each member), charged to the brain's owner.`,
|
|
31
|
+
...(typeof confirmed === 'number' ? [`The total you confirmed ($${confirmed}) is not the current one — the membership changed in between.`] : []),
|
|
32
|
+
'',
|
|
33
|
+
'NOTHING HAS HAPPENED: nobody was added and nothing was charged.',
|
|
34
|
+
`Ask the user whether to accept $${total}/month. Only if they say yes, re-run with the same arguments plus confirm_seat_total: ${total}. Do not confirm it on their behalf.`,
|
|
35
|
+
].join('\n')
|
|
36
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'fs'
|
|
|
5
5
|
import { homedir } from 'os'
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
|
+
import { seatChargeRefusal, seatChargeQuestion } from './seat_charge.mjs'
|
|
8
9
|
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool, heartbeatPolicy } from './diagnose.mjs'
|
|
9
10
|
import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
|
|
10
11
|
import { folderClaimLine } from './folder_claims.mjs'
|
|
@@ -559,7 +560,7 @@ function renderNudge(payload) {
|
|
|
559
560
|
title: z.string().optional().describe('short title for the session'),
|
|
560
561
|
sessionId: z.string().optional().describe('the Claude Code session id — DEFAULTS to the current session, so omit it unless you are logging on behalf of another one. It is shared by every segment and the only thing pairing them, and it is also the join to the pages this session wrote (page_revisions.session_key); without it the record is unattributable forever, since the fallback dedupe key is a timestamp. Deliberately NOT a link: segments never reference each other, so a reader cleared for one brain cannot tell the others exist, while you can join on it across brains'),
|
|
561
562
|
brain: z.string().optional().describe('which brain to record this session in (name or org id, one of your own). REQUIRED IN EFFECT for a multi-brain member: session-class sources route only by an explicit brain or a sole membership, so omitting it stages the log instead of recording it.'),
|
|
562
|
-
privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME.
|
|
563
|
+
privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME. A record is normally read by whoever can read the page it is filed under. Pass "confidential" when the summary names confidential work (a candidate evaluation, a security finding) so it is yours alone from the start — safer than letting it land page-visible and locking it after, which leaves it readable in between. Pass "scoped" to lock it to you and your managers. OMIT it for ordinary work: the record then follows its page, which is what makes it visible to the people working on that page with you.'),
|
|
563
564
|
},
|
|
564
565
|
},
|
|
565
566
|
async ({ summary, segments, project, title, sessionId, brain, privacy }) => {
|
|
@@ -3900,7 +3901,7 @@ function renderNudge(payload) {
|
|
|
3900
3901
|
'add_to_brain',
|
|
3901
3902
|
{
|
|
3902
3903
|
title: 'Add someone to one of your brains',
|
|
3903
|
-
description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. Use when asked to invite/add someone to a brain.",
|
|
3904
|
+
description: "Add a person to one of your brains, or work out what it would take. Resolves which brain, whether you may add to it, and the manager id they are placed under. WITHOUT execute:true it only reports the plan and hands back a console link — call it that way first and show the user what you are about to do. WITH execute:true it performs the add, and then `brain` is REQUIRED: an access grant must never be aimed by a shared write pointer. Adding cannot be undone through the API. IF THE BRAIN PAYS FOR ITS MEMBERS' SEATS, adding someone raises its owner's monthly charge: the first execute is refused with the new total, you ASK THE USER whether to accept that charge, and only on a yes re-run with confirm_seat_total set to that number. Use when asked to invite/add someone to a brain.",
|
|
3904
3905
|
inputSchema: {
|
|
3905
3906
|
email: z.string().describe("the person's email address — the login their Agnoclast account is (or will be) on"),
|
|
3906
3907
|
name: z.string().optional().describe('their full name; falls back to the email local-part'),
|
|
@@ -3909,9 +3910,10 @@ function renderNudge(payload) {
|
|
|
3909
3910
|
brain: z.string().optional().describe('which brain, by name or org id. Optional when planning; REQUIRED with execute:true. Pass the org id when a name matches more than one of your brains'),
|
|
3910
3911
|
execute: z.boolean().optional().describe('default false. false = report the plan only. true = actually add them — not undoable through the API, and requires an explicit brain'),
|
|
3911
3912
|
inherit_page_id: z.string().optional().describe("an existing PERSON page in that brain that becomes their user page (ADR-0023). The plan lists candidates; ask the user which, or omit for a new page"),
|
|
3913
|
+
confirm_seat_total: z.number().optional().describe("ONLY after an execute was refused because this brain pays for its members' seats: the new monthly total in dollars that the refusal named, and ONLY once the user has said yes to that charge in this conversation. Never guess it, never pass it on a first attempt — it puts a recurring charge on the brain owner's card"),
|
|
3912
3914
|
},
|
|
3913
3915
|
},
|
|
3914
|
-
async ({ email, name, title, role, brain, execute, inherit_page_id }) => {
|
|
3916
|
+
async ({ email, name, title, role, brain, execute, inherit_page_id, confirm_seat_total }) => {
|
|
3915
3917
|
const addr = (email ?? '').trim().toLowerCase()
|
|
3916
3918
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(addr)) return toolError(`"${email}" does not look like an email address.`)
|
|
3917
3919
|
|
|
@@ -4003,13 +4005,29 @@ function renderNudge(payload) {
|
|
|
4003
4005
|
done = await fetchCortex(`${BASE}/api/invite`, {
|
|
4004
4006
|
method: 'POST',
|
|
4005
4007
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
4006
|
-
body: JSON.stringify({
|
|
4008
|
+
body: JSON.stringify({
|
|
4009
|
+
...payload,
|
|
4010
|
+
brain: target.orgId,
|
|
4011
|
+
...(inherit_page_id ? { inheritPageId: inherit_page_id } : {}),
|
|
4012
|
+
// ADR-0072 §3.5. Sent only when the caller passed it: the server compares it with the
|
|
4013
|
+
// total THIS add produces and refuses anything else, so a stale or invented number
|
|
4014
|
+
// cannot charge anyone.
|
|
4015
|
+
...(typeof confirm_seat_total === 'number' ? { confirmSeatTotal: confirm_seat_total } : {}),
|
|
4016
|
+
}),
|
|
4007
4017
|
})
|
|
4008
4018
|
} catch (e) {
|
|
4009
4019
|
return toolError(`Could not add ${payload.email} to ${target.name}: ${e.message}`)
|
|
4010
4020
|
}
|
|
4011
4021
|
if (!done.ok) {
|
|
4012
|
-
const
|
|
4022
|
+
const raw = await done.text()
|
|
4023
|
+
// A brain that pays for its members' seats refuses the add until the new monthly total is
|
|
4024
|
+
// confirmed (ADR-0072 §3.5). That is not a failure to report and move on from — it is a
|
|
4025
|
+
// question only the person can answer, because the answer is a charge on the owner's card.
|
|
4026
|
+
const seat = seatChargeRefusal(done.status, raw)
|
|
4027
|
+
if (seat) {
|
|
4028
|
+
return toolError(seatChargeQuestion({ brainName: target.name, email: payload.email, total: seat.total, seats: seat.seats, confirmed: confirm_seat_total }))
|
|
4029
|
+
}
|
|
4030
|
+
const d = classify(done.status, done.headers.get('content-type'), raw, done.headers.get('x-vercel-id'))
|
|
4013
4031
|
return toolError(`Could not add ${payload.email} to ${target.name}: ${d.message}`)
|
|
4014
4032
|
}
|
|
4015
4033
|
const r = await done.json().catch(() => ({}))
|
|
@@ -4024,7 +4042,10 @@ function renderNudge(payload) {
|
|
|
4024
4042
|
const page = r.userPage?.status
|
|
4025
4043
|
? `\nTheir page in ${target.name}: ${r.userPage.status}${r.userPage.error ? ` (${r.userPage.error})` : ''}.`
|
|
4026
4044
|
: ''
|
|
4027
|
-
|
|
4045
|
+
// What the seat sync did, in the server's words: the new monthly total, or that the seat
|
|
4046
|
+
// could NOT be charged and the brain is not paying for them yet.
|
|
4047
|
+
const seatLine = typeof r.seatNote === 'string' && r.seatNote ? `\n${r.seatNote}` : ''
|
|
4048
|
+
return { content: [{ type: 'text', text: `Added ${payload.name} <${payload.email}> to ${target.name} as ${payload.role}, placed under you.\n${how}${page}${seatLine}\n\nThis cannot be undone through the API — removing a membership currently needs direct database access.` }] }
|
|
4028
4049
|
}
|
|
4029
4050
|
|
|
4030
4051
|
// ADR-0023: does this brain already have a page about them? The inviter decides — list, never pick.
|
|
@@ -4052,6 +4073,8 @@ function renderNudge(payload) {
|
|
|
4052
4073
|
` manager you [${target.userId}] (they are placed under you)`,
|
|
4053
4074
|
...pageLines,
|
|
4054
4075
|
'',
|
|
4076
|
+
"If this brain pays for its members' seats, the first execute is refused with the new monthly total; ask the user before confirming it.",
|
|
4077
|
+
'',
|
|
4055
4078
|
'Nothing has happened yet. To go ahead, re-run with execute:true and the same brain —',
|
|
4056
4079
|
`or do it yourself at ${BASE}/?invite=1 with these values:`,
|
|
4057
4080
|
'',
|
|
@@ -4404,10 +4427,10 @@ function renderNudge(payload) {
|
|
|
4404
4427
|
'set_record_privacy',
|
|
4405
4428
|
{
|
|
4406
4429
|
title: 'Set a record\'s privacy tier',
|
|
4407
|
-
description: '
|
|
4430
|
+
description: 'Lock or release one of YOUR OWN records. A record is read by whoever can read the PAGE it is filed under; this is how you make one tighter than its page, or let it follow its page again. "accessible" = no lock, the record follows its page · "scoped" = locked to you and your managers, within the page\'s audience · "confidential" = you only, plus people you explicitly grant. Naming a tier IS the deliberate act: setting "scoped" on a record that already reads scoped locks it (most records are born scoped by default, which restricts nothing). Session summaries captured before 2026-10-01 are locked at scoped until you release them with "accessible". A record filed under no page is yours alone whatever its tier. Get record_id from a `list_records` result or the "id:" on a `my_context` activity line. Only affects records you own — others return an error.',
|
|
4408
4431
|
inputSchema: {
|
|
4409
4432
|
record_id: z.string().describe('the record id (uuid), e.g. the "id:" on a list_records line'),
|
|
4410
|
-
privacy: z.enum(['accessible', 'scoped', 'confidential']).describe('the
|
|
4433
|
+
privacy: z.enum(['accessible', 'scoped', 'confidential']).describe('accessible = follow the page (no lock); scoped = lock to you + your managers; confidential = you only'),
|
|
4411
4434
|
},
|
|
4412
4435
|
},
|
|
4413
4436
|
async ({ record_id, privacy }) => {
|
|
@@ -5069,7 +5092,7 @@ function renderNudge(payload) {
|
|
|
5069
5092
|
'set_page_privacy',
|
|
5070
5093
|
{
|
|
5071
5094
|
title: 'Change who can see a wiki page',
|
|
5072
|
-
description: `Re-tier a wiki page you own or may edit. ${PAGE_TIER_GLOSS}. Every tier but confidential is also readable by the page owner's managers. ⚠ reports / peers / down are RELATIVE TO THE PAGE'S OWNER, computed live: the audience is the OWNER's team, it changes when the owner moves in the org chart or the page is transferred, and ONLY the owner can move a page to or from one of them.
|
|
5095
|
+
description: `Re-tier a wiki page you own or may edit. ${PAGE_TIER_GLOSS}. Every tier but confidential is also readable by the page owner's managers. ⚠ reports / peers / down are RELATIVE TO THE PAGE'S OWNER, computed live: the audience is the OWNER's team, it changes when the owner moves in the org chart or the page is transferred, and ONLY the owner can move a page to or from one of them. RECORDS FOLLOW THE PAGE: a record filed under a page is read by whoever can read that page, so widening a page widens its records and narrowing it narrows them — except a record its owner locked (set_record_privacy) or made confidential, which stays tighter. Demoting a PROJECT page to scoped or confidential also rewrites its evidence records' own tier to match (each record's owner is notified and can revert). Org admins may narrow any page to scoped or confidential, never widen.`,
|
|
5073
5096
|
inputSchema: {
|
|
5074
5097
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
5075
5098
|
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
package/package.json
CHANGED