@theronap/cortex-mcp 0.9.75 → 0.9.77

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.
@@ -52,7 +52,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
52
52
  ` skills install/repair the managed Agnoclast skills — bundled + org-published (also wired by setup)\n` +
53
53
  ` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
54
54
  ` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
55
- ` graphify-sync [path] rebuild the local code graph (graphify) + log an evidence-tier timeline event\n` +
55
+ ` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
56
56
  ` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
57
57
  ` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
58
58
  ` statusline ambient presence line for the Claude Code statusline (local read only)\n` +
@@ -122,7 +122,7 @@ if (cmd === 'login') {
122
122
  } else if (cmd === 'resolve') {
123
123
  // Entity identity dedup: judge the server-flagged fuzzy duplicate pairs locally via `claude -p`.
124
124
  const { runResolve } = await import('../lib/resolve.mjs')
125
- await runResolve()
125
+ await runResolve(rest)
126
126
  const { closeFetch } = await import('../lib/diagnose.mjs')
127
127
  await closeFetch()
128
128
  } else if (cmd === 'materialize') {
package/lib/diagnose.mjs CHANGED
@@ -90,10 +90,19 @@ export function classify(status, contentType, bodyText, requestId) {
90
90
  const isJson = (contentType ?? '').includes('application/json')
91
91
  let appError = null
92
92
  let appHint = null
93
+ let appMessage = null
94
+ let appBrains = null
93
95
  if (isJson) {
94
96
  try {
95
97
  const parsed = JSON.parse(bodyText)
96
98
  appError = parsed?.error ?? null
99
+ // Same lesson as `hint`, one layer up. brain_choice_response.ts writes a full explanation to
100
+ // `message` and every brain's NAME / PAGE COUNT / SAMPLE TITLES to `brains` — its comment says
101
+ // "THE BODY IS THE ANSWER TO ITS OWN QUESTION", precisely so a model can pick a brain from what
102
+ // each one HOLDS. Only `error` survived here, so the agent got the bare code `brain_required`
103
+ // and could not answer it. Measured on the CLI twin of this bug: `✗ brain_required`, full stop.
104
+ appMessage = typeof parsed?.message === 'string' && parsed.message.trim() ? parsed.message.trim() : null
105
+ appBrains = Array.isArray(parsed?.brains) ? parsed.brains : null
97
106
  // `hint` carries the RECOVERY instruction for the errors an agent is meant to act on, not
98
107
  // just report: 409 would_drop ("re-author including the dropped sections…") and 413 too_large
99
108
  // ("split the section…"). It used to be dropped here — only `error` survived — so the agent
@@ -120,6 +129,25 @@ export function classify(status, contentType, bodyText, requestId) {
120
129
  `Vercel firewall / deployment protection on the API route. Re-running setup will not help.${rid}`,
121
130
  }
122
131
  }
132
+ // A brain-choice refusal is ANSWERABLE, so it must arrive as the question it is rather than as a
133
+ // code. Deliberately narrow: only these two errors reshape the message, so every other classify()
134
+ // output keeps its existing wording (and its tests).
135
+ if (isJson && (appError === 'brain_required' || appError === 'unknown_brain') && (appMessage || appBrains)) {
136
+ const list = (appBrains ?? []).map((b) => {
137
+ const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
138
+ const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
139
+ ? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
140
+ // Name AND id: brain names are NOT unique (one account holds two called "Personal"), so a name
141
+ // alone can come back as unknown_brain. The id always resolves.
142
+ return ` - ${b?.name ?? '(unnamed)'}${pages}${titles}${b?.orgId ? ` [${b.orgId}]` : ''}`
143
+ })
144
+ return {
145
+ kind: 'app', retriable: false,
146
+ message: `Agnoclast API ${status}: ${appMessage ?? appError}${list.length ? `\n${list.join('\n')}` : ''}` +
147
+ `\nRe-run this tool with \`brain\` set to one of the names or ids above.${rid}`,
148
+ }
149
+ }
150
+
123
151
  return {
124
152
  kind: 'app', retriable: status >= 500,
125
153
  message: `Agnoclast API ${status}: ${appError ?? 'unknown error'}.${appHint ? ` ${appHint}` : ''}${rid}`,
@@ -26,8 +26,42 @@ function repoFullNameFromRemote(cwd) {
26
26
  return m ? `${m[1]}/${m[2]}` : null
27
27
  }
28
28
 
29
+ // `--brain <name-or-org-id>` — which brain an UNROUTED repo's graph event lands in.
30
+ //
31
+ // Deliberately a flag and nothing cleverer. /api/timeline/graphify routes deterministically by repo
32
+ // (resolveSourceRoute) and only asks when the repo has no route; its comment is blunt about why it
33
+ // must not be guessed: this writes a `records` row unique on (org_id, dedupe_key), so "a repo whose
34
+ // graph updates land in two brains becomes two record sets that never reconcile, and re-pointing
35
+ // later converges on nothing (ADR-0020). It is the one write class where a wrong answer is genuinely
36
+ // unrecoverable." So: no sweep (unlike `resolve`, which is a maintenance pass over everything) and
37
+ // no auto-pick.
38
+ //
39
+ // It does NOT create a source route as a side effect, though that would stop the question recurring:
40
+ // routes are append-only precisely because "re-pointing a live source splits its history
41
+ // irreparably", and a near-irreversible write should not fall out of a CLI flag. This command runs
42
+ // from a per-repo cron/launchd job, so the flag lives in that job's definition — answered once,
43
+ // where it is visible. (Route creation currently has NO client on any surface; that is a separate
44
+ // gap, not this command's to paper over.)
45
+ // Split argv into { cwd, brain }. Pure + exported so the ordering trap below is unit-testable
46
+ // without a git repo, a graphify binary or a network.
47
+ //
48
+ // THE TRAP: argv[0] doubles as the optional repo path, and `--brain`'s VALUE has no leading '-'.
49
+ // Parsed naively, `graphify-sync --brain Personal` reads "Personal" as the path and syncs whatever
50
+ // happens to be there. So the flag and its value are stripped BEFORE the positional check.
51
+ export function parseGraphifyArgs(argv = [], fallbackCwd = process.cwd()) {
52
+ const bIdx = argv.indexOf('--brain')
53
+ const brain = bIdx === -1 ? null : argv[bIdx + 1]
54
+ if (bIdx !== -1 && (!brain || brain.startsWith('-'))) {
55
+ return { error: 'Usage: graphify-sync [path] [--brain <name-or-org-id>]' }
56
+ }
57
+ const rest = bIdx === -1 ? argv : argv.filter((_, i) => i !== bIdx && i !== bIdx + 1)
58
+ return { cwd: rest[0] && !rest[0].startsWith('-') ? rest[0] : fallbackCwd, brain }
59
+ }
60
+
29
61
  export async function runGraphifySync(argv = []) {
30
- const cwd = argv[0] && !argv[0].startsWith('-') ? argv[0] : process.cwd()
62
+ const parsed = parseGraphifyArgs(argv)
63
+ if (parsed.error) { process.stderr.write(parsed.error + '\n'); return 1 }
64
+ const { cwd, brain } = parsed
31
65
  const TOKEN = process.env.CORTEX_TOKEN
32
66
  const BASE = resolveBase(process.env.CORTEX_URL)
33
67
  if (!TOKEN) {
@@ -73,11 +107,22 @@ export async function runGraphifySync(argv = []) {
73
107
  const res = await fetchCortex(`${BASE}/api/timeline/graphify`, {
74
108
  method: 'POST',
75
109
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
76
- body: JSON.stringify({ repo, commitSha, nodeCount, edgeCount, communityCount }),
110
+ body: JSON.stringify({ repo, commitSha, nodeCount, edgeCount, communityCount, ...(brain ? { brain } : {}) }),
77
111
  })
78
112
  if (!res.ok) {
79
113
  const body = await res.text()
80
- process.stderr.write(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message + '\n')
114
+ const d = classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id'))
115
+ process.stderr.write(d.message + '\n')
116
+ // classify names the brains but speaks in MCP terms ("re-run this tool with `brain`"). Say the
117
+ // actual flag, and where to put it — this runs unattended from cron, so the person reading this
118
+ // is looking at a log after the fact, not a prompt.
119
+ if (res.status === 409 && !brain) {
120
+ process.stderr.write(
121
+ `\n${repo} has no routing decision yet, so it cannot be filed without one.\n` +
122
+ `Re-run with: cortex-mcp graphify-sync ${cwd === process.cwd() ? '' : `${cwd} `}--brain "<name-or-org-id>"\n` +
123
+ `and add that flag to this repo's cron/launchd job so it stops asking.\n`,
124
+ )
125
+ }
81
126
  return 1
82
127
  }
83
128
  const payload = await res.json()
package/lib/resolve.mjs CHANGED
@@ -8,47 +8,97 @@ import { edgeSafeEnv } from './edge_extract.mjs'
8
8
  // and pushes decisions back (POST /api/resolve-apply): confident-same → entity_merges, else → rejected
9
9
  // so the pair never re-flags. Conservative by construction. Always exits cleanly.
10
10
 
11
- export async function runResolve() {
11
+ // Which brains to sweep. BOTH endpoints require the brain to be NAMED (ADR-0022 requireBrain —
12
+ // answering a scoped read out of an ARBITRARY brain is the defect that whole class exists to
13
+ // prevent). Until 2026-08-09 this command named none, so a multi-brain caller got 409 on step 1 and
14
+ // the dedup sweep did nothing at all, silently, forever.
15
+ //
16
+ // Naming ONE brain would have been the smaller fix and the wrong one: `resolve` is a MAINTENANCE
17
+ // SWEEP over the user's entities, so doing one brain and reporting success is ADR-0022's other
18
+ // failure — "silently truncating a result set and presenting it as complete". With no --brain we
19
+ // enumerate the caller's brains and sweep EACH, naming it explicitly. Not a guess: every request
20
+ // still names exactly one brain, and all of them actually get done.
21
+ //
22
+ // A sole-brain caller sees today's behaviour: one pass, no flag, no prompt.
23
+ export async function brainsToSweep(base, token, wanted, deps = {}) {
24
+ const fetchFn = deps.fetchCortex ?? fetchCortex
25
+ if (wanted) return [{ orgId: wanted, name: wanted }] // explicit: pass through (name or org id)
26
+ const res = await fetchFn(`${base}/api/brains`, { headers: { Authorization: `Bearer ${token}` } })
27
+ if (!res.ok) {
28
+ const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
29
+ throw new Error(`could not list your brains — ${d.message}`)
30
+ }
31
+ const j = await res.json().catch(() => ({}))
32
+ const brains = Array.isArray(j.brains) ? j.brains : []
33
+ // Carry the ORG ID, never the name: brain names are NOT unique (this account holds two called
34
+ // "Personal"), and a duplicate name comes back as unknown_brain.
35
+ return brains.filter((b) => b?.orgId).map((b) => ({ orgId: b.orgId, name: b.name ?? b.orgId }))
36
+ }
37
+
38
+ export async function runResolve(argv = []) {
12
39
  // recursion guard (we spawn `claude --print`; if its Stop hook fires capture, that no-ops on this flag)
13
40
  if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
14
41
  const token = process.env.CORTEX_TOKEN
15
42
  if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
16
43
  const base = resolveBase(process.env.CORTEX_URL)
17
44
 
45
+ const bIdx = argv.indexOf('--brain')
46
+ const wanted = bIdx === -1 ? null : argv[bIdx + 1]
47
+ if (bIdx !== -1 && (!wanted || wanted.startsWith('-'))) {
48
+ process.stderr.write('Usage: resolve [--brain <name-or-org-id>]\n'); return
49
+ }
50
+
51
+ let targets
52
+ try {
53
+ targets = await brainsToSweep(base, token, wanted)
54
+ } catch (e) { process.stderr.write(`cortex: resolve — ${e.message}\n`); return }
55
+ if (!targets.length) { process.stderr.write('cortex: no brains to sweep\n'); return }
56
+
57
+ for (const t of targets) {
58
+ // Label each line with the brain when sweeping several: an unlabelled "merged 3" cannot be acted
59
+ // on, because you cannot tell WHERE three entities just merged.
60
+ await resolveOneBrain(base, token, t, targets.length > 1)
61
+ }
62
+ }
63
+
64
+ async function resolveOneBrain(base, token, brain, labelled) {
65
+ const tag = labelled ? `[${brain.name}] ` : ''
66
+ const qs = `?brain=${encodeURIComponent(brain.orgId)}`
67
+
18
68
  // 1. pull the flagged candidate pairs
19
69
  let candidates = []
20
70
  try {
21
- const res = await fetchCortex(`${base}/api/resolve-candidates`, { headers: { Authorization: `Bearer ${token}` } })
71
+ const res = await fetchCortex(`${base}/api/resolve-candidates${qs}`, { headers: { Authorization: `Bearer ${token}` } })
22
72
  if (!res.ok) {
23
73
  const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
24
- process.stderr.write(`cortex: resolve-candidates failed — ${d.message}\n`); return
74
+ process.stderr.write(`cortex: ${tag}resolve-candidates failed — ${d.message}\n`); return
25
75
  }
26
76
  const j = await res.json().catch(() => ({}))
27
77
  candidates = Array.isArray(j.candidates) ? j.candidates : []
28
- } catch (e) { process.stderr.write(`cortex: resolve fetch failed — ${e.message}\n`); return }
78
+ } catch (e) { process.stderr.write(`cortex: ${tag}resolve fetch failed — ${e.message}\n`); return }
29
79
 
30
- if (!candidates.length) { process.stderr.write('cortex: no duplicate candidates to judge\n'); return }
31
- process.stderr.write(`cortex: judging ${candidates.length} candidate pair(s) locally…\n`)
80
+ if (!candidates.length) { process.stderr.write(`cortex: ${tag}no duplicate candidates to judge\n`); return }
81
+ process.stderr.write(`cortex: ${tag}judging ${candidates.length} candidate pair(s) locally…\n`)
32
82
 
33
83
  // 2. judge locally on the subscription
34
84
  const decisions = judgeCandidates(candidates)
35
- if (decisions === null) { process.stderr.write('cortex: judge unavailable (is `claude` on PATH?) — skipping\n'); return }
85
+ if (decisions === null) { process.stderr.write(`cortex: ${tag}judge unavailable (is \`claude\` on PATH?) — skipping\n`); return }
36
86
 
37
- // 3. apply judged decisions
87
+ // 3. apply SAME brain the candidates came from, or the merges land in the wrong graph
38
88
  try {
39
- const res = await fetchCortex(`${base}/api/resolve-apply`, {
89
+ const res = await fetchCortex(`${base}/api/resolve-apply${qs}`, {
40
90
  method: 'POST',
41
91
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
42
92
  body: JSON.stringify({ decisions }),
43
93
  })
44
94
  if (res.ok) {
45
95
  const j = await res.json().catch(() => ({}))
46
- process.stderr.write(`cortex: dedup — merged ${j.merged ?? 0}, rejected ${j.rejected ?? 0}, skipped ${j.skipped ?? 0}\n`)
96
+ process.stderr.write(`cortex: ${tag}dedup — merged ${j.merged ?? 0}, rejected ${j.rejected ?? 0}, skipped ${j.skipped ?? 0}\n`)
47
97
  } else {
48
98
  const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
49
- process.stderr.write(`cortex: resolve-apply failed — ${d.message}\n`)
99
+ process.stderr.write(`cortex: ${tag}resolve-apply failed — ${d.message}\n`)
50
100
  }
51
- } catch (e) { process.stderr.write(`cortex: resolve-apply failed — ${e.message}\n`) }
101
+ } catch (e) { process.stderr.write(`cortex: ${tag}resolve-apply failed — ${e.message}\n`) }
52
102
  }
53
103
 
54
104
  // ONE `claude --print` call judges every pair. Returns DedupDecision[] for the apply endpoint, or null
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
 
@@ -885,13 +892,15 @@ export async function runServer(version) {
885
892
  'writing_style',
886
893
  {
887
894
  title: 'How the user writes (for drafting in their voice)',
888
- description: 'Returns the user\'s saved writing-style profile so you can DRAFT in their voice (email, message, doc). Call this right before composing anything on their behalf. Self-only — it is always the calling user\'s own profile. If none is saved, it tells you to derive one and save it with set_writing_style.',
889
- inputSchema: {},
895
+ description: 'Returns the user\'s saved writing-style profile so you can DRAFT in their voice (email, message, doc). Call this right before composing anything on their behalf. Self-only — it is always the calling user\'s own profile. If none is saved, it tells you to derive one and save it with set_writing_style. A profile is stored per (user, BRAIN) — it is injected into authoring IN a brain — so if you hold several, name the one you are drafting in.',
896
+ inputSchema: {
897
+ brain: z.string().optional().describe('which brain\'s style profile, by name or org id. Unnecessary when you only have one brain; pass the org id when a name matches more than one of yours'),
898
+ },
890
899
  },
891
- async () => {
900
+ async ({ brain } = {}) => {
892
901
  let res
893
902
  try {
894
- res = await fetchCortex(`${BASE}/api/style`, { headers: { Authorization: `Bearer ${TOKEN}` } })
903
+ res = await fetchCortex(`${BASE}/api/style${brain ? `?brain=${encodeURIComponent(brain)}` : ''}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
895
904
  } catch (e) {
896
905
  return toolError(`Could not load writing style: ${e.message}`)
897
906
  }
@@ -909,12 +918,15 @@ export async function runServer(version) {
909
918
  {
910
919
  title: 'Save the user\'s writing-style profile',
911
920
  description: 'Save (or update) a description of HOW the user writes — tone, sentence rhythm, structure, formatting habits, signature quirks — derived from prose you have seen them write this session. Store the STYLE, never their private content. Self-only: it always updates the calling user\'s own profile. Pass an empty string to clear it.',
912
- inputSchema: { style_md: z.string().describe('a concise markdown description of the user\'s writing voice (tone/structure/quirks), ~1-2 paragraphs') },
921
+ inputSchema: {
922
+ style_md: z.string().describe('a concise markdown description of the user\'s writing voice (tone/structure/quirks), ~1-2 paragraphs'),
923
+ brain: z.string().optional().describe('which brain to save the profile in, by name or org id. A profile is stored per (user, brain), so this is a real choice when you hold several; pass the org id when a name matches more than one of yours'),
924
+ },
913
925
  },
914
- async ({ style_md }) => {
926
+ async ({ style_md, brain }) => {
915
927
  let res
916
928
  try {
917
- res = await fetchCortex(`${BASE}/api/style`, {
929
+ res = await fetchCortex(`${BASE}/api/style${brain ? `?brain=${encodeURIComponent(brain)}` : ''}`, {
918
930
  method: 'PUT',
919
931
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
920
932
  body: JSON.stringify({ style_md }),
@@ -1152,7 +1164,7 @@ export async function runServer(version) {
1152
1164
  'list_brain_pages',
1153
1165
  {
1154
1166
  title: 'List every authored page in one brain',
1155
- description: 'Enumerate ALL authored pages in ONE brain, by its org id (from my_brains). Unlike my_brains — which ships only a count plus a few sample titles — this returns the FULL page list: one row per node with its kind, validity, tier(s), last-updated date and content hash. Use it to audit a brain, or to VERIFY a brain-to-brain migration — list BOTH brains, diff the page sets, and compare freshness before retiring any original. You can only list a brain you are a member of; reads never widen past your own brains.',
1167
+ description: 'Enumerate ALL authored pages in ONE brain, by its org id (from my_brains). Unlike my_brains — which ships only a count plus a few sample titles — this returns the FULL page list: one row per node with its kind, validity, tier(s), last-updated date and content hash. Use it to audit a brain, or to VERIFY a brain-to-brain migration — list BOTH brains, diff the page sets, and compare freshness before retiring any original. You can only list a brain you are a member of, and within it you see only the pages you are cleared to read — a confidential page owned by someone else is not listed, not even by title.',
1156
1168
  inputSchema: {
1157
1169
  org_id: z.string().describe('the org id of the brain to enumerate (from my_brains)'),
1158
1170
  },
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.75",
3
+ "version": "0.9.77",
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": {