@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 +35 -0
- package/lib/doctor.mjs +26 -2
- package/lib/skills.mjs +30 -2
- package/package.json +1 -1
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(' ✓
|
|
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
|
|
164
|
-
// (bundled wins — core skills are inalterable). Pure, so it's
|
|
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' }
|