@theronap/cortex-mcp 0.9.90 → 0.9.92
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/red_link_triage.mjs +8 -1
- package/lib/server.mjs +159 -27
- package/package.json +1 -1
package/lib/red_link_triage.mjs
CHANGED
|
@@ -21,7 +21,14 @@ export function renderTriage(t, name) {
|
|
|
21
21
|
: ''
|
|
22
22
|
const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
|
|
23
23
|
if (t.demoted) {
|
|
24
|
-
|
|
24
|
+
// KWA-28 — `red_link_targets` is a graph-side object and must carry an as-of. The server ALREADY
|
|
25
|
+
// sends `updatedAt` on this arm (the retirement's own timestamp) and this renderer was dropping it,
|
|
26
|
+
// so "deliberately retired" read as timeless. WHEN it was retired is the load-bearing fact here:
|
|
27
|
+
// the whole point of the arm is to stop an agent authoring over a human decision, and a decision
|
|
28
|
+
// from yesterday and one from eight months ago warrant different confidence about whether it still
|
|
29
|
+
// holds. Explicitly undated rather than silent when the server predates the field.
|
|
30
|
+
const when = t.updatedAt ? ` on ${String(t.updatedAt).slice(0, 10)}` : ' (retirement date not recorded)'
|
|
31
|
+
return `\n\n[[${name}]] EXISTS but is marked ${t.validity ?? 'superseded'} — it was authored and then deliberately retired${when}, so read_page (which serves only current pages) will not show it. It is NOT missing.${refs} Do NOT author over it: that would silently overwrite a decision someone made on purpose. Read it with \`page_history "${name}"\` then \`read_page "${name}"\` with a version. If it genuinely should be live again, revive it deliberately with \`set_page_validity\`.`
|
|
25
32
|
}
|
|
26
33
|
if (t.category === 'node') {
|
|
27
34
|
return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
|
package/lib/server.mjs
CHANGED
|
@@ -40,6 +40,38 @@ async function redLinkTriage(BASE, TOKEN, name) {
|
|
|
40
40
|
// file-request tools, and a module-level `fail` would be silently shadowed by it from that point on.
|
|
41
41
|
const toolError = (text) => ({ content: [{ type: 'text', text }], isError: true })
|
|
42
42
|
|
|
43
|
+
// SECTION CURRENCY (gate 3) — ONE renderer, used by BOTH read_page and project_status.
|
|
44
|
+
//
|
|
45
|
+
// Extracted as a PURE function on the pickBrainMatch precedent: that fix pulled a shared rule out of two
|
|
46
|
+
// resolvers specifically so they could not drift, after they drifted. This is the same situation found
|
|
47
|
+
// 2026-08-15. THREE surfaces render authored sections from the same server-computed fields:
|
|
48
|
+
// • renderAuthoredNodeBody (web/lib/engine/authored_page_tiers.ts) — console/web
|
|
49
|
+
// • read_page here — printed a date and "⚠ Undated section" as two adjacent lines
|
|
50
|
+
// • project_status here — printed NO currency at all: no as-of, no warning, nothing
|
|
51
|
+
// PR #558 fixed only the first. project_status is the tool the routing docs reach for FIRST, and gate 3
|
|
52
|
+
// reads "a reader can date any claim without a second query" — a reader there could date nothing.
|
|
53
|
+
//
|
|
54
|
+
// The two dates are DIFFERENT facts and both true: `asOf` is when the section's text last CHANGED; an
|
|
55
|
+
// explicit date in the prose is when the CLAIM was true. Stated as one sentence they inform; stacked as
|
|
56
|
+
// two lines they read as the page contradicting itself.
|
|
57
|
+
//
|
|
58
|
+
// ⚠ Do NOT "simplify" this by keying on `asOf` — it is set on every section always, so the detector
|
|
59
|
+
// would go silent everywhere. The branch must key on `hasExplicitDate`, and on `=== false` rather than
|
|
60
|
+
// falsy: `undefined` is an older server mid-rolling-deploy that has computed no verdict, and inventing
|
|
61
|
+
// one there is the 0093 don't-impute violation.
|
|
62
|
+
export const sectionCurrencyStamp = (s, day) => {
|
|
63
|
+
const on = s.asOf ? day(s.asOf) : ''
|
|
64
|
+
return s.hasExplicitDate === false
|
|
65
|
+
? `${on ? ` · text last written ${on} —` : ' —'} ⚠ the claim itself carries no date; verify before relying on it`
|
|
66
|
+
: (on ? ` · as of ${on}` : '')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ⚠ The newline is a FIX, not cosmetics. read_page's old form was `${asOf}${currency}${s.body}`, where
|
|
70
|
+
// only the UNDATED branch contributed a trailing \n — so every DATED section ran its body straight onto
|
|
71
|
+
// the header line ("· as of 2026-08-14Identifiers are candidates, never publication."). The defect was
|
|
72
|
+
// invisible on exactly the sections that were healthy.
|
|
73
|
+
export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
|
|
74
|
+
|
|
43
75
|
// The Agnoclast MCP server (stdio). Serves the signed-in employee's scoped org
|
|
44
76
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
45
77
|
|
|
@@ -326,14 +358,20 @@ export async function runServer(version) {
|
|
|
326
358
|
m = await fetchCortex(`${BASE}/api/intake/materialize`, {
|
|
327
359
|
method: 'POST',
|
|
328
360
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
361
|
+
// NESTED under `record`, and snake_case inside it. The route reads the record body
|
|
362
|
+
// from `body.record` and ignores top-level title/summary entirely — a flat body still
|
|
363
|
+
// answers `ok` with a recordId, having written a record with no content. Four such
|
|
364
|
+
// records exist in the corpus from exactly this mistake, and this call was the fifth
|
|
365
|
+
// until 2026-08-15: the close-out reported success while changing nothing.
|
|
329
366
|
body: JSON.stringify({
|
|
330
367
|
intakeItemId: j.intakeItemId,
|
|
331
368
|
orgId,
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
369
|
+
record: {
|
|
370
|
+
title: seg.title,
|
|
371
|
+
summary: seg.summary,
|
|
372
|
+
source: 'claude-code',
|
|
373
|
+
record_type: 'ai_session',
|
|
374
|
+
},
|
|
337
375
|
}),
|
|
338
376
|
})
|
|
339
377
|
} catch (e) {
|
|
@@ -539,7 +577,7 @@ export async function runServer(version) {
|
|
|
539
577
|
{
|
|
540
578
|
title: 'Check private intake changes',
|
|
541
579
|
description:
|
|
542
|
-
'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working.
|
|
580
|
+
'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. cleanupDueCount is advisory: prefer to service it with claimKind=cleanup when you reach a natural break, but never park the work you were actually asked to do in order to drain the queue first. NOTE afterSeq is this feed\'s own sequence — the changeSeq returned by ingest is a different counter, and passing it here seeks past the end and looks like a dead feed.',
|
|
543
581
|
inputSchema: {
|
|
544
582
|
afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
|
|
545
583
|
limit: z.number().optional().describe('max change rows (default 100)'),
|
|
@@ -565,25 +603,66 @@ export async function runServer(version) {
|
|
|
565
603
|
{
|
|
566
604
|
title: 'Claim private intake items',
|
|
567
605
|
description:
|
|
568
|
-
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only.
|
|
606
|
+
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. Requires x-cortex-session-key (set automatically by this MCP server). Aged work never blocks a claim: the response reports cleanupDueCount and how far behind the oldest unit is, and servicing it is expected but always your call. Claiming is a commitment to process — hand back anything you will not finish with intake_release, or intake_defer if it is the owner\'s decision to make.',
|
|
569
607
|
inputSchema: {
|
|
570
608
|
claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
|
|
571
|
-
limit: z.number().optional().describe('max items (default 10)'),
|
|
609
|
+
limit: z.number().optional().describe('max items (default 10). Ignored when intakeItemIds is given.'),
|
|
610
|
+
intakeItemIds: z.array(z.string()).optional().describe(
|
|
611
|
+
'claim these specific units instead of the head of the queue (max 50). Without it you get FIFO, ' +
|
|
612
|
+
'so reaching one known unit means claiming everything ahead of it. Ids come from intake_changes ' +
|
|
613
|
+
'or from an ingest response. The reply adds an `outcomes` entry per requested id — claimed, ' +
|
|
614
|
+
'held_by_you (you already hold a live lease on it — proceed, do not re-claim), ' +
|
|
615
|
+
'held (someone else has a live lease; the holder is a stable hash, never their session key), ' +
|
|
616
|
+
'ineligible (already resolved or deferred to the owner), not_found, or unavailable (a momentary ' +
|
|
617
|
+
'lock — retrying is reasonable). Outcomes are best-effort, not a snapshot you can rely on.',
|
|
618
|
+
),
|
|
572
619
|
},
|
|
573
620
|
},
|
|
574
|
-
async ({ claimKind, limit }) => {
|
|
621
|
+
async ({ claimKind, limit, intakeItemIds }) => {
|
|
575
622
|
const res = await fetchCortex(`${BASE}/api/intake/claim`, {
|
|
576
623
|
method: 'POST',
|
|
577
624
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
578
|
-
body: JSON.stringify({
|
|
625
|
+
body: JSON.stringify({
|
|
626
|
+
claimKind: claimKind ?? 'relevance',
|
|
627
|
+
limit,
|
|
628
|
+
includePayload: true,
|
|
629
|
+
// Forwarded only when present. An empty array is a real request to claim nothing and must
|
|
630
|
+
// survive as one; sending `[]` where the caller sent nothing would silently switch a FIFO
|
|
631
|
+
// claim into a no-op.
|
|
632
|
+
...(intakeItemIds ? { intakeItemIds } : {}),
|
|
633
|
+
}),
|
|
579
634
|
})
|
|
580
635
|
if (!res.ok) {
|
|
581
636
|
const body = await res.text()
|
|
582
637
|
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
638
|
+
// Kept deliberately after the server stopped sending it. A published MCP build outlives any
|
|
639
|
+
// one deployment, so this client will meet servers that still refuse relevance claims while
|
|
640
|
+
// cleanup is due. Surfacing that as its own message beats a generic HTTP failure.
|
|
583
641
|
if (res.status === 409) return toolError(body)
|
|
584
642
|
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
585
643
|
}
|
|
586
|
-
|
|
644
|
+
|
|
645
|
+
const j = await res.json()
|
|
646
|
+
|
|
647
|
+
// The nudge that replaced the 409. It has to be legible without doing arithmetic on a
|
|
648
|
+
// timestamp, so say how far behind rather than printing an ISO string and hoping — "6 days"
|
|
649
|
+
// is a reason to act and "2026-08-09T…" is a field to skim past.
|
|
650
|
+
let nudge = ''
|
|
651
|
+
if ((j?.cleanupDueCount ?? 0) > 0) {
|
|
652
|
+
const n = j.cleanupDueCount
|
|
653
|
+
let behind = ''
|
|
654
|
+
const oldest = j?.oldestCleanupDueAt ? Date.parse(j.oldestCleanupDueAt) : NaN
|
|
655
|
+
if (Number.isFinite(oldest)) {
|
|
656
|
+
const mins = Math.max(0, Math.floor((Date.now() - oldest) / 60000))
|
|
657
|
+
behind = mins >= 1440 ? `, oldest ${Math.floor(mins / 1440)}d overdue`
|
|
658
|
+
: mins >= 60 ? `, oldest ${Math.floor(mins / 60)}h overdue`
|
|
659
|
+
: `, oldest ${mins}m overdue`
|
|
660
|
+
}
|
|
661
|
+
nudge = `\n\n⏳ ${n} intake unit${n === 1 ? '' : 's'} past the cleanup deadline${behind}. `
|
|
662
|
+
+ `Nothing is blocked — run intake_claim with claimKind:"cleanup" when you reach a natural break.`
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return { content: [{ type: 'text', text: JSON.stringify(j, null, 2) + nudge }] }
|
|
587
666
|
},
|
|
588
667
|
)
|
|
589
668
|
|
|
@@ -679,15 +758,16 @@ export async function runServer(version) {
|
|
|
679
758
|
inputSchema: {
|
|
680
759
|
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
681
760
|
question: z.string().describe('what you need the owner to decide, in their words not yours — this is the entire message they get, so "which brain should this iMessage thread go to, if any?" beats "needs triage"'),
|
|
761
|
+
options: z.array(z.string()).optional().describe('the concrete choices, when the decision is a pick rather than an open question — e.g. ["Personal","TTO","Discard — nothing to record"]. Stored on the question and rendered by intake_cleanup_status, so the owner can answer with a choice instead of prose.'),
|
|
682
762
|
},
|
|
683
763
|
},
|
|
684
|
-
async ({ intakeItemId, question }) => {
|
|
764
|
+
async ({ intakeItemId, question, options }) => {
|
|
685
765
|
let res
|
|
686
766
|
try {
|
|
687
767
|
res = await fetchCortex(`${BASE}/api/intake/defer`, {
|
|
688
768
|
method: 'POST',
|
|
689
769
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
690
|
-
body: JSON.stringify({ intakeItemId, question }),
|
|
770
|
+
body: JSON.stringify({ intakeItemId, question, ...(options?.length ? { options } : {}) }),
|
|
691
771
|
})
|
|
692
772
|
} catch (e) {
|
|
693
773
|
return toolError(`Could not defer: ${e.message}`)
|
|
@@ -874,7 +954,11 @@ export async function runServer(version) {
|
|
|
874
954
|
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
875
955
|
const renderMatch = (m) => {
|
|
876
956
|
const blocks = m.tiers.map((t) => {
|
|
877
|
-
|
|
957
|
+
// Was heading-then-body with NO currency at all — no as-of, no warning — while
|
|
958
|
+
// read_page showed it. project_status is what the routing docs reach for first, so a
|
|
959
|
+
// reader here could date nothing. Same renderer as read_page now, so the two cannot
|
|
960
|
+
// drift again.
|
|
961
|
+
const secs = (t.sections ?? []).map((s) => renderSection(s, day)).join('\n\n')
|
|
878
962
|
const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}]`
|
|
879
963
|
return [head, t.summary, secs].filter(Boolean).join('\n')
|
|
880
964
|
})
|
|
@@ -971,6 +1055,14 @@ export async function runServer(version) {
|
|
|
971
1055
|
}
|
|
972
1056
|
lines.push(`· ${out.events.visibleCount} tagged timeline event${out.events.visibleCount === 1 ? '' : 's'} visible to you${out.events.recent.length ? ':' : expand ? '.' : ' — pass expand: true to list recent ones.'}`)
|
|
973
1057
|
for (const e of out.events.recent) lines.push(` - ${String(e.occurredAt).slice(0, 10)} · ${e.source} · ${e.summary}`)
|
|
1058
|
+
// KWA-28 — an identifier node is VIRTUAL: derived per read, zero stored rows, so there is no
|
|
1059
|
+
// as_of to fetch. The item says exactly what to do in that case: "where the object is derived
|
|
1060
|
+
// per-read, stamp the read itself." Note this as-of means something DIFFERENT from every
|
|
1061
|
+
// other one in gate 3 — "this answer was computed now", not "this claim was true then" — and
|
|
1062
|
+
// the wording says so, because collapsing the two under one word is how a resolution that is
|
|
1063
|
+
// merely FRESH gets read as a claim that is VERIFIED. The home and the count are both live
|
|
1064
|
+
// computations over data that can change between two reads a minute apart.
|
|
1065
|
+
lines.push(`· Resolved ${new Date().toISOString().slice(0, 16).replace('T', ' ')}Z — this node is derived per read (no stored row), so this is when the answer was COMPUTED, not when anything was verified.`)
|
|
974
1066
|
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
975
1067
|
}
|
|
976
1068
|
if (r.status === 400) {
|
|
@@ -1011,13 +1103,9 @@ export async function runServer(version) {
|
|
|
1011
1103
|
const renderMatch = (m, tagBrain) => {
|
|
1012
1104
|
const blocks = m.tiers.map((t) => {
|
|
1013
1105
|
const secs = (t.sections ?? []).map((s) => {
|
|
1014
|
-
//
|
|
1015
|
-
// not
|
|
1016
|
-
|
|
1017
|
-
? '\n⚠ **Undated section — verify before relying on its claims.**\n'
|
|
1018
|
-
: ''
|
|
1019
|
-
const asOf = s.asOf ? ` · as of ${day(s.asOf)}` : ''
|
|
1020
|
-
return `### ${s.heading}${asOf}${currency}${s.body}`
|
|
1106
|
+
// Shared with project_status via renderSection — see its definition for why this is one
|
|
1107
|
+
// function and not two (they drifted; #558 fixed one of three surfaces).
|
|
1108
|
+
return renderSection(s, day)
|
|
1021
1109
|
}).join('\n\n')
|
|
1022
1110
|
// ADR-0018: a null version isn't "nothing to show" — it means this variant predates content-
|
|
1023
1111
|
// hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
|
|
@@ -1364,7 +1452,13 @@ export async function runServer(version) {
|
|
|
1364
1452
|
}
|
|
1365
1453
|
const red = Array.isArray(out.redLinks) && out.redLinks.length
|
|
1366
1454
|
? `\nRed-links now on this page: ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1367
|
-
|
|
1455
|
+
// KWA-26 — same advisory flag as `author`, on the path that actually gets used. Absent (not
|
|
1456
|
+
// false) from an older server means "no verdict computed", so say nothing rather than imply the
|
|
1457
|
+
// section is dated — the 0093 don't-impute rule.
|
|
1458
|
+
const undatedNote = out?.undated === true
|
|
1459
|
+
? `\n⚠ This section now carries no explicit calendar date. A reader can see WHEN the text was written but not when the claim was TRUE. If it asserts a status, add the date inline — you still hold the context. The edit already landed; this is advisory.`
|
|
1460
|
+
: ''
|
|
1461
|
+
return { content: [{ type: 'text', text: `Edited "${name}" (${out.brain} · ${out.tier} tier) § ${out.heading}. Only that passage changed; every other section is byte-identical. New version: ${out.version}${red}${undatedNote}` }] }
|
|
1368
1462
|
},
|
|
1369
1463
|
)
|
|
1370
1464
|
|
|
@@ -1644,15 +1738,34 @@ export async function runServer(version) {
|
|
|
1644
1738
|
'list_brain_pages',
|
|
1645
1739
|
{
|
|
1646
1740
|
title: 'List every authored page in one brain',
|
|
1647
|
-
description: '
|
|
1741
|
+
description: 'QUERY the authored pages in ONE brain, by its org id (from my_brains) — filter by owner, recency, tier, kind or name, and sort. Returns one row per node with its kind, validity, tier(s), owner(s), last-updated date and content hash. This is the structural counterpart to `grep`: use it when the question is a FILTER-AND-SORT ("what has X written this week", "which pages are confidential", "what is stale") and grep when you need to match WORDS inside page text. ⚠ `validity` defaults to `current`, so superseded pages are EXCLUDED unless you ask for them — a stale page presented as live is a failure this system keeps hitting. Also use it to VERIFY a brain-to-brain migration: list BOTH brains, diff the page sets, compare freshness before retiring any original. You can only list a brain you are a member of, and within it you see only the pages you are cleared to read — a confidential page owned by someone else is not listed, not even by title, and that holds for every filter combination including `owner`.',
|
|
1648
1742
|
inputSchema: {
|
|
1649
1743
|
org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
|
|
1744
|
+
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.'),
|
|
1745
|
+
updated_within_days: z.number().optional().describe('only pages touched in the last N days'),
|
|
1746
|
+
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.'),
|
|
1747
|
+
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('only nodes of this kind'),
|
|
1748
|
+
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."),
|
|
1749
|
+
name_contains: z.string().optional().describe('case-insensitive substring match on the node name or page title'),
|
|
1750
|
+
sort: z.enum(['recent', 'name']).optional().describe("default 'recent' (newest first). Ties break deterministically, so repeat calls are stable."),
|
|
1751
|
+
limit: z.number().optional().describe('default 50, capped at 500'),
|
|
1650
1752
|
},
|
|
1651
1753
|
},
|
|
1652
|
-
async ({ org_id }) => {
|
|
1754
|
+
async ({ org_id, owner, updated_within_days, tier, kind, validity, name_contains, sort, limit }) => {
|
|
1755
|
+
// Only send params the caller actually set: an omitted filter and an empty one are different
|
|
1756
|
+
// requests, and the route validates enums strictly rather than ignoring unknown values.
|
|
1757
|
+
const qs = new URLSearchParams({ orgId: org_id })
|
|
1758
|
+
if (owner) qs.set('owner', owner)
|
|
1759
|
+
if (updated_within_days != null) qs.set('updated_within_days', String(updated_within_days))
|
|
1760
|
+
if (tier) qs.set('tier', tier)
|
|
1761
|
+
if (kind) qs.set('kind', kind)
|
|
1762
|
+
if (validity) qs.set('validity', validity)
|
|
1763
|
+
if (name_contains) qs.set('name_contains', name_contains)
|
|
1764
|
+
if (sort) qs.set('sort', sort)
|
|
1765
|
+
if (limit != null) qs.set('limit', String(limit))
|
|
1653
1766
|
let res
|
|
1654
1767
|
try {
|
|
1655
|
-
res = await fetchCortex(`${BASE}/api/brains/pages
|
|
1768
|
+
res = await fetchCortex(`${BASE}/api/brains/pages?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
1656
1769
|
} catch (e) {
|
|
1657
1770
|
return toolError(`Could not list pages: ${e.message}`)
|
|
1658
1771
|
}
|
|
@@ -2622,7 +2735,7 @@ export async function runServer(version) {
|
|
|
2622
2735
|
{
|
|
2623
2736
|
title: 'Author a wiki node (live, while it is hot)',
|
|
2624
2737
|
description:
|
|
2625
|
-
'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. CREATES the node if it does not exist yet (project/person/org) — the conversation IS the evidence, so a brand-new entity that surfaced only in this session is authorable on the spot; you do NOT need prior records. Because such a node has nothing external to corroborate it, author it DELIBERATELY: only when you genuinely understand it is a real, distinct entity, and use its exact canonical name so it does not duplicate one already in the namespace (`user` nodes are never created). Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log). Authoring is PRE-AUTHORIZED — never ask the user "should I update the page?" before calling this (every edit is versioned + reversible via page_history/rollback_page); update, then briefly report what you updated. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, SO MAKE IT FROM THE CONTENT. Only a page that exists in NO brain needs this — an update resolves its brain from the page itself. If you hold more than one brain, the server REFUSES a create it cannot attribute (409 `create_needs_brain`) rather than letting the write pointer decide — the pointer is stale out-of-band state that knows nothing about what you are writing. So call `my_brains` (it returns each brain\'s name, page count and sample titles, which is enough to tell what each one is FOR) and pass `brain` up front; that turns a refused round trip into a single call. State which brain you picked and why in one short line, then proceed — do NOT ask when the answer is obvious from the content. DO ask when it is genuinely ambiguous: brains are a confidentiality boundary, so a page born in the wrong one can expose private work to a teammate, and that is not a filing error you can quietly fix later.',
|
|
2738
|
+
'Write your CURRENT understanding of a project/person/org/you into the org wiki as a maintained page. Call `authoring_context` FIRST. Author from your own synthesis of the session — the compiled mental model, not a transcript dump: what it IS, where it stands, dated decisions, open threads, key people. ⚠ EVERY STATUS CLAIM CARRIES AN EXPLICIT INLINE DATE (KWA-26): a sentence asserting what IS or IS NOT true right now — "X is live", "Y is not merged", "Z is blocked" — must say WHEN, in the prose, the way PRD items do. Section-level stamps are NOT enough: they record when the TEXT was written, so a section authored today can carry a six-week-old status claim and still read as current — exactly what made KWA-24 and TML-18 wrong. The response names any section that landed undated so you can fix it in-turn; it never blocks the write. Weave inline [[links]] to other nodes (canonical names from the namespace; red-links for wanted-but-absent nodes). The server re-authorizes the tier and resolves links. CREATES the node if it does not exist yet (project/person/org) — the conversation IS the evidence, so a brand-new entity that surfaced only in this session is authorable on the spot; you do NOT need prior records. Because such a node has nothing external to corroborate it, author it DELIBERATELY: only when you genuinely understand it is a real, distinct entity, and use its exact canonical name so it does not duplicate one already in the namespace (`user` nodes are never created). Use this continuously whenever your understanding of a node meaningfully advanced, and at session end (/log). Authoring is PRE-AUTHORIZED — never ask the user "should I update the page?" before calling this (every edit is versioned + reversible via page_history/rollback_page); update, then briefly report what you updated. ⚠ WHICH BRAIN A NEW PAGE GOES IN IS A CONTENT DECISION, SO MAKE IT FROM THE CONTENT. Only a page that exists in NO brain needs this — an update resolves its brain from the page itself. If you hold more than one brain, the server REFUSES a create it cannot attribute (409 `create_needs_brain`) rather than letting the write pointer decide — the pointer is stale out-of-band state that knows nothing about what you are writing. So call `my_brains` (it returns each brain\'s name, page count and sample titles, which is enough to tell what each one is FOR) and pass `brain` up front; that turns a refused round trip into a single call. State which brain you picked and why in one short line, then proceed — do NOT ask when the answer is obvious from the content. DO ask when it is genuinely ambiguous: brains are a confidentiality boundary, so a page born in the wrong one can expose private work to a teammate, and that is not a filing error you can quietly fix later.',
|
|
2626
2739
|
inputSchema: {
|
|
2627
2740
|
kind: z.enum(['project', 'person', 'org', 'user']).describe('the node type'),
|
|
2628
2741
|
name: z.string().describe('the canonical node name — an EXACT existing name from the namespace to update it, or a new name to create the node (project/person/org). e.g. "Agnoclast" or "Theron Peterson"'),
|
|
@@ -2698,8 +2811,27 @@ export async function runServer(version) {
|
|
|
2698
2811
|
` If that is not the tier you meant, read_page and check which copy you just changed —` +
|
|
2699
2812
|
` a page can exist at several tiers and they drift apart independently.`
|
|
2700
2813
|
: ''
|
|
2814
|
+
// KWA-26 — the advisory undated flag. The server has computed `undatedSections` since #558 and
|
|
2815
|
+
// the route has returned it ever since; NOTHING PRINTED IT, so the one consumer the item names
|
|
2816
|
+
// never saw it: "the author path returns a flag naming undated status claims SO THE AGENT FIXES
|
|
2817
|
+
// THEM IN-TURN." A flag the agent cannot see does not exist. Same shape as tierCorrections
|
|
2818
|
+
// directly above — computed, returned, and silently dropped at the client — and the fourth
|
|
2819
|
+
// instance of it in this subsystem.
|
|
2820
|
+
//
|
|
2821
|
+
// Advisory by DESIGN, not by omission: the write has already landed by the time this prints
|
|
2822
|
+
// (2026-07-28, option (b) "make this blocking" was weighed and rejected — rejecting the write
|
|
2823
|
+
// would lose the session's understanding, the more expensive failure). So this is phrased as
|
|
2824
|
+
// work the agent can do NOW, while the context is still hot, which is the only moment the fix
|
|
2825
|
+
// is cheap.
|
|
2826
|
+
const undated = Array.isArray(out?.undatedSections) && out.undatedSections.length
|
|
2827
|
+
? `\n⚠ Undated (${out.undatedSections.length}): ${out.undatedSections.map((h) => `"${h}"`).join(', ')}.` +
|
|
2828
|
+
` These landed with no explicit calendar date in the heading or body, so a reader cannot tell` +
|
|
2829
|
+
` WHEN the claim was true — only when the text was last written. If any of them assert a` +
|
|
2830
|
+
` STATUS ("X is live", "Y is not merged"), add the date inline with edit_page while you still` +
|
|
2831
|
+
` hold the context. The write already landed; this is advisory.`
|
|
2832
|
+
: ''
|
|
2701
2833
|
const verb = out?.created ? 'Created + authored' : 'Authored'
|
|
2702
|
-
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${corrections}${retiredList}${redList}${stamps}`
|
|
2834
|
+
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}`
|
|
2703
2835
|
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.${corrections}`
|
|
2704
2836
|
return { content: [{ type: 'text', text: note }] }
|
|
2705
2837
|
},
|