@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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { readJson, backupFile, ensureDir, writeJson } from './editors/_fsutil.mjs'
|
|
5
|
+
import { allowedToolsFor } from './editors/claude.mjs'
|
|
6
|
+
|
|
7
|
+
// The C2 key flip, made self-sufficient (ADR-0033 §5, T8).
|
|
8
|
+
//
|
|
9
|
+
// The plan was: C1 adds `mcp__agnoclast__*` to the permission allowlist, then C2 one release later
|
|
10
|
+
// flips `mcpServers.cortex` → `mcpServers.agnoclast`. An earlier draft claimed the release gap made
|
|
11
|
+
// the dangerous ordering "structurally impossible". IT DOES NOT. There is no forced upgrade
|
|
12
|
+
// sequence and — by this ADR's own decision — no version telemetry, so a seat that sits idle
|
|
13
|
+
// through C1 and then upgrades straight to a C2 build receives the flip having never received the
|
|
14
|
+
// allowlist. The gap helps the common case and guarantees nothing.
|
|
15
|
+
//
|
|
16
|
+
// So the ordering guarantee lives HERE, on the machine, not in the release cadence:
|
|
17
|
+
//
|
|
18
|
+
// 1. ensure the new namespace's allow rules are present (additive — nothing is removed)
|
|
19
|
+
// 2. RE-READ from disk and verify they actually landed
|
|
20
|
+
// 3. only then flip the config key
|
|
21
|
+
// 4. re-read and verify the flip landed
|
|
22
|
+
// 5. record it, so a re-run is a no-op
|
|
23
|
+
//
|
|
24
|
+
// If step 2 fails, the key is NOT flipped and the machine stays fully working on the old name. The
|
|
25
|
+
// failure this prevents is silent: renamed tools that fall outside the allowlist do not error, they
|
|
26
|
+
// prompt — which on an unattended seat (cron, scheduled agents, --print) is a hang or a skip.
|
|
27
|
+
//
|
|
28
|
+
// Note this is the REVERSE of the order editors/claude.mjs wire() uses, which writes .claude.json
|
|
29
|
+
// before settings.json. That is fine for a fresh install, where neither exists yet; it is exactly
|
|
30
|
+
// wrong for a migration, where the config key must move last.
|
|
31
|
+
|
|
32
|
+
export const OLD_KEY = 'cortex'
|
|
33
|
+
export const NEW_KEY = 'agnoclast'
|
|
34
|
+
|
|
35
|
+
/** Where the completed migration is recorded. Under ~/.cortex, which ADR-0033 D3 keeps. */
|
|
36
|
+
export const migrationStatePath = (home) => join(home, '.cortex', 'migration.json')
|
|
37
|
+
|
|
38
|
+
/** PURE. The mcpServers object with our entry moved to the new key, preserving the entry verbatim.
|
|
39
|
+
* Returns the same object reference semantics as the input (a copy), plus what it did. */
|
|
40
|
+
export function migratedMcpServers(servers) {
|
|
41
|
+
const out = { ...(servers ?? {}) }
|
|
42
|
+
if (out[NEW_KEY] && !out[OLD_KEY]) return { servers: out, moved: false, reason: 'already-new-key' }
|
|
43
|
+
if (!out[OLD_KEY]) return { servers: out, moved: false, reason: 'no-entry' }
|
|
44
|
+
// Carry the entry across untouched: command, args (the package spec) and env all survive, so a
|
|
45
|
+
// dogfooder pinned to @latest stays pinned and the token is not re-derived.
|
|
46
|
+
out[NEW_KEY] = out[OLD_KEY]
|
|
47
|
+
delete out[OLD_KEY]
|
|
48
|
+
return { servers: out, moved: true, reason: null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** PURE. Allow rules the new namespace needs that this settings object does not yet carry. */
|
|
52
|
+
export function missingAllows(settings, required = allowedToolsFor(NEW_KEY)) {
|
|
53
|
+
const have = new Set(Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [])
|
|
54
|
+
return required.filter((r) => !have.has(r))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** PURE. Settings with the new namespace's rules added. ADDITIVE — the old namespace's rules and
|
|
58
|
+
* every user-added rule survive (editors/claude.mjs:48 invariant), and `deny` is never touched. */
|
|
59
|
+
export function withNewAllows(settings, required = allowedToolsFor(NEW_KEY)) {
|
|
60
|
+
const s = settings && typeof settings === 'object' ? { ...settings } : {}
|
|
61
|
+
s.permissions = s.permissions && typeof s.permissions === 'object' ? { ...s.permissions } : {}
|
|
62
|
+
s.permissions.allow = Array.isArray(s.permissions.allow) ? [...s.permissions.allow] : []
|
|
63
|
+
for (const rule of required) if (!s.permissions.allow.includes(rule)) s.permissions.allow.push(rule)
|
|
64
|
+
return s
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Perform the guarded migration on one machine. Idempotent.
|
|
69
|
+
* Returns { status, ...detail } where status is one of:
|
|
70
|
+
* 'already' — the state file says this machine is done
|
|
71
|
+
* 'nothing-to-migrate' — no entry of ours under either key
|
|
72
|
+
* 'aborted' — the allowlist could not be verified; the key was NOT flipped
|
|
73
|
+
* 'migrated' — allows verified, key flipped, state recorded
|
|
74
|
+
*/
|
|
75
|
+
export function runKeyMigration({ home = homedir(), dryRun = false, io = {} } = {}) {
|
|
76
|
+
const { read = readJson, write = writeJson, backup = backupFile, mkdir = ensureDir, exists = existsSync } = io
|
|
77
|
+
const claudeJson = join(home, '.claude.json')
|
|
78
|
+
const settingsJson = join(home, '.claude', 'settings.json')
|
|
79
|
+
const statePath = migrationStatePath(home)
|
|
80
|
+
const required = allowedToolsFor(NEW_KEY)
|
|
81
|
+
|
|
82
|
+
if (exists(statePath)) {
|
|
83
|
+
try { if (read(statePath)?.to === NEW_KEY) return { status: 'already', statePath } } catch { /* re-run */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let cfg
|
|
87
|
+
try { cfg = read(claudeJson) } catch (e) { return { status: 'aborted', step: 'read-config', reason: e.message } }
|
|
88
|
+
const plan = migratedMcpServers(cfg?.mcpServers)
|
|
89
|
+
if (!plan.moved) return { status: 'nothing-to-migrate', reason: plan.reason }
|
|
90
|
+
|
|
91
|
+
// ── 1+2. Allowlist FIRST, then verify from disk. ───────────────────────────
|
|
92
|
+
let settings
|
|
93
|
+
try { settings = read(settingsJson) } catch (e) { return { status: 'aborted', step: 'read-settings', reason: e.message } }
|
|
94
|
+
const missing = missingAllows(settings, required)
|
|
95
|
+
if (dryRun) return { status: 'dry-run', wouldAdd: missing, wouldFlip: `${OLD_KEY} -> ${NEW_KEY}` }
|
|
96
|
+
|
|
97
|
+
if (missing.length) {
|
|
98
|
+
try {
|
|
99
|
+
backup(settingsJson)
|
|
100
|
+
mkdir(settingsJson)
|
|
101
|
+
write(settingsJson, withNewAllows(settings, required))
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return { status: 'aborted', step: 'write-settings', reason: e.message, added: 0 }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Re-read from disk. Writing is not the same as landing: a full disk, a concurrent writer, or a
|
|
108
|
+
// permission problem can all produce a successful-looking write and an unchanged file.
|
|
109
|
+
let verify
|
|
110
|
+
try { verify = read(settingsJson) } catch (e) {
|
|
111
|
+
return { status: 'aborted', step: 'verify-settings', reason: e.message }
|
|
112
|
+
}
|
|
113
|
+
const stillMissing = missingAllows(verify, required)
|
|
114
|
+
if (stillMissing.length) {
|
|
115
|
+
return { status: 'aborted', step: 'verify-settings', reason: 'allow rules did not persist', stillMissing }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 3+4. Only now flip the key, then verify that too. ──────────────────────
|
|
119
|
+
try {
|
|
120
|
+
backup(claudeJson)
|
|
121
|
+
mkdir(claudeJson)
|
|
122
|
+
write(claudeJson, { ...cfg, mcpServers: plan.servers })
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { status: 'aborted', step: 'write-config', reason: e.message, allowsAdded: missing.length }
|
|
125
|
+
}
|
|
126
|
+
let cfgAfter
|
|
127
|
+
try { cfgAfter = read(claudeJson) } catch (e) {
|
|
128
|
+
return { status: 'aborted', step: 'verify-config', reason: e.message }
|
|
129
|
+
}
|
|
130
|
+
if (!cfgAfter?.mcpServers?.[NEW_KEY]) {
|
|
131
|
+
return { status: 'aborted', step: 'verify-config', reason: 'config key did not persist' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 5. Record it. Best-effort: a missing state file only costs an idempotent re-run. ──
|
|
135
|
+
const record = { from: OLD_KEY, to: NEW_KEY, allowsAdded: missing.length }
|
|
136
|
+
try { mkdir(statePath); write(statePath, record) } catch { /* non-fatal */ }
|
|
137
|
+
|
|
138
|
+
return { status: 'migrated', ...record, statePath }
|
|
139
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The names this package is published under, and how to derive each variant's package.json.
|
|
2
|
+
//
|
|
3
|
+
// ADR-0033 §3. Phase B publishes the SAME build under two names: `@theronap/agnoclast-mcp` going
|
|
4
|
+
// forward, and `@theronap/cortex-mcp` in perpetuity, because the old name is hardcoded in hook
|
|
5
|
+
// command strings and in generated launchd/cron scripts on machines we cannot reach. Generated
|
|
6
|
+
// scripts are frozen at write time (D9), so a script written last year still invokes the old name
|
|
7
|
+
// and no upgrade can repair it. Unpublishing would break those silently.
|
|
8
|
+
//
|
|
9
|
+
// "Byte-identical tarballs" is impossible — the name lives inside the tarball's own package.json —
|
|
10
|
+
// so the guarantee is weaker and more precise: both variants are packed from ONE source tree in ONE
|
|
11
|
+
// release step, and differ ONLY in the fields listed in VARIANT_FIELDS.
|
|
12
|
+
//
|
|
13
|
+
// The `bin` key differs too, and must. npm installs bins by key, so two packages both declaring
|
|
14
|
+
// `cortex-mcp` collide on any machine that resolves both. Each variant declares a bin named after
|
|
15
|
+
// itself. `npx -y <either name>` still works: with exactly one bin declared, npx runs it regardless
|
|
16
|
+
// of what the key is called.
|
|
17
|
+
|
|
18
|
+
/** The fields that may legitimately differ between variants. Anything else differing is a bug. */
|
|
19
|
+
export const VARIANT_FIELDS = ['name', 'bin']
|
|
20
|
+
|
|
21
|
+
/** Publish targets, primary first. Additive only: a name that has ever been published stays here. */
|
|
22
|
+
export const PUBLISH_TARGETS = [
|
|
23
|
+
{ name: '@theronap/agnoclast-mcp', bin: 'agnoclast-mcp', role: 'primary' },
|
|
24
|
+
{ name: '@theronap/cortex-mcp', bin: 'cortex-mcp', role: 'alias' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** The entry point every variant's bin points at. One file, many names. */
|
|
28
|
+
export const BIN_ENTRY = 'bin/cortex-mcp.mjs'
|
|
29
|
+
|
|
30
|
+
/** PURE. The package.json to publish for one target, derived from the source manifest. */
|
|
31
|
+
export function variantPackageJson(sourcePkg, target) {
|
|
32
|
+
if (!sourcePkg || typeof sourcePkg !== 'object') throw new TypeError('variantPackageJson: sourcePkg must be an object')
|
|
33
|
+
if (!target?.name || !target?.bin) throw new TypeError('variantPackageJson: target needs { name, bin }')
|
|
34
|
+
return { ...sourcePkg, name: target.name, bin: { [target.bin]: BIN_ENTRY } }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PURE. Fields that differ between two manifests, so a release can assert only VARIANT_FIELDS do.
|
|
38
|
+
* Returns a sorted list of top-level keys whose JSON serialization differs. */
|
|
39
|
+
export function differingFields(a, b) {
|
|
40
|
+
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])
|
|
41
|
+
const out = []
|
|
42
|
+
for (const k of keys) {
|
|
43
|
+
if (JSON.stringify(a?.[k]) !== JSON.stringify(b?.[k])) out.push(k)
|
|
44
|
+
}
|
|
45
|
+
return out.sort()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** PURE. True when two variants differ ONLY in the fields allowed to differ. */
|
|
49
|
+
export function variantsAreEquivalent(a, b) {
|
|
50
|
+
return differingFields(a, b).every((f) => VARIANT_FIELDS.includes(f))
|
|
51
|
+
}
|
package/lib/redact.mjs
CHANGED
|
@@ -24,7 +24,7 @@ const PATTERNS = [
|
|
|
24
24
|
// Agnoclast's own login token wherever it appears in KEY=value / KEY: value form (hooks.json,
|
|
25
25
|
// config.toml, shell commands — the 2026-07-02 finding: a grep of hooks.json put the live token
|
|
26
26
|
// in a transcript and nothing below caught it). Specific pattern first for the accurate label.
|
|
27
|
-
[/(
|
|
27
|
+
[/((?:AGNOCLAST|CORTEX)_TOKEN["']?\s*[=:]\s*["']?)[0-9a-fA-F][0-9a-fA-F-]{30,}/g, '$1[REDACTED:agnoclast-token]'],
|
|
28
28
|
// Generic secret-shaped assignment: an UPPER_SNAKE name ending in TOKEN/SECRET/PASSWORD/
|
|
29
29
|
// API_KEY/APIKEY assigned a ≥16-char value. Conservative: the name-suffix + length floor keep
|
|
30
30
|
// ordinary prose, short placeholders, and bare UUIDs (record ids) intact.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NEW_KEY, OLD_KEY } from './migrate_key.mjs'
|
|
2
|
+
|
|
3
|
+
// The C1 warning (ADR-0033 §4, T7).
|
|
4
|
+
//
|
|
5
|
+
// THIS WHOLE MODULE IS TEMPORARY. It ships in C1, one release ahead of the C2 key flip, and is
|
|
6
|
+
// DELETED a release after C2. If you are reading this and C2 shipped more than one release ago,
|
|
7
|
+
// delete the file, its test, and the two lines that call it in doctor.mjs.
|
|
8
|
+
//
|
|
9
|
+
// The decision it implements: warn once that the flip is coming, then it is the user's file and
|
|
10
|
+
// their call. No detection loop, no dismiss state, no expiry to guess. Earlier drafts proposed
|
|
11
|
+
// grepping every user's CLAUDE.md on a cadence and nagging until it changed; that was rejected —
|
|
12
|
+
// the package has never written CLAUDE.md and should not start, and a nag that cannot tell a fixed
|
|
13
|
+
// file from an unfixed one is wrong for some readers the entire time it runs.
|
|
14
|
+
//
|
|
15
|
+
// It is self-limiting without any state: the notice renders only while this machine is still on the
|
|
16
|
+
// OLD config key. Once `migrate-key` flips it, the condition is false and the message stops. That is
|
|
17
|
+
// "warn, then leave them alone" expressed as a condition rather than as a timer.
|
|
18
|
+
|
|
19
|
+
/** C1 sets this true. Until then the notice is inert, so building it now changes nothing for the
|
|
20
|
+
* people currently on @latest. Flipping this constant IS the C1 release. */
|
|
21
|
+
export const RENAME_NOTICE_ACTIVE = false
|
|
22
|
+
|
|
23
|
+
/** PURE. The one-line warning, or null when it should stay quiet.
|
|
24
|
+
* `key` is which config key answered on this machine ('cortex' | 'agnoclast' | null). */
|
|
25
|
+
export function renderRenameNotice(key, { active = RENAME_NOTICE_ACTIVE } = {}) {
|
|
26
|
+
if (!active) return null
|
|
27
|
+
if (key !== OLD_KEY) return null // already flipped, or nothing wired — nothing to warn about
|
|
28
|
+
return `Agnoclast: heads up — the MCP tools are being renamed from mcp__${OLD_KEY}__* to `
|
|
29
|
+
+ `mcp__${NEW_KEY}__*. If your CLAUDE.md names the old tools, update it when convenient. `
|
|
30
|
+
+ `Run \`npx -y @theronap/cortex-mcp migrate-key\` to switch now, or wait and it will happen for you.`
|
|
31
|
+
}
|
package/lib/resolve.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'child_process'
|
|
2
|
-
import { fetchCortex, resolveBase, classify } from './diagnose.mjs'
|
|
2
|
+
import { fetchCortex, resolveBase, resolveEnvToken, classify } from './diagnose.mjs'
|
|
3
3
|
import { edgeSafeEnv } from './edge_extract.mjs'
|
|
4
4
|
|
|
5
5
|
// `cortex-mcp resolve` — the JUDGE half of entity identity dedup (Grey harvest). The server FLAGS the
|
|
@@ -38,7 +38,7 @@ export async function brainsToSweep(base, token, wanted, deps = {}) {
|
|
|
38
38
|
export async function runResolve(argv = []) {
|
|
39
39
|
// recursion guard (we spawn `claude --print`; if its Stop hook fires capture, that no-ops on this flag)
|
|
40
40
|
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
|
|
41
|
-
const token =
|
|
41
|
+
const token = resolveEnvToken().token
|
|
42
42
|
if (!token) { process.stderr.write('cortex: CORTEX_TOKEN not set, skipping\n'); return }
|
|
43
43
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
44
44
|
|
package/lib/server.mjs
CHANGED
|
@@ -5,8 +5,8 @@ import { writeFileSync, mkdirSync } from 'fs'
|
|
|
5
5
|
import { homedir } from 'os'
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { createHash, randomUUID } from 'crypto'
|
|
8
|
-
import { fetchCortex, classify, resolveBase, setSessionKey } from './diagnose.mjs'
|
|
9
|
-
import { resolveSessionKey } from './session_key.mjs'
|
|
8
|
+
import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey } from './diagnose.mjs'
|
|
9
|
+
import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
|
|
10
10
|
import { runSendImessage } from './imessage_send.mjs'
|
|
11
11
|
import { formatGrepHits } from './grep_cli.mjs'
|
|
12
12
|
import { renderTriage } from './red_link_triage.mjs'
|
|
@@ -76,7 +76,7 @@ export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp
|
|
|
76
76
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
77
77
|
|
|
78
78
|
export async function runServer(version) {
|
|
79
|
-
const TOKEN =
|
|
79
|
+
const TOKEN = resolveEnvToken().token
|
|
80
80
|
const BASE = resolveBase(process.env.CORTEX_URL)
|
|
81
81
|
|
|
82
82
|
if (!TOKEN) {
|
|
@@ -108,7 +108,11 @@ export async function runServer(version) {
|
|
|
108
108
|
await fetchCortex(`${BASE}/api/session-ping`, {
|
|
109
109
|
method: 'POST',
|
|
110
110
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
111
|
-
|
|
111
|
+
// mcpVersion (ADR-0033 T9): the fleet currently has NO way to report which client build a
|
|
112
|
+
// seat runs — session-ping carried liveness only. That is why every version gate in this
|
|
113
|
+
// ADR was unobservable. Additive and forward-compatible: the route destructures sessionKey
|
|
114
|
+
// and cwd and ignores the rest, so this is inert until a column exists to store it.
|
|
115
|
+
body: JSON.stringify({ sessionKey: SESSION_KEY, cwd: process.cwd(), mcpVersion: version }),
|
|
112
116
|
})
|
|
113
117
|
} catch { /* best-effort heartbeat — never disrupt the session */ }
|
|
114
118
|
}
|
|
@@ -224,7 +228,7 @@ export async function runServer(version) {
|
|
|
224
228
|
'log_session',
|
|
225
229
|
{
|
|
226
230
|
title: 'Log this session to Agnoclast',
|
|
227
|
-
description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary.
|
|
231
|
+
description: 'Persist a CURATED summary of this work session as its durable Agnoclast record (authoritative — supersedes the auto-capture hook). Call at session close after composing the summary. `sessionId` now defaults to this session automatically (from CLAUDE_CODE_SESSION_ID) so the log dedupes with the auto-capture of the same session — pass it explicitly only to log on behalf of a DIFFERENT session, e.g. recovering one whose own close-out failed. If you belong to more than one brain you MUST name one — without a brain a session log has no route and is STAGED rather than recorded. **If the session touched work belonging to different brains, pass `segments` instead of `summary` and split it** — one segment per brain, each summary standing on its own and never alluding to the others. Reports every segment individually; a partial result is reported as PARTIAL, never as success.',
|
|
228
232
|
inputSchema: {
|
|
229
233
|
summary: z.string().optional().describe('the curated session summary — the single-brain form. Omit when passing `segments`'),
|
|
230
234
|
segments: z.array(z.object({
|
|
@@ -235,12 +239,33 @@ export async function runServer(version) {
|
|
|
235
239
|
})).optional().describe('SPLIT the log, one entry per brain. Use whenever a session touched work belonging to different brains: a session is a container of time, not a topic, and every single-brain answer is wrong — filing it all in the org brain exposes personal work to colleagues, filing it all in the personal one denies the org its record, and summarizing half silently drops the other half. Max ONE segment per brain (they share this session\'s dedupe key, so two aimed at the same brain would overwrite each other). When a chunk is ambiguous, put it in the MORE PRIVATE brain — a misfile there is private, a misfile the other way is visible to everyone in the org.'),
|
|
236
240
|
project: z.string().optional().describe('project key/name this session worked in'),
|
|
237
241
|
title: z.string().optional().describe('short title for the session'),
|
|
238
|
-
sessionId: z.string().optional().describe('the Claude Code session id — shared by every segment
|
|
242
|
+
sessionId: z.string().optional().describe('the Claude Code session id — DEFAULTS to the current session, so omit it unless you are logging on behalf of another one. It is shared by every segment and the only thing pairing them, and it is also the join to the pages this session wrote (page_revisions.session_key); without it the record is unattributable forever, since the fallback dedupe key is a timestamp. Deliberately NOT a link: segments never reference each other, so a reader cleared for one brain cannot tell the others exist, while you can join on it across brains'),
|
|
239
243
|
brain: z.string().optional().describe('which brain to record this session in (name or org id, one of your own). REQUIRED IN EFFECT for a multi-brain member: session-class sources route only by an explicit brain or a sole membership, so omitting it stages the log instead of recording it.'),
|
|
240
244
|
privacy: z.enum(['accessible', 'scoped', 'confidential']).optional().describe('tier this record AT WRITE TIME. Use when the summary names confidential work (a candidate evaluation, a security finding) — safer than letting it land org-visible and re-tiering after, which leaves it readable in between.'),
|
|
241
245
|
},
|
|
242
246
|
},
|
|
243
247
|
async ({ summary, segments, project, title, sessionId, brain, privacy }) => {
|
|
248
|
+
// Default the session id from the environment when the caller omits it.
|
|
249
|
+
//
|
|
250
|
+
// `sessionId` is optional and the model routinely does not pass it, so the record lands with a
|
|
251
|
+
// TIMESTAMP dedupe key (`claude-code:<ISO>`) and a null payload.session_id. Measured against
|
|
252
|
+
// prod 2026-08-19: of 136 skill records, only 33 carried a session id — the other 103 are
|
|
253
|
+
// unattributable by any means, because the timestamp key is not a fallback identity. That is
|
|
254
|
+
// 76% of every /cortex-log close-out unable to be joined to the pages that session wrote
|
|
255
|
+
// (page_revisions.session_key), which is the join the whole currency audit runs on.
|
|
256
|
+
//
|
|
257
|
+
// The process has always known this value. It is the same CLAUDE_CODE_SESSION_ID the capture
|
|
258
|
+
// hook sends as hook.session_id, which is precisely what makes the two dedupe onto one record
|
|
259
|
+
// instead of two — the stated purpose of the parameter in this tool's own description.
|
|
260
|
+
//
|
|
261
|
+
// ⚠ resolveLogSessionId, NOT resolveSessionKey / SESSION_KEY. The latter falls back to
|
|
262
|
+
// randomUUID(), and a random per-process uuid here would be WORSE than the timestamp it
|
|
263
|
+
// replaces: it looks like a real session id, mints `claude-code:<random>` as a dedupe key that
|
|
264
|
+
// pairs with no capture record and matches no page_revisions.session_key, and so manufactures a
|
|
265
|
+
// confident-looking join that is silently wrong. A missing id is honest; an invented one is not.
|
|
266
|
+
// Hosts that do not set the var keep today's timestamp behaviour. Negative-controlled in
|
|
267
|
+
// session_key.test.mjs.
|
|
268
|
+
sessionId = resolveLogSessionId(sessionId, process.env)
|
|
244
269
|
// A session is a container of TIME, not a topic (ADR-0029 step 4). Normalize to a list of
|
|
245
270
|
// segments; the single-brain call is just a one-segment list.
|
|
246
271
|
const list = Array.isArray(segments) && segments.length
|
package/lib/session_key.mjs
CHANGED
|
@@ -11,3 +11,27 @@
|
|
|
11
11
|
export function resolveSessionKey(env, randomUUID) {
|
|
12
12
|
return env.CLAUDE_CODE_SESSION_ID || randomUUID()
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
// Which session id a `log_session` call should RECORD. Pure — see session_key.test.mjs.
|
|
16
|
+
//
|
|
17
|
+
// Distinct from resolveSessionKey above, and deliberately NOT built on it. That one answers "which
|
|
18
|
+
// session is this process" and may invent a random id, which is correct for a write pointer: any
|
|
19
|
+
// stable value works because it is only ever compared to itself.
|
|
20
|
+
//
|
|
21
|
+
// This answers "which session produced this record", and there an invented value is actively harmful.
|
|
22
|
+
// The id becomes the record's dedupe key (`claude-code:<id>`) and its payload.session_id, which is the
|
|
23
|
+
// ONLY join to the pages the session wrote (page_revisions.session_key). A random per-process uuid
|
|
24
|
+
// would look exactly like a real session id while pairing with no capture record and matching no
|
|
25
|
+
// revision — a confident join that is silently wrong. The timestamp fallback it would replace is at
|
|
26
|
+
// least honestly unattributable.
|
|
27
|
+
//
|
|
28
|
+
// So: caller's value, else the real conversation id, else NOTHING. Never a random.
|
|
29
|
+
//
|
|
30
|
+
// Measured against prod 2026-08-19: 103 of 136 skill records had no session id because the parameter
|
|
31
|
+
// is optional and callers omit it — 76% of every close-out permanently unjoinable to its own work.
|
|
32
|
+
export function resolveLogSessionId(explicit, env) {
|
|
33
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
|
|
34
|
+
const fromEnv = env?.CLAUDE_CODE_SESSION_ID
|
|
35
|
+
if (typeof fromEnv === 'string' && fromEnv.trim()) return fromEnv.trim()
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
package/lib/setup.mjs
CHANGED
|
@@ -20,14 +20,32 @@ const PKG = '@theronap/cortex-mcp'
|
|
|
20
20
|
|
|
21
21
|
// mergeCodexToml / mergeCodexHooks moved to ./editors/codex.mjs (imported above, re-exported below).
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// PURE, exported for tests. The dist-tag decision is setup's own logic and it has a scar: a bare
|
|
24
|
+
// spec lets npx reuse a stale cached build, a frozen @x.y.z strands the machine forever (the
|
|
25
|
+
// 0.9.5→0.9.6 freeze that stranded a pilot install), and blindly writing @stable silently knocks a
|
|
26
|
+
// dogfooder off @latest. So: preserve an intentional @latest, otherwise @stable, never a version.
|
|
27
|
+
export function pickSpec(distTag, pkg = PKG) {
|
|
28
|
+
return distTag === 'latest' ? `${pkg}@latest` : `${pkg}@stable`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// PURE, exported for tests. Returns { token } or { error } — the caller owns process.exit, so the
|
|
32
|
+
// validation itself is reachable from a test runner.
|
|
33
|
+
export function parseSetupArgs(argv, pkg = PKG) {
|
|
34
|
+
const token = (argv ?? [])[0]
|
|
35
|
+
if (!token || token.startsWith('-')) {
|
|
36
|
+
return { error: `Usage: npx ${pkg} setup <CORTEX_TOKEN>\n\nGet your token from the Agnoclast console → Connect your AI.\n` }
|
|
37
|
+
}
|
|
38
|
+
return { token }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readJson(path) {
|
|
24
42
|
if (!existsSync(path)) return {}
|
|
25
43
|
const raw = readFileSync(path, 'utf8').trim()
|
|
26
44
|
if (!raw) return {}
|
|
27
45
|
return JSON.parse(raw) // throws on malformed — caller handles
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
function backup(path) {
|
|
48
|
+
export function backup(path) {
|
|
31
49
|
if (!existsSync(path)) return null
|
|
32
50
|
const bak = `${path}.cortex-bak`
|
|
33
51
|
copyFileSync(path, bak)
|
|
@@ -40,14 +58,9 @@ function ensureDir(path) {
|
|
|
40
58
|
}
|
|
41
59
|
|
|
42
60
|
export async function runSetup(argv, version) {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
'Usage: npx @theronap/cortex-mcp setup <CORTEX_TOKEN>\n\n' +
|
|
47
|
-
'Get your token from the Agnoclast console → Connect your AI.\n'
|
|
48
|
-
)
|
|
49
|
-
process.exit(1)
|
|
50
|
-
}
|
|
61
|
+
const parsed = parseSetupArgs(argv)
|
|
62
|
+
if (parsed.error) { process.stderr.write(parsed.error); process.exit(1) }
|
|
63
|
+
const token = parsed.token
|
|
51
64
|
// Wire a moving dist-tag — NOT a frozen version. A bare spec lets npx reuse a stale cached build; a
|
|
52
65
|
// frozen `@x.y.z` freezes the machine on that version forever (the 0.9.5→0.9.6 freeze that stranded a
|
|
53
66
|
// pilot install). A tag is re-resolved by npx against the registry, so machines pick up promoted
|
|
@@ -55,7 +68,7 @@ export async function runSetup(argv, version) {
|
|
|
55
68
|
// npm dist-tag add @theronap/cortex-mcp@<version> stable
|
|
56
69
|
// PRESERVE an intentional @latest (dogfood) pin across re-runs (repair/setup) — otherwise this
|
|
57
70
|
// silently knocks a dogfooder back to @stable. Fresh installs and existing @stable get @stable.
|
|
58
|
-
const spec = wiredDistTag()
|
|
71
|
+
const spec = pickSpec(wiredDistTag())
|
|
59
72
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
60
73
|
const home = homedir()
|
|
61
74
|
const claudeJson = join(home, '.claude.json')
|
package/lib/skills.mjs
CHANGED
|
@@ -206,8 +206,8 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
206
206
|
const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
|
|
207
207
|
const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
|
|
208
208
|
|
|
209
|
-
const { resolveBase,
|
|
210
|
-
const token =
|
|
209
|
+
const { resolveBase, resolveTokenSource, fetchCortex } = await import('./diagnose.mjs')
|
|
210
|
+
const token = resolveTokenSource().token
|
|
211
211
|
if (!token) { log(' · org skills: no token wired — skipped'); return summary }
|
|
212
212
|
|
|
213
213
|
// Previous org-installed names, so we can remove what the org deleted/disabled.
|
|
@@ -300,8 +300,8 @@ export function renderPushError(body, status) {
|
|
|
300
300
|
// This is the WRITE twin of the GET bug fixed in #467. The read was miscategorised and now fans out;
|
|
301
301
|
// the write was categorised correctly and simply had no input for the answer it demanded.
|
|
302
302
|
export async function runSkillsPush(argv) {
|
|
303
|
-
const { resolveBase,
|
|
304
|
-
const token =
|
|
303
|
+
const { resolveBase, resolveTokenSource, fetchCortex } = await import('./diagnose.mjs')
|
|
304
|
+
const token = resolveTokenSource().token
|
|
305
305
|
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
306
306
|
const base = resolveBase(process.env.CORTEX_URL)
|
|
307
307
|
|