@theronap/agnoclast-mcp 0.9.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/bin/cortex-mcp.mjs +223 -0
- package/lib/capture.mjs +470 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/context_log.mjs +92 -0
- package/lib/diagnose.mjs +360 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/doctor.mjs +117 -0
- package/lib/edge_extract.mjs +156 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +130 -0
- package/lib/editors/claude.mjs +202 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/graphify_sync.mjs +134 -0
- package/lib/grep_cli.mjs +82 -0
- package/lib/hydrate.mjs +181 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/ingest_folder.mjs +170 -0
- package/lib/install.mjs +163 -0
- package/lib/login.mjs +148 -0
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/presence.mjs +226 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/red_link_triage.mjs +37 -0
- package/lib/redact.mjs +40 -0
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +153 -0
- package/lib/server.mjs +2986 -0
- package/lib/session_key.mjs +37 -0
- package/lib/setup.mjs +215 -0
- package/lib/skills.mjs +374 -0
- package/lib/statusline.mjs +67 -0
- package/lib/uninstall.mjs +237 -0
- package/lib/use_brain.mjs +82 -0
- package/lib/with_token.mjs +66 -0
- package/package.json +36 -0
- package/skills/author-docs/SKILL.md +74 -0
- package/skills/context/SKILL.md +25 -0
- package/skills/log/SKILL.md +114 -0
- package/skills/walkthrough/SKILL.md +189 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { renderStatusline } from './presence.mjs'
|
|
5
|
+
|
|
6
|
+
// `cortex-mcp statusline` — the ambient half of the presence cue.
|
|
7
|
+
//
|
|
8
|
+
// Claude Code runs a statusline command on each redraw and prints its stdout under the prompt, so
|
|
9
|
+
// this is the only place Agnoclast can sit permanently in view. It must therefore be the cheapest
|
|
10
|
+
// thing in the product: no network, no auth, no org call. It reads one small local file that
|
|
11
|
+
// hydration wrote and renders a line.
|
|
12
|
+
//
|
|
13
|
+
// It also has to be honest when nothing is happening. An indicator that looks alive while the system
|
|
14
|
+
// is disconnected is worse than no indicator — that is precisely how the hook on this machine sat
|
|
15
|
+
// dead for five days without anyone noticing. Hence the decay to `idle`, and `not wired` when there
|
|
16
|
+
// is no breadcrumb at all.
|
|
17
|
+
|
|
18
|
+
const STATE_DIR = () => join(homedir(), '.cortex')
|
|
19
|
+
const STATE_FILE = () => join(STATE_DIR(), 'presence.json')
|
|
20
|
+
const TICK_FILE = () => join(STATE_DIR(), 'presence-tick')
|
|
21
|
+
|
|
22
|
+
function readState() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(STATE_FILE(), 'utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The blob advances one frame per redraw. Persisting the counter is what makes it BREATHE across
|
|
31
|
+
// renders rather than freeze on a single glyph — without it every render would draw frame 0.
|
|
32
|
+
function nextTick() {
|
|
33
|
+
try {
|
|
34
|
+
const prev = Number.parseInt(readFileSync(TICK_FILE(), 'utf8'), 10)
|
|
35
|
+
const tick = (Number.isFinite(prev) ? prev + 1 : 0) % 1_000_000
|
|
36
|
+
writeFileSync(TICK_FILE(), String(tick))
|
|
37
|
+
return tick
|
|
38
|
+
} catch {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(STATE_DIR())) mkdirSync(STATE_DIR(), { recursive: true })
|
|
41
|
+
writeFileSync(TICK_FILE(), '0')
|
|
42
|
+
} catch {
|
|
43
|
+
/* ignore — the statusline must never fail loudly */
|
|
44
|
+
}
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Record what hydration served, for the statusline to read. Best-effort by design: a failure here
|
|
50
|
+
// must never affect the turn it was observing.
|
|
51
|
+
export function writePresence({ pages = [], kind = 'hit', at = new Date().toISOString() } = {}) {
|
|
52
|
+
try {
|
|
53
|
+
if (!existsSync(STATE_DIR())) mkdirSync(STATE_DIR(), { recursive: true })
|
|
54
|
+
writeFileSync(STATE_FILE(), JSON.stringify({ pages, kind, at }))
|
|
55
|
+
} catch {
|
|
56
|
+
/* best-effort */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runStatusline() {
|
|
61
|
+
try {
|
|
62
|
+
process.stdout.write(renderStatusline(readState(), Date.now(), nextTick()) + '\n')
|
|
63
|
+
} catch {
|
|
64
|
+
// Print nothing rather than an error: a broken statusline should vanish, not shout.
|
|
65
|
+
}
|
|
66
|
+
return 0
|
|
67
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, rmSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { execFileSync } from 'child_process'
|
|
5
|
+
import { isManagedCommand } from './managed.mjs'
|
|
6
|
+
|
|
7
|
+
// Full uninstall — the reverse of setup.mjs. Removes EVERY touch-point Agnoclast writes onto a machine:
|
|
8
|
+
// 1. ~/.claude.json → mcpServers.cortex / .agnoclast
|
|
9
|
+
// 1b. ~/.cursor/mcp.json → same shape (editors/cursor.mjs writes it; was never removed)
|
|
10
|
+
// 2. ~/.claude/settings.json → Stop/SessionStart/UserPromptSubmit/PreCompact hooks + allow rules
|
|
11
|
+
// 3. ~/.codex/config.toml → [mcp_servers.cortex] + [mcp_servers.cortex.env]
|
|
12
|
+
// 4. ~/.codex/hooks.json → cortex capture Stop hook
|
|
13
|
+
// 5. managed skills → ~/.claude/skills/cortex* + ~/.codex/skills/cortex*
|
|
14
|
+
// 6. launchd agent → ~/Library/LaunchAgents/com.cortex.*.plist (unload + remove)
|
|
15
|
+
// 7. crontab → lines tagged "# cortex ..."
|
|
16
|
+
// 8. off-switch env → launchctl unsetenv CORTEX_SUMMARIZE_DISABLED (macOS)
|
|
17
|
+
// --purge also removes: ~/.cortex state dir, the npx cache, and every .cortex-bak backup.
|
|
18
|
+
//
|
|
19
|
+
// Safe by construction: --dry-run prints the plan and changes nothing; every JSON/TOML file is backed
|
|
20
|
+
// up to <file>.cortex-uninstall-bak before edit; JSON surgery filters only entries we wrote (see
|
|
21
|
+
// managed.mjs — marker first, legacy name match second) and leaves every other MCP server / hook
|
|
22
|
+
// untouched. Idempotent — re-running is a no-op.
|
|
23
|
+
|
|
24
|
+
const HOME = homedir()
|
|
25
|
+
const CLAUDE_JSON = join(HOME, '.claude.json')
|
|
26
|
+
const SETTINGS = join(HOME, '.claude', 'settings.json')
|
|
27
|
+
const CODEX_TOML = join(HOME, '.codex', 'config.toml')
|
|
28
|
+
const CODEX_HOOKS = join(HOME, '.codex', 'hooks.json')
|
|
29
|
+
const CURSOR_JSON = join(HOME, '.cursor', 'mcp.json')
|
|
30
|
+
const CORTEX_DIR = join(HOME, '.cortex')
|
|
31
|
+
const LAUNCH_AGENTS = join(HOME, 'Library', 'LaunchAgents')
|
|
32
|
+
// Hook events the installer writes (editors/claude.mjs). UserPromptSubmit was MISSING here until
|
|
33
|
+
// 2026-08-19, so every uninstall left the `hydrate` hook behind while reporting success.
|
|
34
|
+
export const MANAGED_HOOK_EVENTS = ['Stop', 'SessionStart', 'UserPromptSubmit', 'PreCompact']
|
|
35
|
+
|
|
36
|
+
export function runUninstall(argv = []) {
|
|
37
|
+
const dry = argv.includes('--dry-run') || argv.includes('-n')
|
|
38
|
+
const purge = argv.includes('--purge')
|
|
39
|
+
const plan = [] // human-readable action log
|
|
40
|
+
const act = (msg) => plan.push(msg)
|
|
41
|
+
const write = (path, data) => { if (!dry) { backup(path); writeFileSync(path, data) } }
|
|
42
|
+
|
|
43
|
+
process.stdout.write(dry ? '\nAgnoclast uninstall — DRY RUN (nothing will change):\n\n' : '\nAgnoclast uninstall — removing all wiring…\n\n')
|
|
44
|
+
|
|
45
|
+
// 1. MCP server out of ~/.claude.json — and 1b, out of ~/.cursor/mcp.json, which uses the same
|
|
46
|
+
// shape (editors/cursor.mjs:15) and which uninstall never touched until 2026-08-19, so every
|
|
47
|
+
// `install --editor all` left Cursor permanently wired.
|
|
48
|
+
for (const path of [CLAUDE_JSON, CURSOR_JSON]) {
|
|
49
|
+
editJson(path, (cfg) => {
|
|
50
|
+
const removed = stripManagedMcpServers(cfg)
|
|
51
|
+
if (removed.length) act(` - mcpServers.${removed.join(' / ')} ← ${path}`)
|
|
52
|
+
return removed.length > 0
|
|
53
|
+
}, write)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Hooks + allow rules out of ~/.claude/settings.json
|
|
57
|
+
editJson(SETTINGS, (s) => {
|
|
58
|
+
const hooks = stripManagedHooks(s)
|
|
59
|
+
const allows = stripManagedAllows(s)
|
|
60
|
+
if (hooks) act(` - ${MANAGED_HOOK_EVENTS.join('/')} hooks ← ${SETTINGS}`)
|
|
61
|
+
if (allows) act(` - mcp__cortex__* / mcp__agnoclast__* permission allow rules ← ${SETTINGS}`)
|
|
62
|
+
return hooks || allows
|
|
63
|
+
}, write)
|
|
64
|
+
|
|
65
|
+
// 3. Codex MCP tables (strip [mcp_servers.cortex] + [mcp_servers.cortex.env])
|
|
66
|
+
if (existsSync(CODEX_TOML)) {
|
|
67
|
+
const text = readFileSync(CODEX_TOML, 'utf8')
|
|
68
|
+
const stripped = stripCodexCortexTables(text)
|
|
69
|
+
if (stripped !== text) { write(CODEX_TOML, stripped); act(` - [mcp_servers.cortex] tables ← ${CODEX_TOML}`) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. Codex capture hook
|
|
73
|
+
editJson(CODEX_HOOKS, (h) => {
|
|
74
|
+
let changed = false
|
|
75
|
+
if (Array.isArray(h.hooks?.Stop)) {
|
|
76
|
+
for (const grp of h.hooks.Stop) {
|
|
77
|
+
if (!Array.isArray(grp.hooks)) continue
|
|
78
|
+
const before = grp.hooks.length
|
|
79
|
+
grp.hooks = grp.hooks.filter((c) => !isManagedCommand(c?.command))
|
|
80
|
+
if (grp.hooks.length !== before) changed = true
|
|
81
|
+
}
|
|
82
|
+
h.hooks.Stop = h.hooks.Stop.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
|
|
83
|
+
}
|
|
84
|
+
if (changed) act(` - cortex capture hook ← ${CODEX_HOOKS}`)
|
|
85
|
+
return changed
|
|
86
|
+
}, write)
|
|
87
|
+
|
|
88
|
+
// 5. Managed skills
|
|
89
|
+
for (const skillsRoot of [join(HOME, '.claude', 'skills'), join(HOME, '.codex', 'skills')]) {
|
|
90
|
+
for (const name of ['cortex', 'cortex-author-docs', 'cortex-context', 'cortex-log', 'cortex-walkthrough',
|
|
91
|
+
'agnoclast', 'agnoclast-author-docs', 'agnoclast-context', 'agnoclast-log', 'agnoclast-walkthrough']) {
|
|
92
|
+
const p = join(skillsRoot, name)
|
|
93
|
+
if (existsSync(p)) { act(` - skill ${p}`); if (!dry) rmSync(p, { recursive: true, force: true }) }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 6. launchd agents (com.cortex.*)
|
|
98
|
+
if (existsSync(LAUNCH_AGENTS)) {
|
|
99
|
+
for (const f of safeReaddir(LAUNCH_AGENTS)) {
|
|
100
|
+
if (!/^com\.(cortex|agnoclast)\..*\.plist$/.test(f)) continue
|
|
101
|
+
const p = join(LAUNCH_AGENTS, f)
|
|
102
|
+
act(` - launchd agent ${p} (unload + remove)`)
|
|
103
|
+
if (!dry) {
|
|
104
|
+
tryExec('launchctl', ['bootout', `gui/${process.getuid?.() ?? ''}/${f.replace(/\.plist$/, '')}`])
|
|
105
|
+
tryExec('launchctl', ['unload', p])
|
|
106
|
+
rmSync(p, { force: true })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 7. crontab — drop cortex-tagged lines
|
|
112
|
+
const cron = tryExecOut('crontab', ['-l'])
|
|
113
|
+
if (cron != null) {
|
|
114
|
+
const kept = cron.split('\n').filter((l) => !isManagedCommand(l) && !/\.cortex\//.test(l))
|
|
115
|
+
if (kept.join('\n') !== cron) {
|
|
116
|
+
act(' - crontab cortex entries')
|
|
117
|
+
if (!dry) tryExecIn('crontab', ['-'], kept.join('\n').replace(/\n+$/, '') + '\n')
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 8. off-switch env var (macOS launchd session env)
|
|
122
|
+
act(' - launchctl unsetenv CORTEX_SUMMARIZE_DISABLED')
|
|
123
|
+
if (!dry) tryExec('launchctl', ['unsetenv', 'CORTEX_SUMMARIZE_DISABLED'])
|
|
124
|
+
|
|
125
|
+
// --purge: state dir + npx cache + backups
|
|
126
|
+
if (purge) {
|
|
127
|
+
if (existsSync(CORTEX_DIR)) { act(` - PURGE state dir ${CORTEX_DIR}`); if (!dry) rmSync(CORTEX_DIR, { recursive: true, force: true }) }
|
|
128
|
+
for (const cache of npxCacheDirs()) { act(` - PURGE npx cache ${cache}`); if (!dry) rmSync(cache, { recursive: true, force: true }) }
|
|
129
|
+
for (const bak of [CLAUDE_JSON, SETTINGS, CODEX_TOML, CODEX_HOOKS]) {
|
|
130
|
+
for (const suffix of ['.cortex-bak', '.cortex-uninstall-bak']) {
|
|
131
|
+
const b = bak + suffix
|
|
132
|
+
if (existsSync(b)) { act(` - PURGE backup ${b}`); if (!dry) rmSync(b, { force: true }) }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (plan.length === 0) { process.stdout.write(' Nothing to remove — this machine has no Agnoclast wiring.\n\n'); return }
|
|
138
|
+
process.stdout.write(plan.join('\n') + '\n\n')
|
|
139
|
+
if (dry) {
|
|
140
|
+
process.stdout.write('DRY RUN — nothing changed. Re-run without --dry-run to apply.\n')
|
|
141
|
+
process.stdout.write(' Full wipe (state dir + npx cache + backups too): add --purge\n\n')
|
|
142
|
+
} else {
|
|
143
|
+
process.stdout.write('Done. Fully quit and reopen Claude Code (and Codex) to drop the server.\n')
|
|
144
|
+
if (!purge) process.stdout.write(' State dir ~/.cortex and .cortex-bak backups were KEPT. Remove them too with --purge.\n')
|
|
145
|
+
process.stdout.write('\n')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
150
|
+
function backup(path) {
|
|
151
|
+
if (!existsSync(path)) return
|
|
152
|
+
copyFileSync(path, `${path}.cortex-uninstall-bak`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Read JSON, run mutator (returns true if it changed anything), write back via `write` if so.
|
|
156
|
+
// Malformed JSON is left untouched (never risk corrupting a file we can't parse).
|
|
157
|
+
function editJson(path, mutate, write) {
|
|
158
|
+
if (!existsSync(path)) return
|
|
159
|
+
let obj
|
|
160
|
+
try { obj = JSON.parse(readFileSync(path, 'utf8') || '{}') } catch { process.stderr.write(` ! ${path} is not valid JSON — skipped (left as-is)\n`); return }
|
|
161
|
+
if (mutate(obj)) write(path, JSON.stringify(obj, null, 2) + '\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// PURE. Removes our MCP server entry under EITHER spelling. uninstall.mjs:45 used to delete only
|
|
165
|
+
// `cortex`, so after the Phase C key flip (ADR-0033 §5) the server entry — the single most important
|
|
166
|
+
// thing uninstall removes — would survive a "successful" uninstall. Returns the keys it removed so
|
|
167
|
+
// the caller can report which spelling this machine actually carried.
|
|
168
|
+
export function stripManagedMcpServers(cfg) {
|
|
169
|
+
const removed = []
|
|
170
|
+
for (const key of ['cortex', 'agnoclast']) {
|
|
171
|
+
if (cfg?.mcpServers && cfg.mcpServers[key]) { delete cfg.mcpServers[key]; removed.push(key) }
|
|
172
|
+
}
|
|
173
|
+
return removed
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// PURE (mutates the passed object, returns whether anything changed) so uninstall's riskiest logic
|
|
177
|
+
// is testable without touching a real filesystem. Empty hook groups and empty events are pruned so
|
|
178
|
+
// a fully-uninstalled settings.json is indistinguishable from one we never touched.
|
|
179
|
+
export function stripManagedHooks(s) {
|
|
180
|
+
let changed = false
|
|
181
|
+
for (const evt of MANAGED_HOOK_EVENTS) {
|
|
182
|
+
if (!Array.isArray(s?.hooks?.[evt])) continue
|
|
183
|
+
for (const grp of s.hooks[evt]) {
|
|
184
|
+
if (!Array.isArray(grp.hooks)) continue
|
|
185
|
+
const before = grp.hooks.length
|
|
186
|
+
grp.hooks = grp.hooks.filter((h) => !isManagedCommand(h?.command))
|
|
187
|
+
if (grp.hooks.length !== before) changed = true
|
|
188
|
+
}
|
|
189
|
+
s.hooks[evt] = s.hooks[evt].filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0)
|
|
190
|
+
if (s.hooks[evt].length === 0) delete s.hooks[evt]
|
|
191
|
+
}
|
|
192
|
+
return changed
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Strip every allow rule for OUR tool namespaces. Both spellings: a machine mid-migration can carry
|
|
196
|
+
// mcp__cortex__* and mcp__agnoclast__* at once, because C1 adds the new rules without removing the
|
|
197
|
+
// old ones (ADR-0033 §5). The user's `deny` list is never touched — a user deny always wins.
|
|
198
|
+
export function stripManagedAllows(s) {
|
|
199
|
+
if (!Array.isArray(s?.permissions?.allow)) return false
|
|
200
|
+
const before = s.permissions.allow.length
|
|
201
|
+
s.permissions.allow = s.permissions.allow.filter((r) => !/^mcp__(cortex|agnoclast)__/.test(String(r)))
|
|
202
|
+
if (s.permissions.allow.length === before) return false
|
|
203
|
+
if (s.permissions.allow.length === 0) delete s.permissions.allow
|
|
204
|
+
if (Object.keys(s.permissions).length === 0) delete s.permissions
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Strip [mcp_servers.cortex] and [mcp_servers.cortex.env] tables from a Codex config.toml.
|
|
209
|
+
// Same table-skip logic as setup.mergeCodexToml, in reverse.
|
|
210
|
+
export function stripCodexCortexTables(text) {
|
|
211
|
+
const targets = new Set(['[mcp_servers.cortex]', '[mcp_servers.cortex.env]',
|
|
212
|
+
'[mcp_servers.agnoclast]', '[mcp_servers.agnoclast.env]'])
|
|
213
|
+
const kept = []
|
|
214
|
+
let skipping = false
|
|
215
|
+
for (const line of (text || '').split('\n')) {
|
|
216
|
+
const t = line.trim()
|
|
217
|
+
if (t.startsWith('[') && t.endsWith(']')) skipping = targets.has(t)
|
|
218
|
+
if (!skipping) kept.push(line)
|
|
219
|
+
}
|
|
220
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function safeReaddir(dir) {
|
|
224
|
+
try { return execFileSync('ls', ['-1', dir], { encoding: 'utf8' }).split('\n').filter(Boolean) } catch { return [] }
|
|
225
|
+
}
|
|
226
|
+
function npxCacheDirs() {
|
|
227
|
+
const base = join(HOME, '.npm', '_npx')
|
|
228
|
+
const out = []
|
|
229
|
+
for (const d of safeReaddir(base)) {
|
|
230
|
+
const dir = join(base, d)
|
|
231
|
+
try { if (execFileSync('grep', ['-rlE', '@theronap/(cortex|agnoclast)-mcp', join(dir, 'package.json')], { encoding: 'utf8' }).trim()) out.push(dir) } catch { /* not one of ours */ }
|
|
232
|
+
}
|
|
233
|
+
return out
|
|
234
|
+
}
|
|
235
|
+
function tryExec(cmd, args) { try { execFileSync(cmd, args, { stdio: 'ignore' }) } catch { /* best-effort */ } }
|
|
236
|
+
function tryExecOut(cmd, args) { try { return execFileSync(cmd, args, { encoding: 'utf8' }) } catch { return null } }
|
|
237
|
+
function tryExecIn(cmd, args, input) { try { execFileSync(cmd, args, { input }) } catch { /* best-effort */ } }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
2
|
+
import { resolveToken } from './doctor.mjs'
|
|
3
|
+
|
|
4
|
+
// `use-brain` — set (or show) which brain this machine's unattended session captures land in.
|
|
5
|
+
//
|
|
6
|
+
// WHY A SUBCOMMAND EXISTS AT ALL. The server side of this shipped 2026-08-09 with NO client surface:
|
|
7
|
+
// no CLI, no MCP tool, no console setting. The only way to set a capture default was a raw
|
|
8
|
+
// authenticated HTTP call, which meant the only people who could fix a broken capture were the ones
|
|
9
|
+
// who could hand-write a curl with a bearer token. Three real users needed it; one of them is not
|
|
10
|
+
// technical. A fix only its author can operate is not a fix.
|
|
11
|
+
//
|
|
12
|
+
// Pairs with the SessionStart notice: the notice tells you captures are being held and names this
|
|
13
|
+
// command, so the loop from "something is wrong" to "it is fixed" is one paste with no docs.
|
|
14
|
+
|
|
15
|
+
function out(m) { process.stdout.write(m + '\n') }
|
|
16
|
+
|
|
17
|
+
export async function runUseBrain(args) {
|
|
18
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
19
|
+
const { token } = resolveToken()
|
|
20
|
+
if (!token) {
|
|
21
|
+
out('Agnoclast: no token found. Run: npx -y @theronap/cortex-mcp setup <token>')
|
|
22
|
+
return 1
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Everything after the subcommand is the brain, joined — so an unquoted multi-word name still
|
|
26
|
+
// works. `use-brain Real estate` is what a person actually types; refusing it over a missing pair
|
|
27
|
+
// of quotes would be the same species of unhelpfulness this command exists to remove.
|
|
28
|
+
const wanted = (args ?? []).filter((a) => !a.startsWith('--')).join(' ').trim()
|
|
29
|
+
const url = `${base}/api/brain/capture-default`
|
|
30
|
+
|
|
31
|
+
if (!wanted) {
|
|
32
|
+
// No argument: report the current state rather than erroring. "What is it set to?" is a fair
|
|
33
|
+
// question and the answer is one GET away.
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const body = await res.text()
|
|
38
|
+
out(`Agnoclast: could not read your capture settings — ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`)
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
const { defaults } = await res.json()
|
|
42
|
+
if (!defaults?.length) {
|
|
43
|
+
out('Agnoclast: no capture brain set. Your unattended session captures land in a brain only if')
|
|
44
|
+
out(' you belong to exactly one; otherwise they are HELD outside every brain until you set this.')
|
|
45
|
+
out(' Set one: npx -y @theronap/cortex-mcp use-brain "<brain name or org id>"')
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
for (const d of defaults) out(`Agnoclast: ${d.sourceType} captures land in "${d.orgName}" (${d.orgId})`)
|
|
49
|
+
return 0
|
|
50
|
+
} catch (e) {
|
|
51
|
+
out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
|
|
52
|
+
return 1
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetchCortex(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({ sourceType: 'claude-code', brain: wanted }),
|
|
61
|
+
})
|
|
62
|
+
const body = await res.text()
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
let msg = classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message
|
|
65
|
+
// The server's own error text is better than a generic one here: a 404 names the brain that was
|
|
66
|
+
// not found, and a 409 lists the org ids of an ambiguous name — which is the whole remedy.
|
|
67
|
+
try { const j = JSON.parse(body); if (j.error) msg = j.error } catch { /* keep the classified message */ }
|
|
68
|
+
out(`Agnoclast: ${msg}`)
|
|
69
|
+
return 1
|
|
70
|
+
}
|
|
71
|
+
const j = JSON.parse(body)
|
|
72
|
+
out(`Agnoclast: ✓ your Claude Code sessions now land in "${j.brain}".`)
|
|
73
|
+
// Say plainly what this does NOT do. The setter's own server-side note makes the same point,
|
|
74
|
+
// because "I fixed it" reading as "and the backlog is handled" is how held records stay held.
|
|
75
|
+
out(' Sessions captured BEFORE now are still held — they keep their original dates until sorted.')
|
|
76
|
+
out(' Ask your assistant to file them (they may not all belong in the same brain).')
|
|
77
|
+
return 0
|
|
78
|
+
} catch (e) {
|
|
79
|
+
out(`Agnoclast: could not reach the server (${e?.message ?? String(e)})`)
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { resolveTokenSource, TOKEN_ENV_VARS } from './diagnose.mjs'
|
|
3
|
+
|
|
4
|
+
// `with-token -- <command…>` — run a command with the resolved Agnoclast token in its environment.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists (ADR-0033 D9). Generated helper scripts used to inline their own config parse:
|
|
7
|
+
//
|
|
8
|
+
// TOKEN="$(node -e "…JSON.parse(…'/.claude.json')?.mcpServers?.cortex?.env?.CORTEX_TOKEN…")"
|
|
9
|
+
// CORTEX_TOKEN="$TOKEN" bun run …
|
|
10
|
+
//
|
|
11
|
+
// Those scripts are written to disk once and FROZEN. Upgrading the package never rewrites a file
|
|
12
|
+
// that already exists, so any change to config shape strands them — and the Phase C key flip would
|
|
13
|
+
// have done exactly that, silently, because the script's own `catch { exit 0 }` swallows it.
|
|
14
|
+
// Delegating means the resolution rule lives in one versioned place and a script written today keeps
|
|
15
|
+
// working through arbitrary future config changes.
|
|
16
|
+
//
|
|
17
|
+
// It also removes the secret from the shell. Previously the token landed in a shell variable and was
|
|
18
|
+
// re-exported by the caller; here it goes straight from the resolver into the child's environment,
|
|
19
|
+
// so it is never a shell value, never in a log line, and never in an argv the process table shows.
|
|
20
|
+
//
|
|
21
|
+
// Both variable names are injected so the child works whichever one it reads — the whole point of a
|
|
22
|
+
// transition period is that callers need not be updated in lockstep.
|
|
23
|
+
|
|
24
|
+
/** Split `with-token -- cmd args` (or `with-token cmd args`) into the command to run. */
|
|
25
|
+
export function parseWithTokenArgs(rest = []) {
|
|
26
|
+
const args = rest.filter((a) => a !== undefined && a !== null)
|
|
27
|
+
const sep = args.indexOf('--')
|
|
28
|
+
const cmd = sep === -1 ? args : args.slice(sep + 1)
|
|
29
|
+
if (!cmd.length) {
|
|
30
|
+
return { error: 'Usage: cortex-mcp with-token -- <command> [args…]\n Runs <command> with the wired Agnoclast token in its environment.\n' }
|
|
31
|
+
}
|
|
32
|
+
return { command: cmd[0], args: cmd.slice(1) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build the child environment. Exported so a test can assert it without spawning anything. */
|
|
36
|
+
export function tokenEnv(token, baseEnv = process.env) {
|
|
37
|
+
const out = { ...baseEnv }
|
|
38
|
+
for (const name of TOKEN_ENV_VARS) out[name] = token
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Exit codes, distinct so a caller's log can tell them apart:
|
|
43
|
+
// 3 — no token wired (the "skip quietly" case a cron wrapper wants)
|
|
44
|
+
// 2 — bad usage
|
|
45
|
+
// otherwise the child's own code
|
|
46
|
+
export const EXIT_NO_TOKEN = 3
|
|
47
|
+
export const EXIT_USAGE = 2
|
|
48
|
+
|
|
49
|
+
export function runWithToken(rest = [], deps = {}) {
|
|
50
|
+
const { resolve = resolveTokenSource, spawn = spawnSync, stderr = (m) => process.stderr.write(m) } = deps
|
|
51
|
+
const parsed = parseWithTokenArgs(rest)
|
|
52
|
+
if (parsed.error) { stderr(parsed.error); return EXIT_USAGE }
|
|
53
|
+
|
|
54
|
+
const { token } = resolve()
|
|
55
|
+
if (!token) {
|
|
56
|
+
stderr('cortex-mcp with-token: no Agnoclast token in the environment or wired config — skipping.\n')
|
|
57
|
+
return EXIT_NO_TOKEN
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const res = spawn(parsed.command, parsed.args, { stdio: 'inherit', env: tokenEnv(token) })
|
|
61
|
+
if (res?.error) {
|
|
62
|
+
stderr(`cortex-mcp with-token: could not run ${parsed.command}: ${res.error.message}\n`)
|
|
63
|
+
return 1
|
|
64
|
+
}
|
|
65
|
+
return res?.status ?? 1
|
|
66
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theronap/agnoclast-mcp",
|
|
3
|
+
"version": "0.9.96",
|
|
4
|
+
"description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agnoclast-mcp": "bin/cortex-mcp.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"skills",
|
|
13
|
+
"!lib/**/*.test.mjs",
|
|
14
|
+
"!scripts"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mcp",
|
|
25
|
+
"cortex",
|
|
26
|
+
"claude",
|
|
27
|
+
"ai",
|
|
28
|
+
"org-intelligence"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"release": "node scripts/release.mjs release",
|
|
33
|
+
"promote": "node scripts/release.mjs promote",
|
|
34
|
+
"rollback": "node scripts/release.mjs rollback"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cortex-author-docs
|
|
3
|
+
description: Push new/changed documentation (specs, plans, design docs) from disk into Agnoclast as authored wiki pages. Run after writing a spec/plan/design doc, when the user asks to sync docs to Agnoclast, or as part of session close-out.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
> **Agnoclast-managed skill.** This file is installed and kept up to date by Agnoclast. 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 Agnoclast — 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 Agnoclast.
|
|
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. **Triage each pending doc into exactly ONE of three dispositions.** Every pending doc gets one —
|
|
28
|
+
there is no fourth "deal with it later" state:
|
|
29
|
+
- **AUTHOR** it (below), then mark it.
|
|
30
|
+
- **ABSORBED** — the doc is substantive, but an existing page *already covers it as well or
|
|
31
|
+
better*. Common for build-notes and handoffs: the page kept getting updated while the doc
|
|
32
|
+
stayed frozen at its writing date. Authoring it again would duplicate, or worse, overwrite a
|
|
33
|
+
current page with a stale snapshot. **Mark it anyway** (`--mark`), and say which page absorbed
|
|
34
|
+
it. Do NOT leave it unmarked: it is genuinely handled, and leaving it pending makes every
|
|
35
|
+
future sweep re-read and re-litigate it. If the doc has one or two durable details the page
|
|
36
|
+
lacks, add just those to the page, then mark.
|
|
37
|
+
- **NON-SUBSTANTIVE** — scratch notes, generated output, throwaway logs. Leave unmarked and say
|
|
38
|
+
so, so a human can decide whether it should be registered at all.
|
|
39
|
+
|
|
40
|
+
⚠ **A doc records the state at its writing date, never the state now.** Before writing any status
|
|
41
|
+
claim into a page, verify it against the code — a build-notes doc saying "NOT built yet" is
|
|
42
|
+
evidence about the past, not the present. Copying its status forward is the single most common way
|
|
43
|
+
this skill injects a false claim into the wiki.
|
|
44
|
+
|
|
45
|
+
To author:
|
|
46
|
+
- Read the file. Decide the target node: a substantial standalone doc becomes its own
|
|
47
|
+
project-kind node named by the doc's H1 title; a small note folds into its parent project's
|
|
48
|
+
page as a section. Check the namespace first (`authoring_context`) — enrich an existing node
|
|
49
|
+
rather than minting a synonym.
|
|
50
|
+
- Call `author` with a distilled summary + sections — a synthesis of what the doc establishes
|
|
51
|
+
(decisions, design, status), not a paste. Emit inline `[[links]]`: ALWAYS link up to the
|
|
52
|
+
parent project node, plus related nodes; include the `[[repo:owner/name]]` stamp when the doc
|
|
53
|
+
lives in a git repo (that joins the page to its commit timeline).
|
|
54
|
+
- **Directionality is a hard rule:** the doc page links UP to the hub; NEVER author the hub
|
|
55
|
+
page just to add a link back to a doc. Fan-in is queryable (`grep "[[hub]]"` = backlinks;
|
|
56
|
+
`read_page history:true` = the node's event ledger) — hub pages stay curated prose, and a doc
|
|
57
|
+
belongs on the hub only when a human-judged synthesis mentions it.
|
|
58
|
+
3. **Mark:** run `npx -y @theronap/cortex-mcp docs-scan --mark <path>` for every doc you AUTHORED or
|
|
59
|
+
judged ABSORBED. Never mark a doc whose `author` failed — it must stay pending for the next sweep.
|
|
60
|
+
4. **Verify before reporting.** For each doc you claim you authored, confirm the write actually landed
|
|
61
|
+
(`read_page` or `page_history`) — an `author` that returns "no change" when you intended an update
|
|
62
|
+
did NOT land. Never report a page you did not confirm.
|
|
63
|
+
5. **Report:** one short block — each doc → the page it became, the page that absorbed it, or why it
|
|
64
|
+
was left unmarked. The count of pending docs should be zero afterwards except for the
|
|
65
|
+
non-substantive ones you deliberately left.
|
|
66
|
+
|
|
67
|
+
## Safety rules
|
|
68
|
+
|
|
69
|
+
- Do NOT register or sweep the local brain repo (`~/Documents/brain`) while the Robin parity soak
|
|
70
|
+
is running — the experiment forbids re-syncing Robin into Agnoclast mid-window.
|
|
71
|
+
- Respect tiers: if a doc is clearly personal/sensitive, author it `confidential` or ask; default
|
|
72
|
+
for work docs is the author path's normal default.
|
|
73
|
+
- This skill writes wiki pages via the `author` tool only. It never sends external messages and
|
|
74
|
+
never deletes anything.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cortex-context
|
|
3
|
+
description: Automatically hydrate Agnoclast context at the start of a substantive session. Use when Agnoclast MCP is available and the user has made a real request, so the first answer is grounded in query-centered org context instead of the static baseline alone.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
> **Agnoclast-managed skill.** This file is installed and kept up to date by Agnoclast. 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
|
+
## When to use
|
|
10
|
+
|
|
11
|
+
At the beginning of a work session, once the user has given a real request or question. Skip trivial
|
|
12
|
+
chit-chat and requests where org context is obviously irrelevant.
|
|
13
|
+
|
|
14
|
+
## Steps
|
|
15
|
+
|
|
16
|
+
1. Call `session_context` with the user's opening request, preserving the actual topic in their words.
|
|
17
|
+
2. Use that returned block as the primary Agnoclast grounding for the first response.
|
|
18
|
+
3. If `session_context` is unavailable or errors, fall back to `my_context`.
|
|
19
|
+
4. If the conversation materially changes topics later, call `session_context` again for the new topic.
|
|
20
|
+
|
|
21
|
+
## Safety rules
|
|
22
|
+
|
|
23
|
+
- Do not fabricate Agnoclast context if the tool fails.
|
|
24
|
+
- Prefer the query-centered `session_context` over static `my_context` whenever the user's topic is clear.
|
|
25
|
+
- Do not call `session_context` for every tiny follow-up; refresh only when the topic meaningfully shifts.
|