@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.
Files changed (44) hide show
  1. package/README.md +47 -0
  2. package/bin/cortex-mcp.mjs +223 -0
  3. package/lib/capture.mjs +470 -0
  4. package/lib/code_graph_cli.mjs +59 -0
  5. package/lib/context_log.mjs +92 -0
  6. package/lib/diagnose.mjs +360 -0
  7. package/lib/docs_scan.mjs +171 -0
  8. package/lib/doctor.mjs +117 -0
  9. package/lib/edge_extract.mjs +156 -0
  10. package/lib/editors/_fsutil.mjs +31 -0
  11. package/lib/editors/antigravity.mjs +130 -0
  12. package/lib/editors/claude.mjs +202 -0
  13. package/lib/editors/codex.mjs +111 -0
  14. package/lib/editors/cursor.mjs +77 -0
  15. package/lib/editors/index.mjs +42 -0
  16. package/lib/extract_typed.mjs +68 -0
  17. package/lib/graphify_sync.mjs +134 -0
  18. package/lib/grep_cli.mjs +82 -0
  19. package/lib/hydrate.mjs +181 -0
  20. package/lib/imessage_send.mjs +88 -0
  21. package/lib/ingest_folder.mjs +170 -0
  22. package/lib/install.mjs +163 -0
  23. package/lib/login.mjs +148 -0
  24. package/lib/managed.mjs +49 -0
  25. package/lib/migrate_key.mjs +139 -0
  26. package/lib/presence.mjs +226 -0
  27. package/lib/publish_targets.mjs +51 -0
  28. package/lib/red_link_triage.mjs +37 -0
  29. package/lib/redact.mjs +40 -0
  30. package/lib/rename_notice.mjs +31 -0
  31. package/lib/resolve.mjs +153 -0
  32. package/lib/server.mjs +2986 -0
  33. package/lib/session_key.mjs +37 -0
  34. package/lib/setup.mjs +215 -0
  35. package/lib/skills.mjs +374 -0
  36. package/lib/statusline.mjs +67 -0
  37. package/lib/uninstall.mjs +237 -0
  38. package/lib/use_brain.mjs +82 -0
  39. package/lib/with_token.mjs +66 -0
  40. package/package.json +36 -0
  41. package/skills/author-docs/SKILL.md +74 -0
  42. package/skills/context/SKILL.md +25 -0
  43. package/skills/log/SKILL.md +114 -0
  44. 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.