@theronap/cortex-mcp 0.9.83 → 0.9.85
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/README.md +3 -3
- package/bin/cortex-mcp.mjs +40 -23
- package/lib/capture.mjs +1 -1
- package/lib/code_graph_cli.mjs +1 -1
- package/lib/context_log.mjs +5 -5
- package/lib/diagnose.mjs +81 -11
- package/lib/docs_scan.mjs +4 -4
- package/lib/doctor.mjs +48 -11
- package/lib/editors/antigravity.mjs +1 -1
- package/lib/editors/claude.mjs +29 -14
- package/lib/editors/codex.mjs +2 -2
- package/lib/editors/cursor.mjs +2 -2
- package/lib/editors/index.mjs +1 -1
- package/lib/graphify_sync.mjs +49 -4
- package/lib/grep_cli.mjs +1 -1
- package/lib/hydrate.mjs +1 -1
- package/lib/imessage_send.mjs +1 -1
- package/lib/ingest_folder.mjs +2 -2
- package/lib/install.mjs +6 -6
- package/lib/redact.mjs +1 -1
- package/lib/resolve.mjs +62 -12
- package/lib/server.mjs +211 -77
- package/lib/setup.mjs +17 -15
- package/lib/skills.mjs +250 -21
- package/lib/uninstall.mjs +3 -3
- package/lib/use_brain.mjs +82 -0
- package/package.json +1 -1
- package/skills/author-docs/SKILL.md +5 -5
- package/skills/context/SKILL.md +4 -4
- package/skills/log/SKILL.md +6 -6
- package/skills/walkthrough/SKILL.md +1 -1
- package/lib/precompact.mjs +0 -16
package/lib/skills.mjs
CHANGED
|
@@ -3,9 +3,9 @@ import { homedir } from 'os'
|
|
|
3
3
|
import { join, dirname } from 'path'
|
|
4
4
|
import { fileURLToPath } from 'url'
|
|
5
5
|
|
|
6
|
-
// Managed
|
|
6
|
+
// Managed Agnoclast skills.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Three 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,6 +13,9 @@ 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.
|
|
16
19
|
//
|
|
17
20
|
// Installed into EVERY agent CLI present on the machine, at the flat layout each one discovers:
|
|
18
21
|
// ~/.claude/skills/<name>/SKILL.md (Claude Code)
|
|
@@ -30,13 +33,16 @@ import { fileURLToPath } from 'url'
|
|
|
30
33
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
31
34
|
const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
|
|
32
35
|
const ORG_CACHE = join(homedir(), '.cortex', 'org-skills-cache.json')
|
|
36
|
+
const PERSONAL_CACHE = join(homedir(), '.cortex', 'personal-skills-cache.json')
|
|
33
37
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ // mirrors the org_skills check; also blocks path tricks
|
|
34
38
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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.
|
|
37
42
|
const CLIS = [
|
|
38
43
|
{ id: 'Claude Code', dir: join(homedir(), '.claude') },
|
|
39
44
|
{ id: 'Codex', dir: join(homedir(), '.codex') },
|
|
45
|
+
{ id: 'Cursor', dir: join(homedir(), '.cursor') },
|
|
40
46
|
]
|
|
41
47
|
|
|
42
48
|
// djb2 — tiny, dependency-free content fingerprint for the manifest (drift detection, not security).
|
|
@@ -155,21 +161,61 @@ export function installSkills(opts = {}) {
|
|
|
155
161
|
|
|
156
162
|
if (quiet && (summary.installed.length || summary.repaired.length)) {
|
|
157
163
|
// SessionStart surfaces one line in Claude Code so a silent self-heal isn't invisible.
|
|
158
|
-
process.stdout.write(`
|
|
164
|
+
process.stdout.write(`Agnoclast: synced managed skill(s) into ${summary.targets.join(' + ')}.\n`)
|
|
159
165
|
}
|
|
160
166
|
return summary
|
|
161
167
|
}
|
|
162
168
|
|
|
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
|
|
169
|
+
// Decide what to install from a served org-skill list: drop invalid names, bundled collisions
|
|
170
|
+
// (bundled wins — core skills are inalterable) and cross-brain collisions. Pure, so it's
|
|
171
|
+
// unit-testable without a network.
|
|
165
172
|
export function planOrgInstall(served, bundledNames) {
|
|
166
173
|
const bundled = new Set(bundledNames)
|
|
167
174
|
const install = []
|
|
168
175
|
const skipped = []
|
|
176
|
+
|
|
177
|
+
// /api/skills now fans out across EVERY brain the caller belongs to (ADR-0022 acrossMyBrains), so
|
|
178
|
+
// for the first time two brains can serve the SAME skill name. Installing both would write one
|
|
179
|
+
// file twice and silently leave whichever landed last — an executable body from a brain the user
|
|
180
|
+
// never chose. There is no principled winner, so a cross-brain collision installs NEITHER and says
|
|
181
|
+
// so, matching how a bundled collision already resolves: when in doubt, do not install.
|
|
182
|
+
const byName = new Map()
|
|
183
|
+
for (const s of served ?? []) {
|
|
184
|
+
const n = (s?.name ?? '').trim()
|
|
185
|
+
if (!byName.has(n)) byName.set(n, [])
|
|
186
|
+
byName.get(n).push(s)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const s of served ?? []) {
|
|
190
|
+
const name = (s?.name ?? '').trim()
|
|
191
|
+
if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
|
|
192
|
+
if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
|
|
193
|
+
const dupes = byName.get(name) ?? []
|
|
194
|
+
if (dupes.length > 1) {
|
|
195
|
+
// Name the brains so the owner knows which to rename. `brain` is the tag acrossMyBrains adds.
|
|
196
|
+
const brains = [...new Set(dupes.map((d) => d?.brain).filter(Boolean))]
|
|
197
|
+
skipped.push({ name, why: `published by ${dupes.length} brains (${brains.join(', ') || 'unknown'}) — rename one; installing neither` })
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
install.push({ name, source: s.body_md })
|
|
201
|
+
}
|
|
202
|
+
return { install, skipped }
|
|
203
|
+
}
|
|
204
|
+
|
|
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()
|
|
169
213
|
for (const s of served ?? []) {
|
|
170
214
|
const name = (s?.name ?? '').trim()
|
|
171
215
|
if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
|
|
172
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)
|
|
173
219
|
install.push({ name, source: s.body_md })
|
|
174
220
|
}
|
|
175
221
|
return { install, skipped }
|
|
@@ -201,6 +247,13 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
201
247
|
if (res.ok) {
|
|
202
248
|
served = (await res.json())?.skills ?? []
|
|
203
249
|
summary.source = 'server'
|
|
250
|
+
} else {
|
|
251
|
+
// ⚠ DO NOT MAKE THIS SILENT AGAIN. `if (res.ok)` alone is how a 100% failure hid for weeks:
|
|
252
|
+
// /api/skills answered 409 to every multi-brain caller, this fell through to the cache below,
|
|
253
|
+
// and the only log line ('unreachable and no cache') does NOT print when a cache exists. So
|
|
254
|
+
// org skills silently stopped updating and nothing anywhere said so. Fail-soft is right —
|
|
255
|
+
// SessionStart must not break — but fail-soft is not fail-quiet.
|
|
256
|
+
log(` · org skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
|
|
204
257
|
}
|
|
205
258
|
} catch { /* fall through to cache */ }
|
|
206
259
|
if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
|
|
@@ -230,29 +283,142 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
230
283
|
const bits = []
|
|
231
284
|
if (changed.length) bits.push(`synced ${changed.join(', ')}`)
|
|
232
285
|
if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
|
|
233
|
-
process.stdout.write(`
|
|
286
|
+
process.stdout.write(`Agnoclast: org skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
|
|
234
287
|
} else if (!quiet) {
|
|
235
288
|
log(` ✓ org skills up to date (${currentNames.length} published${skipped.length ? `, ${skipped.length} skipped` : ''})`)
|
|
236
289
|
}
|
|
237
290
|
return summary
|
|
238
291
|
}
|
|
239
292
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Pull the caller's personal skill library and project it into every compatible local editor.
|
|
295
|
+
* It is deliberately fail-soft for the same reason as org sync: a network outage must not break a
|
|
296
|
+
* session start. A personal skill wins an unpinned org skill by being installed after org sync.
|
|
297
|
+
*/
|
|
298
|
+
export async function syncPersonalSkills(opts = {}) {
|
|
299
|
+
const quiet = !!opts.quiet
|
|
300
|
+
const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
|
|
301
|
+
const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
|
|
302
|
+
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
303
|
+
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
304
|
+
if (!token) { log(' · personal skills: no token wired — skipped'); return summary }
|
|
305
|
+
|
|
306
|
+
let cache = null
|
|
307
|
+
try { cache = JSON.parse(readFileSync(PERSONAL_CACHE, 'utf8')) } catch { /* none */ }
|
|
308
|
+
let served = null
|
|
309
|
+
try {
|
|
310
|
+
const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/personal-skills`, {
|
|
311
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
312
|
+
})
|
|
313
|
+
if (res.ok) {
|
|
314
|
+
served = (await res.json())?.skills ?? []
|
|
315
|
+
summary.source = 'server'
|
|
316
|
+
} else {
|
|
317
|
+
log(` · personal skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
|
|
318
|
+
}
|
|
319
|
+
} catch { /* fall through to cache */ }
|
|
320
|
+
if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
|
|
321
|
+
if (!served) { log(' · personal skills: unreachable and no cache — skipped'); return summary }
|
|
322
|
+
|
|
323
|
+
const bundledNames = bundledSkills().map((s) => s.name)
|
|
324
|
+
const { install, skipped } = planPersonalInstall(served, bundledNames)
|
|
325
|
+
summary.skipped = skipped
|
|
326
|
+
const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
|
|
327
|
+
const currentNames = install.map((s) => s.name)
|
|
328
|
+
const retiredNames = prevNames.filter((n) => !currentNames.includes(n))
|
|
329
|
+
|
|
330
|
+
// If a personal override was disabled, put back a cached org skill of that name rather than
|
|
331
|
+
// deleting it. The normal sync sequence has already refreshed ORG_CACHE before this function.
|
|
332
|
+
let orgCache = null
|
|
333
|
+
try { orgCache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* no org fallback */ }
|
|
334
|
+
const { install: orgInstall } = planOrgInstall(orgCache?.skills, bundledNames)
|
|
335
|
+
const orgByName = new Map(orgInstall.map((s) => [s.name, s]))
|
|
336
|
+
const fallback = retiredNames.map((name) => orgByName.get(name)).filter(Boolean)
|
|
337
|
+
const removeNames = retiredNames.filter((name) => !orgByName.has(name))
|
|
338
|
+
|
|
339
|
+
for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
|
|
340
|
+
// Fallbacks first, personal second: personal > org, but bundled was installed before either.
|
|
341
|
+
const r = installInto(join(cli.dir, 'skills'), [...fallback, ...install], { removeNames })
|
|
342
|
+
summary.installed.push(...r.installed)
|
|
343
|
+
summary.repaired.push(...r.repaired)
|
|
344
|
+
summary.removed.push(...r.removed)
|
|
345
|
+
}
|
|
346
|
+
if (summary.source === 'server') {
|
|
347
|
+
ensureDir(dirname(PERSONAL_CACHE))
|
|
348
|
+
writeFileSync(PERSONAL_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const changed = [...new Set([...summary.installed, ...summary.repaired])]
|
|
352
|
+
if (changed.length || summary.removed.length) {
|
|
353
|
+
const bits = []
|
|
354
|
+
if (changed.length) bits.push(`synced ${changed.join(', ')}`)
|
|
355
|
+
if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
|
|
356
|
+
process.stdout.write(`Agnoclast: personal skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
|
|
357
|
+
} else if (!quiet) {
|
|
358
|
+
log(` ✓ personal skills up to date (${currentNames.length} private${skipped.length ? `, ${skipped.length} skipped` : ''})`)
|
|
359
|
+
}
|
|
360
|
+
return summary
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
|
|
364
|
+
// body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
|
|
365
|
+
// the caller can choose "by what each one HOLDS, not by which name sounds related". This function
|
|
366
|
+
// existed as `body.error ?? HTTP ${status}`, which printed the bare code `brain_required` and threw
|
|
367
|
+
// all of that away — the user saw a two-word error with no notion of what a brain is, which ones they
|
|
368
|
+
// have, or what to type next. Pure + exported so the shape is unit-testable without a network.
|
|
369
|
+
export function renderPushError(body, status) {
|
|
370
|
+
const lines = [body?.message ?? body?.error ?? `HTTP ${status}`]
|
|
371
|
+
const brains = Array.isArray(body?.brains) ? body.brains : []
|
|
372
|
+
if (brains.length) {
|
|
373
|
+
lines.push('', ' Your brains:')
|
|
374
|
+
for (const b of brains) {
|
|
375
|
+
const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
|
|
376
|
+
const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
|
|
377
|
+
? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
|
|
378
|
+
lines.push(` ${b?.name ?? b?.orgId ?? '(unnamed)'}${pages}${titles}`)
|
|
379
|
+
}
|
|
380
|
+
lines.push('', ' Re-run with --brain "<name>".')
|
|
381
|
+
}
|
|
382
|
+
return lines.join('\n')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// `skills push <file> [--brain <name>]` / `skills push --disable <name> [--brain <name>]` /
|
|
386
|
+
// `skills push --enable <name> [--brain <name>]` — publish or toggle an org skill
|
|
387
|
+
// (owner/manager/admin; the server enforces the role).
|
|
388
|
+
//
|
|
389
|
+
// ⚠ --brain IS NOT OPTIONAL FOR A MULTI-BRAIN CALLER, and until 2026-08-08 there was no way to pass
|
|
390
|
+
// it. Publishing MODIFIES one brain, so the server correctly uses ADR-0022's `requireBrain` half and
|
|
391
|
+
// refuses to guess — but this command sent no `brain`, so every push and every --disable from a
|
|
392
|
+
// multi-brain account died on `brain_required` with no way forward. Confirmed against production:
|
|
393
|
+
// `skills push --disable cortex-author-docs` → `✗ brain_required`, full stop.
|
|
394
|
+
//
|
|
395
|
+
// This is the WRITE twin of the GET bug fixed in #467. The read was miscategorised and now fans out;
|
|
396
|
+
// the write was categorised correctly and simply had no input for the answer it demanded.
|
|
397
|
+
export async function runSkillsPush(argv) {
|
|
243
398
|
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
244
399
|
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
245
|
-
if (!token) { process.stderr.write('No
|
|
400
|
+
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
246
401
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
247
402
|
|
|
403
|
+
const brainIdx = argv.findIndex((a) => a === '--brain')
|
|
404
|
+
const brain = brainIdx === -1 ? null : argv[brainIdx + 1]
|
|
405
|
+
if (brainIdx !== -1 && (!brain || brain.startsWith('-'))) {
|
|
406
|
+
process.stderr.write('Usage: skills push … --brain <name> (missing brain name)\n')
|
|
407
|
+
return 1
|
|
408
|
+
}
|
|
409
|
+
// Strip --brain AND its value before any positional parsing below. The value does not start with
|
|
410
|
+
// '-', so the `argv.find(a => !a.startsWith('-'))` file lookup would otherwise take it as the
|
|
411
|
+
// SKILL.md path and push the wrong thing.
|
|
412
|
+
const rest = brainIdx === -1 ? argv : argv.filter((_, i) => i !== brainIdx && i !== brainIdx + 1)
|
|
413
|
+
|
|
248
414
|
let payload
|
|
249
|
-
const toggleIdx =
|
|
415
|
+
const toggleIdx = rest.findIndex((a) => a === '--disable' || a === '--enable')
|
|
250
416
|
if (toggleIdx !== -1) {
|
|
251
|
-
const name =
|
|
252
|
-
if (!name) { process.stderr.write(`Usage: skills push ${
|
|
253
|
-
payload = { name, enabled:
|
|
417
|
+
const name = rest[toggleIdx + 1]
|
|
418
|
+
if (!name) { process.stderr.write(`Usage: skills push ${rest[toggleIdx]} <name>\n`); return 1 }
|
|
419
|
+
payload = { name, enabled: rest[toggleIdx] === '--enable' }
|
|
254
420
|
} else {
|
|
255
|
-
const file =
|
|
421
|
+
const file = rest.find((a) => !a.startsWith('-'))
|
|
256
422
|
if (!file || !existsSync(file)) { process.stderr.write('Usage: skills push <SKILL.md> (file not found)\n'); return 1 }
|
|
257
423
|
const body_md = readFileSync(file, 'utf8')
|
|
258
424
|
const name = frontmatterName(body_md, '').toLowerCase()
|
|
@@ -264,13 +430,14 @@ async function runSkillsPush(argv) {
|
|
|
264
430
|
}
|
|
265
431
|
|
|
266
432
|
try {
|
|
267
|
-
const
|
|
433
|
+
const qs = brain ? `?brain=${encodeURIComponent(brain)}` : ''
|
|
434
|
+
const res = await fetchCortex(`${base}/api/skills${qs}`, {
|
|
268
435
|
method: 'POST',
|
|
269
436
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
270
437
|
body: JSON.stringify(payload),
|
|
271
438
|
})
|
|
272
439
|
const body = await res.json().catch(() => ({}))
|
|
273
|
-
if (!res.ok) { process.stderr.write(`✗ ${body
|
|
440
|
+
if (!res.ok) { process.stderr.write(`✗ ${renderPushError(body, res.status)}\n`); return 1 }
|
|
274
441
|
process.stdout.write(
|
|
275
442
|
payload.body_md
|
|
276
443
|
? `✓ published "${payload.name}" to your org — every seat installs it on next session start.\n`
|
|
@@ -283,13 +450,72 @@ async function runSkillsPush(argv) {
|
|
|
283
450
|
}
|
|
284
451
|
}
|
|
285
452
|
|
|
453
|
+
export function discoverSkillFiles(root) {
|
|
454
|
+
if (!root || !existsSync(root)) return []
|
|
455
|
+
return readdirSync(root, { withFileTypes: true })
|
|
456
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(root, entry.name, 'SKILL.md')))
|
|
457
|
+
.map((entry) => join(root, entry.name, 'SKILL.md'))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// `skills import --from claude` is the deliberate migration command. It uploads only the user's
|
|
461
|
+
// skill bodies to their private library, then projects the served result; it never publishes to an
|
|
462
|
+
// organization and it never treats a local copy as proof that server sync worked.
|
|
463
|
+
export async function runSkillsImport(argv) {
|
|
464
|
+
if (argv.length !== 2 || argv[0] !== '--from' || argv[1] !== 'claude') {
|
|
465
|
+
process.stderr.write('Usage: skills import --from claude\n')
|
|
466
|
+
return 1
|
|
467
|
+
}
|
|
468
|
+
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
469
|
+
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
470
|
+
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
471
|
+
const files = discoverSkillFiles(join(homedir(), '.claude', 'skills'))
|
|
472
|
+
if (!files.length) { process.stderr.write('No Claude Code SKILL.md files found.\n'); return 1 }
|
|
473
|
+
|
|
474
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
475
|
+
let imported = 0
|
|
476
|
+
const skipped = []
|
|
477
|
+
const seen = new Set()
|
|
478
|
+
for (const file of files) {
|
|
479
|
+
const body_md = readFileSync(file, 'utf8')
|
|
480
|
+
const name = frontmatterName(body_md, '').toLowerCase()
|
|
481
|
+
if (!NAME_RE.test(name)) { skipped.push(`${file}: invalid frontmatter name`); continue }
|
|
482
|
+
if (seen.has(name)) { skipped.push(`${file}: duplicate name ${name}`); continue }
|
|
483
|
+
seen.add(name)
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetchCortex(`${base}/api/personal-skills`, {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
488
|
+
body: JSON.stringify({ name, body_md, enabled: true }),
|
|
489
|
+
})
|
|
490
|
+
if (!res.ok) {
|
|
491
|
+
const response = await res.json().catch(() => ({}))
|
|
492
|
+
skipped.push(`${file}: ${response.error ?? `HTTP ${res.status}`}`)
|
|
493
|
+
continue
|
|
494
|
+
}
|
|
495
|
+
imported++
|
|
496
|
+
} catch (e) {
|
|
497
|
+
skipped.push(`${file}: network error: ${e.message}`)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
process.stdout.write(`Agnoclast: imported ${imported}/${files.length} Claude Code skills into your private library.\n`)
|
|
501
|
+
for (const reason of skipped) process.stdout.write(` · skipped ${reason}\n`)
|
|
502
|
+
if (imported) await syncPersonalSkills({ quiet: false })
|
|
503
|
+
return skipped.length ? 1 : 0
|
|
504
|
+
}
|
|
505
|
+
|
|
286
506
|
// CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
|
|
287
507
|
// are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
|
|
288
508
|
export async function runSkills(argv = []) {
|
|
289
509
|
if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
|
|
510
|
+
if (argv[0] === 'import') return runSkillsImport(argv.slice(1))
|
|
511
|
+
const syncing = argv[0] === 'sync'
|
|
512
|
+
if (argv[0] && !syncing && argv[0] !== '--repair' && argv[0] !== '--quiet') {
|
|
513
|
+
process.stderr.write('Usage: skills [sync|--repair] | skills import --from claude | skills push <SKILL.md> [--brain <name>]\n')
|
|
514
|
+
return 1
|
|
515
|
+
}
|
|
290
516
|
|
|
291
517
|
const quiet = argv.includes('--quiet')
|
|
292
|
-
if (!quiet) process.stdout.write('\
|
|
518
|
+
if (!quiet) process.stdout.write('\nAgnoclast skills — installing managed skills…\n')
|
|
293
519
|
const r = installSkills({ quiet })
|
|
294
520
|
if (!quiet) {
|
|
295
521
|
if (!r.targets.length) process.stdout.write(' ! No agent CLI found (~/.claude or ~/.codex). Nothing to install.\n')
|
|
@@ -297,6 +523,9 @@ export async function runSkills(argv = []) {
|
|
|
297
523
|
process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
|
|
298
524
|
}
|
|
299
525
|
}
|
|
300
|
-
if (r.targets.length)
|
|
526
|
+
if (r.targets.length) {
|
|
527
|
+
await syncOrgSkills({ quiet })
|
|
528
|
+
await syncPersonalSkills({ quiet })
|
|
529
|
+
}
|
|
301
530
|
return 0
|
|
302
531
|
}
|
package/lib/uninstall.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from 'os'
|
|
|
3
3
|
import { join } from 'path'
|
|
4
4
|
import { execFileSync } from 'child_process'
|
|
5
5
|
|
|
6
|
-
// Full uninstall — the reverse of setup.mjs. Removes EVERY touch-point
|
|
6
|
+
// Full uninstall — the reverse of setup.mjs. Removes EVERY touch-point Agnoclast writes onto a machine:
|
|
7
7
|
// 1. ~/.claude.json → mcpServers.cortex
|
|
8
8
|
// 2. ~/.claude/settings.json → Stop/SessionStart/PreCompact cortex hooks
|
|
9
9
|
// 3. ~/.codex/config.toml → [mcp_servers.cortex] + [mcp_servers.cortex.env]
|
|
@@ -34,7 +34,7 @@ export function runUninstall(argv = []) {
|
|
|
34
34
|
const act = (msg) => plan.push(msg)
|
|
35
35
|
const write = (path, data) => { if (!dry) { backup(path); writeFileSync(path, data) } }
|
|
36
36
|
|
|
37
|
-
process.stdout.write(dry ? '\
|
|
37
|
+
process.stdout.write(dry ? '\nAgnoclast uninstall — DRY RUN (nothing will change):\n\n' : '\nAgnoclast uninstall — removing all wiring…\n\n')
|
|
38
38
|
|
|
39
39
|
// 1. MCP server out of ~/.claude.json
|
|
40
40
|
editJson(CLAUDE_JSON, (cfg) => {
|
|
@@ -144,7 +144,7 @@ export function runUninstall(argv = []) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
if (plan.length === 0) { process.stdout.write(' Nothing to remove — this machine has no
|
|
147
|
+
if (plan.length === 0) { process.stdout.write(' Nothing to remove — this machine has no Agnoclast wiring.\n\n'); return }
|
|
148
148
|
process.stdout.write(plan.join('\n') + '\n\n')
|
|
149
149
|
if (dry) {
|
|
150
150
|
process.stdout.write('DRY RUN — nothing changed. Re-run without --dry-run to apply.\n')
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
2
|
+
import { resolveToken } from './doctor.mjs'
|
|
3
|
+
|
|
4
|
+
// `use-brain` — set (or show) which brain this machine's unattended session captures land in.
|
|
5
|
+
//
|
|
6
|
+
// WHY A SUBCOMMAND EXISTS AT ALL. The server side of this shipped 2026-08-09 with NO client surface:
|
|
7
|
+
// no CLI, no MCP tool, no console setting. The only way to set a capture default was a raw
|
|
8
|
+
// authenticated HTTP call, which meant the only people who could fix a broken capture were the ones
|
|
9
|
+
// who could hand-write a curl with a bearer token. Three real users needed it; one of them is not
|
|
10
|
+
// technical. A fix only its author can operate is not a fix.
|
|
11
|
+
//
|
|
12
|
+
// Pairs with the SessionStart notice: the notice tells you captures are being held and names this
|
|
13
|
+
// command, so the loop from "something is wrong" to "it is fixed" is one paste with no docs.
|
|
14
|
+
|
|
15
|
+
function out(m) { process.stdout.write(m + '\n') }
|
|
16
|
+
|
|
17
|
+
export async function runUseBrain(args) {
|
|
18
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
19
|
+
const { token } = resolveToken()
|
|
20
|
+
if (!token) {
|
|
21
|
+
out('Agnoclast: no token found. Run: npx -y @theronap/cortex-mcp setup <token>')
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Everything after the subcommand is the brain, joined — so an unquoted multi-word name still
|
|
26
|
+
// works. `use-brain Real estate` is what a person actually types; refusing it over a missing pair
|
|
27
|
+
// of quotes would be the same species of unhelpfulness this command exists to remove.
|
|
28
|
+
const wanted = (args ?? []).filter((a) => !a.startsWith('--')).join(' ').trim()
|
|
29
|
+
const url = `${base}/api/brain/capture-default`
|
|
30
|
+
|
|
31
|
+
if (!wanted) {
|
|
32
|
+
// No argument: report the current state rather than erroring. "What is it set to?" is a fair
|
|
33
|
+
// question and the answer is one GET away.
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const body = await res.text()
|
|
38
|
+
out(`Agnoclast: could not read your capture settings — ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`)
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
const { defaults } = await res.json()
|
|
42
|
+
if (!defaults?.length) {
|
|
43
|
+
out('Agnoclast: no capture brain set. Your unattended session captures land in a brain only if')
|
|
44
|
+
out(' you belong to exactly one; otherwise they are HELD outside every brain until you set this.')
|
|
45
|
+
out(' Set one: npx -y @theronap/cortex-mcp use-brain "<brain name or org id>"')
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
for (const d of defaults) out(`Agnoclast: ${d.sourceType} captures land in "${d.orgName}" (${d.orgId})`)
|
|
49
|
+
return 0
|
|
50
|
+
} catch (e) {
|
|
51
|
+
out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
|
|
52
|
+
return 1
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetchCortex(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ sourceType: 'claude-code', brain: wanted }),
|
|
61
|
+
})
|
|
62
|
+
const body = await res.text()
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
let msg = classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message
|
|
65
|
+
// The server's own error text is better than a generic one here: a 404 names the brain that was
|
|
66
|
+
// not found, and a 409 lists the org ids of an ambiguous name — which is the whole remedy.
|
|
67
|
+
try { const j = JSON.parse(body); if (j.error) msg = j.error } catch { /* keep the classified message */ }
|
|
68
|
+
out(`Agnoclast: ${msg}`)
|
|
69
|
+
return 1
|
|
70
|
+
}
|
|
71
|
+
const j = JSON.parse(body)
|
|
72
|
+
out(`Agnoclast: ✓ your Claude Code sessions now land in "${j.brain}".`)
|
|
73
|
+
// Say plainly what this does NOT do. The setter's own server-side note makes the same point,
|
|
74
|
+
// because "I fixed it" reading as "and the backlog is handled" is how held records stay held.
|
|
75
|
+
out(' Sessions captured BEFORE now are still held — they keep their original dates until sorted.')
|
|
76
|
+
out(' Ask your assistant to file them (they may not all belong in the same brain).')
|
|
77
|
+
return 0
|
|
78
|
+
} catch (e) {
|
|
79
|
+
out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cortex-author-docs
|
|
3
|
-
description: Push new/changed documentation (specs, plans, design docs) from disk into
|
|
3
|
+
description: Push new/changed documentation (specs, plans, design docs) from disk into Agnoclast as authored wiki pages. Run after writing a spec/plan/design doc, when the user asks to sync docs to Agnoclast, or as part of session close-out.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
> **
|
|
6
|
+
> **Agnoclast-managed skill.** This file is installed and kept up to date by Agnoclast. Local edits are
|
|
7
7
|
> restored on the next session (a backup of your version is saved alongside). Don't rely on changes here.
|
|
8
8
|
|
|
9
9
|
## Why this exists
|
|
10
10
|
|
|
11
11
|
Specs, plans, and design docs get written to disk (a repo's `docs/`, skill-generated design docs)
|
|
12
|
-
and never reach
|
|
12
|
+
and never reach Agnoclast — so the org brain misses its richest artifacts. A spec IS a page: this
|
|
13
13
|
skill turns pending docs into authored wiki pages. You (the live session) are the pipe — you read
|
|
14
14
|
the doc and author a synthesis. Never dump raw markdown into a page.
|
|
15
15
|
|
|
16
16
|
## When to use
|
|
17
17
|
|
|
18
18
|
- Right after you write or substantially update a spec/plan/design/runbook doc on disk.
|
|
19
|
-
- When the user asks to push/sync docs to
|
|
19
|
+
- When the user asks to push/sync docs to Agnoclast.
|
|
20
20
|
- During session close-out (`/cortex-log` runs this as a sweep step).
|
|
21
21
|
|
|
22
22
|
## Steps
|
|
@@ -67,7 +67,7 @@ the doc and author a synthesis. Never dump raw markdown into a page.
|
|
|
67
67
|
## Safety rules
|
|
68
68
|
|
|
69
69
|
- Do NOT register or sweep the local brain repo (`~/Documents/brain`) while the Robin parity soak
|
|
70
|
-
is running — the experiment forbids re-syncing Robin into
|
|
70
|
+
is running — the experiment forbids re-syncing Robin into Agnoclast mid-window.
|
|
71
71
|
- Respect tiers: if a doc is clearly personal/sensitive, author it `confidential` or ask; default
|
|
72
72
|
for work docs is the author path's normal default.
|
|
73
73
|
- This skill writes wiki pages via the `author` tool only. It never sends external messages and
|
package/skills/context/SKILL.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cortex-context
|
|
3
|
-
description: Automatically hydrate
|
|
3
|
+
description: Automatically hydrate Agnoclast context at the start of a substantive session. Use when Agnoclast MCP is available and the user has made a real request, so the first answer is grounded in query-centered org context instead of the static baseline alone.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
> **
|
|
6
|
+
> **Agnoclast-managed skill.** This file is installed and kept up to date by Agnoclast. Local edits are
|
|
7
7
|
> restored on the next session (a backup of your version is saved alongside). Don't rely on changes here.
|
|
8
8
|
|
|
9
9
|
## When to use
|
|
@@ -14,12 +14,12 @@ chit-chat and requests where org context is obviously irrelevant.
|
|
|
14
14
|
## Steps
|
|
15
15
|
|
|
16
16
|
1. Call `session_context` with the user's opening request, preserving the actual topic in their words.
|
|
17
|
-
2. Use that returned block as the primary
|
|
17
|
+
2. Use that returned block as the primary Agnoclast grounding for the first response.
|
|
18
18
|
3. If `session_context` is unavailable or errors, fall back to `my_context`.
|
|
19
19
|
4. If the conversation materially changes topics later, call `session_context` again for the new topic.
|
|
20
20
|
|
|
21
21
|
## Safety rules
|
|
22
22
|
|
|
23
|
-
- Do not fabricate
|
|
23
|
+
- Do not fabricate Agnoclast context if the tool fails.
|
|
24
24
|
- Prefer the query-centered `session_context` over static `my_context` whenever the user's topic is clear.
|
|
25
25
|
- Do not call `session_context` for every tiny follow-up; refresh only when the topic meaningfully shifts.
|
package/skills/log/SKILL.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cortex-log
|
|
3
|
-
description: Close out a work session into
|
|
3
|
+
description: Close out a work session into Agnoclast — summarize what happened, confirm it reached the org, and surface anything teammates should know. Run at or near the end of any working session.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
> **
|
|
6
|
+
> **Agnoclast-managed skill.** This file is installed and kept up to date by Agnoclast. Local edits are
|
|
7
7
|
> restored on the next session (a backup of your version is saved alongside). Don't rely on changes here.
|
|
8
8
|
|
|
9
9
|
## Model: session-primary, daily-derived
|
|
10
10
|
|
|
11
|
-
The **session is the primary atomic unit** — one session = one durable
|
|
11
|
+
The **session is the primary atomic unit** — one session = one durable Agnoclast record (via
|
|
12
12
|
`log_session`, keyed by `sessionId`), which is also the per-record privacy unit (`set_record_privacy`
|
|
13
13
|
is per record). Any "what happened today / this week" view is a **derived rollup** over those session
|
|
14
14
|
records, never a separately-authored primary. This mirrors records(atomic) → digests(derived); the
|
|
@@ -16,7 +16,7 @@ personal `/log` skill follows the same shape against the local brain.
|
|
|
16
16
|
|
|
17
17
|
## When to use
|
|
18
18
|
|
|
19
|
-
At the end of a Claude Code session, or after finishing a meaningful phase of work.
|
|
19
|
+
At the end of a Claude Code session, or after finishing a meaningful phase of work. Agnoclast keeps a
|
|
20
20
|
background auto-capture as a fallback, but this skill is the *authoritative* close-out: it composes a
|
|
21
21
|
clean, structured summary and persists THAT as the session's durable record (superseding the
|
|
22
22
|
auto-capture's raw-transcript re-derivation).
|
|
@@ -33,7 +33,7 @@ No arguments. Read the conversation context.
|
|
|
33
33
|
These are the things a teammate or manager would want to know without reading the whole transcript.
|
|
34
34
|
3. **Persist it as the durable record** — call the `log_session` MCP tool with your curated `summary`
|
|
35
35
|
(plus `project`, and the Claude Code `sessionId` if you know it). This writes YOUR summary as the
|
|
36
|
-
session's authoritative
|
|
36
|
+
session's authoritative Agnoclast record (`capture_source='skill'`). The background auto-capture is a
|
|
37
37
|
fallback and will not overwrite it; passing the same `sessionId` the auto-capture uses dedupes them
|
|
38
38
|
onto one record. This — not the raw-transcript re-derivation — is the canonical record going forward.
|
|
39
39
|
4. **Confirm + flag privacy** — `log_session` returns a confirmation; if it errors, tell the user to
|
|
@@ -72,7 +72,7 @@ No arguments. Read the conversation context.
|
|
|
72
72
|
> verify a write; the result you already have in hand is the strongest.
|
|
73
73
|
>
|
|
74
74
|
> **What replaces it — at the moment of each write, not at the end:** a rejected `author` comes back
|
|
75
|
-
> as an ORDINARY tool result with **no error flag** — `Could not author "<page>":
|
|
75
|
+
> as an ORDINARY tool result with **no error flag** — `Could not author "<page>": Agnoclast API 409:
|
|
76
76
|
> <reason>` — and `No change to "<page>"` is a **200 OK that wrote nothing**. Neither is an error at
|
|
77
77
|
> the protocol level, so nothing will interrupt you. **Read the result text of every write; never
|
|
78
78
|
> skim it.** That inline read is where this step's value actually was.
|
|
@@ -3,7 +3,7 @@ name: cortex-walkthrough
|
|
|
3
3
|
description: Run the guided Agnoclast walkthrough for someone new. Use when the person asks for the walkthrough, a tutorial, or getting started — "give me the walkthrough", "walk me through this", "how do I use this", "show me around", "what can this do", "remind me how this works" — or when a brand-new user needs orienting for the first time.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
> **
|
|
6
|
+
> **Agnoclast-managed skill.** This file is installed and kept up to date by Agnoclast. Local edits are
|
|
7
7
|
> restored on the next session (a backup of your version is saved alongside). Don't rely on changes here.
|
|
8
8
|
|
|
9
9
|
# The Agnoclast walkthrough
|
package/lib/precompact.mjs
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// PreCompact "author now" reminder (③ live wiki authoring, [[cortex-wiki-authoring-spec]] D2).
|
|
2
|
-
//
|
|
3
|
-
// Wired by setup.mjs as a PreCompact hook. A hook CANNOT force a model turn — it can only inject text
|
|
4
|
-
// the model sees on its next turn (best-effort). So this prints a reminder to sweep understanding into
|
|
5
|
-
// the wiki BEFORE compaction discards the session's hot mental model. The HARD backstop is the /log
|
|
6
|
-
// skill (cortex-log step 5); this catches the in-session compaction that would otherwise lose the magic.
|
|
7
|
-
//
|
|
8
|
-
// Output goes to stdout, which the Claude Code harness surfaces as additional context for the next turn.
|
|
9
|
-
export function runPrecompactReminder() {
|
|
10
|
-
process.stdout.write(
|
|
11
|
-
'Cortex: context is about to compact. If your understanding of any node (the project(s) you worked ' +
|
|
12
|
-
'on, people you coordinated with, or yourself) advanced this session, AUTHOR it into the wiki NOW ' +
|
|
13
|
-
'before it is lost: call `authoring_context` then `author` for each. This is a synthesis of your ' +
|
|
14
|
-
'compiled understanding with inline [[links]], not a transcript dump. Skip nodes you did not advance.\n',
|
|
15
|
-
)
|
|
16
|
-
}
|