@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.
@@ -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, readWiredToken } from './diagnose.mjs'
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
- // Re-invoke this same CLI as `capture` in a fully detached child (own session, stdio ignored) and
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', 'ignore', 'ignore'],
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 = process.env.CORTEX_TOKEN || readWiredToken()
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
- process.stderr.write(`cortex: ${e.message}\n`)
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
- if (res.ok) {
384
- const j = await res.json().catch(() => ({}))
385
- process.stderr.write(`cortex: ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
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 ${d.message}\n`)
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
@@ -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
- function resolveToken() {
7
- if (process.env.CORTEX_TOKEN) return process.env.CORTEX_TOKEN
8
- const claudeJson = join(homedir(), '.claude.json')
9
- if (!existsSync(claudeJson)) return null
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
- export function readWiredToken() {
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(join(homedir(), '.claude.json'), 'utf8'))
28
- const t = cfg?.mcpServers?.cortex?.env?.CORTEX_TOKEN
29
- if (t) return t
30
- } catch { /* fall through */ }
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(join(homedir(), '.codex', 'config.toml'), 'utf8')
33
- const m = toml.match(/\[mcp_servers\.cortex\.env\][\s\S]*?CORTEX_TOKEN\s*=\s*"([^"]+)"/)
34
- if (m) return m[1]
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 function resolveToken() {
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 from the wired MCP config at runtime (never stored in plist/script).
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
- NODE="/usr/local/bin/node"
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
- CORTEX_TOKEN="$TOKEN" CORTEX_WATCH="$HOME/.cortex-agent" "$BUN" run src/index.ts antigravity-sync >> "$LOG" 2>&1
76
- echo "$(stamp) exit=$?" >> "$LOG"
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
  }
@@ -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 CORTEX_ALLOWED_TOOLS = [
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
- ].map((t) => `mcp__cortex__${t}`)
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) => !/cortex-mcp.*capture|capture-session-cloud/.test(h.command ?? ''))
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) => !/cortex-mcp(@[^ ]*)? status/.test(h.command ?? ''))
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) => !/cortex-mcp(@[^ ]*)? skills/.test(h.command ?? ''))
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) => !/cortex-mcp(@[^ ]*)? snapshot-context/.test(h.command ?? ''))
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) => !/cortex-mcp.*hydrate/.test(h.command ?? ''))
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) }
@@ -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 = process.env.CORTEX_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 = process.env.CORTEX_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, readWiredToken } from './diagnose.mjs'
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 = process.env.CORTEX_TOKEN || readWiredToken()
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
 
@@ -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 = process.env.CORTEX_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',
@@ -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
+ }