@theronap/agnoclast-mcp 0.9.99 → 0.9.102
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/capture.mjs +22 -13
- package/lib/edge_extract.mjs +61 -2
- package/lib/server.mjs +81 -8
- package/package.json +1 -1
package/lib/capture.mjs
CHANGED
|
@@ -232,7 +232,7 @@ export async function runCapture() {
|
|
|
232
232
|
// Recursion guard: edge extraction shells out to `claude --print` with CORTEX_SUMMARIZING=1. That
|
|
233
233
|
// headless session fires its own Stop hook → this same capture command. Without this guard it would
|
|
234
234
|
// recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
|
|
235
|
-
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write(
|
|
235
|
+
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write(`cortex: ${new Date().toISOString()} (summarizer subprocess) skipping — recursion guard\n`); return }
|
|
236
236
|
|
|
237
237
|
const stdinRaw = readStdin()
|
|
238
238
|
|
|
@@ -291,14 +291,29 @@ function spawnDetachedWorker(stdinRaw) {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
async function captureWork(stdinRaw) {
|
|
294
|
+
// Parse the hook payload FIRST so every diagnostic below can name its session.
|
|
295
|
+
//
|
|
296
|
+
// This used to sit after the token check, which meant the `no CORTEX_TOKEN` line — the one that
|
|
297
|
+
// fires on a whole misconfigured machine — was the only failure that could not say which session
|
|
298
|
+
// it lost. Parsing stdin depends on nothing, so the old order bought nothing.
|
|
299
|
+
let hook = {}
|
|
300
|
+
try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
|
|
301
|
+
|
|
302
|
+
// Every line in ~/.cortex/capture.log carries an ISO timestamp and the session id, so a silent
|
|
303
|
+
// stretch can be reconstructed afterwards and matched against `records` and
|
|
304
|
+
// `page_revisions.session_key`. A line without a session id is a line you cannot act on: it tells
|
|
305
|
+
// you something was lost and not what. That was the state of all five early returns below until
|
|
306
|
+
// now — visible since the log landed, but unattributable, which is the same shape as the defect
|
|
307
|
+
// this whole file has been chasing.
|
|
308
|
+
const stamp = new Date().toISOString()
|
|
309
|
+
const sid = hook.session_id ?? '(no session_id)'
|
|
310
|
+
|
|
294
311
|
// Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
|
|
295
312
|
// MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
|
|
296
313
|
const token = resolveTokenSource().token
|
|
297
|
-
if (!token) { process.stderr.write(
|
|
314
|
+
if (!token) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — no CORTEX_TOKEN in env or wired config\n`); return }
|
|
298
315
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
299
316
|
|
|
300
|
-
let hook = {}
|
|
301
|
-
try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
|
|
302
317
|
const repo = projectFrom(hook.cwd)
|
|
303
318
|
// Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
|
|
304
319
|
// feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
|
|
@@ -322,11 +337,11 @@ async function captureWork(stdinRaw) {
|
|
|
322
337
|
// unreadable for a moment is picked up by the next turn, so a transient miss self-heals; only a
|
|
323
338
|
// genuinely persistence-disabled session is dropped, and that session has no content to capture.
|
|
324
339
|
if (hook.transcript_path && !transcriptReadable(hook.transcript_path)) {
|
|
325
|
-
process.stderr.write(
|
|
340
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — transcript_path unreadable (session persistence disabled?)\n`)
|
|
326
341
|
return
|
|
327
342
|
}
|
|
328
343
|
|
|
329
|
-
if (!transcript && !hook.session_id) { process.stderr.write(
|
|
344
|
+
if (!transcript && !hook.session_id) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — empty session\n`); return }
|
|
330
345
|
|
|
331
346
|
// T11: digest node refs surfaced to this session (stashed by the MCP server's my_context, keyed by
|
|
332
347
|
// cwd). Forwarded as hydratedFrom so the materializer excludes this session from the digests it
|
|
@@ -370,7 +385,7 @@ async function captureWork(stdinRaw) {
|
|
|
370
385
|
...(hydratedFrom.length ? { hydratedFrom } : {}),
|
|
371
386
|
}
|
|
372
387
|
const extracted = transcript ? extractSession(transcript) : null
|
|
373
|
-
if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(
|
|
388
|
+
if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — summarizer classified it a no-op session\n`); return }
|
|
374
389
|
const ingestBody = extracted
|
|
375
390
|
? { ...common, summary: extracted.summary, people: extracted.people, entities: extracted.namedEntities }
|
|
376
391
|
: { ...common, transcript }
|
|
@@ -388,12 +403,6 @@ async function captureWork(stdinRaw) {
|
|
|
388
403
|
} catch { /* best-effort — never block capture */ }
|
|
389
404
|
}
|
|
390
405
|
|
|
391
|
-
// Defined before the fetch so the throw path below can stamp its line the same way. Every line in
|
|
392
|
-
// capture.log carries an ISO timestamp and the session id, so a silent day can be reconstructed
|
|
393
|
-
// afterwards and matched against `records` / `page_revisions.session_key`.
|
|
394
|
-
const stamp = new Date().toISOString()
|
|
395
|
-
const sid = hook.session_id ?? '(no session_id)'
|
|
396
|
-
|
|
397
406
|
let res
|
|
398
407
|
try {
|
|
399
408
|
// Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -105,9 +105,67 @@ const ENTITY_FRAGMENT =
|
|
|
105
105
|
'"importance" ("high" if central to the work, "low" if incidental). ' +
|
|
106
106
|
'Do NOT include people or companies (those go in "people"). Omit vague/generic mentions. Empty array is fine.)'
|
|
107
107
|
|
|
108
|
+
// PURE. The scheduled task this transcript is an unattended run of, or null for a human session.
|
|
109
|
+
// Claude Code wraps a scheduled run's instructions in <scheduled-task name="..." file="...">.
|
|
110
|
+
export function scheduledTaskName(transcript) {
|
|
111
|
+
const m = String(transcript ?? '').match(/<scheduled-task\s+[^>]*name="([^"]+)"/)
|
|
112
|
+
return m ? m[1] : null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// PURE. Drop the <scheduled-task>…</scheduled-task> blocks — they are the task's OWN INSTRUCTIONS,
|
|
116
|
+
// not anything the run observed, and Claude Code emits them TWICE per transcript. Measured
|
|
117
|
+
// 2026-08-19 on real units: ~2.1KB of instructions repeated, so ~4.2KB of the summarizer's 12KB
|
|
118
|
+
// window went to text describing what the agent was told to do before a single line of what it
|
|
119
|
+
// found. Stripping them spends the budget on the run's actual output.
|
|
120
|
+
export function stripScheduledTaskBlocks(transcript) {
|
|
121
|
+
return String(transcript ?? '').replace(/<scheduled-task\b[\s\S]*?<\/scheduled-task>/g, '').trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The NOOP bar for an unattended run. The generic bar ("greetings, no tasks") does not fit these and
|
|
125
|
+
// was never meant to: a scheduled run DID do something — it read a file, evaluated a condition,
|
|
126
|
+
// maybe sent a notification — so a summarizer following the generic instruction correctly writes
|
|
127
|
+
// "Automated scripture study check ran; no confirmation for 2026-08-14; reminder sent." That is a
|
|
128
|
+
// faithful summary of a heartbeat, and it is why 36% of session records since 2026-08-01 are
|
|
129
|
+
// automation reporting its own execution. The summarizer was not malfunctioning; it was answering
|
|
130
|
+
// the question it was asked.
|
|
131
|
+
//
|
|
132
|
+
// The distinction that matters is not attended-vs-unattended, it is CHANGED-vs-UNCHANGED. A routine
|
|
133
|
+
// run that found the expected state is a heartbeat. The SAME task finding the domain finally
|
|
134
|
+
// reactivated, or the backlog crossing a threshold, is exactly the thing the knowledge base exists
|
|
135
|
+
// to hold — so this deliberately does not blanket-skip scheduled tasks, which would throw away the
|
|
136
|
+
// one run in a hundred that matters. There are 12 tasks configured and one of them alone fires up to
|
|
137
|
+
// 11 times a day.
|
|
138
|
+
// ⚠ THE WORDING HERE IS LOAD-BEARING AND WAS WRONG ON THE FIRST ATTEMPT. Verified 2026-08-19 against
|
|
139
|
+
// two real transcripts: a first draft ended "when it is genuinely borderline, prefer NOOP" and
|
|
140
|
+
// correctly suppressed the heartbeat — but ALSO suppressed a run of the same task that had flagged a
|
|
141
|
+
// live read path into the frozen Robin archive, a genuine finding. Every unit test still passed,
|
|
142
|
+
// because they cover the stripping, not the judgement. The prompt was killing signal with the noise.
|
|
143
|
+
//
|
|
144
|
+
// Two fixes: the borderline tilt is gone (it resolved every mixed case toward silence), and an
|
|
145
|
+
// incidental observation now counts explicitly — these findings arrive as an aside at the END of an
|
|
146
|
+
// otherwise routine report, which is exactly where a "was this routine?" reading drops them.
|
|
147
|
+
const scheduledTaskFragment = (name) =>
|
|
148
|
+
'\n\nIMPORTANT — this session is an UNATTENDED run of the scheduled task "' + name + '". ' +
|
|
149
|
+
'Its instructions have been removed; what remains is only what the run reported. ' +
|
|
150
|
+
'Judge what it FOUND and what it CHANGED — never merely that it executed. ' +
|
|
151
|
+
'If the run only confirmed the state it expected and changed nothing, set summary to exactly ' +
|
|
152
|
+
'"NOOP": a routine run that found nothing new is a heartbeat, not knowledge, and these fire many ' +
|
|
153
|
+
'times a day. ' +
|
|
154
|
+
'BUT summarize whenever the run found something changed, hit an error, took an action with a ' +
|
|
155
|
+
'lasting effect, OR raised a problem, risk or observation the operator would not already know — ' +
|
|
156
|
+
'INCLUDING one mentioned only in passing at the very end of an otherwise routine report. ' +
|
|
157
|
+
'One genuine finding outweighs an otherwise unremarkable run; summarize the FINDING, not the run.'
|
|
158
|
+
|
|
108
159
|
export function extractSession(transcript) {
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
160
|
+
const raw = (transcript ?? '').trim()
|
|
161
|
+
if (!raw || process.env.CORTEX_SUMMARIZE_DISABLED) return null
|
|
162
|
+
const task = scheduledTaskName(raw)
|
|
163
|
+
// Strip instructions only for scheduled runs; a human transcript has no such blocks and is passed
|
|
164
|
+
// through untouched, so this cannot change how ordinary sessions are summarized.
|
|
165
|
+
const text = task ? stripScheduledTaskBlocks(raw) : raw
|
|
166
|
+
// A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
|
|
167
|
+
// heartbeat by definition — skip without spending a `claude -p` call on it.
|
|
168
|
+
if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [] }
|
|
111
169
|
const prompt =
|
|
112
170
|
'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
|
|
113
171
|
'(no prose, no markdown fences) with EXACTLY these three keys:\n' +
|
|
@@ -115,6 +173,7 @@ export function extractSession(transcript) {
|
|
|
115
173
|
'had no real work — greetings, no tasks — set summary to exactly "NOOP"),\n' +
|
|
116
174
|
PEOPLE_FRAGMENT + ',\n' +
|
|
117
175
|
ENTITY_FRAGMENT +
|
|
176
|
+
(task ? scheduledTaskFragment(task) : '') +
|
|
118
177
|
'\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
|
|
119
178
|
// Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
|
|
120
179
|
// server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
|
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
|
|
@@ -2893,10 +2937,11 @@ export async function runServer(version) {
|
|
|
2893
2937
|
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
2938
|
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
2939
|
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.'),
|
|
2940
|
+
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
2941
|
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
2942
|
},
|
|
2898
2943
|
},
|
|
2899
|
-
async ({ kind, name, summary, sections, tier, base_version, reason, change_kind, brain }) => {
|
|
2944
|
+
async ({ kind, name, summary, sections, tier, base_version, reason, change_kind, brain, identity_claim_ack }) => {
|
|
2900
2945
|
// No client-side tier default — the server computes the per-kind safe default (page-privacy
|
|
2901
2946
|
// T4/D10) so version-pinned installs can't bake a stale policy.
|
|
2902
2947
|
const pages = [{ ...(tier ? { tier } : {}), summary, base_version, sections: Array.isArray(sections) ? sections : [] }]
|
|
@@ -2908,7 +2953,7 @@ export async function runServer(version) {
|
|
|
2908
2953
|
// `brain` is forwarded only when the caller named one. The server's resolveAuthorBrain gives
|
|
2909
2954
|
// an explicit brain top precedence and 409s on an unknown one rather than falling back to
|
|
2910
2955
|
// 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 } : {}) }),
|
|
2956
|
+
body: JSON.stringify({ kind, name, pages, reason, change_kind, ...(brain ? { brain } : {}), ...(identity_claim_ack ? { identity_claim_ack: true } : {}) }),
|
|
2912
2957
|
})
|
|
2913
2958
|
} catch (e) {
|
|
2914
2959
|
return toolError(`Could not author "${name}": ${e.message}`)
|
|
@@ -2935,6 +2980,23 @@ export async function runServer(version) {
|
|
|
2935
2980
|
`\n\nRe-run author with brain:"<name>" — choose by what each brain HOLDS, not by its name.`,
|
|
2936
2981
|
)
|
|
2937
2982
|
}
|
|
2983
|
+
// IDENTITY-CLAIM REFUSAL — carries the identifiers and the three ways out. classify() would
|
|
2984
|
+
// flatten this to "409", which is the one failure mode that must NOT be generic: an agent that
|
|
2985
|
+
// cannot see WHICH address it tried to publish, or that a private alternative exists, will
|
|
2986
|
+
// simply re-send with the ack. The refusal has to teach, or it just trains the bypass.
|
|
2987
|
+
if (err?.error === 'identity_claim_unacked') {
|
|
2988
|
+
const claims = Array.isArray(err.claims) ? err.claims.join(', ') : ''
|
|
2989
|
+
return toolError(
|
|
2990
|
+
`Refused — NOTHING was written to "${name}".` +
|
|
2991
|
+
`\n\nThis write puts ${claims ? `${claims} ` : 'an identity identifier '}into the page BODY at the ${err.tier} tier.` +
|
|
2992
|
+
` A body claim is page TEXT governed by that tier — it is NOT the private, claimant-read` +
|
|
2993
|
+
` routing claim — so everyone who can read this page can read the address or phone number.` +
|
|
2994
|
+
`\n\nPick one:` +
|
|
2995
|
+
`\n • the subject is publishing their OWN contact detail → re-send with identity_claim_ack: true` +
|
|
2996
|
+
`\n • it belongs on the page but not to everyone → put it in a confidential section` +
|
|
2997
|
+
`\n • you just want your own records to join here → set_routing_identifier (private to you)`,
|
|
2998
|
+
)
|
|
2999
|
+
}
|
|
2938
3000
|
const d = classify(res.status, res.headers.get('content-type'), raw, res.headers.get('x-vercel-id'))
|
|
2939
3001
|
return toolError(`Could not author "${name}": ${d.message}`)
|
|
2940
3002
|
}
|
|
@@ -2975,8 +3037,19 @@ export async function runServer(version) {
|
|
|
2975
3037
|
` STATUS ("X is live", "Y is not merged"), add the date inline with edit_page while you still` +
|
|
2976
3038
|
` hold the context. The write already landed; this is advisory.`
|
|
2977
3039
|
: ''
|
|
3040
|
+
// PARTIAL-WRITE REFUSAL. A multi-tier call where one tier landed and another was gated returns
|
|
3041
|
+
// 200 (per the route's mixed-outcome rule), so the 409 branch never runs and the refusal would
|
|
3042
|
+
// ride silently in `skipped` — which this success path does not print. That is the exact
|
|
3043
|
+
// computed-but-never-printed failure the KWA-26 note below is about, and a privacy refusal is
|
|
3044
|
+
// the worst thing to lose it on: the agent would read "Authored" and believe the claim landed.
|
|
3045
|
+
const identityRefused = Array.isArray(out?.identityClaimTiers) && out.identityClaimTiers.length
|
|
3046
|
+
? `\n⚠ REFUSED at ${out.identityClaimTiers.map((t) => `${t.tier} (${t.claims.join(', ')})`).join('; ')}` +
|
|
3047
|
+
` — that tier was NOT written. A body claim is page text at that tier, readable by everyone who` +
|
|
3048
|
+
` can read the page. Re-send with identity_claim_ack: true only if the subject is publishing` +
|
|
3049
|
+
` their own contact detail; otherwise use a confidential section or set_routing_identifier.`
|
|
3050
|
+
: ''
|
|
2978
3051
|
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}`
|
|
3052
|
+
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
3053
|
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.${corrections}`
|
|
2981
3054
|
return { content: [{ type: 'text', text: note }] }
|
|
2982
3055
|
},
|
package/package.json
CHANGED