@theronap/cortex-mcp 0.9.74 → 0.9.76

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/server.mjs CHANGED
@@ -719,7 +719,14 @@ export async function runServer(version) {
719
719
  }
720
720
  const out = await res.json().catch(() => null)
721
721
  if (!res.ok) return toolError(`Could not roll back "${name}": ${out?.error ?? res.status}`)
722
- return { content: [{ type: 'text', text: `Done "${name}" ${out.note}. \`read_page "${name}"\` to confirm the current content.` }] }
722
+ // #477: when the target revision had no summary, the page's CURRENT summary was kept rather than
723
+ // erased. Say it on its own line instead of at the tail of `note` — this is the 2026-08-04 W3
724
+ // shape, where empty summaries copied over populated ones destroyed 31 of them under a report
725
+ // that read as success. A rescue the operator does not see is still a silent write.
726
+ const keptLine = out.summaryKept === 'current'
727
+ ? `\n\n⚠ That revision had NO summary, so the page's current summary was KEPT rather than erased — check it still describes the restored body, and use set_summary if not.`
728
+ : ''
729
+ return { content: [{ type: 'text', text: `Done — "${name}" ${out.note}. \`read_page "${name}"\` to confirm the current content.${keptLine}` }] }
723
730
  },
724
731
  )
725
732
 
package/lib/skills.mjs CHANGED
@@ -265,22 +265,65 @@ export async function syncOrgSkills(opts = {}) {
265
265
  return summary
266
266
  }
267
267
 
268
- // `skills push <file>` / `skills push --disable <name>` / `skills push --enable <name>`
269
- // publish or toggle an org skill (owner/manager/admin; the server enforces the role).
270
- async function runSkillsPush(argv) {
268
+ // Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION brain_choice_response.ts builds a
269
+ // body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
270
+ // the caller can choose "by what each one HOLDS, not by which name sounds related". This function
271
+ // existed as `body.error ?? HTTP ${status}`, which printed the bare code `brain_required` and threw
272
+ // all of that away — the user saw a two-word error with no notion of what a brain is, which ones they
273
+ // have, or what to type next. Pure + exported so the shape is unit-testable without a network.
274
+ export function renderPushError(body, status) {
275
+ const lines = [body?.message ?? body?.error ?? `HTTP ${status}`]
276
+ const brains = Array.isArray(body?.brains) ? body.brains : []
277
+ if (brains.length) {
278
+ lines.push('', ' Your brains:')
279
+ for (const b of brains) {
280
+ const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
281
+ const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
282
+ ? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
283
+ lines.push(` ${b?.name ?? b?.orgId ?? '(unnamed)'}${pages}${titles}`)
284
+ }
285
+ lines.push('', ' Re-run with --brain "<name>".')
286
+ }
287
+ return lines.join('\n')
288
+ }
289
+
290
+ // `skills push <file> [--brain <name>]` / `skills push --disable <name> [--brain <name>]` /
291
+ // `skills push --enable <name> [--brain <name>]` — publish or toggle an org skill
292
+ // (owner/manager/admin; the server enforces the role).
293
+ //
294
+ // ⚠ --brain IS NOT OPTIONAL FOR A MULTI-BRAIN CALLER, and until 2026-08-08 there was no way to pass
295
+ // it. Publishing MODIFIES one brain, so the server correctly uses ADR-0022's `requireBrain` half and
296
+ // refuses to guess — but this command sent no `brain`, so every push and every --disable from a
297
+ // multi-brain account died on `brain_required` with no way forward. Confirmed against production:
298
+ // `skills push --disable cortex-author-docs` → `✗ brain_required`, full stop.
299
+ //
300
+ // This is the WRITE twin of the GET bug fixed in #467. The read was miscategorised and now fans out;
301
+ // the write was categorised correctly and simply had no input for the answer it demanded.
302
+ export async function runSkillsPush(argv) {
271
303
  const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
272
304
  const token = process.env.CORTEX_TOKEN || readWiredToken()
273
305
  if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
274
306
  const base = resolveBase(process.env.CORTEX_URL)
275
307
 
308
+ const brainIdx = argv.findIndex((a) => a === '--brain')
309
+ const brain = brainIdx === -1 ? null : argv[brainIdx + 1]
310
+ if (brainIdx !== -1 && (!brain || brain.startsWith('-'))) {
311
+ process.stderr.write('Usage: skills push … --brain <name> (missing brain name)\n')
312
+ return 1
313
+ }
314
+ // Strip --brain AND its value before any positional parsing below. The value does not start with
315
+ // '-', so the `argv.find(a => !a.startsWith('-'))` file lookup would otherwise take it as the
316
+ // SKILL.md path and push the wrong thing.
317
+ const rest = brainIdx === -1 ? argv : argv.filter((_, i) => i !== brainIdx && i !== brainIdx + 1)
318
+
276
319
  let payload
277
- const toggleIdx = argv.findIndex((a) => a === '--disable' || a === '--enable')
320
+ const toggleIdx = rest.findIndex((a) => a === '--disable' || a === '--enable')
278
321
  if (toggleIdx !== -1) {
279
- const name = argv[toggleIdx + 1]
280
- if (!name) { process.stderr.write(`Usage: skills push ${argv[toggleIdx]} <name>\n`); return 1 }
281
- payload = { name, enabled: argv[toggleIdx] === '--enable' }
322
+ const name = rest[toggleIdx + 1]
323
+ if (!name) { process.stderr.write(`Usage: skills push ${rest[toggleIdx]} <name>\n`); return 1 }
324
+ payload = { name, enabled: rest[toggleIdx] === '--enable' }
282
325
  } else {
283
- const file = argv.find((a) => !a.startsWith('-'))
326
+ const file = rest.find((a) => !a.startsWith('-'))
284
327
  if (!file || !existsSync(file)) { process.stderr.write('Usage: skills push <SKILL.md> (file not found)\n'); return 1 }
285
328
  const body_md = readFileSync(file, 'utf8')
286
329
  const name = frontmatterName(body_md, '').toLowerCase()
@@ -292,13 +335,14 @@ async function runSkillsPush(argv) {
292
335
  }
293
336
 
294
337
  try {
295
- const res = await fetchCortex(`${base}/api/skills`, {
338
+ const qs = brain ? `?brain=${encodeURIComponent(brain)}` : ''
339
+ const res = await fetchCortex(`${base}/api/skills${qs}`, {
296
340
  method: 'POST',
297
341
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
298
342
  body: JSON.stringify(payload),
299
343
  })
300
344
  const body = await res.json().catch(() => ({}))
301
- if (!res.ok) { process.stderr.write(`✗ ${body.error ?? `HTTP ${res.status}`}\n`); return 1 }
345
+ if (!res.ok) { process.stderr.write(`✗ ${renderPushError(body, res.status)}\n`); return 1 }
302
346
  process.stdout.write(
303
347
  payload.body_md
304
348
  ? `✓ published "${payload.name}" to your org — every seat installs it on next session start.\n`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.74",
3
+ "version": "0.9.76",
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": {