@theronap/cortex-mcp 0.9.32 → 0.9.34
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/bin/cortex-mcp.mjs +12 -2
- package/lib/capture.mjs +5 -3
- package/lib/diagnose.mjs +24 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/docs_scan.test.mjs +73 -0
- package/lib/redact.mjs +8 -0
- package/lib/redact.test.mjs +33 -0
- package/lib/server.mjs +14 -6
- package/lib/setup.mjs +17 -18
- package/lib/skills.mjs +193 -30
- package/lib/skills.test.mjs +39 -0
- package/package.json +1 -1
- package/skills/author-docs/SKILL.md +53 -0
- package/skills/log/SKILL.md +4 -0
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -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
|
|
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
|
package/lib/capture.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'fs'
|
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { resolve } from 'path'
|
|
4
4
|
import { createHash } from 'crypto'
|
|
5
|
-
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
5
|
+
import { fetchCortex, classify, resolveBase, readWiredToken } from './diagnose.mjs'
|
|
6
6
|
import { extractSession } from './edge_extract.mjs'
|
|
7
7
|
import { extractTyped } from './extract_typed.mjs'
|
|
8
8
|
import { redactSecrets } from './redact.mjs'
|
|
@@ -86,8 +86,10 @@ export async function runCapture() {
|
|
|
86
86
|
// would recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
|
|
87
87
|
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
|
|
90
|
+
// MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
|
|
91
|
+
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
92
|
+
if (!token) { process.stderr.write('cortex: no CORTEX_TOKEN in env or wired config, skipping\n'); return }
|
|
91
93
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
92
94
|
|
|
93
95
|
let hook = {}
|
package/lib/diagnose.mjs
CHANGED
|
@@ -9,9 +9,33 @@
|
|
|
9
9
|
// 4xx/5xx means infrastructure handled the request, not Cortex auth — re-running setup or
|
|
10
10
|
// regenerating the token will not help; it is usually transient and worth a retry.
|
|
11
11
|
|
|
12
|
+
import { readFileSync } from 'fs'
|
|
13
|
+
import { homedir } from 'os'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
|
|
12
16
|
export const isUuid = (s) =>
|
|
13
17
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s ?? '')
|
|
14
18
|
|
|
19
|
+
// Find the token already wired on this machine (Claude's ~/.claude.json first, then Codex's
|
|
20
|
+
// config.toml). Lives HERE — the shared leaf module — so both setup/repair and the capture hook
|
|
21
|
+
// use one resolver: hook commands carry NO inline token (token-hygiene, 2026-07-02 — an inlined
|
|
22
|
+
// `CORTEX_TOKEN=…` in hooks.json shows the secret to anything that reads or greps the file, and
|
|
23
|
+
// from there to captured transcripts); `capture` resolves the token itself from the same config
|
|
24
|
+
// the MCP server already holds.
|
|
25
|
+
export function readWiredToken() {
|
|
26
|
+
try {
|
|
27
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'))
|
|
28
|
+
const t = cfg?.mcpServers?.cortex?.env?.CORTEX_TOKEN
|
|
29
|
+
if (t) return t
|
|
30
|
+
} catch { /* fall through */ }
|
|
31
|
+
try {
|
|
32
|
+
const toml = readFileSync(join(homedir(), '.codex', 'config.toml'), 'utf8')
|
|
33
|
+
const m = toml.match(/\[mcp_servers\.cortex\.env\][\s\S]*?CORTEX_TOKEN\s*=\s*"([^"]+)"/)
|
|
34
|
+
if (m) return m[1]
|
|
35
|
+
} catch { /* fall through */ }
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
// The production alias — exempt from Vercel Deployment Protection.
|
|
16
40
|
export const CANONICAL_BASE = 'https://cortex-console.vercel.app'
|
|
17
41
|
|
|
@@ -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/redact.mjs
CHANGED
|
@@ -21,6 +21,14 @@ const PATTERNS = [
|
|
|
21
21
|
[/xox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack]'],
|
|
22
22
|
[/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[REDACTED:jwt]'],
|
|
23
23
|
[/(Bearer\s+)[A-Za-z0-9._-]{20,}/g, '$1[REDACTED]'],
|
|
24
|
+
// Cortex's own login token wherever it appears in KEY=value / KEY: value form (hooks.json,
|
|
25
|
+
// config.toml, shell commands — the 2026-07-02 finding: a grep of hooks.json put the live token
|
|
26
|
+
// in a transcript and nothing below caught it). Specific pattern first for the accurate label.
|
|
27
|
+
[/(CORTEX_TOKEN["']?\s*[=:]\s*["']?)[0-9a-fA-F][0-9a-fA-F-]{30,}/g, '$1[REDACTED:cortex-token]'],
|
|
28
|
+
// Generic secret-shaped assignment: an UPPER_SNAKE name ending in TOKEN/SECRET/PASSWORD/
|
|
29
|
+
// API_KEY/APIKEY assigned a ≥16-char value. Conservative: the name-suffix + length floor keep
|
|
30
|
+
// ordinary prose, short placeholders, and bare UUIDs (record ids) intact.
|
|
31
|
+
[/([A-Z][A-Z0-9_]{2,}(?:TOKEN|SECRET|PASSWORD|API_KEY|APIKEY)["']?\s*[=:]\s*["']?)[A-Za-z0-9._/+-]{16,}/g, '$1[REDACTED:env]'],
|
|
24
32
|
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED:private-key]'],
|
|
25
33
|
]
|
|
26
34
|
|
package/lib/redact.test.mjs
CHANGED
|
@@ -48,6 +48,39 @@ test('leaves ordinary prose and record UUIDs untouched', () => {
|
|
|
48
48
|
expect(redactSecrets(prose)).toBe(prose)
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
+
// The 2026-07-02 finding: a grep of ~/.codex/hooks.json printed the live CORTEX_TOKEN (a bare
|
|
52
|
+
// UUID — no pattern above catches those) into a transcript. The inline form is gone from hooks
|
|
53
|
+
// (capture self-resolves now), but transcripts can still carry config lines from ANY tool read.
|
|
54
|
+
test('redacts CORTEX_TOKEN in env-assignment and TOML/JSON forms', () => {
|
|
55
|
+
const uuid = 'ee0455d0-49bd-4141-984d-ece9311f9407'
|
|
56
|
+
const cases = [
|
|
57
|
+
`CORTEX_TOKEN=${uuid} npx -y @theronap/cortex-mcp@latest capture`, // legacy hook command
|
|
58
|
+
`"CORTEX_TOKEN": "${uuid}"`, // ~/.claude.json
|
|
59
|
+
`CORTEX_TOKEN = "${uuid}"`, // config.toml
|
|
60
|
+
]
|
|
61
|
+
for (const s of cases) {
|
|
62
|
+
const out = redactSecrets(s)
|
|
63
|
+
expect(out).toContain('[REDACTED:cortex-token]')
|
|
64
|
+
expect(out).not.toContain(uuid)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('redacts generic secret-shaped env assignments', () => {
|
|
69
|
+
const out = redactSecrets('export SUPABASE_SERVICE_SECRET=abcDEF0123456789xyz && run')
|
|
70
|
+
expect(out).toBe('export SUPABASE_SERVICE_SECRET=[REDACTED:env] && run')
|
|
71
|
+
const out2 = redactSecrets('MY_API_KEY: "0123456789abcdef0123"')
|
|
72
|
+
expect(out2).toContain('[REDACTED:env]')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('generic env pattern leaves short placeholders and prose intact', () => {
|
|
76
|
+
const cases = [
|
|
77
|
+
'set YOUR_TOKEN=xxx in the env', // short placeholder value
|
|
78
|
+
'the CORTEX_TOKEN is stored in ~/.claude.json', // prose, no assignment value
|
|
79
|
+
'record 5203eacd-321b-43f7-bae2-4d6e7cae96ab', // bare UUID, no key name
|
|
80
|
+
]
|
|
81
|
+
for (const s of cases) expect(redactSecrets(s)).toBe(s)
|
|
82
|
+
})
|
|
83
|
+
|
|
51
84
|
test('handles empty / non-string input safely', () => {
|
|
52
85
|
expect(redactSecrets('')).toBe('')
|
|
53
86
|
expect(redactSecrets(null)).toBe(null)
|
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
|
-
|
|
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,
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join, dirname } from 'path'
|
|
4
|
-
import { checkToken, resolveBase } from './diagnose.mjs'
|
|
4
|
+
import { checkToken, resolveBase, readWiredToken } from './diagnose.mjs'
|
|
5
5
|
import { installSkills } from './skills.mjs'
|
|
6
6
|
|
|
7
7
|
// One-command employee onboarding. Wires both:
|
|
@@ -155,7 +155,10 @@ export async function runSetup(argv, version) {
|
|
|
155
155
|
let existingHooks
|
|
156
156
|
try { existingHooks = readJson(codexHooks) } catch { existingHooks = {} } // malformed → start fresh, don't block
|
|
157
157
|
const bak = backup(codexHooks)
|
|
158
|
-
|
|
158
|
+
// NO inline token (token-hygiene, 2026-07-02): `capture` resolves CORTEX_TOKEN itself via
|
|
159
|
+
// readWiredToken() from the config.toml this same setup run writes. An inlined secret in
|
|
160
|
+
// hooks.json shows up in every read/grep of the file — and from there in captured transcripts.
|
|
161
|
+
const captureCmd = `npx -y ${spec} capture`
|
|
159
162
|
ensureDir(codexHooks)
|
|
160
163
|
writeFileSync(codexHooks, JSON.stringify(mergeCodexHooks(existingHooks, captureCmd), null, 2))
|
|
161
164
|
log(` ✓ Capture hook → ${codexHooks}${bak ? ' (backup saved)' : ''}`)
|
|
@@ -176,7 +179,9 @@ export async function runSetup(argv, version) {
|
|
|
176
179
|
s.hooks = s.hooks ?? {}
|
|
177
180
|
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
178
181
|
|
|
179
|
-
|
|
182
|
+
// NO inline token — same hygiene as the Codex hook above; `capture` self-resolves from
|
|
183
|
+
// ~/.claude.json (which this setup run writes). The filter below drops the old inlined form.
|
|
184
|
+
const captureCmd = `npx -y ${spec} capture`
|
|
180
185
|
// Remove any prior cortex capture hook (idempotent: drop old token / old path forms).
|
|
181
186
|
for (const grp of s.hooks.Stop) {
|
|
182
187
|
if (Array.isArray(grp.hooks)) {
|
|
@@ -247,8 +252,14 @@ export async function runSetup(argv, version) {
|
|
|
247
252
|
}
|
|
248
253
|
|
|
249
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.
|
|
250
258
|
try {
|
|
251
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 })
|
|
252
263
|
} catch (e) {
|
|
253
264
|
// Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
|
|
254
265
|
log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
|
|
@@ -276,21 +287,9 @@ export async function runSetup(argv, version) {
|
|
|
276
287
|
log('')
|
|
277
288
|
}
|
|
278
289
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
export
|
|
282
|
-
try {
|
|
283
|
-
const cfg = readJson(join(homedir(), '.claude.json'))
|
|
284
|
-
const t = cfg?.mcpServers?.cortex?.env?.CORTEX_TOKEN
|
|
285
|
-
if (t) return t
|
|
286
|
-
} catch { /* fall through */ }
|
|
287
|
-
try {
|
|
288
|
-
const toml = readFileSync(join(homedir(), '.codex', 'config.toml'), 'utf8')
|
|
289
|
-
const m = toml.match(/\[mcp_servers\.cortex\.env\][\s\S]*?CORTEX_TOKEN\s*=\s*"([^"]+)"/)
|
|
290
|
-
if (m) return m[1]
|
|
291
|
-
} catch { /* fall through */ }
|
|
292
|
-
return null
|
|
293
|
-
}
|
|
290
|
+
// readWiredToken moved to diagnose.mjs (the shared leaf module) so `capture` can use the same
|
|
291
|
+
// resolver — hook commands no longer inline the token. Re-exported for compatibility.
|
|
292
|
+
export { readWiredToken }
|
|
294
293
|
|
|
295
294
|
// `repair`: re-run the FULL setup at THIS version using the already-wired token. The one-command fix
|
|
296
295
|
// for a machine set up with an older version (e.g. when skills were installed to the old nested path,
|
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
|
-
//
|
|
9
|
-
//
|
|
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:
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
63
|
-
function
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
127
|
-
//
|
|
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
|
@@ -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.
|
package/skills/log/SKILL.md
CHANGED
|
@@ -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
|
|