@theronap/cortex-mcp 0.9.86 → 0.9.87

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.
@@ -50,9 +50,8 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
50
50
  ` doctor live health check — confirm your token works (no restart needed)\n` +
51
51
  ` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
52
52
  ` use-brain [<brain>] where your session captures are saved — no arg shows the current setting\n` +
53
- ` skills [sync] install/repair bundled, org, and private cross-editor skills\n` +
54
- ` skills import --from claude import all local Claude Code skills into your private library\n` +
55
- ` skills push <file> [--brain <name>] publish a SKILL.md to your org (owner/manager/admin)\n` +
53
+ ` skills install/repair the managed Agnoclast skills — bundled + org-published (also wired by setup)\n` +
54
+ ` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
56
55
  ` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
57
56
  ` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
58
57
  ` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
@@ -70,7 +70,7 @@ export default {
70
70
  promptTimeInjection: true,
71
71
  sessionStart: false,
72
72
  captureHook: true,
73
- skillDir: '~/.codex/skills',
73
+ skillDir: null,
74
74
  docTrigger: 'stop-hook',
75
75
  },
76
76
 
@@ -32,7 +32,7 @@ export default {
32
32
  promptTimeInjection: false,
33
33
  sessionStart: false,
34
34
  captureHook: true, // ~/.cursor/hooks/cortex-cursor.mjs
35
- skillDir: '~/.cursor/skills', // Flat SKILL.md directory verified on the supported Cursor build.
35
+ skillDir: null, // Cursor rules/commands dir revisit in Pillar 2
36
36
  docTrigger: 'hook',
37
37
  },
38
38
 
package/lib/server.mjs CHANGED
@@ -223,6 +223,61 @@ export async function runServer(version) {
223
223
  },
224
224
  )
225
225
 
226
+ server.registerTool(
227
+ 'maintenance_candidates',
228
+ {
229
+ title: 'Review recent project-linked maintenance evidence',
230
+ description: 'Start here when preparing a handoff, status, or next step for a NAMED project. Returns a bounded, RLS-scoped set of recent raw records already linked to that project, before you trust the authored page. These are candidate evidence, not a command to edit: read them, call project_status, then decide whether a material contradiction warrants a minimal accountable correction. When correcting, retain the decisive factual qualifier (for example completion date or state) in the current-status text. Never mutate merely to clear a candidate.',
231
+ inputSchema: {
232
+ project: z.string().describe('the project key or exact project name from the task, e.g. "checkout-v2" or "Checkout v2"'),
233
+ brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
234
+ since_days: z.number().optional().describe('how far back to inspect (1-90 days, default 30)'),
235
+ limit: z.number().optional().describe('max candidate records (1-50, default 20)'),
236
+ },
237
+ },
238
+ async ({ project, brain, since_days, limit }) => {
239
+ const qs = new URLSearchParams({ project })
240
+ if (brain) qs.set('brain', brain)
241
+ if (since_days != null) qs.set('since_days', String(since_days))
242
+ if (limit != null) qs.set('limit', String(limit))
243
+ const res = await fetchCortex(`${BASE}/api/maintenance/candidates?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
244
+ if (!res.ok) {
245
+ const body = await res.text()
246
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
247
+ }
248
+ const { text } = await res.json()
249
+ return { content: [{ type: 'text', text }] }
250
+ },
251
+ )
252
+
253
+ server.registerTool(
254
+ 'gate3_status',
255
+ {
256
+ title: 'Gate 3 currency monitor',
257
+ description: 'Read the aggregate-only Gate 3 currency-monitor status for this brain. It counts explicit maintenance-candidate reviews and their same-session durable corrections in the rolling window; it never exposes project names, evidence content, or session keys. "machine_evidence_ready" means enough ordinary-work evidence has accumulated to request a human closure decision, not that the monitor closes the gate itself.',
258
+ inputSchema: {
259
+ days: z.number().optional().describe('rolling window in days (1-90, default 14)'),
260
+ brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
261
+ },
262
+ },
263
+ async ({ days, brain }) => {
264
+ const qs = new URLSearchParams()
265
+ if (days != null) qs.set('days', String(days))
266
+ if (brain) qs.set('brain', brain)
267
+ const suffix = qs.size ? `?${qs}` : ''
268
+ const res = await fetchCortex(`${BASE}/api/gates/3/status${suffix}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
269
+ if (!res.ok) {
270
+ const body = await res.text()
271
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
272
+ }
273
+ const status = await res.json()
274
+ const text = status.machineEvidenceReady
275
+ ? `Gate 3 machine evidence is ready: ${status.maintenanceReviewSessions} independent maintenance-review sessions and ${status.correctionSessions} correction sessions in ${status.days} days. A human should confirm these were ordinary work before closing the gate.`
276
+ : `Gate 3 is still collecting evidence: ${status.maintenanceReviewSessions}/${status.requiredReviewSessions} independent maintenance-review sessions and ${status.correctionSessions}/${status.requiredCorrectionSessions} correction sessions in ${status.days} days. No gate decision has been made.`
277
+ return { content: [{ type: 'text', text }] }
278
+ },
279
+ )
280
+
226
281
  server.registerTool(
227
282
  'session_context',
228
283
  {
@@ -333,6 +388,122 @@ export async function runServer(version) {
333
388
  },
334
389
  )
335
390
 
391
+ // ── Gate 4 private intake (Slice 3) ──────────────────────────────────────────────────────────
392
+ server.registerTool(
393
+ 'intake_changes',
394
+ {
395
+ title: 'Check private intake changes',
396
+ description:
397
+ '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. If cleanupDueCount > 0, call intake_claim with claimKind=cleanup before ordinary work.',
398
+ inputSchema: {
399
+ afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
400
+ limit: z.number().optional().describe('max change rows (default 100)'),
401
+ },
402
+ },
403
+ async ({ afterSeq, limit }) => {
404
+ const params = new URLSearchParams()
405
+ if (typeof afterSeq === 'number') params.set('afterSeq', String(afterSeq))
406
+ if (typeof limit === 'number') params.set('limit', String(limit))
407
+ const qs = params.toString() ? `?${params}` : ''
408
+ const res = await fetchCortex(`${BASE}/api/intake/changes${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
409
+ if (!res.ok) {
410
+ const body = await res.text()
411
+ if (res.status === 403) return toolError('Private intake is not enabled for this account.')
412
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
413
+ }
414
+ return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
415
+ },
416
+ )
417
+
418
+ server.registerTool(
419
+ 'intake_claim',
420
+ {
421
+ title: 'Claim private intake items',
422
+ description:
423
+ 'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. If cleanup is due, relevance claims return 409 — process cleanup first. Requires x-cortex-session-key (set automatically by this MCP server).',
424
+ inputSchema: {
425
+ claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
426
+ limit: z.number().optional().describe('max items (default 10)'),
427
+ },
428
+ },
429
+ async ({ claimKind, limit }) => {
430
+ const res = await fetchCortex(`${BASE}/api/intake/claim`, {
431
+ method: 'POST',
432
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
433
+ body: JSON.stringify({ claimKind: claimKind ?? 'relevance', limit, includePayload: true }),
434
+ })
435
+ if (!res.ok) {
436
+ const body = await res.text()
437
+ if (res.status === 403) return toolError('Private intake is not enabled for this account.')
438
+ if (res.status === 409) return toolError(body)
439
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
440
+ }
441
+ return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
442
+ },
443
+ )
444
+
445
+ server.registerTool(
446
+ 'intake_materialize',
447
+ {
448
+ title: 'Materialize a private intake item',
449
+ description:
450
+ 'Atomically publish a claimed intake item into one brain. Deterministic identifier homes in that brain are always attached; documentIds may add further pages. Sets record confidentiality to the strictest attached page tier. Never writes private intake into search/history before this call.',
451
+ inputSchema: {
452
+ intakeItemId: z.string().describe('intake item uuid'),
453
+ orgId: z.string().describe('destination brain org uuid'),
454
+ documentIds: z.array(z.string()).optional().describe('additional brain_documents ids in orgId (deterministic homes are merged automatically)'),
455
+ title: z.string().optional(),
456
+ summary: z.string().optional(),
457
+ source: z.string().optional(),
458
+ recordType: z.string().optional(),
459
+ dedupeKey: z.string().optional(),
460
+ origin: z.enum(['deterministic', 'llm', 'user']).optional(),
461
+ },
462
+ },
463
+ async (args) => {
464
+ const res = await fetchCortex(`${BASE}/api/intake/materialize`, {
465
+ method: 'POST',
466
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
467
+ body: JSON.stringify({
468
+ intakeItemId: args.intakeItemId,
469
+ orgId: args.orgId,
470
+ documentIds: args.documentIds ?? [],
471
+ record: {
472
+ title: args.title,
473
+ summary: args.summary,
474
+ source: args.source,
475
+ record_type: args.recordType,
476
+ dedupe_key: args.dedupeKey,
477
+ },
478
+ attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
479
+ }),
480
+ })
481
+ if (!res.ok) {
482
+ const body = await res.text()
483
+ return toolError(body)
484
+ }
485
+ return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
486
+ },
487
+ )
488
+
489
+ server.registerTool(
490
+ 'intake_cleanup_status',
491
+ {
492
+ title: 'Private intake cleanup status',
493
+ description: 'Counts of pending/claimed/awaiting/cleanup-due intake items plus open clarifying questions for the owner.',
494
+ inputSchema: {},
495
+ },
496
+ async () => {
497
+ const res = await fetchCortex(`${BASE}/api/intake/cleanup-status`, { headers: { Authorization: `Bearer ${TOKEN}` } })
498
+ if (!res.ok) {
499
+ const body = await res.text()
500
+ if (res.status === 403) return toolError('Private intake is not enabled for this account.')
501
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
502
+ }
503
+ return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
504
+ },
505
+ )
506
+
336
507
  server.registerTool(
337
508
  'search_org',
338
509
  {
@@ -1451,6 +1622,174 @@ export async function runServer(version) {
1451
1622
  },
1452
1623
  )
1453
1624
 
1625
+ server.registerTool(
1626
+ 'set_routing_identifier',
1627
+ {
1628
+ title: 'Claim a routing identifier on a page',
1629
+ description: 'Declare that a page is a Gate 4 attach HOME for an identifier (repo:owner/name or file:owner/name:path). Body [[repo:…]] stamps are navigation only and do NOT drive attach — use this instead. Prefer the narrowest id: mother pages own repo:; feature pages own file: paths. Do not copy every body mention into a routing claim.',
1630
+ inputSchema: {
1631
+ kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
1632
+ name: z.string().optional().describe('page title (or pass ref)'),
1633
+ ref: z.string().optional().describe('node ref from read_page — prefer over name when available'),
1634
+ brain: z.string().optional().describe('brain label when the title is ambiguous across brains'),
1635
+ identifier: z.string().describe('canonical identifier, e.g. repo:theronap/cortex or file:theronap/cortex:web/lib/engine/github_intake.ts'),
1636
+ },
1637
+ },
1638
+ async ({ kind, name, ref, brain, identifier }) => {
1639
+ if (!name && !ref) return toolError('Pass name or ref')
1640
+ let res
1641
+ try {
1642
+ res = await fetchCortex(`${BASE}/api/brain/routing-identifiers`, {
1643
+ method: 'POST',
1644
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1645
+ body: JSON.stringify({ kind, name, ref, brain, identifier }),
1646
+ })
1647
+ } catch (e) {
1648
+ return toolError(`Could not set routing identifier: ${e.message}`)
1649
+ }
1650
+ const out = await res.json().catch(() => null)
1651
+ if (!res.ok) return toolError(`Could not set routing identifier: ${out?.error ?? res.status}`)
1652
+ const set = out?.set?.join(', ') ?? identifier
1653
+ return { content: [{ type: 'text', text: `Routing identifier set on document ${out?.documentId ?? '?'}: ${set}. Future matching events will attach here (body mentions alone will not).` }] }
1654
+ },
1655
+ )
1656
+
1657
+ // ── Gate 4 record triage ──────────────────────────────────────────────────────────────────
1658
+ // A connector event materializes in seconds and has no idea what the work WAS. The session that
1659
+ // did the work knows exactly, and arrives later. These three tools are that handoff: look at what
1660
+ // landed, claim what is yours, route it when you know where it goes.
1661
+
1662
+ server.registerTool(
1663
+ 'pending_records',
1664
+ {
1665
+ title: 'Records waiting for a home',
1666
+ description: 'List connector records (GitHub pushes, PRs, email) that landed WITHOUT a confident home and are waiting for judgment. Check this when your session starts if the headline count sounds related to what you are about to work on — records from your own recent commits are usually in here. Returns titles and current homes only, never payloads. Use view "sweep" on one record to get graded page-name candidates without reading any pages.',
1667
+ inputSchema: {
1668
+ view: z.enum(['digest', 'sweep']).optional().describe('digest = what is waiting (default); sweep = cheap graded candidates for one record'),
1669
+ recordId: z.string().optional().describe('required for view "sweep"'),
1670
+ hours: z.number().int().positive().optional().describe('lookback window, default 3'),
1671
+ stale: z.boolean().optional().describe('include old unclaimed records — the cleanup pile, not the live one'),
1672
+ limit: z.number().int().positive().optional(),
1673
+ },
1674
+ },
1675
+ async ({ view, recordId, hours, stale, limit }) => {
1676
+ const params = new URLSearchParams()
1677
+ params.set('view', view === 'sweep' ? 'sweep' : 'digest')
1678
+ if (recordId) params.set('recordId', recordId)
1679
+ if (hours) params.set('hours', String(hours))
1680
+ if (stale) params.set('stale', '1')
1681
+ if (limit) params.set('limit', String(limit))
1682
+
1683
+ let res
1684
+ try {
1685
+ res = await fetchCortex(`${BASE}/api/brain/triage?${params}`, {
1686
+ headers: { Authorization: `Bearer ${TOKEN}` },
1687
+ })
1688
+ } catch (e) {
1689
+ return toolError(`Could not read pending records: ${e.message}`)
1690
+ }
1691
+ const out = await res.json().catch(() => null)
1692
+ if (!res.ok) return toolError(`Could not read pending records: ${out?.error ?? res.status}`)
1693
+
1694
+ if (view === 'sweep') {
1695
+ const cands = out?.candidates ?? []
1696
+ if (cands.length === 0) {
1697
+ return { content: [{ type: 'text', text: `No page-name candidates for "${out?.title ?? recordId}". Park it — a stale record with no match is not worth reading pages over.` }] }
1698
+ }
1699
+ const lines = cands.map((c) => ` ${c.strength === 'strong' ? '●' : '○'} ${c.title} (${c.strength}) — ${c.documentId}`)
1700
+ const rec = out?.recommendation
1701
+ return {
1702
+ content: [{
1703
+ type: 'text',
1704
+ text: `Candidates for "${out?.title}":\n${lines.join('\n')}\n\nRecommended: ${rec?.action} — ${rec?.why}\n● strong = titles contain each other, safe to route. ○ weak = one generic word matched; read those pages ONLY if this record is worth the tokens, otherwise park.`,
1705
+ }],
1706
+ }
1707
+ }
1708
+
1709
+ const records = out?.records ?? []
1710
+ if (records.length === 0) {
1711
+ return { content: [{ type: 'text', text: 'Nothing waiting for a home.' }] }
1712
+ }
1713
+ const lines = records.map((r) => {
1714
+ const homes = r.currentHomes?.length ? r.currentHomes.join(', ') : 'nothing'
1715
+ const held = r.claimedBySession ? ` [claimed: ${String(r.claimedBySession).slice(0, 12)}…]` : ''
1716
+ return ` • ${r.title}\n ${r.source} · ${r.ageHours}h ago · on: ${homes}${held}\n ${r.recordId}`
1717
+ })
1718
+ return {
1719
+ content: [{
1720
+ type: 'text',
1721
+ text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs.`,
1722
+ }],
1723
+ }
1724
+ },
1725
+ )
1726
+
1727
+ server.registerTool(
1728
+ 'claim_record',
1729
+ {
1730
+ title: 'Claim a pending record as your work',
1731
+ description: 'Say "this record is mine, I will route it once I know where it goes." Use it as soon as you recognize your own work in pending_records, even before you know the destination page — the claim stops a cheap automatic sweep from guessing at something you have real context on. Claims expire, so a dead session never holds a record hostage. Pass release=true to give one back when it turns out not to be yours.',
1732
+ inputSchema: {
1733
+ recordId: z.string().describe('record id from pending_records'),
1734
+ note: z.string().optional().describe('what you think this is — kept for audit'),
1735
+ leaseMinutes: z.number().int().positive().optional().describe('how long you need it, default 90'),
1736
+ release: z.boolean().optional().describe('give the claim back instead of taking it'),
1737
+ },
1738
+ },
1739
+ async ({ recordId, note, leaseMinutes, release }) => {
1740
+ let res
1741
+ try {
1742
+ res = await fetchCortex(`${BASE}/api/brain/triage`, {
1743
+ method: 'POST',
1744
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1745
+ body: JSON.stringify({ action: release ? 'release' : 'claim', recordId, note, leaseMinutes }),
1746
+ })
1747
+ } catch (e) {
1748
+ return toolError(`Could not claim record: ${e.message}`)
1749
+ }
1750
+ const out = await res.json().catch(() => null)
1751
+ if (!res.ok) {
1752
+ if (out?.error === 'already_claimed') {
1753
+ return toolError(`Another live session is already holding that record (${String(out.heldBy).slice(0, 16)}…). Leave it to them.`)
1754
+ }
1755
+ return toolError(`Could not claim record: ${out?.error ?? res.status}`)
1756
+ }
1757
+ if (release) return { content: [{ type: 'text', text: 'Released — it is back in the pending pool.' }] }
1758
+ return { content: [{ type: 'text', text: `Claimed until ${out?.expiresAt ?? 'the lease expires'}. Call route_record when you know where it belongs.` }] }
1759
+ },
1760
+ )
1761
+
1762
+ server.registerTool(
1763
+ 'route_record',
1764
+ {
1765
+ title: 'Route a record to the pages it belongs on',
1766
+ description: 'Attach a pending record to the pages you judge correct — the point of the whole triage path. Use this when you have real context on what the work was; that judgment is better than any rule the webhook could run. Attachments are additive: existing deterministic homes (routing identifiers, your profile) stay. Pass park=true instead when you have looked and there is genuinely no good home — parking beats attaching to a page that merely shares a word.',
1767
+ inputSchema: {
1768
+ recordId: z.string().describe('record id from pending_records'),
1769
+ documentIds: z.array(z.string()).optional().describe('page document ids to attach (from pending_records sweep, or read_page)'),
1770
+ reason: z.string().describe('why these pages — recorded with the attachment'),
1771
+ park: z.boolean().optional().describe('no good home exists; leave it alone rather than guessing'),
1772
+ tier: z.number().int().optional().describe('2 when this came from the cheap sweep rather than your own context'),
1773
+ },
1774
+ },
1775
+ async ({ recordId, documentIds, reason, park, tier }) => {
1776
+ let res
1777
+ try {
1778
+ res = await fetchCortex(`${BASE}/api/brain/triage`, {
1779
+ method: 'POST',
1780
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1781
+ body: JSON.stringify({ action: park ? 'park' : 'route', recordId, documentIds, reason, tier }),
1782
+ })
1783
+ } catch (e) {
1784
+ return toolError(`Could not route record: ${e.message}`)
1785
+ }
1786
+ const out = await res.json().catch(() => null)
1787
+ if (!res.ok) return toolError(`Could not route record: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
1788
+ if (park) return { content: [{ type: 'text', text: 'Parked. It stays visible and unrouted rather than badly attached.' }] }
1789
+ return { content: [{ type: 'text', text: `Routed — attached to ${out?.attached?.length ?? 0} page(s). Recorded as a session judgment, not a rule match.` }] }
1790
+ },
1791
+ )
1792
+
1454
1793
  server.registerTool(
1455
1794
  'snooze_red_link',
1456
1795
  {
@@ -1699,7 +2038,7 @@ export async function runServer(version) {
1699
2038
  'request_person_page_merge',
1700
2039
  {
1701
2040
  title: 'Offer one duplicate person page for merge',
1702
- description: 'Offer YOUR accessible person page to be merged into another current accessible person page in the SAME named brain. Use only when the two pages are unquestionably the same real person. Read both pages first and pass their refs and versions. This never crosses brains and does not use fuzzy matching.',
2041
+ description: 'Offer YOUR accessible person NODE for full merge into another current accessible person node in the SAME named brain. Use only when the two nodes are unquestionably the same real person. Read both pages first and pass their refs and versions. This never crosses brains and does not use fuzzy matching.',
1703
2042
  inputSchema: {
1704
2043
  brain: z.string().describe('the exact brain name or brain id; pass an id if names collide'),
1705
2044
  source_ref: z.string().describe('ref of YOUR duplicate source person page'),
@@ -1727,7 +2066,7 @@ export async function runServer(version) {
1727
2066
  'person_page_merge_requests',
1728
2067
  {
1729
2068
  title: 'Review duplicate-person page merge requests',
1730
- description: 'List pending same-brain person-page merges where you own the canonical page. Read both pages before applying. Applying copies every source summary and section verbatim into the canonical page, preserves its links and as-of dates, and supersedes rather than deletes the source page.',
2069
+ description: 'List pending same-brain person-node merges where you own the canonical page. Read both pages before applying. Applying preserves source page prose/history, moves its raw person attachments (mentions, actor evidence, identifiers, aliases and graph edges) onto the canonical node, and leaves a hidden source alias plus redirect. It refuses conflicting private variants rather than widening them.',
1731
2070
  inputSchema: {},
1732
2071
  },
1733
2072
  async () => {
@@ -1746,7 +2085,7 @@ export async function runServer(version) {
1746
2085
  'apply_person_page_merge',
1747
2086
  {
1748
2087
  title: 'Apply a reviewed duplicate-person page merge',
1749
- description: 'Apply a pending same-brain person-page merge you own. Re-read the canonical page immediately before applying and pass its version. This is preservation-first: all source summary/sections are retained verbatim in the canonical page, the source page is superseded (not deleted), and its old link target redirects to the canonical page.',
2088
+ description: 'Apply a pending full same-brain person-node merge you own. Re-read the canonical page immediately before applying and pass its version. This is atomic and preservation-first: source prose/history is retained, raw evidence and connections are re-pointed to the canonical node, identifiers are unioned, and the source becomes a hidden alias with a durable redirect. A privacy or identity-metadata conflict refuses the whole merge.',
1750
2089
  inputSchema: {
1751
2090
  request_id: z.string().describe('id from person_page_merge_requests'),
1752
2091
  target_version: z.string().describe('current canonical-page version from a fresh read'),
@@ -1763,7 +2102,7 @@ export async function runServer(version) {
1763
2102
  } catch (e) { return toolError(`Could not apply page merge: ${e.message}`) }
1764
2103
  const out = await res.json().catch(() => null)
1765
2104
  if (!res.ok) return toolError(`Could not apply page merge: ${out?.error ?? res.status}`)
1766
- return { content: [{ type: 'text', text: `Merged "${out.sourceTitle}" into "${out.targetTitle}". Preserved ${out.preservedSections} source block(s); source page is superseded, not deleted.` }] }
2105
+ return { content: [{ type: 'text', text: `Fully merged "${out.sourceTitle}" into "${out.targetTitle}". Preserved ${out.preservedSections} source page block(s) and moved ${out.movedAttachments} raw attachment(s); source is now a hidden alias with a durable redirect.` }] }
1767
2106
  },
1768
2107
  )
1769
2108
 
package/lib/setup.mjs CHANGED
@@ -141,16 +141,15 @@ export async function runSetup(argv, version) {
141
141
  process.exit(1)
142
142
  }
143
143
 
144
- // ── 3. Managed skills — installed flat into every compatible editor ──────────────────────────
145
- // Bundled first (sync, network-free), then org + private served sets (fail-soft pulls), so a
146
- // fresh seat has its skills at first session, not second. CORTEX_TOKEN is in env for the pull
147
- // only if the caller exported it; each sync falls back to the token this setup just wired.
144
+ // ── 3. Managed skills — installed flat into every agent CLI present (Claude + Codex) ──
145
+ // Bundled first (sync, network-free), then the org-published set (fail-soft pull), so a fresh
146
+ // seat has its org's skills at first session, not second. CORTEX_TOKEN is in env for the pull
147
+ // only if the caller exported it; syncOrgSkills falls back to the token this setup just wired.
148
148
  try {
149
149
  installSkills({ quiet: false })
150
- const { syncOrgSkills, syncPersonalSkills } = await import('./skills.mjs')
150
+ const { syncOrgSkills } = await import('./skills.mjs')
151
151
  process.env.CORTEX_TOKEN = process.env.CORTEX_TOKEN || token
152
152
  await syncOrgSkills({ quiet: false })
153
- await syncPersonalSkills({ quiet: false })
154
153
  } catch (e) {
155
154
  // Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
156
155
  log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
package/lib/skills.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'
5
5
 
6
6
  // Managed Agnoclast skills.
7
7
  //
8
- // Three sources, one install pipeline:
8
+ // Two sources, one install pipeline:
9
9
  // 1. BUNDLED — shipped inside this package (cortex-log, cortex-context, cortex-author-docs).
10
10
  // Inalterable: canonical content always wins; user edits are backed up + restored.
11
11
  // 2. ORG-PUBLISHED — rows in the org's `org_skills` table (documentation-ingestion-spec.md
@@ -13,9 +13,6 @@ import { fileURLToPath } from 'url'
13
13
  // SessionStart `skills --repair` hook pulls + installs it here. Bundled names WIN collisions,
14
14
  // so an org can never shadow a core skill. The pull is fail-soft (offline → last-good cache
15
15
  // at ~/.cortex/org-skills-cache.json → skip), because SessionStart must never break.
16
- // 3. PERSONAL — rows in `personal_skills`, keyed by auth_id. These are private to one person and
17
- // are projected into every compatible editor on their machines. Personal wins an org collision
18
- // on that person's machine; bundled core still wins every collision.
19
16
  //
20
17
  // Installed into EVERY agent CLI present on the machine, at the flat layout each one discovers:
21
18
  // ~/.claude/skills/<name>/SKILL.md (Claude Code)
@@ -33,16 +30,13 @@ import { fileURLToPath } from 'url'
33
30
  const HERE = dirname(fileURLToPath(import.meta.url))
34
31
  const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
35
32
  const ORG_CACHE = join(homedir(), '.cortex', 'org-skills-cache.json')
36
- const PERSONAL_CACHE = join(homedir(), '.cortex', 'personal-skills-cache.json')
37
33
  const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ // mirrors the org_skills check; also blocks path tricks
38
34
 
39
- // Editor skill roots with the compatible flat SKILL.md discovery layout. Cursor's path was verified
40
- // on-machine before declaring it supported; Antigravity has no documented skill surface yet, so we
41
- // deliberately do not fabricate one.
35
+ // Agent CLIs we install skills into. Claude is primary; Codex is included whenever it's present.
36
+ // Both use the same flat <cli>/skills/<name>/SKILL.md discovery layout.
42
37
  const CLIS = [
43
38
  { id: 'Claude Code', dir: join(homedir(), '.claude') },
44
39
  { id: 'Codex', dir: join(homedir(), '.codex') },
45
- { id: 'Cursor', dir: join(homedir(), '.cursor') },
46
40
  ]
47
41
 
48
42
  // djb2 — tiny, dependency-free content fingerprint for the manifest (drift detection, not security).
@@ -202,25 +196,6 @@ export function planOrgInstall(served, bundledNames) {
202
196
  return { install, skipped }
203
197
  }
204
198
 
205
- // Personal skills share the same validation and bundled-collision rule as org skills, but never use
206
- // the cross-brain collision rule: their auth_id uniqueness means only one personal body can exist per
207
- // name. Keeping this pure makes the precedence contract testable without touching a real home dir.
208
- export function planPersonalInstall(served, bundledNames) {
209
- const bundled = new Set(bundledNames)
210
- const install = []
211
- const skipped = []
212
- const seen = new Set()
213
- for (const s of served ?? []) {
214
- const name = (s?.name ?? '').trim()
215
- if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
216
- if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
217
- if (seen.has(name)) { skipped.push({ name, why: 'duplicate personal skill' }); continue }
218
- seen.add(name)
219
- install.push({ name, source: s.body_md })
220
- }
221
- return { install, skipped }
222
- }
223
-
224
199
  /**
225
200
  * Pull the org's published skills and install them beside the bundled ones. FAIL-SOFT by design:
226
201
  * no token → skip; fetch failure → last-good cache; nothing → skip. SessionStart must never break.
@@ -261,10 +236,7 @@ export async function syncOrgSkills(opts = {}) {
261
236
 
262
237
  const { install, skipped } = planOrgInstall(served, bundledSkills().map((s) => s.name))
263
238
  summary.skipped = skipped
264
- // Only a cache written by this personal-sync implementation owns local files it may retire. An
265
- // older/cache-shaped-alike file is not proof of ownership; treating it as one deleted source
266
- // skills that had not yet been accepted by the server. First sync after an upgrade is additive.
267
- const prevNames = cache?.version === 1 && Array.isArray(cache?.installed) ? cache.installed : []
239
+ const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
268
240
  const currentNames = install.map((s) => s.name)
269
241
  const removeNames = prevNames.filter((n) => !currentNames.includes(n))
270
242
 
@@ -293,76 +265,6 @@ export async function syncOrgSkills(opts = {}) {
293
265
  return summary
294
266
  }
295
267
 
296
- /**
297
- * Pull the caller's personal skill library and project it into every compatible local editor.
298
- * It is deliberately fail-soft for the same reason as org sync: a network outage must not break a
299
- * session start. A personal skill wins an unpinned org skill by being installed after org sync.
300
- */
301
- export async function syncPersonalSkills(opts = {}) {
302
- const quiet = !!opts.quiet
303
- const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
304
- const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
305
- const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
306
- const token = process.env.CORTEX_TOKEN || readWiredToken()
307
- if (!token) { log(' · personal skills: no token wired — skipped'); return summary }
308
-
309
- let cache = null
310
- try { cache = JSON.parse(readFileSync(PERSONAL_CACHE, 'utf8')) } catch { /* none */ }
311
- let served = null
312
- try {
313
- const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/personal-skills`, {
314
- headers: { Authorization: `Bearer ${token}` },
315
- })
316
- if (res.ok) {
317
- served = (await res.json())?.skills ?? []
318
- summary.source = 'server'
319
- } else {
320
- log(` · personal skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
321
- }
322
- } catch { /* fall through to cache */ }
323
- if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
324
- if (!served) { log(' · personal skills: unreachable and no cache — skipped'); return summary }
325
-
326
- const bundledNames = bundledSkills().map((s) => s.name)
327
- const { install, skipped } = planPersonalInstall(served, bundledNames)
328
- summary.skipped = skipped
329
- const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
330
- const currentNames = install.map((s) => s.name)
331
- const retiredNames = prevNames.filter((n) => !currentNames.includes(n))
332
-
333
- // If a personal override was disabled, put back a cached org skill of that name rather than
334
- // deleting it. The normal sync sequence has already refreshed ORG_CACHE before this function.
335
- let orgCache = null
336
- try { orgCache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* no org fallback */ }
337
- const { install: orgInstall } = planOrgInstall(orgCache?.skills, bundledNames)
338
- const orgByName = new Map(orgInstall.map((s) => [s.name, s]))
339
- const fallback = retiredNames.map((name) => orgByName.get(name)).filter(Boolean)
340
- const removeNames = retiredNames.filter((name) => !orgByName.has(name))
341
-
342
- for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
343
- // Fallbacks first, personal second: personal > org, but bundled was installed before either.
344
- const r = installInto(join(cli.dir, 'skills'), [...fallback, ...install], { removeNames })
345
- summary.installed.push(...r.installed)
346
- summary.repaired.push(...r.repaired)
347
- summary.removed.push(...r.removed)
348
- }
349
- if (summary.source === 'server') {
350
- ensureDir(dirname(PERSONAL_CACHE))
351
- writeFileSync(PERSONAL_CACHE, JSON.stringify({ version: 1, fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
352
- }
353
-
354
- const changed = [...new Set([...summary.installed, ...summary.repaired])]
355
- if (changed.length || summary.removed.length) {
356
- const bits = []
357
- if (changed.length) bits.push(`synced ${changed.join(', ')}`)
358
- if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
359
- process.stdout.write(`Agnoclast: personal skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
360
- } else if (!quiet) {
361
- log(` ✓ personal skills up to date (${currentNames.length} private${skipped.length ? `, ${skipped.length} skipped` : ''})`)
362
- }
363
- return summary
364
- }
365
-
366
268
  // Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
367
269
  // body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
368
270
  // the caller can choose "by what each one HOLDS, not by which name sounds related". This function
@@ -453,83 +355,10 @@ export async function runSkillsPush(argv) {
453
355
  }
454
356
  }
455
357
 
456
- export function discoverSkillFiles(root) {
457
- if (!root || !existsSync(root)) return []
458
- const out = []
459
- const walk = (dir) => {
460
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
461
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue
462
- const child = join(dir, entry.name)
463
- const skill = join(child, 'SKILL.md')
464
- if (existsSync(skill)) out.push(skill)
465
- walk(child)
466
- }
467
- }
468
- walk(root)
469
- // Prefer a direct skill over an equally named nested vendor copy. This gives a user override
470
- // priority while still discovering bundled sub-skills such as gstack's long-form workflows.
471
- return out.sort((a, b) => {
472
- const depth = (path) => path.slice(root.length).split('/').filter(Boolean).length
473
- return depth(a) - depth(b) || a.localeCompare(b)
474
- })
475
- }
476
-
477
- // `skills import --from claude` is the deliberate migration command. It uploads only the user's
478
- // skill bodies to their private library, then projects the served result; it never publishes to an
479
- // organization and it never treats a local copy as proof that server sync worked.
480
- export async function runSkillsImport(argv) {
481
- if (argv.length !== 2 || argv[0] !== '--from' || argv[1] !== 'claude') {
482
- process.stderr.write('Usage: skills import --from claude\n')
483
- return 1
484
- }
485
- const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
486
- const token = process.env.CORTEX_TOKEN || readWiredToken()
487
- if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
488
- const files = discoverSkillFiles(join(homedir(), '.claude', 'skills'))
489
- if (!files.length) { process.stderr.write('No Claude Code SKILL.md files found.\n'); return 1 }
490
-
491
- const base = resolveBase(process.env.CORTEX_URL)
492
- let imported = 0
493
- const skipped = []
494
- const seen = new Set()
495
- for (const file of files) {
496
- const body_md = readFileSync(file, 'utf8')
497
- const name = frontmatterName(body_md, '').toLowerCase()
498
- if (!NAME_RE.test(name)) { skipped.push(`${file}: invalid frontmatter name`); continue }
499
- if (seen.has(name)) { skipped.push(`${file}: duplicate name ${name}`); continue }
500
- seen.add(name)
501
- try {
502
- const res = await fetchCortex(`${base}/api/personal-skills`, {
503
- method: 'POST',
504
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
505
- body: JSON.stringify({ name, body_md, enabled: true }),
506
- })
507
- if (!res.ok) {
508
- const response = await res.json().catch(() => ({}))
509
- skipped.push(`${file}: ${response.error ?? `HTTP ${res.status}`}`)
510
- continue
511
- }
512
- imported++
513
- } catch (e) {
514
- skipped.push(`${file}: network error: ${e.message}`)
515
- }
516
- }
517
- process.stdout.write(`Agnoclast: imported ${imported}/${files.length} Claude Code skills into your private library.\n`)
518
- for (const reason of skipped) process.stdout.write(` · skipped ${reason}\n`)
519
- if (imported) await syncPersonalSkills({ quiet: false })
520
- return skipped.length ? 1 : 0
521
- }
522
-
523
358
  // CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
524
359
  // are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
525
360
  export async function runSkills(argv = []) {
526
361
  if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
527
- if (argv[0] === 'import') return runSkillsImport(argv.slice(1))
528
- const syncing = argv[0] === 'sync'
529
- if (argv[0] && !syncing && argv[0] !== '--repair' && argv[0] !== '--quiet') {
530
- process.stderr.write('Usage: skills [sync|--repair] | skills import --from claude | skills push <SKILL.md> [--brain <name>]\n')
531
- return 1
532
- }
533
362
 
534
363
  const quiet = argv.includes('--quiet')
535
364
  if (!quiet) process.stdout.write('\nAgnoclast skills — installing managed skills…\n')
@@ -540,9 +369,6 @@ export async function runSkills(argv = []) {
540
369
  process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
541
370
  }
542
371
  }
543
- if (r.targets.length) {
544
- await syncOrgSkills({ quiet })
545
- await syncPersonalSkills({ quiet })
546
- }
372
+ if (r.targets.length) await syncOrgSkills({ quiet })
547
373
  return 0
548
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.86",
3
+ "version": "0.9.87",
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": {