@theronap/cortex-mcp 0.9.95 → 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/bin/cortex-mcp.mjs +17 -0
- package/lib/capture.mjs +87 -11
- package/lib/context_log.mjs +5 -12
- package/lib/diagnose.mjs +72 -9
- package/lib/doctor.mjs +11 -13
- package/lib/editors/antigravity.mjs +13 -6
- package/lib/editors/claude.mjs +20 -12
- package/lib/graphify_sync.mjs +2 -2
- package/lib/grep_cli.mjs +2 -2
- package/lib/hydrate.mjs +2 -2
- package/lib/ingest_folder.mjs +2 -2
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/redact.mjs +1 -1
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +2 -2
- package/lib/server.mjs +31 -6
- package/lib/session_key.mjs +24 -0
- package/lib/setup.mjs +24 -11
- package/lib/skills.mjs +4 -4
- package/lib/uninstall.mjs +79 -44
- package/lib/with_token.mjs +66 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -56,6 +56,8 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
56
56
|
` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
|
|
57
57
|
` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
|
|
58
58
|
` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
|
|
59
|
+
` migrate-key [--dry-run] move the MCP config key to its new name, allow rules first\n` +
|
|
60
|
+
` with-token -- <cmd> run <cmd> with your token in its environment (for cron/launchd wrappers)\n` +
|
|
59
61
|
` statusline ambient presence line for the Claude Code statusline (local read only)\n` +
|
|
60
62
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
61
63
|
` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
|
|
@@ -161,6 +163,21 @@ if (cmd === 'login') {
|
|
|
161
163
|
process.exitCode = await runHydrate()
|
|
162
164
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
163
165
|
await closeFetch()
|
|
166
|
+
} else if (cmd === 'migrate-key') {
|
|
167
|
+
// ADR-0033 §5 C2. Guarded: adds the new namespace's allow rules and VERIFIES them from disk
|
|
168
|
+
// before flipping mcpServers.cortex -> .agnoclast, so a seat that never received C1 cannot end up
|
|
169
|
+
// with renamed tools and no permissions. Idempotent; --dry-run reports the plan and changes
|
|
170
|
+
// nothing. NOT wired into any hook yet — activating it is the C2 release itself.
|
|
171
|
+
const { runKeyMigration } = await import('../lib/migrate_key.mjs')
|
|
172
|
+
const res = runKeyMigration({ dryRun: rest.includes('--dry-run') })
|
|
173
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n')
|
|
174
|
+
process.exitCode = res.status === 'aborted' ? 1 : 0
|
|
175
|
+
} else if (cmd === 'with-token') {
|
|
176
|
+
// Run a command with the resolved token in its environment. Exists so generated helper scripts
|
|
177
|
+
// (launchd/cron wrappers) never inline their own config parse — those files are frozen on disk and
|
|
178
|
+
// a config change strands them silently. See lib/with_token.mjs.
|
|
179
|
+
const { runWithToken } = await import('../lib/with_token.mjs')
|
|
180
|
+
process.exitCode = runWithToken(rest)
|
|
164
181
|
} else if (cmd === 'statusline') {
|
|
165
182
|
// Ambient presence: read one local breadcrumb and print a line. No network, no auth — it redraws
|
|
166
183
|
// constantly, so anything more expensive than a file read does not belong here.
|
package/lib/capture.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { readFileSync, accessSync, constants } from 'fs'
|
|
1
|
+
import { readFileSync, accessSync, constants, openSync, mkdirSync } from 'fs'
|
|
2
2
|
import { spawn, execFileSync } from 'child_process'
|
|
3
3
|
import { homedir } from 'os'
|
|
4
4
|
import { resolve, dirname, join } from 'path'
|
|
5
5
|
import { fileURLToPath } from 'url'
|
|
6
6
|
import { createHash } from 'crypto'
|
|
7
|
-
import { fetchCortex, classify, resolveBase,
|
|
7
|
+
import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
8
8
|
import { extractSession } from './edge_extract.mjs'
|
|
9
9
|
import { extractTyped } from './extract_typed.mjs'
|
|
10
10
|
import { redactSecrets } from './redact.mjs'
|
|
@@ -244,7 +244,31 @@ export async function runCapture() {
|
|
|
244
244
|
await captureWork(stdinRaw)
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
//
|
|
247
|
+
// Where a DETACHED worker's stdout/stderr go. Returns a writable fd, or 'ignore' if the log cannot be
|
|
248
|
+
// opened — a capture must never fail because a log file could not be created.
|
|
249
|
+
//
|
|
250
|
+
// WHY THIS EXISTS. The worker previously ran with `stdio: ['pipe', 'ignore', 'ignore']`, so every
|
|
251
|
+
// diagnostic capture prints — `no CORTEX_TOKEN`, `transcript_path unreadable`, `no-op session,
|
|
252
|
+
// skipping`, `ingest failed — <reason>` — was written to a discarded stream. In production that is
|
|
253
|
+
// EVERY capture: the sync path only runs under CORTEX_CAPTURE_SYNC, which nothing sets. So the
|
|
254
|
+
// messages carefully added at each failure branch have never once been readable by anyone.
|
|
255
|
+
//
|
|
256
|
+
// Measured 2026-08-19: session-record writes fell from 107/day (08-10) to 1/day (08-13) and stayed
|
|
257
|
+
// at 0-3/day for six days with no error surfacing anywhere. The failure was found only by forcing
|
|
258
|
+
// CORTEX_CAPTURE_SYNC=1 by hand and watching stderr — which is not a thing anyone will think to do
|
|
259
|
+
// about a subsystem that reports success. Append-only, one line per capture; the file is the only
|
|
260
|
+
// place a silent worker can leave a trace.
|
|
261
|
+
function captureLogFd() {
|
|
262
|
+
try {
|
|
263
|
+
const dir = join(homedir(), '.cortex')
|
|
264
|
+
mkdirSync(dir, { recursive: true })
|
|
265
|
+
return openSync(join(dir, 'capture.log'), 'a')
|
|
266
|
+
} catch {
|
|
267
|
+
return 'ignore'
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Re-invoke this same CLI as `capture` in a fully detached child (own session, output to capture.log) and
|
|
248
272
|
// feed it the hook payload on its stdin. Returns true if the child was launched (parent may return
|
|
249
273
|
// immediately), false if spawning failed (caller then does the work in-band). The child sees
|
|
250
274
|
// CORTEX_CAPTURE_DETACHED=1 so it runs captureWork() directly instead of forking again.
|
|
@@ -254,7 +278,7 @@ function spawnDetachedWorker(stdinRaw) {
|
|
|
254
278
|
const child = spawn(process.execPath, [bin, 'capture'], {
|
|
255
279
|
env: { ...process.env, CORTEX_CAPTURE_DETACHED: '1' },
|
|
256
280
|
detached: true,
|
|
257
|
-
stdio: ['pipe',
|
|
281
|
+
stdio: ['pipe', captureLogFd(), captureLogFd()],
|
|
258
282
|
})
|
|
259
283
|
child.on('error', () => {}) // never let an async spawn error crash the hook
|
|
260
284
|
child.stdin.on('error', () => {})
|
|
@@ -269,7 +293,7 @@ function spawnDetachedWorker(stdinRaw) {
|
|
|
269
293
|
async function captureWork(stdinRaw) {
|
|
270
294
|
// Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
|
|
271
295
|
// MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
|
|
272
|
-
const token =
|
|
296
|
+
const token = resolveTokenSource().token
|
|
273
297
|
if (!token) { process.stderr.write('cortex: no CORTEX_TOKEN in env or wired config, skipping\n'); return }
|
|
274
298
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
275
299
|
|
|
@@ -364,6 +388,12 @@ async function captureWork(stdinRaw) {
|
|
|
364
388
|
} catch { /* best-effort — never block capture */ }
|
|
365
389
|
}
|
|
366
390
|
|
|
391
|
+
// Defined before the fetch so the throw path below can stamp its line the same way. Every line in
|
|
392
|
+
// capture.log carries an ISO timestamp and the session id, so a silent day can be reconstructed
|
|
393
|
+
// afterwards and matched against `records` / `page_revisions.session_key`.
|
|
394
|
+
const stamp = new Date().toISOString()
|
|
395
|
+
const sid = hook.session_id ?? '(no session_id)'
|
|
396
|
+
|
|
367
397
|
let res
|
|
368
398
|
try {
|
|
369
399
|
// Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
|
|
@@ -375,17 +405,63 @@ async function captureWork(stdinRaw) {
|
|
|
375
405
|
timeoutMs: 10_000,
|
|
376
406
|
}, { retries: 1 })
|
|
377
407
|
} catch (e) {
|
|
378
|
-
// Never break a session — just report and move on.
|
|
379
|
-
|
|
408
|
+
// Never break a session — just report and move on. This is where a 10s timeout against a slow
|
|
409
|
+
// /api/ingest lands, and with the old discarded stdio it was completely invisible: the session
|
|
410
|
+
// simply never appeared and nothing anywhere said why. The endpoint is measurably flaky — a
|
|
411
|
+
// SessionStart context fetch timed out at 09:54 and again at 11:59 on 2026-08-19 while direct
|
|
412
|
+
// curls answered in ~150ms, so intermittent timeouts here are a live hypothesis for the
|
|
413
|
+
// 2026-08-13 collapse, not a theoretical one.
|
|
414
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — ${e.message}\n`)
|
|
380
415
|
return
|
|
381
416
|
}
|
|
382
417
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
418
|
+
// A 2xx FROM /api/ingest DOES NOT MEAN A RECORD EXISTS. The route answers `ok: true` on at least
|
|
419
|
+
// six outcomes and only one of them writes a session record:
|
|
420
|
+
//
|
|
421
|
+
// { ok: true, id, inserted, title } -> RECORDED (the only success)
|
|
422
|
+
// { ok: true, staged: true, id, reason } -> held in staged_records, NOT recorded
|
|
423
|
+
// { ok: true, skipped: '<why>' } -> no-op session / past the ingest horizon
|
|
424
|
+
// { ok: false, skipped: '<why>' } -> connector excluded from this brain
|
|
425
|
+
// { ok: true, discarded: true } -> tombstoned by private intake
|
|
426
|
+
// { ok: true, queued: true } -> accepted for later work
|
|
427
|
+
// { ok: true, via: 'private_intake', intakeItemId } -> an intake unit, not a session record
|
|
428
|
+
//
|
|
429
|
+
// The old line read `j.inserted ? 'captured' : 'updated'`, so EVERY one of the six non-writing
|
|
430
|
+
// outcomes printed "updated" — the word for a successful upsert. Worse, `.json().catch(() => ({}))`
|
|
431
|
+
// means an unparseable body also yields `{}` and therefore also printed "updated". Verified against
|
|
432
|
+
// prod 2026-08-19: a capture printed `cortex: updated "general" → general` for a session that has no
|
|
433
|
+
// row in `records` and none in `staged_records` either.
|
|
434
|
+
//
|
|
435
|
+
// NOTE `staged` CARRIES AN `id`, so testing for an id alone is not enough — that id is the
|
|
436
|
+
// staged_records row, not a record. This is the same false-success defect already fixed once in
|
|
437
|
+
// log_session ("`inserted` is merely falsy when nothing is recorded — an agent reported a session as
|
|
438
|
+
// saved when it was not"); the fix was applied there and not here. The server route already knew:
|
|
439
|
+
// its own comment at the no_route_for_source branch says "`{ok: true}` reads as success to
|
|
440
|
+
// everything that is not looking closely" and notes 84 rows accumulating behind that wording.
|
|
441
|
+
if (res.ok || res.status === 200) {
|
|
442
|
+
const raw = await res.text()
|
|
443
|
+
let j
|
|
444
|
+
try { j = JSON.parse(raw) } catch { j = null }
|
|
445
|
+
if (j && j.id && !j.staged) {
|
|
446
|
+
process.stderr.write(`cortex: ${stamp} ${sid} ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
|
|
447
|
+
} else if (j && j.staged) {
|
|
448
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — staged (${j.reason ?? 'no reason given'}); staged session logs are not drainable by /api/staged/promote\n`)
|
|
449
|
+
} else if (j && j.skipped) {
|
|
450
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — server skipped: ${j.skipped}\n`)
|
|
451
|
+
} else if (j && j.discarded) {
|
|
452
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — discarded by private intake\n`)
|
|
453
|
+
} else if (j && j.queued) {
|
|
454
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — queued for later processing\n`)
|
|
455
|
+
} else if (j && j.intakeItemId) {
|
|
456
|
+
process.stderr.write(`cortex: ${stamp} ${sid} not a session record — filed as private intake unit ${j.intakeItemId}\n`)
|
|
457
|
+
} else {
|
|
458
|
+
// Unparseable or unrecognised 2xx. Deliberately NOT reported as success: an unknown shape is
|
|
459
|
+
// exactly the case the old code laundered into "updated".
|
|
460
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — unrecognised 2xx response: ${raw.slice(0, 200)}\n`)
|
|
461
|
+
}
|
|
386
462
|
} else {
|
|
387
463
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
388
|
-
process.stderr.write(`cortex: ingest failed
|
|
464
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — ingest failed: ${d.message}\n`)
|
|
389
465
|
}
|
|
390
466
|
|
|
391
467
|
// The post-capture edge-materialize batch (CORTEX_MATERIALIZE → runMaterialize) was EXCISED
|
package/lib/context_log.mjs
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
4
|
+
import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
try {
|
|
11
|
-
const cfg = JSON.parse(readFileSync(claudeJson, 'utf8'))
|
|
12
|
-
return cfg?.mcpServers?.cortex?.env?.CORTEX_TOKEN ?? null
|
|
13
|
-
} catch {
|
|
14
|
-
return null
|
|
15
|
-
}
|
|
16
|
-
}
|
|
6
|
+
// The private third copy of this lived here until 2026-08-19. doctor.mjs:14 warned that a third
|
|
7
|
+
// copy is how a machine ends up connected to one command and 'no token found' to another; it also
|
|
8
|
+
// missed Codex entirely, so a Codex-only seat WAS in exactly that state. Shared resolver now.
|
|
9
|
+
const resolveToken = () => resolveTokenSource().token
|
|
17
10
|
|
|
18
11
|
function ensureDir(path) {
|
|
19
12
|
if (!existsSync(path)) mkdirSync(path, { recursive: true })
|
package/lib/diagnose.mjs
CHANGED
|
@@ -22,18 +22,81 @@ export const isUuid = (s) =>
|
|
|
22
22
|
// `CORTEX_TOKEN=…` in hooks.json shows the secret to anything that reads or greps the file, and
|
|
23
23
|
// from there to captured transcripts); `capture` resolves the token itself from the same config
|
|
24
24
|
// the MCP server already holds.
|
|
25
|
-
|
|
25
|
+
// ── Token resolution ─────────────────────────────────────────────────────────
|
|
26
|
+
// TWO resolvers, deliberately. An audit of all twelve call sites (ADR-0033 T1) found they are NOT
|
|
27
|
+
// semantically interchangeable: five REQUIRE an explicit token and five may fall back to the wired
|
|
28
|
+
// config. Collapsing both into one function would have silently granted config-based auth to the
|
|
29
|
+
// five that deliberately demand it — the failure the independent review flagged.
|
|
30
|
+
//
|
|
31
|
+
// resolveEnvToken() env only → server, graphify-sync, grep, ingest-folder, resolve
|
|
32
|
+
// resolveTokenSource() env, then wired → capture, hydrate, skills×2, context_log, doctor
|
|
33
|
+
//
|
|
34
|
+
// Precedence is PRESENCE-based, not validity-based: the first variable that is set wins, so a stale
|
|
35
|
+
// AGNOCLAST_TOKEN shadows a working CORTEX_TOKEN. Validity-based precedence would need a network
|
|
36
|
+
// round-trip on every resolution, which this path cannot afford. Instead the winner is REPORTED in
|
|
37
|
+
// `source`, so `doctor` shows which variable was used and a shadow is visible rather than silent.
|
|
38
|
+
|
|
39
|
+
/** In precedence order. Additive only — never drop a name a machine may still be wired with. */
|
|
40
|
+
export const TOKEN_ENV_VARS = ['AGNOCLAST_TOKEN', 'CORTEX_TOKEN']
|
|
41
|
+
const CONFIG_KEYS = ['agnoclast', 'cortex']
|
|
42
|
+
|
|
43
|
+
/** Environment only. For call sites where an explicit token is the point. */
|
|
44
|
+
export function resolveEnvToken(env = process.env) {
|
|
45
|
+
for (const name of TOKEN_ENV_VARS) {
|
|
46
|
+
if (env?.[name]) return { token: env[name], source: `${name} env`, key: null }
|
|
47
|
+
}
|
|
48
|
+
return { token: null, source: null, key: null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Positive results only, keyed by home. A miss re-reads, so a token wired mid-process (setup, then
|
|
52
|
+
// a hook in the same run) is still picked up; a hit is stable because a token cannot change while a
|
|
53
|
+
// process lives.
|
|
54
|
+
//
|
|
55
|
+
// `home` is an explicit parameter rather than always homedir() because BUN'S os.homedir() IGNORES
|
|
56
|
+
// the HOME variable (verified 2026-08-19: node honours it, bun returns the real home regardless),
|
|
57
|
+
// so a bun test cannot redirect it. editors/*.mjs already take `home` for the same reason.
|
|
58
|
+
const _wiredCache = new Map()
|
|
59
|
+
|
|
60
|
+
/** Reset the wired-token memo. Tests only. */
|
|
61
|
+
export function resetWiredTokenCache() { _wiredCache.clear() }
|
|
62
|
+
|
|
63
|
+
/** The wired config: ~/.claude.json first, then Codex config.toml. Memoized because this parses a
|
|
64
|
+
* ~117KB JSON file and the twelve call sites above are reached from per-prompt hooks. */
|
|
65
|
+
export function readWiredTokenSource(home = homedir()) {
|
|
66
|
+
if (_wiredCache.has(home)) return _wiredCache.get(home)
|
|
67
|
+
const claudeJson = join(home, '.claude.json')
|
|
26
68
|
try {
|
|
27
|
-
const cfg = JSON.parse(readFileSync(
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
69
|
+
const cfg = JSON.parse(readFileSync(claudeJson, 'utf8'))
|
|
70
|
+
for (const key of CONFIG_KEYS) {
|
|
71
|
+
const env = cfg?.mcpServers?.[key]?.env
|
|
72
|
+
for (const name of TOKEN_ENV_VARS) {
|
|
73
|
+
if (env?.[name]) { const r = { token: env[name], source: `${claudeJson} (mcpServers.${key})`, key }; _wiredCache.set(home, r); return r }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch { /* malformed or absent — fall through */ }
|
|
77
|
+
const codexToml = join(home, '.codex', 'config.toml')
|
|
31
78
|
try {
|
|
32
|
-
const toml = readFileSync(
|
|
33
|
-
const
|
|
34
|
-
|
|
79
|
+
const toml = readFileSync(codexToml, 'utf8')
|
|
80
|
+
for (const key of CONFIG_KEYS) {
|
|
81
|
+
for (const name of TOKEN_ENV_VARS) {
|
|
82
|
+
const m = toml.match(new RegExp(`\\[mcp_servers\\.${key}\\.env\\][\\s\\S]*?${name}\\s*=\\s*"([^"]+)"`))
|
|
83
|
+
if (m) { const r = { token: m[1], source: `${codexToml} ([mcp_servers.${key}])`, key }; _wiredCache.set(home, r); return r }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
35
86
|
} catch { /* fall through */ }
|
|
36
|
-
return null
|
|
87
|
+
return { token: null, source: null, key: null }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Environment, then wired config. The canonical resolver for background/hook work. */
|
|
91
|
+
export function resolveTokenSource(env = process.env, home = homedir()) {
|
|
92
|
+
const fromEnv = resolveEnvToken(env)
|
|
93
|
+
return fromEnv.token ? fromEnv : readWiredTokenSource(home)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Bare-token form of the WIRED lookup only — deliberately NOT env-aware, because install.mjs:70
|
|
97
|
+
* calls it as `argToken || readWiredToken()` where the whole point is "what is already wired". */
|
|
98
|
+
export function readWiredToken(home = homedir()) {
|
|
99
|
+
return readWiredTokenSource(home).token
|
|
37
100
|
}
|
|
38
101
|
|
|
39
102
|
// Pure: pull the cortex-mcp dist-tag / version out of a wired command line. Exported for tests.
|
package/lib/doctor.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import { checkToken, checkSkills, resolveBase } from './diagnose.mjs'
|
|
4
|
+
import { checkToken, checkSkills, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
5
|
+
import { renderRenameNotice } from './rename_notice.mjs'
|
|
5
6
|
|
|
6
7
|
// `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
|
|
7
8
|
//
|
|
@@ -14,18 +15,7 @@ import { checkToken, checkSkills, resolveBase } from './diagnose.mjs'
|
|
|
14
15
|
// Exported so `use-brain` resolves the token EXACTLY as doctor/status do. There is already a second,
|
|
15
16
|
// subtly different copy of this in context_log.mjs (returns a bare token, not {token, source}); a
|
|
16
17
|
// third copy is how a machine ends up "connected" to one command and "no token found" to another.
|
|
17
|
-
export
|
|
18
|
-
if (process.env.CORTEX_TOKEN) return { token: process.env.CORTEX_TOKEN, source: 'CORTEX_TOKEN env' }
|
|
19
|
-
const claudeJson = join(homedir(), '.claude.json')
|
|
20
|
-
if (existsSync(claudeJson)) {
|
|
21
|
-
try {
|
|
22
|
-
const cfg = JSON.parse(readFileSync(claudeJson, 'utf8'))
|
|
23
|
-
const t = cfg?.mcpServers?.cortex?.env?.CORTEX_TOKEN
|
|
24
|
-
if (t) return { token: t, source: `${claudeJson} (mcpServers.cortex)` }
|
|
25
|
-
} catch { /* malformed config — fall through to "not found" */ }
|
|
26
|
-
}
|
|
27
|
-
return { token: null, source: null }
|
|
28
|
-
}
|
|
18
|
+
export const resolveToken = resolveTokenSource
|
|
29
19
|
|
|
30
20
|
// `status` — the one-line SessionStart variant of doctor: a visible "is Agnoclast capturing?"
|
|
31
21
|
// signal inside Claude Code itself (three-machine dry-run finding 2026-06-09: with no
|
|
@@ -54,6 +44,14 @@ export async function runStatus() {
|
|
|
54
44
|
}
|
|
55
45
|
const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
|
|
56
46
|
out(`Agnoclast: connected — sessions on this machine are captured to your org${n}.`)
|
|
47
|
+
// TEMPORARY (ADR-0033 T7) — delete with lib/rename_notice.mjs a release after C2.
|
|
48
|
+
// This one FOLLOWS the happy line rather than replacing it, unlike captureNotice above. The
|
|
49
|
+
// rule there exists because captureNotice CONTRADICTS the reassurance ("connected" was true
|
|
50
|
+
// while the work was landing nowhere). This does not contradict anything: the connection is
|
|
51
|
+
// genuinely fine and a rename is separately scheduled. Replacing the status line with it would
|
|
52
|
+
// hide a fact the reader needs in order to report a real problem.
|
|
53
|
+
const notice = renderRenameNotice(resolveToken().key)
|
|
54
|
+
if (notice) out(notice)
|
|
57
55
|
} else {
|
|
58
56
|
out(`Agnoclast: NOT connected — ${r.diagnosis?.message ?? 'check failed'}. Run: npx -y @theronap/cortex-mcp doctor`)
|
|
59
57
|
}
|
|
@@ -57,23 +57,30 @@ export function renderAntigravitySyncSh() {
|
|
|
57
57
|
return `#!/bin/bash
|
|
58
58
|
# Agnoclast ⇄ Antigravity one-shot sync, triggered by launchd WatchPaths on the Antigravity
|
|
59
59
|
# trajectory store. There is no resident daemon — launchd wakes this on change and it exits.
|
|
60
|
-
# Token is resolved
|
|
60
|
+
# Token is resolved by 'cortex-mcp with-token' at runtime (never stored in plist/script, and
|
|
61
|
+
# never parsed here -- see ADR-0033 D9 on frozen generated scripts).
|
|
61
62
|
# ⚠ AGENT_DIR points at a local dev checkout — see antigravity.mjs BLOCKER note (not coworker-portable).
|
|
62
63
|
set -o pipefail
|
|
63
64
|
LOG="$HOME/.cortex/antigravity-sync.log"
|
|
64
|
-
|
|
65
|
+
NPX="/usr/local/bin/npx"
|
|
65
66
|
BUN="$HOME/.bun/bin/bun"
|
|
66
67
|
AGENT_DIR="$HOME/dev/cortex/packages/cortex-agent"
|
|
67
68
|
|
|
68
69
|
stamp() { date -u +%FT%TZ; }
|
|
69
70
|
|
|
70
|
-
TOKEN="$("$NODE" -e "try{console.log(JSON.parse(require('fs').readFileSync(require('os').homedir()+'/.claude.json','utf8'))?.mcpServers?.cortex?.env?.CORTEX_TOKEN||'')}catch(e){process.exit(0)}" 2>/dev/null)"
|
|
71
|
-
if [ -z "$TOKEN" ]; then echo "$(stamp) no CORTEX_TOKEN wired, skipping" >> "$LOG"; exit 0; fi
|
|
72
71
|
if [ ! -d "$AGENT_DIR" ]; then echo "$(stamp) agent dir missing: $AGENT_DIR" >> "$LOG"; exit 0; fi
|
|
73
72
|
|
|
73
|
+
# The token is resolved by the package, not parsed here. This script is written once and never
|
|
74
|
+
# rewritten by an upgrade, so an inlined config read would silently stop working the moment the
|
|
75
|
+
# config shape changed -- and its own error handling would swallow that. with-token also keeps the
|
|
76
|
+
# secret out of this shell entirely: it goes from the resolver straight into the child environment.
|
|
77
|
+
# Exit 3 means "nothing wired", which is a skip rather than a failure.
|
|
74
78
|
cd "$AGENT_DIR" || exit 0
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
CORTEX_WATCH="$HOME/.cortex-agent" "$NPX" -y @theronap/cortex-mcp@stable with-token -- \
|
|
80
|
+
"$BUN" run src/index.ts antigravity-sync >> "$LOG" 2>&1
|
|
81
|
+
CODE=$?
|
|
82
|
+
if [ "$CODE" = "3" ]; then echo "$(stamp) no token wired, skipping" >> "$LOG"; exit 0; fi
|
|
83
|
+
echo "$(stamp) exit=$CODE" >> "$LOG"
|
|
77
84
|
exit 0
|
|
78
85
|
`
|
|
79
86
|
}
|
package/lib/editors/claude.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from 'node:os'
|
|
|
5
5
|
import { existsSync } from 'node:fs'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { readJson, backupFile, ensureDir, writeJson } from './_fsutil.mjs'
|
|
8
|
+
import { markCommand, isManagedSubcommand } from '../managed.mjs'
|
|
8
9
|
|
|
9
10
|
/** Merge the Agnoclast MCP server into a ~/.claude.json object. Pure + idempotent: sets only the
|
|
10
11
|
* `cortex` entry (type:'stdio'), preserves every other server. `spec` = the package@dist-tag string. */
|
|
@@ -27,7 +28,7 @@ export function mergeClaudeMcp(existing, spec, token) {
|
|
|
27
28
|
* lossy at the row level, so the prompt IS the guard D1 argued for), rollback_page, decide_page_merge /
|
|
28
29
|
* decide_file_request / request_file / get_file, create_brain / set_active_brain, alias_page,
|
|
29
30
|
* set_writing_style. */
|
|
30
|
-
export const
|
|
31
|
+
export const ALLOWED_TOOL_NAMES = [
|
|
31
32
|
// read surface
|
|
32
33
|
'grep', 'read_page', 'my_context', 'project_status', 'session_context', 'search_org',
|
|
33
34
|
'list_records', 'page_history', 'page_diff', 'timeline_pull', 'my_brains', 'my_sessions', 'writing_style',
|
|
@@ -36,7 +37,14 @@ export const CORTEX_ALLOWED_TOOLS = [
|
|
|
36
37
|
'authoring_context', 'author', 'log_session',
|
|
37
38
|
// routine, reversible maintenance
|
|
38
39
|
'set_page_validity', 'snooze_red_link', 'attribute_thread',
|
|
39
|
-
]
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/** The same allowlist, rendered for a given tool namespace. ONE source of names, so the cortex and
|
|
43
|
+
* agnoclast lists cannot drift apart during the migration — C2 adds the new namespace's rules
|
|
44
|
+
* before flipping the config key, and it derives them from here rather than a second copy. */
|
|
45
|
+
export const allowedToolsFor = (namespace) => ALLOWED_TOOL_NAMES.map((t) => `mcp__${namespace}__${t}`)
|
|
46
|
+
|
|
47
|
+
export const CORTEX_ALLOWED_TOOLS = allowedToolsFor('cortex')
|
|
40
48
|
|
|
41
49
|
/** Merge Agnoclast's Claude Code hooks into a ~/.claude/settings.json object. Pure + idempotent: drops
|
|
42
50
|
* any prior cortex entry (old token/path/version) from each hook array before appending the current
|
|
@@ -53,9 +61,9 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
53
61
|
|
|
54
62
|
// Stop — capture.
|
|
55
63
|
s.hooks.Stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
56
|
-
const captureCmd = `npx -y ${spec} capture`
|
|
64
|
+
const captureCmd = markCommand(`npx -y ${spec} capture`)
|
|
57
65
|
for (const grp of s.hooks.Stop) {
|
|
58
|
-
if (Array.isArray(grp.hooks)) grp.hooks = grp.hooks.filter((h) =>
|
|
66
|
+
if (Array.isArray(grp.hooks)) grp.hooks = grp.hooks.filter((h) => !isManagedSubcommand(h.command, 'capture'))
|
|
59
67
|
}
|
|
60
68
|
let grp = s.hooks.Stop.find((g) => (g.matcher ?? '') === '')
|
|
61
69
|
if (!grp) { grp = { matcher: '', hooks: [] }; s.hooks.Stop.push(grp) }
|
|
@@ -64,24 +72,24 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
64
72
|
|
|
65
73
|
// SessionStart — status, then skills self-heal, then snapshot-context (order matters: matches setup).
|
|
66
74
|
s.hooks.SessionStart = Array.isArray(s.hooks.SessionStart) ? s.hooks.SessionStart : []
|
|
67
|
-
const statusCmd = `npx -y ${spec} status`
|
|
75
|
+
const statusCmd = markCommand(`npx -y ${spec} status`)
|
|
68
76
|
for (const sg of s.hooks.SessionStart) {
|
|
69
|
-
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) =>
|
|
77
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !isManagedSubcommand(h.command, 'status'))
|
|
70
78
|
}
|
|
71
79
|
let sgrp = s.hooks.SessionStart.find((g) => (g.matcher ?? '') === '')
|
|
72
80
|
if (!sgrp) { sgrp = { matcher: '', hooks: [] }; s.hooks.SessionStart.push(sgrp) }
|
|
73
81
|
sgrp.hooks = sgrp.hooks ?? []
|
|
74
82
|
sgrp.hooks.push({ type: 'command', command: statusCmd })
|
|
75
83
|
|
|
76
|
-
const skillsCmd = `npx -y ${spec} skills --repair --quiet`
|
|
84
|
+
const skillsCmd = markCommand(`npx -y ${spec} skills --repair --quiet`)
|
|
77
85
|
for (const sg of s.hooks.SessionStart) {
|
|
78
|
-
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) =>
|
|
86
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !isManagedSubcommand(h.command, 'skills'))
|
|
79
87
|
}
|
|
80
88
|
sgrp.hooks.push({ type: 'command', command: skillsCmd })
|
|
81
89
|
|
|
82
|
-
const snapshotCmd = `npx -y ${spec} snapshot-context`
|
|
90
|
+
const snapshotCmd = markCommand(`npx -y ${spec} snapshot-context`)
|
|
83
91
|
for (const sg of s.hooks.SessionStart) {
|
|
84
|
-
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) =>
|
|
92
|
+
if (Array.isArray(sg.hooks)) sg.hooks = sg.hooks.filter((h) => !isManagedSubcommand(h.command, 'snapshot-context'))
|
|
85
93
|
}
|
|
86
94
|
sgrp.hooks.push({ type: 'command', command: snapshotCmd })
|
|
87
95
|
|
|
@@ -97,9 +105,9 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
97
105
|
// dead for five days when its worktree was deleted — the `-f` guard fails open, so the hook silently
|
|
98
106
|
// became a no-op with the config still looking wired. Re-running setup should heal that, not skip it.
|
|
99
107
|
s.hooks.UserPromptSubmit = Array.isArray(s.hooks.UserPromptSubmit) ? s.hooks.UserPromptSubmit : []
|
|
100
|
-
const hydrateCmd = `npx -y ${spec} hydrate`
|
|
108
|
+
const hydrateCmd = markCommand(`npx -y ${spec} hydrate`)
|
|
101
109
|
for (const hg of s.hooks.UserPromptSubmit) {
|
|
102
|
-
if (Array.isArray(hg.hooks)) hg.hooks = hg.hooks.filter((h) =>
|
|
110
|
+
if (Array.isArray(hg.hooks)) hg.hooks = hg.hooks.filter((h) => !isManagedSubcommand(h.command, 'hydrate'))
|
|
103
111
|
}
|
|
104
112
|
let hgrp = s.hooks.UserPromptSubmit.find((g) => (g.matcher ?? '') === '')
|
|
105
113
|
if (!hgrp) { hgrp = { matcher: '', hooks: [] }; s.hooks.UserPromptSubmit.push(hgrp) }
|
package/lib/graphify_sync.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process'
|
|
2
2
|
import { existsSync, readFileSync } from 'fs'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
4
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken } from './diagnose.mjs'
|
|
5
5
|
|
|
6
6
|
// `cortex-mcp graphify-sync [path]` — local producer: incrementally rebuild a repo's structural
|
|
7
7
|
// code graph (graphify — tree-sitter AST, no LLM) and log an evidence-tier timeline event via
|
|
@@ -62,7 +62,7 @@ export async function runGraphifySync(argv = []) {
|
|
|
62
62
|
const parsed = parseGraphifyArgs(argv)
|
|
63
63
|
if (parsed.error) { process.stderr.write(parsed.error + '\n'); return 1 }
|
|
64
64
|
const { cwd, brain } = parsed
|
|
65
|
-
const TOKEN =
|
|
65
|
+
const TOKEN = resolveEnvToken().token
|
|
66
66
|
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
67
67
|
if (!TOKEN) {
|
|
68
68
|
process.stderr.write('cortex-mcp graphify-sync: CORTEX_TOKEN is required.\n')
|
package/lib/grep_cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
1
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken } from './diagnose.mjs'
|
|
2
2
|
|
|
3
3
|
// `cortex grep` CLI + shared formatting for the MCP grep tool (Lane B / T6). Thin client of
|
|
4
4
|
// GET /api/grep — the server runs the RLS-INVOKER RPC AS the viewer, so no DB credentials live here.
|
|
@@ -53,7 +53,7 @@ export function formatGrepHits(payload, query) {
|
|
|
53
53
|
|
|
54
54
|
// Effectful: run the CLI subcommand. Returns an exit code.
|
|
55
55
|
export async function runGrep(rest = []) {
|
|
56
|
-
const TOKEN =
|
|
56
|
+
const TOKEN = resolveEnvToken().token
|
|
57
57
|
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
58
58
|
if (!TOKEN) {
|
|
59
59
|
process.stderr.write('cortex grep: CORTEX_TOKEN is required (get yours from the Agnoclast console).\n')
|
package/lib/hydrate.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'fs'
|
|
2
2
|
import { homedir } from 'os'
|
|
3
3
|
import { join } from 'path'
|
|
4
|
-
import { fetchCortex, classify, resolveBase,
|
|
4
|
+
import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
5
5
|
import { redactSecrets } from './redact.mjs'
|
|
6
6
|
import { formatReceipt, renderUncounted, subjectOf } from './presence.mjs'
|
|
7
7
|
import { writePresence } from './statusline.mjs'
|
|
@@ -114,7 +114,7 @@ export async function runHydrate() {
|
|
|
114
114
|
})
|
|
115
115
|
if (action !== 'go') return 0
|
|
116
116
|
|
|
117
|
-
const token =
|
|
117
|
+
const token = resolveTokenSource().token
|
|
118
118
|
if (!token) return 0 // not wired → silent no-op (don't mark done; a later session may be wired)
|
|
119
119
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
120
120
|
|
package/lib/ingest_folder.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, relative, basename, extname } from 'node:path'
|
|
3
|
-
import { fetchCortex, classify, resolveBase } from './diagnose.mjs'
|
|
3
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken } from './diagnose.mjs'
|
|
4
4
|
|
|
5
5
|
// `cortex-mcp ingest-folder <path>` — walk a local markdown folder and upsert each file as an
|
|
6
6
|
// AUTHORED record in Agnoclast (source='brain'), so a user's personal digest is built from THEIR
|
|
@@ -105,7 +105,7 @@ export async function runIngestFolder(argv) {
|
|
|
105
105
|
return
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
const token =
|
|
108
|
+
const token = resolveEnvToken().token
|
|
109
109
|
if (!token) {
|
|
110
110
|
process.stderr.write(
|
|
111
111
|
'cortex: CORTEX_TOKEN not set. Run setup first, or export CORTEX_TOKEN=<your-token>.\n',
|
package/lib/managed.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Identity for the entries we write onto a machine — hooks in settings.json, crontab lines.
|
|
2
|
+
//
|
|
3
|
+
// The problem this exists to solve: uninstall used to find our entries by matching the product
|
|
4
|
+
// name (`CORTEX_RE = /cortex-mcp|.../`). That works only until the product is renamed, at which
|
|
5
|
+
// point uninstall removes the OLD entries, reports success, and leaves the NEW ones wired —
|
|
6
|
+
// firing on every session against a possibly revoked token. `67920bd` caught the same class of
|
|
7
|
+
// bug from the other side (renamed skills left uninstall unable to find the skills on disk).
|
|
8
|
+
// The installer had it too: its dedup filters matched `cortex-mcp.*capture`, so after a rename
|
|
9
|
+
// a `repair` run would fail to recognize its own previous hooks and APPEND duplicates rather
|
|
10
|
+
// than replace them.
|
|
11
|
+
//
|
|
12
|
+
// So identity stops depending on what the product is called. Every command we write carries an
|
|
13
|
+
// inert marker flag that is stable across renames. Name matching stays as a LEGACY fallback only, because machines wired
|
|
14
|
+
// before the marker existed can be found no other way — it can never be deleted, only demoted.
|
|
15
|
+
|
|
16
|
+
/** Inert flag appended to every command we write. Never change this string: it is the only thing
|
|
17
|
+
* that lets a future version recognize entries written by this one.
|
|
18
|
+
*
|
|
19
|
+
* Why a FLAG and not a trailing `# comment`: a comment is only inert if the hook command is run
|
|
20
|
+
* through a shell, and whether Claude Code does that is not something this package can verify.
|
|
21
|
+
* An argv token is inert either way — with a shell it is an argument, without one it is still an
|
|
22
|
+
* argument. The only requirement is that our own CLI ignore it, which holds because bin dispatches
|
|
23
|
+
* on argv[2] and every subcommand parses `rest` with `.includes()` (verified 2026-08-19:
|
|
24
|
+
* `uninstall --dry-run` and `uninstall --dry-run --agnoclast-managed` produce identical output).
|
|
25
|
+
* Do not move it before the subcommand — it must land in `rest`, not in argv[2]. */
|
|
26
|
+
export const MANAGED_MARKER = '--agnoclast-managed'
|
|
27
|
+
|
|
28
|
+
/** Names we shipped under, for machines predating MANAGED_MARKER. Additive only — a name that
|
|
29
|
+
* ever appeared in a written command must stay here forever. `capture-session-cloud` is an
|
|
30
|
+
* early Stop-hook command that predates the npx form. */
|
|
31
|
+
const LEGACY_NAME_RE = /cortex-mcp|agnoclast-mcp|capture-session-cloud|@theronap\/(cortex|agnoclast)/
|
|
32
|
+
|
|
33
|
+
/** Stamp a command as ours. Appended, never substituted, so the command still runs unchanged. */
|
|
34
|
+
export function markCommand(command) {
|
|
35
|
+
const c = String(command ?? '')
|
|
36
|
+
return c.includes(MANAGED_MARKER) ? c : `${c} ${MANAGED_MARKER}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True if we wrote this command — by marker (rename-proof) or by legacy name (pre-marker seats). */
|
|
40
|
+
export function isManagedCommand(command) {
|
|
41
|
+
const c = String(command ?? '')
|
|
42
|
+
return c.includes(MANAGED_MARKER) || LEGACY_NAME_RE.test(c)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True if this is one of ours AND runs the named subcommand. Used by the installer to replace
|
|
46
|
+
* its own prior entry for a given hook rather than appending a duplicate beside it. */
|
|
47
|
+
export function isManagedSubcommand(command, subcommand) {
|
|
48
|
+
return isManagedCommand(command) && new RegExp(`\\b${subcommand}\\b`).test(String(command ?? ''))
|
|
49
|
+
}
|