@theronap/cortex-mcp 0.9.73 → 0.9.75

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/diagnose.mjs CHANGED
@@ -215,6 +215,41 @@ export async function checkToken(token, base) {
215
215
  return { ok: true, status: 200, projectCount, requestId }
216
216
  }
217
217
 
218
+ // Probe the org-skills surface. SEPARATE FROM checkToken ON PURPOSE.
219
+ //
220
+ // WHY THIS EXISTS. `doctor` checked exactly one endpoint — /api/mcp-context — and reported PASS on
221
+ // the strength of it. Meanwhile /api/skills answered 409 to every multi-brain caller for three days
222
+ // (measured 2026-08-08: 14 requests, 14 × 409, zero successes; the local cache sat frozen from Aug 5
223
+ // to Aug 8). Org-published skills silently never installed, the client swallowed the status, and
224
+ // doctor — the tool setup.txt tells people to run FIRST when something is wrong — said everything
225
+ // was fine, every single time. A check that probes one surface is a health check for that surface,
226
+ // not for the system, and must not be reported as the latter.
227
+ //
228
+ // Returns checkToken's shape so the caller renders both the same way. `skillCount` is
229
+ // informational: an org with zero published skills is perfectly healthy, so it is NOT a failure.
230
+ export async function checkSkills(token, base) {
231
+ if (!token || !isUuid(token)) {
232
+ return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
233
+ message: 'no usable token — the context check above already covers this' } }
234
+ }
235
+ const url = `${(base ?? CANONICAL_BASE).replace(/\/$/, '')}/api/skills`
236
+ let res
237
+ try {
238
+ res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
239
+ } catch (e) {
240
+ return { ok: false, status: 0, diagnosis: { kind: 'network', retriable: true, message: e.message } }
241
+ }
242
+ const requestId = res.headers.get('x-vercel-id') ?? null
243
+ const contentType = res.headers.get('content-type')
244
+ const body = await res.text()
245
+ if (!res.ok) {
246
+ return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
247
+ }
248
+ let skillCount
249
+ try { skillCount = (JSON.parse(body)?.skills ?? []).length } catch { /* shape drift — non-fatal */ }
250
+ return { ok: true, status: 200, skillCount, requestId }
251
+ }
252
+
218
253
  // Release the keep-alive sockets the global fetch (undici) holds, so a short-lived CLI command
219
254
  // can tear down cleanly. Without this, calling process.exit() right after a fetch can race a
220
255
  // lingering socket handle and trip a libuv assertion on Windows
package/lib/doctor.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, existsSync } from 'fs'
2
2
  import { homedir } from 'os'
3
3
  import { join } from 'path'
4
- import { checkToken, resolveBase } from './diagnose.mjs'
4
+ import { checkToken, checkSkills, resolveBase } from './diagnose.mjs'
5
5
 
6
6
  // `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
7
7
  //
@@ -64,9 +64,33 @@ export async function runDoctor() {
64
64
  const r = await checkToken(token, base)
65
65
 
66
66
  if (r.ok) {
67
- out(' ✓ PASS — your token authenticates and Agnoclast returned your context.')
67
+ out(' ✓ context — your token authenticates and Agnoclast returned your context.')
68
68
  if (typeof r.projectCount === 'number') out(` You can currently see ${r.projectCount} project(s).`)
69
69
  if (r.requestId) out(` (request id: ${r.requestId})`)
70
+
71
+ // A SECOND surface, because one endpoint answering is not "the system is healthy". /api/skills
72
+ // 409'd every multi-brain caller for three days while this command printed PASS on the strength
73
+ // of /api/mcp-context alone. See checkSkills' header for the full record.
74
+ const s = await checkSkills(token, base)
75
+ if (s.ok) {
76
+ const n = typeof s.skillCount === 'number'
77
+ ? ` (${s.skillCount} org skill${s.skillCount === 1 ? '' : 's'} published)` : ''
78
+ out(` ✓ skills — the org-skill surface answers${n}.`)
79
+ } else {
80
+ // NOT a total failure — context works, so capture and retrieval are fine. But it must never
81
+ // again render as PASS, because org skills silently stop updating when this breaks.
82
+ out(` ✗ skills — ${s.diagnosis?.kind ?? 'error'}${s.status ? ` (HTTP ${s.status})` : ''}`)
83
+ out(` ${s.diagnosis?.message ?? 'unknown error'}`)
84
+ out('')
85
+ out(' ⚠ PARTIAL — your connection is fine and capture/retrieval work, but org-published')
86
+ out(' skills cannot be fetched, so they will silently stop updating (the client falls')
87
+ out(' back to its last-good cache). Report this rather than ignoring it.')
88
+ out('')
89
+ return 1
90
+ }
91
+
92
+ out('')
93
+ out(' ✓ PASS — every surface checked is answering.')
70
94
  out('')
71
95
  out(' If your AI still does not see Agnoclast, the server just is not loaded yet —')
72
96
  out(' fully quit and reopen Claude Code (the MCP server starts on launch).')
package/lib/skills.mjs CHANGED
@@ -160,16 +160,37 @@ export function installSkills(opts = {}) {
160
160
  return summary
161
161
  }
162
162
 
163
- // Decide what to install from a served org-skill list: drop invalid names and bundled collisions
164
- // (bundled wins — core skills are inalterable). Pure, so it's unit-testable without a network.
163
+ // Decide what to install from a served org-skill list: drop invalid names, bundled collisions
164
+ // (bundled wins — core skills are inalterable) and cross-brain collisions. Pure, so it's
165
+ // unit-testable without a network.
165
166
  export function planOrgInstall(served, bundledNames) {
166
167
  const bundled = new Set(bundledNames)
167
168
  const install = []
168
169
  const skipped = []
170
+
171
+ // /api/skills now fans out across EVERY brain the caller belongs to (ADR-0022 acrossMyBrains), so
172
+ // for the first time two brains can serve the SAME skill name. Installing both would write one
173
+ // file twice and silently leave whichever landed last — an executable body from a brain the user
174
+ // never chose. There is no principled winner, so a cross-brain collision installs NEITHER and says
175
+ // so, matching how a bundled collision already resolves: when in doubt, do not install.
176
+ const byName = new Map()
177
+ for (const s of served ?? []) {
178
+ const n = (s?.name ?? '').trim()
179
+ if (!byName.has(n)) byName.set(n, [])
180
+ byName.get(n).push(s)
181
+ }
182
+
169
183
  for (const s of served ?? []) {
170
184
  const name = (s?.name ?? '').trim()
171
185
  if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
172
186
  if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
187
+ const dupes = byName.get(name) ?? []
188
+ if (dupes.length > 1) {
189
+ // Name the brains so the owner knows which to rename. `brain` is the tag acrossMyBrains adds.
190
+ const brains = [...new Set(dupes.map((d) => d?.brain).filter(Boolean))]
191
+ skipped.push({ name, why: `published by ${dupes.length} brains (${brains.join(', ') || 'unknown'}) — rename one; installing neither` })
192
+ continue
193
+ }
173
194
  install.push({ name, source: s.body_md })
174
195
  }
175
196
  return { install, skipped }
@@ -201,6 +222,13 @@ export async function syncOrgSkills(opts = {}) {
201
222
  if (res.ok) {
202
223
  served = (await res.json())?.skills ?? []
203
224
  summary.source = 'server'
225
+ } else {
226
+ // ⚠ DO NOT MAKE THIS SILENT AGAIN. `if (res.ok)` alone is how a 100% failure hid for weeks:
227
+ // /api/skills answered 409 to every multi-brain caller, this fell through to the cache below,
228
+ // and the only log line ('unreachable and no cache') does NOT print when a cache exists. So
229
+ // org skills silently stopped updating and nothing anywhere said so. Fail-soft is right —
230
+ // SessionStart must not break — but fail-soft is not fail-quiet.
231
+ log(` · org skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
204
232
  }
205
233
  } catch { /* fall through to cache */ }
206
234
  if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.73",
3
+ "version": "0.9.75",
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": {