@theronap/cortex-mcp 0.9.33 → 0.9.35

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.
@@ -45,7 +45,9 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
45
45
  ` repair re-run setup at the latest version using your existing token (no token needed)\n` +
46
46
  ` doctor live health check — confirm your token works (no restart needed)\n` +
47
47
  ` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
48
- ` skills install/repair the managed Cortex skills (also wired by setup)\n` +
48
+ ` skills install/repair the managed Cortex skills — bundled + org-published (also wired by setup)\n` +
49
+ ` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
50
+ ` docs-scan detect new/changed local docs pending Cortex authoring (used by /cortex-author-docs)\n` +
49
51
  ` snapshot-context save the exact startup context Cortex served to a local snapshot\n` +
50
52
  ` capture Stop-hook capturer (invoked by Claude Code)\n` +
51
53
  ` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
@@ -117,9 +119,17 @@ if (cmd === 'setup') {
117
119
  const { closeFetch } = await import('../lib/diagnose.mjs')
118
120
  await closeFetch()
119
121
  } else if (cmd === 'skills') {
120
- // Install / repair the managed Cortex skills repository. No network exits naturally.
122
+ // Install / repair the managed Cortex skills bundled + org-published (`skills push` publishes).
123
+ // Org sync is network-fail-soft so the SessionStart hook stays safe offline.
121
124
  const { runSkills } = await import('../lib/skills.mjs')
122
125
  process.exitCode = await runSkills(rest)
126
+ const { closeFetch } = await import('../lib/diagnose.mjs')
127
+ await closeFetch()
128
+ } else if (cmd === 'docs-scan') {
129
+ // Documentation ingestion, detection half: hash-diff registered doc roots for new/changed *.md.
130
+ // The cortex-author-docs skill authors the pending docs into wiki pages. No network.
131
+ const { runDocsScan } = await import('../lib/docs_scan.mjs')
132
+ process.exitCode = await runDocsScan(rest)
123
133
  } else if (cmd === 'precompact') {
124
134
  // PreCompact hook (③ live wiki authoring, best-effort): print an "author now" reminder so the session
125
135
  // sweeps its understanding into the wiki BEFORE compaction drops it. A hook cannot force a model turn
@@ -0,0 +1,171 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs'
2
+ import { createHash } from 'crypto'
3
+ import { homedir } from 'os'
4
+ import { join, resolve, dirname } from 'path'
5
+
6
+ // Documentation ingestion — detection half (D1 of docs/documentation-ingestion-spec.md).
7
+ //
8
+ // Specs/plans/design docs get written to disk (repo docs/, ~/.gstack/projects/…) and never reach
9
+ // Cortex as pages. This subcommand DETECTS new/changed markdown under registered roots by content
10
+ // hash; the AUTHORING is done by the live session (the cortex-author-docs skill reads each pending
11
+ // doc and calls the `author` MCP tool) — the agent is the pipe, never a raw-markdown dump.
12
+ //
13
+ // State is local (~/.cortex/docs-sync.json): watched roots + sha256 per pushed file. Files are
14
+ // marked ONLY after a successful author (`--mark`), so a failed author simply stays pending.
15
+ // No network; exits naturally (CLI-subcommand convention).
16
+
17
+ const STATE_PATH = join(homedir(), '.cortex', 'docs-sync.json')
18
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next'])
19
+ const MAX_BYTES = 256 * 1024
20
+
21
+ export function loadState(path = STATE_PATH) {
22
+ try {
23
+ const s = JSON.parse(readFileSync(path, 'utf8'))
24
+ return { version: 1, roots: Array.isArray(s.roots) ? s.roots : [], files: s.files && typeof s.files === 'object' ? s.files : {} }
25
+ } catch {
26
+ return { version: 1, roots: [], files: {} }
27
+ }
28
+ }
29
+
30
+ export function saveState(state, path = STATE_PATH) {
31
+ const dir = dirname(path)
32
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
33
+ writeFileSync(path, JSON.stringify(state, null, 2))
34
+ }
35
+
36
+ export const hashContent = (s) => createHash('sha256').update(s).digest('hex')
37
+
38
+ // Recursively collect candidate .md files under one root, applying the skip rules.
39
+ export function collectMarkdown(root) {
40
+ const out = []
41
+ const walk = (dir) => {
42
+ let entries
43
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
44
+ for (const e of entries) {
45
+ if (e.isDirectory()) {
46
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name))
47
+ continue
48
+ }
49
+ if (!e.name.endsWith('.md') || e.name.endsWith('.bak.md')) continue
50
+ if (e.name.endsWith('.md.bak')) continue
51
+ const path = join(dir, e.name)
52
+ try { if (statSync(path).size > MAX_BYTES) continue } catch { continue }
53
+ out.push(path)
54
+ }
55
+ }
56
+ walk(root)
57
+ return out.sort()
58
+ }
59
+
60
+ // Diff the filesystem against state. Returns { pending: [{path, status:'new'|'changed'}], scanned }.
61
+ export function scanRoots(state) {
62
+ const pending = []
63
+ let scanned = 0
64
+ for (const root of state.roots) {
65
+ for (const path of collectMarkdown(root)) {
66
+ scanned++
67
+ let body
68
+ try { body = readFileSync(path, 'utf8') } catch { continue }
69
+ const h = hashContent(body)
70
+ const prev = state.files[path]?.hash
71
+ if (!prev) pending.push({ path, status: 'new' })
72
+ else if (prev !== h) pending.push({ path, status: 'changed' })
73
+ }
74
+ }
75
+ return { pending, scanned }
76
+ }
77
+
78
+ // Record the CURRENT content hash for the given files (call after a successful author).
79
+ export function markFiles(state, paths, now = new Date().toISOString()) {
80
+ const marked = []
81
+ const missing = []
82
+ for (const p of paths) {
83
+ const abs = resolve(p)
84
+ let body
85
+ try { body = readFileSync(abs, 'utf8') } catch { missing.push(abs); continue }
86
+ state.files[abs] = { hash: hashContent(body), markedAt: now }
87
+ marked.push(abs)
88
+ }
89
+ return { marked, missing }
90
+ }
91
+
92
+ // Heuristic soak guardrail (spec D5): the Robin parity experiment forbids re-syncing the local
93
+ // brain into Cortex during the window, so warn when a root looks like the brain repo.
94
+ const looksLikeBrain = (dir) => /\/Documents\/brain(\/|$)/.test(dir)
95
+
96
+ export async function runDocsScan(argv = []) {
97
+ const state = loadState()
98
+ const out = (m) => process.stdout.write(m + '\n')
99
+
100
+ const flagIdx = (f) => argv.indexOf(f)
101
+
102
+ if (flagIdx('--roots') !== -1) {
103
+ if (!state.roots.length) out('No roots registered. Add one: cortex-mcp docs-scan --add-root <dir>')
104
+ else state.roots.forEach((r) => out(r))
105
+ return 0
106
+ }
107
+
108
+ if (flagIdx('--add-root') !== -1) {
109
+ const dir = argv[flagIdx('--add-root') + 1]
110
+ if (!dir) { process.stderr.write('Usage: docs-scan --add-root <dir>\n'); return 1 }
111
+ const abs = resolve(dir)
112
+ if (!existsSync(abs)) { process.stderr.write(`Not a directory: ${abs}\n`); return 1 }
113
+ if (looksLikeBrain(abs)) {
114
+ out(`⚠ ${abs} looks like the local brain repo — the Robin parity soak forbids re-syncing it into Cortex.`)
115
+ out(' Registering anyway is on you; the cortex-author-docs skill will also warn.')
116
+ }
117
+ if (!state.roots.includes(abs)) state.roots.push(abs)
118
+ saveState(state)
119
+ out(`✓ watching ${abs} (${state.roots.length} root${state.roots.length === 1 ? '' : 's'})`)
120
+ return 0
121
+ }
122
+
123
+ if (flagIdx('--remove-root') !== -1) {
124
+ const dir = argv[flagIdx('--remove-root') + 1]
125
+ if (!dir) { process.stderr.write('Usage: docs-scan --remove-root <dir>\n'); return 1 }
126
+ const abs = resolve(dir)
127
+ state.roots = state.roots.filter((r) => r !== abs)
128
+ saveState(state)
129
+ out(`✓ removed ${abs}`)
130
+ return 0
131
+ }
132
+
133
+ if (flagIdx('--mark') !== -1) {
134
+ const files = argv.slice(flagIdx('--mark') + 1).filter((a) => !a.startsWith('-'))
135
+ if (!files.length) { process.stderr.write('Usage: docs-scan --mark <file>...\n'); return 1 }
136
+ const { marked, missing } = markFiles(state, files)
137
+ saveState(state)
138
+ marked.forEach((p) => out(`✓ marked ${p}`))
139
+ missing.forEach((p) => process.stderr.write(`✗ unreadable, not marked: ${p}\n`))
140
+ return missing.length ? 1 : 0
141
+ }
142
+
143
+ if (flagIdx('--mark-all') !== -1) {
144
+ // Baseline initialization: acknowledge the whole current backlog without authoring it.
145
+ const { pending } = scanRoots(state)
146
+ const { marked } = markFiles(state, pending.map((p) => p.path))
147
+ saveState(state)
148
+ out(`✓ baseline set — marked ${marked.length} doc(s) as already-known`)
149
+ return 0
150
+ }
151
+
152
+ // Default: scan and report.
153
+ const json = flagIdx('--json') !== -1
154
+ if (!state.roots.length) {
155
+ if (json) out(JSON.stringify({ roots: [], pending: [] }))
156
+ else out('No roots registered. Add one: cortex-mcp docs-scan --add-root <dir>')
157
+ return 0
158
+ }
159
+ const { pending, scanned } = scanRoots(state)
160
+ if (json) {
161
+ out(JSON.stringify({ roots: state.roots, scanned, pending }, null, 2))
162
+ return 0
163
+ }
164
+ if (!pending.length) out(`✓ up to date — ${scanned} doc(s) scanned, nothing pending`)
165
+ else {
166
+ out(`${pending.length} doc(s) pending Cortex authoring (of ${scanned} scanned):`)
167
+ for (const p of pending) out(` ${p.status === 'new' ? '+ ' : '~ '}${p.path}`)
168
+ out('Author them via the cortex-author-docs skill, then: docs-scan --mark <file>...')
169
+ }
170
+ return 0
171
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect, beforeEach } from 'bun:test'
2
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import { loadState, saveState, scanRoots, markFiles, collectMarkdown, hashContent } from './docs_scan.mjs'
6
+
7
+ const tmp = () => mkdtempSync(join(tmpdir(), 'docs-scan-'))
8
+
9
+ describe('state round-trip', () => {
10
+ it('missing/corrupt state loads as empty and saves back', () => {
11
+ const dir = tmp()
12
+ const path = join(dir, 'nested', 'docs-sync.json')
13
+ expect(loadState(path)).toEqual({ version: 1, roots: [], files: {} })
14
+ const s = { version: 1, roots: ['/a'], files: { '/a/x.md': { hash: 'h', markedAt: 't' } } }
15
+ saveState(s, path)
16
+ expect(loadState(path)).toEqual(s)
17
+ writeFileSync(path, '{not json')
18
+ expect(loadState(path)).toEqual({ version: 1, roots: [], files: {} })
19
+ })
20
+ })
21
+
22
+ describe('collectMarkdown skip rules', () => {
23
+ let root
24
+ beforeEach(() => {
25
+ root = tmp()
26
+ writeFileSync(join(root, 'spec.md'), '# spec')
27
+ writeFileSync(join(root, 'notes.txt'), 'not md')
28
+ writeFileSync(join(root, 'old.md.bak'), 'backup')
29
+ mkdirSync(join(root, 'node_modules', 'pkg'), { recursive: true })
30
+ writeFileSync(join(root, 'node_modules', 'pkg', 'README.md'), 'dep readme')
31
+ mkdirSync(join(root, '.git'))
32
+ writeFileSync(join(root, '.git', 'x.md'), 'git internals')
33
+ mkdirSync(join(root, 'sub'))
34
+ writeFileSync(join(root, 'sub', 'plan.md'), '# plan')
35
+ writeFileSync(join(root, 'big.md'), 'x'.repeat(256 * 1024 + 1))
36
+ })
37
+ it('finds nested .md, skips node_modules/.git/.bak/non-md/oversize', () => {
38
+ const found = collectMarkdown(root)
39
+ expect(found).toEqual([join(root, 'spec.md'), join(root, 'sub', 'plan.md')])
40
+ })
41
+ })
42
+
43
+ describe('scan + mark lifecycle', () => {
44
+ it('new → pending; mark → clean; edit → changed; re-mark → clean', () => {
45
+ const root = tmp()
46
+ const doc = join(root, 'design.md')
47
+ writeFileSync(doc, 'v1')
48
+ const state = { version: 1, roots: [root], files: {} }
49
+
50
+ let r = scanRoots(state)
51
+ expect(r.pending).toEqual([{ path: doc, status: 'new' }])
52
+
53
+ const m = markFiles(state, [doc], '2026-07-02T00:00:00Z')
54
+ expect(m.marked).toEqual([doc])
55
+ expect(state.files[doc].hash).toBe(hashContent('v1'))
56
+ expect(scanRoots(state).pending).toEqual([])
57
+
58
+ writeFileSync(doc, 'v2')
59
+ r = scanRoots(state)
60
+ expect(r.pending).toEqual([{ path: doc, status: 'changed' }])
61
+
62
+ markFiles(state, [doc])
63
+ expect(scanRoots(state).pending).toEqual([])
64
+ })
65
+ it('mark of an unreadable file reports missing and records nothing', () => {
66
+ const state = { version: 1, roots: [], files: {} }
67
+ const gone = join(tmp(), 'nope.md')
68
+ const m = markFiles(state, [gone])
69
+ expect(m.marked).toEqual([])
70
+ expect(m.missing).toEqual([gone])
71
+ expect(state.files[gone]).toBeUndefined()
72
+ })
73
+ })
package/lib/server.mjs CHANGED
@@ -333,7 +333,14 @@ export async function runServer(version) {
333
333
  const day = (d) => (d ? String(d).slice(0, 10) : '')
334
334
  const blocks = page.tiers.map((t) => {
335
335
  const secs = (t.sections ?? []).map((s) => `### ${s.heading}\n${s.body}`).join('\n\n')
336
- const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${t.version ? `\nversion: ${t.version}` : ''}]`
336
+ // ADR-0018: a null version isn't "nothing to show" it means this variant predates content-
337
+ // hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
338
+ // admin backfills it. Silently omitting the line here is exactly what sent callers into an
339
+ // unrecoverable base_version guessing loop; say so explicitly instead.
340
+ const versionLine = t.version
341
+ ? `\nversion: ${t.version}`
342
+ : `\nversion: none (this variant predates content-hash tracking — base_version writes will always fail here; ask an admin about the ADR-0018 backfill)`
343
+ const head = `[${t.tier}${day(t.updated_at) ? ` · authored ${day(t.updated_at)}` : ''}${t.validity && t.validity !== 'current' ? ` · ${t.validity}` : ''}${versionLine}]`
337
344
  return [head, t.summary, secs].filter(Boolean).join('\n')
338
345
  })
339
346
  let footer = `— Follow any [[links]] above with read_page to go deeper.\n— If you hold fresher FIRST-HAND truth than this page — something you established THIS session (ran the command, made the change, hold the artifact) that a section here gets concretely wrong — re-author just those sections now with \`author\` (any node you are permitted to edit; concise; a material contradiction, not merely extra detail). Pass this page's \`version\` as base_version when you re-author — if the save comes back stale, the page changed under you: re-read, then re-author. Reading a stale page you can fix IS the trigger to fix it.`
@@ -521,22 +528,23 @@ export async function runServer(version) {
521
528
  'set_page_privacy',
522
529
  {
523
530
  title: 'Change who can see a wiki page',
524
- description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. If the target tier already has a page, you\'ll be asked to merge via author first, then re-run with absorb=true. Org admins may demote any page, never promote.',
531
+ description: 'Re-tier a wiki page you own or may edit: "accessible" (anyone in the org), "scoped" (owner + their management chain), or "confidential" (owner only, plus explicit grants). Demoting a PROJECT page also demotes its evidence records (demote-only; each record\'s owner is notified and can revert). Promotions never touch records. If the target tier already has a page, merge your content into it via `author` FIRST, then read_page the target again to get its fresh version, then re-run this with absorb=true and target_version=<that version> — absorb will REJECT (not silently drop content) if target_version doesn\'t match what\'s actually there, so a merge that didn\'t really land can\'t destroy your source page. Org admins may demote any page, never promote.',
525
532
  inputSchema: {
526
533
  kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
527
534
  name: z.string().describe('the exact page name'),
528
535
  tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the new visibility tier'),
529
536
  source_tier: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('when the node has multiple tier variants: which one to move'),
530
- absorb: z.boolean().optional().describe('after merging your content into an existing target-tier page via author: true removes your now-absorbed source variant'),
537
+ absorb: z.boolean().optional().describe('after merging your content into an existing target-tier page via author: true removes your now-absorbed source variant. Requires target_version.'),
538
+ target_version: z.string().optional().describe('REQUIRED with absorb=true — the target page\'s version, read via read_page AFTER your author() merge landed. Proves the merge actually happened before your source page is deleted; a stale or guessed value is rejected, not silently accepted.'),
531
539
  },
532
540
  },
533
- async ({ kind, name, tier, source_tier, absorb }) => {
541
+ async ({ kind, name, tier, source_tier, absorb, target_version }) => {
534
542
  let res
535
543
  try {
536
544
  res = await fetchCortex(`${BASE}/api/brain/page-privacy`, {
537
545
  method: 'POST',
538
546
  headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
539
- body: JSON.stringify({ kind, name, tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}) }),
547
+ body: JSON.stringify({ kind, name, tier, ...(source_tier ? { source_tier } : {}), ...(absorb ? { absorb: true } : {}), ...(target_version ? { target_version } : {}) }),
540
548
  })
541
549
  } catch (e) {
542
550
  return { content: [{ type: 'text', text: `Could not set page privacy: ${e.message}` }] }
@@ -547,7 +555,7 @@ export async function runServer(version) {
547
555
  // surface the server's structured message verbatim so the agent can follow it.
548
556
  if (out?.error) {
549
557
  const extra = out.collision === 'readable' && out.blocking
550
- ? `\nYour ${out.source?.tier} page: ${out.source?.summary ?? out.source?.title}\nExisting ${out.blocking.tier} page: ${out.blocking.summary ?? out.blocking.title}`
558
+ ? `\nYour ${out.source?.tier} page: ${out.source?.summary ?? out.source?.title}\nExisting ${out.blocking.tier} page (current version: ${out.blocking.version ?? 'none — this page predates content-hash tracking and cannot be re-authored via base_version; ask an admin about a backfill'}): ${out.blocking.summary ?? out.blocking.title}`
551
559
  : ''
552
560
  return { content: [{ type: 'text', text: `Could not set page privacy: ${out.error}${extra}` }] }
553
561
  }
package/lib/setup.mjs CHANGED
@@ -252,8 +252,14 @@ export async function runSetup(argv, version) {
252
252
  }
253
253
 
254
254
  // ── 3. Managed skills — installed flat into every agent CLI present (Claude + Codex) ──
255
+ // Bundled first (sync, network-free), then the org-published set (fail-soft pull), so a fresh
256
+ // seat has its org's skills at first session, not second. CORTEX_TOKEN is in env for the pull
257
+ // only if the caller exported it; syncOrgSkills falls back to the token this setup just wired.
255
258
  try {
256
259
  installSkills({ quiet: false })
260
+ const { syncOrgSkills } = await import('./skills.mjs')
261
+ process.env.CORTEX_TOKEN = process.env.CORTEX_TOKEN || token
262
+ await syncOrgSkills({ quiet: false })
257
263
  } catch (e) {
258
264
  // Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
259
265
  log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
package/lib/skills.mjs CHANGED
@@ -1,12 +1,20 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync } from 'fs'
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, rmSync } from 'fs'
2
2
  import { homedir } from 'os'
3
3
  import { join, dirname } from 'path'
4
4
  import { fileURLToPath } from 'url'
5
5
 
6
6
  // Managed Cortex skills.
7
7
  //
8
- // Cortex ships mandatory, self-healing skills (e.g. cortex-log) and installs them into EVERY agent
9
- // CLI present on the machine, at the flat layout each one discovers:
8
+ // Two sources, one install pipeline:
9
+ // 1. BUNDLED shipped inside this package (cortex-log, cortex-context, cortex-author-docs).
10
+ // Inalterable: canonical content always wins; user edits are backed up + restored.
11
+ // 2. ORG-PUBLISHED — rows in the org's `org_skills` table (documentation-ingestion-spec.md
12
+ // Part B). An owner/manager/admin publishes a SKILL.md once (`skills push`); every seat's
13
+ // SessionStart `skills --repair` hook pulls + installs it here. Bundled names WIN collisions,
14
+ // so an org can never shadow a core skill. The pull is fail-soft (offline → last-good cache
15
+ // at ~/.cortex/org-skills-cache.json → skip), because SessionStart must never break.
16
+ //
17
+ // Installed into EVERY agent CLI present on the machine, at the flat layout each one discovers:
10
18
  // ~/.claude/skills/<name>/SKILL.md (Claude Code)
11
19
  // ~/.codex/skills/<name>/SKILL.md (OpenAI Codex CLI — only if ~/.codex exists)
12
20
  //
@@ -14,13 +22,15 @@ import { fileURLToPath } from 'url'
14
22
  // skills as <cli>/skills/<name>/SKILL.md. An earlier layout nested them under skills/cortex/core/,
15
23
  // which is installed but INVISIBLE to discovery (the skill never appears as a slash command).
16
24
  //
17
- // "Inalterable" in practice: the bundled skills are the source of truth. On every install AND on
18
- // every Claude session start (the SessionStart --repair hook), any managed skill whose on-disk
19
- // content drifted is RESTORED — the user's version is backed up to SKILL.md.user-bak first, so
20
- // nothing is lost, but the canonical skill always wins. Identical content is a no-op.
25
+ // "Inalterable" in practice: on every install AND on every Claude session start (the SessionStart
26
+ // --repair hook), any managed skill whose on-disk content drifted is RESTORED — the user's version
27
+ // is backed up to SKILL.md.user-bak first, so nothing is lost, but the canonical skill always wins.
28
+ // Identical content is a no-op.
21
29
 
22
30
  const HERE = dirname(fileURLToPath(import.meta.url))
23
31
  const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
32
+ const ORG_CACHE = join(homedir(), '.cortex', 'org-skills-cache.json')
33
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ // mirrors the org_skills check; also blocks path tricks
24
34
 
25
35
  // Agent CLIs we install skills into. Claude is primary; Codex is included whenever it's present.
26
36
  // Both use the same flat <cli>/skills/<name>/SKILL.md discovery layout.
@@ -59,38 +69,65 @@ function bundledSkills() {
59
69
  })
60
70
  }
61
71
 
62
- // Install/repair all bundled skills into one CLI's skills root. Returns per-CLI change lists.
63
- function installInto(skillsRoot, skills) {
72
+ // Write one skill file with the repair semantics: backup user drift, canonical wins, no-op on match.
73
+ function writeSkillFile(dest, source) {
74
+ if (!existsSync(dest)) {
75
+ ensureDir(dirname(dest))
76
+ writeFileSync(dest, source)
77
+ return 'installed'
78
+ }
79
+ const current = readFileSync(dest, 'utf8')
80
+ if (current === source) return 'unchanged'
81
+ try { copyFileSync(dest, `${dest}.user-bak`) } catch { /* best-effort backup */ }
82
+ writeFileSync(dest, source)
83
+ return 'repaired'
84
+ }
85
+
86
+ // Install/repair a skill list into one CLI's skills root, plus remove org skills we previously
87
+ // installed that are no longer served. Merges the manifest rather than clobbering it, so bundled
88
+ // and org passes can run independently.
89
+ function installInto(skillsRoot, skills, { removeNames = [] } = {}) {
64
90
  ensureDir(skillsRoot)
65
- const result = { installed: [], repaired: [], unchanged: [] }
66
- const manifest = { managed: [], updated_by: 'cortex-mcp', skills: {} }
91
+ const result = { installed: [], repaired: [], unchanged: [], removed: [] }
67
92
 
68
93
  for (const sk of skills) {
69
94
  const dest = join(skillsRoot, sk.name, 'SKILL.md')
70
- manifest.managed.push(`${sk.name}/SKILL.md`)
71
- manifest.skills[sk.name] = hash(sk.source)
72
-
73
- if (!existsSync(dest)) {
74
- ensureDir(dirname(dest))
75
- writeFileSync(dest, sk.source)
76
- result.installed.push(sk.name)
77
- continue
78
- }
79
- const current = readFileSync(dest, 'utf8')
80
- if (current === sk.source) { result.unchanged.push(sk.name); continue }
95
+ const status = writeSkillFile(dest, sk.source)
96
+ result[status].push(sk.name)
97
+ }
81
98
 
82
- // Drift: preserve the user's version, then restore canonical.
83
- try { copyFileSync(dest, `${dest}.user-bak`) } catch { /* best-effort backup */ }
84
- writeFileSync(dest, sk.source)
85
- result.repaired.push(sk.name)
99
+ for (const name of removeNames) {
100
+ const destDir = join(skillsRoot, name)
101
+ const dest = join(destDir, 'SKILL.md')
102
+ if (!existsSync(dest)) continue
103
+ try {
104
+ copyFileSync(dest, `${dest}.removed-bak`)
105
+ rmSync(dest)
106
+ rmSync(`${dest}.removed-bak`)
107
+ rmSync(destDir, { recursive: true }) // dir now holds only leftovers we created
108
+ result.removed.push(name)
109
+ } catch { /* best-effort — a stubborn dir just stays */ }
86
110
  }
87
111
 
88
- writeFileSync(join(skillsRoot, '.cortex-skills.json'), JSON.stringify(manifest, null, 2))
112
+ // Merge manifest: keep entries from the other pass, replace ours.
113
+ const manifestPath = join(skillsRoot, '.cortex-skills.json')
114
+ let manifest = { managed: [], updated_by: 'cortex-mcp', skills: {} }
115
+ try { manifest = { ...manifest, ...JSON.parse(readFileSync(manifestPath, 'utf8')) } } catch { /* fresh */ }
116
+ for (const name of removeNames) {
117
+ delete manifest.skills[name]
118
+ manifest.managed = (manifest.managed ?? []).filter((m) => m !== `${name}/SKILL.md`)
119
+ }
120
+ for (const sk of skills) {
121
+ if (!manifest.managed.includes(`${sk.name}/SKILL.md`)) manifest.managed.push(`${sk.name}/SKILL.md`)
122
+ manifest.skills[sk.name] = hash(sk.source)
123
+ }
124
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
89
125
  return result
90
126
  }
91
127
 
92
128
  /**
93
- * Install / repair the managed Cortex skills into every agent CLI present on the machine.
129
+ * Install / repair the BUNDLED managed skills into every agent CLI present on the machine.
130
+ * Synchronous + network-free (setup calls this before any await).
94
131
  * @param {{ quiet?: boolean }} opts quiet → only emit on actual change (for the SessionStart hook)
95
132
  * @returns {{ installed: string[], repaired: string[], unchanged: string[], targets: string[] }}
96
133
  */
@@ -123,9 +160,134 @@ export function installSkills(opts = {}) {
123
160
  return summary
124
161
  }
125
162
 
126
- // CLI entry: `cortex-mcp skills [--repair] [--quiet]`. (--repair and plain install are the same
127
- // idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
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.
165
+ export function planOrgInstall(served, bundledNames) {
166
+ const bundled = new Set(bundledNames)
167
+ const install = []
168
+ const skipped = []
169
+ for (const s of served ?? []) {
170
+ const name = (s?.name ?? '').trim()
171
+ if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
172
+ if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
173
+ install.push({ name, source: s.body_md })
174
+ }
175
+ return { install, skipped }
176
+ }
177
+
178
+ /**
179
+ * Pull the org's published skills and install them beside the bundled ones. FAIL-SOFT by design:
180
+ * no token → skip; fetch failure → last-good cache; nothing → skip. SessionStart must never break.
181
+ * Also removes org skills we previously installed that are no longer served (tracked via cache).
182
+ */
183
+ export async function syncOrgSkills(opts = {}) {
184
+ const quiet = !!opts.quiet
185
+ const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
186
+ const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
187
+
188
+ const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
189
+ const token = process.env.CORTEX_TOKEN || readWiredToken()
190
+ if (!token) { log(' · org skills: no token wired — skipped'); return summary }
191
+
192
+ // Previous org-installed names, so we can remove what the org deleted/disabled.
193
+ let cache = null
194
+ try { cache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* none */ }
195
+
196
+ let served = null
197
+ try {
198
+ const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/skills`, {
199
+ headers: { Authorization: `Bearer ${token}` },
200
+ })
201
+ if (res.ok) {
202
+ served = (await res.json())?.skills ?? []
203
+ summary.source = 'server'
204
+ }
205
+ } catch { /* fall through to cache */ }
206
+ if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
207
+ if (!served) { log(' · org skills: unreachable and no cache — skipped'); return summary }
208
+
209
+ const { install, skipped } = planOrgInstall(served, bundledSkills().map((s) => s.name))
210
+ summary.skipped = skipped
211
+ const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
212
+ const currentNames = install.map((s) => s.name)
213
+ const removeNames = prevNames.filter((n) => !currentNames.includes(n))
214
+
215
+ for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
216
+ const r = installInto(join(cli.dir, 'skills'), install, { removeNames })
217
+ summary.installed.push(...r.installed)
218
+ summary.repaired.push(...r.repaired)
219
+ summary.removed.push(...r.removed)
220
+ }
221
+
222
+ // Persist last-good only when the server actually answered (a cache-fed run keeps the old one).
223
+ if (summary.source === 'server') {
224
+ ensureDir(dirname(ORG_CACHE))
225
+ writeFileSync(ORG_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
226
+ }
227
+
228
+ const changed = [...new Set([...summary.installed, ...summary.repaired])]
229
+ if (changed.length || summary.removed.length) {
230
+ const bits = []
231
+ if (changed.length) bits.push(`synced ${changed.join(', ')}`)
232
+ if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
233
+ process.stdout.write(`Cortex: org skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
234
+ } else if (!quiet) {
235
+ log(` ✓ org skills up to date (${currentNames.length} published${skipped.length ? `, ${skipped.length} skipped` : ''})`)
236
+ }
237
+ return summary
238
+ }
239
+
240
+ // `skills push <file>` / `skills push --disable <name>` / `skills push --enable <name>` —
241
+ // publish or toggle an org skill (owner/manager/admin; the server enforces the role).
242
+ async function runSkillsPush(argv) {
243
+ const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
244
+ const token = process.env.CORTEX_TOKEN || readWiredToken()
245
+ if (!token) { process.stderr.write('No Cortex token wired — run setup first.\n'); return 1 }
246
+ const base = resolveBase(process.env.CORTEX_URL)
247
+
248
+ let payload
249
+ const toggleIdx = argv.findIndex((a) => a === '--disable' || a === '--enable')
250
+ if (toggleIdx !== -1) {
251
+ const name = argv[toggleIdx + 1]
252
+ if (!name) { process.stderr.write(`Usage: skills push ${argv[toggleIdx]} <name>\n`); return 1 }
253
+ payload = { name, enabled: argv[toggleIdx] === '--enable' }
254
+ } else {
255
+ const file = argv.find((a) => !a.startsWith('-'))
256
+ if (!file || !existsSync(file)) { process.stderr.write('Usage: skills push <SKILL.md> (file not found)\n'); return 1 }
257
+ const body_md = readFileSync(file, 'utf8')
258
+ const name = frontmatterName(body_md, '').toLowerCase()
259
+ if (!NAME_RE.test(name)) {
260
+ process.stderr.write(`SKILL.md needs frontmatter \`name:\` in lowercase kebab (got "${name || 'nothing'}").\n`)
261
+ return 1
262
+ }
263
+ payload = { name, body_md, enabled: true }
264
+ }
265
+
266
+ try {
267
+ const res = await fetchCortex(`${base}/api/skills`, {
268
+ method: 'POST',
269
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
270
+ body: JSON.stringify(payload),
271
+ })
272
+ const body = await res.json().catch(() => ({}))
273
+ if (!res.ok) { process.stderr.write(`✗ ${body.error ?? `HTTP ${res.status}`}\n`); return 1 }
274
+ process.stdout.write(
275
+ payload.body_md
276
+ ? `✓ published "${payload.name}" to your org — every seat installs it on next session start.\n`
277
+ : `✓ "${payload.name}" ${payload.enabled ? 'enabled' : 'disabled'}.\n`,
278
+ )
279
+ return 0
280
+ } catch (e) {
281
+ process.stderr.write(`✗ network error: ${e.message}\n`)
282
+ return 1
283
+ }
284
+ }
285
+
286
+ // CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
287
+ // are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
128
288
  export async function runSkills(argv = []) {
289
+ if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
290
+
129
291
  const quiet = argv.includes('--quiet')
130
292
  if (!quiet) process.stdout.write('\nCortex skills — installing managed skills…\n')
131
293
  const r = installSkills({ quiet })
@@ -135,5 +297,6 @@ export async function runSkills(argv = []) {
135
297
  process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
136
298
  }
137
299
  }
300
+ if (r.targets.length) await syncOrgSkills({ quiet })
138
301
  return 0
139
302
  }
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import { planOrgInstall } from './skills.mjs'
3
+
4
+ describe('planOrgInstall — the collision + validity gate', () => {
5
+ it('installs valid served skills', () => {
6
+ const { install, skipped } = planOrgInstall(
7
+ [{ name: 'deploy-runbook', body_md: '---\nname: deploy-runbook\n---\nbody' }],
8
+ ['cortex-log', 'cortex-context'],
9
+ )
10
+ expect(install).toEqual([{ name: 'deploy-runbook', source: '---\nname: deploy-runbook\n---\nbody' }])
11
+ expect(skipped).toEqual([])
12
+ })
13
+ it('bundled core skills win name collisions (org can never shadow cortex-log)', () => {
14
+ const { install, skipped } = planOrgInstall(
15
+ [{ name: 'cortex-log', body_md: 'evil override' }],
16
+ ['cortex-log'],
17
+ )
18
+ expect(install).toEqual([])
19
+ expect(skipped[0]).toMatchObject({ name: 'cortex-log' })
20
+ })
21
+ it('drops invalid names (path tricks, uppercase, empty) and empty bodies', () => {
22
+ const { install, skipped } = planOrgInstall(
23
+ [
24
+ { name: '../escape', body_md: 'x' },
25
+ { name: 'UPPER', body_md: 'x' },
26
+ { name: 'ok-name', body_md: ' ' },
27
+ { name: '', body_md: 'x' },
28
+ { name: 'fine', body_md: 'x' },
29
+ ],
30
+ [],
31
+ )
32
+ expect(install).toEqual([{ name: 'fine', source: 'x' }])
33
+ expect(skipped.length).toBe(4)
34
+ })
35
+ it('tolerates a malformed served list', () => {
36
+ expect(planOrgInstall(null, []).install).toEqual([])
37
+ expect(planOrgInstall([null, {}, { name: 'a' }], []).install).toEqual([])
38
+ })
39
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.33",
3
+ "version": "0.9.35",
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": {
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: cortex-author-docs
3
+ description: Push new/changed documentation (specs, plans, design docs) from disk into Cortex as authored wiki pages. Run after writing a spec/plan/design doc, when the user asks to sync docs to Cortex, or as part of session close-out.
4
+ ---
5
+
6
+ > **Cortex-managed skill.** This file is installed and kept up to date by Cortex. Local edits are
7
+ > restored on the next session (a backup of your version is saved alongside). Don't rely on changes here.
8
+
9
+ ## Why this exists
10
+
11
+ Specs, plans, and design docs get written to disk (a repo's `docs/`, skill-generated design docs)
12
+ and never reach Cortex — so the org brain misses its richest artifacts. A spec IS a page: this
13
+ skill turns pending docs into authored wiki pages. You (the live session) are the pipe — you read
14
+ the doc and author a synthesis. Never dump raw markdown into a page.
15
+
16
+ ## When to use
17
+
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 Cortex.
20
+ - During session close-out (`/cortex-log` runs this as a sweep step).
21
+
22
+ ## Steps
23
+
24
+ 1. **Detect:** run `npx -y @theronap/cortex-mcp docs-scan --json`. If `pending` is empty, stop —
25
+ report nothing. (If no roots are registered and you just wrote docs somewhere, suggest
26
+ `docs-scan --add-root <dir>` to the user once; don't nag.)
27
+ 2. **Author each pending doc** (skip any you judge non-substantive — scratch notes, generated
28
+ output; leave them unmarked and say so):
29
+ - Read the file. Decide the target node: a substantial standalone doc becomes its own
30
+ project-kind node named by the doc's H1 title; a small note folds into its parent project's
31
+ page as a section. Check the namespace first (`authoring_context`) — enrich an existing node
32
+ rather than minting a synonym.
33
+ - Call `author` with a distilled summary + sections — a synthesis of what the doc establishes
34
+ (decisions, design, status), not a paste. Emit inline `[[links]]`: ALWAYS link up to the
35
+ parent project node, plus related nodes; include the `[[repo:owner/name]]` stamp when the doc
36
+ lives in a git repo (that joins the page to its commit timeline).
37
+ - **Directionality is a hard rule:** the doc page links UP to the hub; NEVER author the hub
38
+ page just to add a link back to a doc. Fan-in is queryable (`grep "[[hub]]"` = backlinks;
39
+ `read_page history:true` = the node's event ledger) — hub pages stay curated prose, and a doc
40
+ belongs on the hub only when a human-judged synthesis mentions it.
41
+ 3. **Mark:** after each successful `author`, run
42
+ `npx -y @theronap/cortex-mcp docs-scan --mark <path>`. Never mark a doc whose author failed —
43
+ it should stay pending for the next sweep.
44
+ 4. **Report:** one short block — each doc → the page it became (or why skipped).
45
+
46
+ ## Safety rules
47
+
48
+ - Do NOT register or sweep the local brain repo (`~/Documents/brain`) while the Robin parity soak
49
+ is running — the experiment forbids re-syncing Robin into Cortex mid-window.
50
+ - Respect tiers: if a doc is clearly personal/sensitive, author it `confidential` or ask; default
51
+ for work docs is the author path's normal default.
52
+ - This skill writes wiki pages via the `author` tool only. It never sends external messages and
53
+ never deletes anything.
@@ -48,6 +48,10 @@ No arguments. Read the conversation context.
48
48
  nodes). This is a synthesis, not a transcript dump. Skip nodes you didn't actually advance. If you
49
49
  already authored a node mid-session and nothing changed since, `author` will report "no change" —
50
50
  that's fine.
51
+ 6. **Sweep pending documentation** — run `npx -y @theronap/cortex-mcp docs-scan --json`; if any
52
+ docs are pending, follow the `cortex-author-docs` skill (author each into its page, then
53
+ `docs-scan --mark`). Specs/plans written to disk this session must not die on disk — a spec IS
54
+ a page. If no roots are registered or nothing is pending, skip silently.
51
55
 
52
56
  ## Output
53
57