@theronap/agnoclast-mcp 0.9.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/bin/cortex-mcp.mjs +223 -0
- package/lib/capture.mjs +470 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/context_log.mjs +92 -0
- package/lib/diagnose.mjs +360 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/doctor.mjs +117 -0
- package/lib/edge_extract.mjs +156 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +130 -0
- package/lib/editors/claude.mjs +202 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/graphify_sync.mjs +134 -0
- package/lib/grep_cli.mjs +82 -0
- package/lib/hydrate.mjs +181 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/ingest_folder.mjs +170 -0
- package/lib/install.mjs +163 -0
- package/lib/login.mjs +148 -0
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/presence.mjs +226 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/red_link_triage.mjs +37 -0
- package/lib/redact.mjs +40 -0
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +153 -0
- package/lib/server.mjs +2986 -0
- package/lib/session_key.mjs +37 -0
- package/lib/setup.mjs +215 -0
- package/lib/skills.mjs +374 -0
- package/lib/statusline.mjs +67 -0
- package/lib/uninstall.mjs +237 -0
- package/lib/use_brain.mjs +82 -0
- package/lib/with_token.mjs +66 -0
- package/package.json +36 -0
- package/skills/author-docs/SKILL.md +74 -0
- package/skills/context/SKILL.md +25 -0
- package/skills/log/SKILL.md +114 -0
- package/skills/walkthrough/SKILL.md +189 -0
package/lib/diagnose.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Shared health / diagnosis helpers for the cortex MCP client.
|
|
2
|
+
//
|
|
3
|
+
// The whole reason this file exists: a transient infrastructure block (Vercel firewall / bot
|
|
4
|
+
// protection / deployment protection) once surfaced as a bare "Agnoclast API 403: unknown", which
|
|
5
|
+
// read like an auth failure and sent everyone chasing token regeneration for hours. The fix is
|
|
6
|
+
// to tell the truth about WHAT failed.
|
|
7
|
+
//
|
|
8
|
+
// KEY SIGNAL: the Agnoclast app ALWAYS returns JSON ({ error: ... }). So a NON-JSON body on a
|
|
9
|
+
// 4xx/5xx means infrastructure handled the request, not Agnoclast auth — re-running setup or
|
|
10
|
+
// regenerating the token will not help; it is usually transient and worth a retry.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'fs'
|
|
13
|
+
import { homedir } from 'os'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
|
|
16
|
+
export const isUuid = (s) =>
|
|
17
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s ?? '')
|
|
18
|
+
|
|
19
|
+
// Find the token already wired on this machine (Claude's ~/.claude.json first, then Codex's
|
|
20
|
+
// config.toml). Lives HERE — the shared leaf module — so both setup/repair and the capture hook
|
|
21
|
+
// use one resolver: hook commands carry NO inline token (token-hygiene, 2026-07-02 — an inlined
|
|
22
|
+
// `CORTEX_TOKEN=…` in hooks.json shows the secret to anything that reads or greps the file, and
|
|
23
|
+
// from there to captured transcripts); `capture` resolves the token itself from the same config
|
|
24
|
+
// the MCP server already holds.
|
|
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')
|
|
68
|
+
try {
|
|
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')
|
|
78
|
+
try {
|
|
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
|
+
}
|
|
86
|
+
} catch { /* fall through */ }
|
|
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
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pure: pull the cortex-mcp dist-tag / version out of a wired command line. Exported for tests.
|
|
103
|
+
// Returns 'latest' | 'stable' | a version string (e.g. '0.9.4') | null (no cortex-mcp spec present).
|
|
104
|
+
export function parseDistTag(line) {
|
|
105
|
+
const m = String(line || '').match(/@theronap\/cortex-mcp@(latest|stable|[0-9][^\s"']*)/)
|
|
106
|
+
return m ? m[1] : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Read the dist-tag / version this machine is currently wired to (from the ~/.claude.json cortex MCP
|
|
110
|
+
// command), so a re-run of setup/repair can PRESERVE an intentional channel instead of forcing @stable
|
|
111
|
+
// every time — that silently knocks a dogfooder on @latest back to the pilot channel (bit Theron
|
|
112
|
+
// 2026-07-24). Returns 'latest' | 'stable' | a version string | null (nothing wired yet).
|
|
113
|
+
export function wiredDistTag() {
|
|
114
|
+
try {
|
|
115
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'))
|
|
116
|
+
const c = cfg?.mcpServers?.cortex
|
|
117
|
+
return parseDistTag([c?.command, ...(c?.args ?? [])].filter(Boolean).join(' '))
|
|
118
|
+
} catch { /* fall through */ }
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The production alias — exempt from Vercel Deployment Protection.
|
|
123
|
+
export const CANONICAL_BASE = 'https://cortex-console.vercel.app'
|
|
124
|
+
|
|
125
|
+
// Resolve the API base from CORTEX_URL. A Vercel *deployment* URL
|
|
126
|
+
// (cortex-console-<hash>-<team>.vercel.app, or any non-canonical *.vercel.app) is guarded by
|
|
127
|
+
// Deployment Protection and returns an HTML auth page to programmatic clients — which looks
|
|
128
|
+
// exactly like an "infra block" (403/401, non-JSON). The production alias is exempt. So if
|
|
129
|
+
// CORTEX_URL points at a deployment URL, we fall back to the alias (and warn on stderr). A real
|
|
130
|
+
// custom domain (not *.vercel.app) is respected as-is. This makes the client robust to a stale
|
|
131
|
+
// CORTEX_URL pointing at a protected preview/deployment — the root cause of the Windows 403.
|
|
132
|
+
export function resolveBase(rawUrl) {
|
|
133
|
+
const raw = (rawUrl ?? '').trim()
|
|
134
|
+
if (!raw) return CANONICAL_BASE
|
|
135
|
+
let host
|
|
136
|
+
try { host = new URL(raw).host } catch { return CANONICAL_BASE }
|
|
137
|
+
if (host.endsWith('.vercel.app') && host !== 'cortex-console.vercel.app') {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`cortex: CORTEX_URL (${raw}) is a protected Vercel deployment URL; using ${CANONICAL_BASE} instead.\n`,
|
|
140
|
+
)
|
|
141
|
+
return CANONICAL_BASE
|
|
142
|
+
}
|
|
143
|
+
return raw.replace(/\/$/, '')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
147
|
+
|
|
148
|
+
// A non-OK response → an actionable diagnosis: { kind, retriable, message }.
|
|
149
|
+
// kind: 'auth' → token bad/revoked/wrong-deployment (NOT retriable)
|
|
150
|
+
// 'infra' → blocked by infrastructure, non-JSON body (retriable, usually transient)
|
|
151
|
+
// 'app' → a real Agnoclast API error with a JSON message (retriable only if 5xx)
|
|
152
|
+
export function classify(status, contentType, bodyText, requestId) {
|
|
153
|
+
const isJson = (contentType ?? '').includes('application/json')
|
|
154
|
+
let appError = null
|
|
155
|
+
let appHint = null
|
|
156
|
+
let appMessage = null
|
|
157
|
+
let appBrains = null
|
|
158
|
+
if (isJson) {
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(bodyText)
|
|
161
|
+
appError = parsed?.error ?? null
|
|
162
|
+
// Same lesson as `hint`, one layer up. brain_choice_response.ts writes a full explanation to
|
|
163
|
+
// `message` and every brain's NAME / PAGE COUNT / SAMPLE TITLES to `brains` — its comment says
|
|
164
|
+
// "THE BODY IS THE ANSWER TO ITS OWN QUESTION", precisely so a model can pick a brain from what
|
|
165
|
+
// each one HOLDS. Only `error` survived here, so the agent got the bare code `brain_required`
|
|
166
|
+
// and could not answer it. Measured on the CLI twin of this bug: `✗ brain_required`, full stop.
|
|
167
|
+
appMessage = typeof parsed?.message === 'string' && parsed.message.trim() ? parsed.message.trim() : null
|
|
168
|
+
appBrains = Array.isArray(parsed?.brains) ? parsed.brains : null
|
|
169
|
+
// `hint` carries the RECOVERY instruction for the errors an agent is meant to act on, not
|
|
170
|
+
// just report: 409 would_drop ("re-author including the dropped sections…") and 413 too_large
|
|
171
|
+
// ("split the section…"). It used to be dropped here — only `error` survived — so the agent
|
|
172
|
+
// saw a bare code like "would_drop_sections" and had no idea what to do with it. The routes
|
|
173
|
+
// were writing careful self-heal guidance that never reached anyone.
|
|
174
|
+
appHint = typeof parsed?.hint === 'string' && parsed.hint.trim() ? parsed.hint.trim() : null
|
|
175
|
+
} catch { /* not json after all */ }
|
|
176
|
+
}
|
|
177
|
+
const rid = requestId ? ` [request id: ${requestId}]` : ''
|
|
178
|
+
|
|
179
|
+
if (status === 401 || (isJson && appError === 'invalid token')) {
|
|
180
|
+
return {
|
|
181
|
+
kind: 'auth', retriable: false,
|
|
182
|
+
message: `Token rejected (HTTP ${status}: ${appError ?? 'unauthorized'}). The token is invalid, ` +
|
|
183
|
+
`revoked, or for a different deployment — not an infrastructure problem. Get a fresh token from ` +
|
|
184
|
+
`the Agnoclast console → Connect your AI, then re-run setup.${rid}`,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!isJson) {
|
|
188
|
+
return {
|
|
189
|
+
kind: 'infra', retriable: true,
|
|
190
|
+
message: `Blocked by infrastructure (HTTP ${status}, non-JSON response) — NOT by Agnoclast auth. This is ` +
|
|
191
|
+
`usually a transient firewall / bot-protection hiccup; retrying often clears it. If it persists, check ` +
|
|
192
|
+
`Vercel firewall / deployment protection on the API route. Re-running setup will not help.${rid}`,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// A brain-choice refusal is ANSWERABLE, so it must arrive as the question it is rather than as a
|
|
196
|
+
// code. Deliberately narrow: only these two errors reshape the message, so every other classify()
|
|
197
|
+
// output keeps its existing wording (and its tests).
|
|
198
|
+
if (isJson && (appError === 'brain_required' || appError === 'unknown_brain') && (appMessage || appBrains)) {
|
|
199
|
+
const list = (appBrains ?? []).map((b) => {
|
|
200
|
+
const pages = typeof b?.pageCount === 'number' ? ` — ${b.pageCount} page${b.pageCount === 1 ? '' : 's'}` : ''
|
|
201
|
+
const titles = Array.isArray(b?.sampleTitles) && b.sampleTitles.length
|
|
202
|
+
? `: ${b.sampleTitles.slice(0, 2).join('; ')}` : ''
|
|
203
|
+
// Name AND id: brain names are NOT unique (one account holds two called "Personal"), so a name
|
|
204
|
+
// alone can come back as unknown_brain. The id always resolves.
|
|
205
|
+
return ` - ${b?.name ?? '(unnamed)'}${pages}${titles}${b?.orgId ? ` [${b.orgId}]` : ''}`
|
|
206
|
+
})
|
|
207
|
+
return {
|
|
208
|
+
kind: 'app', retriable: false,
|
|
209
|
+
message: `Agnoclast API ${status}: ${appMessage ?? appError}${list.length ? `\n${list.join('\n')}` : ''}` +
|
|
210
|
+
`\nRe-run this tool with \`brain\` set to one of the names or ids above.${rid}`,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
kind: 'app', retriable: status >= 500,
|
|
216
|
+
message: `Agnoclast API ${status}: ${appError ?? 'unknown error'}.${appHint ? ` ${appHint}` : ''}${rid}`,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// fetch with retry on TRANSIENT responses only (429, 5xx, and infra-style non-JSON 403).
|
|
221
|
+
// App-level 401/403-with-JSON are returned immediately (retrying won't change the verdict).
|
|
222
|
+
// Throws a clear network error if the host is unreachable after retries.
|
|
223
|
+
//
|
|
224
|
+
// PER-ATTEMPT TIMEOUT (added 2026-07-08): every attempt is bounded by an AbortSignal so a
|
|
225
|
+
// HANGING server (not a fast 5xx — an ingest endpoint that just never responds, observed this day)
|
|
226
|
+
// can't stall a hook indefinitely. Without it, `fetch` waits forever and the retry loop made it
|
|
227
|
+
// WORSE — 3 unbounded attempts stacked. Now worst case = (retries+1) * timeoutMs + backoff, and a
|
|
228
|
+
// timed-out attempt is treated as transient (retried, then surfaced as the reach error the callers
|
|
229
|
+
// already swallow). Tunable via CORTEX_HTTP_TIMEOUT_MS; per-call override via opts.timeoutMs.
|
|
230
|
+
// ADR-0020 Stage 2 — this process's session identity, stamped on every request so the server can
|
|
231
|
+
// resolve THIS session's write pointer instead of the person-wide one. Set once by the MCP server at
|
|
232
|
+
// startup (setSessionKey); left null in the one-shot hook processes (capture, context_log), which
|
|
233
|
+
// have no session of their own and correctly fall back to the account pointer.
|
|
234
|
+
//
|
|
235
|
+
// Injected HERE rather than at each call site because fetchCortex is the single chokepoint every
|
|
236
|
+
// caller already funnels through — ~30 call sites pass their own `headers` object, and a header this
|
|
237
|
+
// load-bearing must not depend on remembering it at each one.
|
|
238
|
+
let SESSION_KEY = null
|
|
239
|
+
export function setSessionKey(key) { SESSION_KEY = key || null }
|
|
240
|
+
|
|
241
|
+
export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 400 } = {}) {
|
|
242
|
+
const { timeoutMs: optTimeout, signal: callerSignal, ...rest } = opts
|
|
243
|
+
const fetchOpts = SESSION_KEY
|
|
244
|
+
? { ...rest, headers: { ...(rest.headers ?? {}), 'x-cortex-session-key': SESSION_KEY } }
|
|
245
|
+
: rest
|
|
246
|
+
const timeoutMs = optTimeout ?? (Number(process.env.CORTEX_HTTP_TIMEOUT_MS) || 15_000)
|
|
247
|
+
let lastErr
|
|
248
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
249
|
+
try {
|
|
250
|
+
// Fresh timeout signal per attempt; compose with any caller-supplied signal.
|
|
251
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
252
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal
|
|
253
|
+
const res = await fetch(url, { ...fetchOpts, signal })
|
|
254
|
+
const ct = res.headers.get('content-type') ?? ''
|
|
255
|
+
const transient =
|
|
256
|
+
res.status === 429 ||
|
|
257
|
+
res.status >= 500 ||
|
|
258
|
+
(res.status === 403 && !ct.includes('application/json')) // infra block, not app authz
|
|
259
|
+
if (transient && attempt < retries) {
|
|
260
|
+
await sleep(baseDelayMs * 2 ** attempt)
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
return res
|
|
264
|
+
} catch (e) {
|
|
265
|
+
lastErr = e
|
|
266
|
+
if (attempt < retries) { await sleep(baseDelayMs * 2 ** attempt); continue }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
throw new Error(
|
|
270
|
+
`Could not reach Agnoclast at ${url} — ${lastErr?.message ?? 'network error'}. ` +
|
|
271
|
+
`Check your connection (and CORTEX_URL if you set it).`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Live health probe: hit /api/mcp-context with the token and classify the result.
|
|
276
|
+
// Returns { ok, status, projectCount?, requestId?, diagnosis? }.
|
|
277
|
+
export async function checkToken(token, base) {
|
|
278
|
+
if (!token) {
|
|
279
|
+
return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
|
|
280
|
+
message: 'No CORTEX_TOKEN found. Run: npx -y @theronap/cortex-mcp setup <your-token>' } }
|
|
281
|
+
}
|
|
282
|
+
if (!isUuid(token)) {
|
|
283
|
+
return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
|
|
284
|
+
message: `Token "${String(token).slice(0, 8)}…" is not a valid Agnoclast token (expected a UUID). ` +
|
|
285
|
+
`Re-run setup with the token from the console.` } }
|
|
286
|
+
}
|
|
287
|
+
const url = `${(base ?? 'https://cortex-console.vercel.app').replace(/\/$/, '')}/api/mcp-context`
|
|
288
|
+
let res
|
|
289
|
+
try {
|
|
290
|
+
res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
291
|
+
} catch (e) {
|
|
292
|
+
return { ok: false, status: 0, diagnosis: { kind: 'network', retriable: true, message: e.message } }
|
|
293
|
+
}
|
|
294
|
+
const requestId = res.headers.get('x-vercel-id') ?? null
|
|
295
|
+
const contentType = res.headers.get('content-type')
|
|
296
|
+
const body = await res.text()
|
|
297
|
+
if (!res.ok) {
|
|
298
|
+
return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
|
|
299
|
+
}
|
|
300
|
+
let projectCount
|
|
301
|
+
let captureNotice = null
|
|
302
|
+
try {
|
|
303
|
+
const parsed = JSON.parse(body)
|
|
304
|
+
const ctx = parsed.context ?? ''
|
|
305
|
+
const m = ctx.match(/## Projects \((\d+)\)/)
|
|
306
|
+
if (m) projectCount = Number(m[1])
|
|
307
|
+
// The server's "your work is not landing" advisory. Optional by design: an older server does not
|
|
308
|
+
// send it and this must stay a health check, so a missing field is simply no notice.
|
|
309
|
+
if (parsed.captureNotice && typeof parsed.captureNotice.message === 'string') {
|
|
310
|
+
captureNotice = parsed.captureNotice
|
|
311
|
+
}
|
|
312
|
+
} catch { /* context shape changed — non-fatal for a health check */ }
|
|
313
|
+
return { ok: true, status: 200, projectCount, requestId, captureNotice }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Probe the org-skills surface. SEPARATE FROM checkToken ON PURPOSE.
|
|
317
|
+
//
|
|
318
|
+
// WHY THIS EXISTS. `doctor` checked exactly one endpoint — /api/mcp-context — and reported PASS on
|
|
319
|
+
// the strength of it. Meanwhile /api/skills answered 409 to every multi-brain caller for three days
|
|
320
|
+
// (measured 2026-08-08: 14 requests, 14 × 409, zero successes; the local cache sat frozen from Aug 5
|
|
321
|
+
// to Aug 8). Org-published skills silently never installed, the client swallowed the status, and
|
|
322
|
+
// doctor — the tool setup.txt tells people to run FIRST when something is wrong — said everything
|
|
323
|
+
// was fine, every single time. A check that probes one surface is a health check for that surface,
|
|
324
|
+
// not for the system, and must not be reported as the latter.
|
|
325
|
+
//
|
|
326
|
+
// Returns checkToken's shape so the caller renders both the same way. `skillCount` is
|
|
327
|
+
// informational: an org with zero published skills is perfectly healthy, so it is NOT a failure.
|
|
328
|
+
export async function checkSkills(token, base) {
|
|
329
|
+
if (!token || !isUuid(token)) {
|
|
330
|
+
return { ok: false, status: 0, diagnosis: { kind: 'config', retriable: false,
|
|
331
|
+
message: 'no usable token — the context check above already covers this' } }
|
|
332
|
+
}
|
|
333
|
+
const url = `${(base ?? CANONICAL_BASE).replace(/\/$/, '')}/api/skills`
|
|
334
|
+
let res
|
|
335
|
+
try {
|
|
336
|
+
res = await fetchCortex(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
337
|
+
} catch (e) {
|
|
338
|
+
return { ok: false, status: 0, diagnosis: { kind: 'network', retriable: true, message: e.message } }
|
|
339
|
+
}
|
|
340
|
+
const requestId = res.headers.get('x-vercel-id') ?? null
|
|
341
|
+
const contentType = res.headers.get('content-type')
|
|
342
|
+
const body = await res.text()
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
return { ok: false, status: res.status, requestId, diagnosis: classify(res.status, contentType, body, requestId) }
|
|
345
|
+
}
|
|
346
|
+
let skillCount
|
|
347
|
+
try { skillCount = (JSON.parse(body)?.skills ?? []).length } catch { /* shape drift — non-fatal */ }
|
|
348
|
+
return { ok: true, status: 200, skillCount, requestId }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Release the keep-alive sockets the global fetch (undici) holds, so a short-lived CLI command
|
|
352
|
+
// can tear down cleanly. Without this, calling process.exit() right after a fetch can race a
|
|
353
|
+
// lingering socket handle and trip a libuv assertion on Windows
|
|
354
|
+
// (`!(handle->flags & UV_HANDLE_CLOSING)`, src\win\async.c). Best-effort + never throws.
|
|
355
|
+
export async function closeFetch() {
|
|
356
|
+
try {
|
|
357
|
+
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')]
|
|
358
|
+
if (dispatcher && typeof dispatcher.close === 'function') await dispatcher.close()
|
|
359
|
+
} catch { /* cleanup must never break the caller */ }
|
|
360
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs'
|
|
2
|
+
import { createHash } from 'crypto'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import { join, resolve, dirname } from 'path'
|
|
5
|
+
|
|
6
|
+
// Documentation ingestion — detection half (D1 of docs/documentation-ingestion-spec.md).
|
|
7
|
+
//
|
|
8
|
+
// Specs/plans/design docs get written to disk (repo docs/, ~/.gstack/projects/…) and never reach
|
|
9
|
+
// Agnoclast as pages. This subcommand DETECTS new/changed markdown under registered roots by content
|
|
10
|
+
// hash; the AUTHORING is done by the live session (the cortex-author-docs skill reads each pending
|
|
11
|
+
// doc and calls the `author` MCP tool) — the agent is the pipe, never a raw-markdown dump.
|
|
12
|
+
//
|
|
13
|
+
// State is local (~/.cortex/docs-sync.json): watched roots + sha256 per pushed file. Files are
|
|
14
|
+
// marked ONLY after a successful author (`--mark`), so a failed author simply stays pending.
|
|
15
|
+
// No network; exits naturally (CLI-subcommand convention).
|
|
16
|
+
|
|
17
|
+
const STATE_PATH = join(homedir(), '.cortex', 'docs-sync.json')
|
|
18
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next'])
|
|
19
|
+
const MAX_BYTES = 256 * 1024
|
|
20
|
+
|
|
21
|
+
export function loadState(path = STATE_PATH) {
|
|
22
|
+
try {
|
|
23
|
+
const s = JSON.parse(readFileSync(path, 'utf8'))
|
|
24
|
+
return { version: 1, roots: Array.isArray(s.roots) ? s.roots : [], files: s.files && typeof s.files === 'object' ? s.files : {} }
|
|
25
|
+
} catch {
|
|
26
|
+
return { version: 1, roots: [], files: {} }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function saveState(state, path = STATE_PATH) {
|
|
31
|
+
const dir = dirname(path)
|
|
32
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
33
|
+
writeFileSync(path, JSON.stringify(state, null, 2))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const hashContent = (s) => createHash('sha256').update(s).digest('hex')
|
|
37
|
+
|
|
38
|
+
// Recursively collect candidate .md files under one root, applying the skip rules.
|
|
39
|
+
export function collectMarkdown(root) {
|
|
40
|
+
const out = []
|
|
41
|
+
const walk = (dir) => {
|
|
42
|
+
let entries
|
|
43
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
if (e.isDirectory()) {
|
|
46
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name))
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (!e.name.endsWith('.md') || e.name.endsWith('.bak.md')) continue
|
|
50
|
+
if (e.name.endsWith('.md.bak')) continue
|
|
51
|
+
const path = join(dir, e.name)
|
|
52
|
+
try { if (statSync(path).size > MAX_BYTES) continue } catch { continue }
|
|
53
|
+
out.push(path)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
walk(root)
|
|
57
|
+
return out.sort()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Diff the filesystem against state. Returns { pending: [{path, status:'new'|'changed'}], scanned }.
|
|
61
|
+
export function scanRoots(state) {
|
|
62
|
+
const pending = []
|
|
63
|
+
let scanned = 0
|
|
64
|
+
for (const root of state.roots) {
|
|
65
|
+
for (const path of collectMarkdown(root)) {
|
|
66
|
+
scanned++
|
|
67
|
+
let body
|
|
68
|
+
try { body = readFileSync(path, 'utf8') } catch { continue }
|
|
69
|
+
const h = hashContent(body)
|
|
70
|
+
const prev = state.files[path]?.hash
|
|
71
|
+
if (!prev) pending.push({ path, status: 'new' })
|
|
72
|
+
else if (prev !== h) pending.push({ path, status: 'changed' })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { pending, scanned }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Record the CURRENT content hash for the given files (call after a successful author).
|
|
79
|
+
export function markFiles(state, paths, now = new Date().toISOString()) {
|
|
80
|
+
const marked = []
|
|
81
|
+
const missing = []
|
|
82
|
+
for (const p of paths) {
|
|
83
|
+
const abs = resolve(p)
|
|
84
|
+
let body
|
|
85
|
+
try { body = readFileSync(abs, 'utf8') } catch { missing.push(abs); continue }
|
|
86
|
+
state.files[abs] = { hash: hashContent(body), markedAt: now }
|
|
87
|
+
marked.push(abs)
|
|
88
|
+
}
|
|
89
|
+
return { marked, missing }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Heuristic soak guardrail (spec D5): the Robin parity experiment forbids re-syncing the local
|
|
93
|
+
// brain into Agnoclast during the window, so warn when a root looks like the brain repo.
|
|
94
|
+
const looksLikeBrain = (dir) => /\/Documents\/brain(\/|$)/.test(dir)
|
|
95
|
+
|
|
96
|
+
export async function runDocsScan(argv = []) {
|
|
97
|
+
const state = loadState()
|
|
98
|
+
const out = (m) => process.stdout.write(m + '\n')
|
|
99
|
+
|
|
100
|
+
const flagIdx = (f) => argv.indexOf(f)
|
|
101
|
+
|
|
102
|
+
if (flagIdx('--roots') !== -1) {
|
|
103
|
+
if (!state.roots.length) out('No roots registered. Add one: cortex-mcp docs-scan --add-root <dir>')
|
|
104
|
+
else state.roots.forEach((r) => out(r))
|
|
105
|
+
return 0
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (flagIdx('--add-root') !== -1) {
|
|
109
|
+
const dir = argv[flagIdx('--add-root') + 1]
|
|
110
|
+
if (!dir) { process.stderr.write('Usage: docs-scan --add-root <dir>\n'); return 1 }
|
|
111
|
+
const abs = resolve(dir)
|
|
112
|
+
if (!existsSync(abs)) { process.stderr.write(`Not a directory: ${abs}\n`); return 1 }
|
|
113
|
+
if (looksLikeBrain(abs)) {
|
|
114
|
+
out(`⚠ ${abs} looks like the local brain repo — the Robin parity soak forbids re-syncing it into Agnoclast.`)
|
|
115
|
+
out(' Registering anyway is on you; the cortex-author-docs skill will also warn.')
|
|
116
|
+
}
|
|
117
|
+
if (!state.roots.includes(abs)) state.roots.push(abs)
|
|
118
|
+
saveState(state)
|
|
119
|
+
out(`✓ watching ${abs} (${state.roots.length} root${state.roots.length === 1 ? '' : 's'})`)
|
|
120
|
+
return 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (flagIdx('--remove-root') !== -1) {
|
|
124
|
+
const dir = argv[flagIdx('--remove-root') + 1]
|
|
125
|
+
if (!dir) { process.stderr.write('Usage: docs-scan --remove-root <dir>\n'); return 1 }
|
|
126
|
+
const abs = resolve(dir)
|
|
127
|
+
state.roots = state.roots.filter((r) => r !== abs)
|
|
128
|
+
saveState(state)
|
|
129
|
+
out(`✓ removed ${abs}`)
|
|
130
|
+
return 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (flagIdx('--mark') !== -1) {
|
|
134
|
+
const files = argv.slice(flagIdx('--mark') + 1).filter((a) => !a.startsWith('-'))
|
|
135
|
+
if (!files.length) { process.stderr.write('Usage: docs-scan --mark <file>...\n'); return 1 }
|
|
136
|
+
const { marked, missing } = markFiles(state, files)
|
|
137
|
+
saveState(state)
|
|
138
|
+
marked.forEach((p) => out(`✓ marked ${p}`))
|
|
139
|
+
missing.forEach((p) => process.stderr.write(`✗ unreadable, not marked: ${p}\n`))
|
|
140
|
+
return missing.length ? 1 : 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (flagIdx('--mark-all') !== -1) {
|
|
144
|
+
// Baseline initialization: acknowledge the whole current backlog without authoring it.
|
|
145
|
+
const { pending } = scanRoots(state)
|
|
146
|
+
const { marked } = markFiles(state, pending.map((p) => p.path))
|
|
147
|
+
saveState(state)
|
|
148
|
+
out(`✓ baseline set — marked ${marked.length} doc(s) as already-known`)
|
|
149
|
+
return 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Default: scan and report.
|
|
153
|
+
const json = flagIdx('--json') !== -1
|
|
154
|
+
if (!state.roots.length) {
|
|
155
|
+
if (json) out(JSON.stringify({ roots: [], pending: [] }))
|
|
156
|
+
else out('No roots registered. Add one: cortex-mcp docs-scan --add-root <dir>')
|
|
157
|
+
return 0
|
|
158
|
+
}
|
|
159
|
+
const { pending, scanned } = scanRoots(state)
|
|
160
|
+
if (json) {
|
|
161
|
+
out(JSON.stringify({ roots: state.roots, scanned, pending }, null, 2))
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
if (!pending.length) out(`✓ up to date — ${scanned} doc(s) scanned, nothing pending`)
|
|
165
|
+
else {
|
|
166
|
+
out(`${pending.length} doc(s) pending Agnoclast authoring (of ${scanned} scanned):`)
|
|
167
|
+
for (const p of pending) out(` ${p.status === 'new' ? '+ ' : '~ '}${p.path}`)
|
|
168
|
+
out('Author them via the cortex-author-docs skill, then: docs-scan --mark <file>...')
|
|
169
|
+
}
|
|
170
|
+
return 0
|
|
171
|
+
}
|
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { checkToken, checkSkills, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
5
|
+
import { renderRenameNotice } from './rename_notice.mjs'
|
|
6
|
+
|
|
7
|
+
// `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
|
|
8
|
+
//
|
|
9
|
+
// This is the "is it ACTUALLY working?" tool that was missing: it reads your token, calls the
|
|
10
|
+
// real Agnoclast API, and prints a clear PASS/FAIL with the actual cause. No Claude Code restart
|
|
11
|
+
// needed — so onboarding can confirm the connection independently of "did the server load."
|
|
12
|
+
|
|
13
|
+
// Token resolution: env first, then the Claude config the setup command wrote (so `doctor`
|
|
14
|
+
// works the moment after `setup`, before any restart). Returns { token, source }.
|
|
15
|
+
// Exported so `use-brain` resolves the token EXACTLY as doctor/status do. There is already a second,
|
|
16
|
+
// subtly different copy of this in context_log.mjs (returns a bare token, not {token, source}); a
|
|
17
|
+
// third copy is how a machine ends up "connected" to one command and "no token found" to another.
|
|
18
|
+
export const resolveToken = resolveTokenSource
|
|
19
|
+
|
|
20
|
+
// `status` — the one-line SessionStart variant of doctor: a visible "is Agnoclast capturing?"
|
|
21
|
+
// signal inside Claude Code itself (three-machine dry-run finding 2026-06-09: with no
|
|
22
|
+
// indicator, a user can't tell whether their sessions are flowing to the org).
|
|
23
|
+
// Always returns 0 — a status line must never break a session start.
|
|
24
|
+
export async function runStatus() {
|
|
25
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
26
|
+
const out = (m) => process.stdout.write(m + '\n')
|
|
27
|
+
const { token } = resolveToken()
|
|
28
|
+
if (!token) {
|
|
29
|
+
out('Agnoclast: NOT connected — no token found. Run: npx -y @theronap/cortex-mcp setup <token>')
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const r = await checkToken(token, base)
|
|
34
|
+
if (r.ok) {
|
|
35
|
+
// ⚠ THE NOTICE REPLACES THE HAPPY LINE RATHER THAN FOLLOWING IT.
|
|
36
|
+
// "connected — sessions on this machine are captured to your org" was printed truthfully to
|
|
37
|
+
// three people whose sessions were, at that moment, landing in NO brain: connected is a fact
|
|
38
|
+
// about the TOKEN, and every reader takes it as a fact about their WORK. Printing both would
|
|
39
|
+
// leave the reassurance that caused four days of silent loss sitting directly above the
|
|
40
|
+
// warning that contradicts it.
|
|
41
|
+
if (r.captureNotice?.message) {
|
|
42
|
+
out(`Agnoclast: ⚠ ${r.captureNotice.message}`)
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
|
|
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)
|
|
55
|
+
} else {
|
|
56
|
+
out(`Agnoclast: NOT connected — ${r.diagnosis?.message ?? 'check failed'}. Run: npx -y @theronap/cortex-mcp doctor`)
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
out(`Agnoclast: status check failed (${e?.message ?? String(e)}) — run doctor.`)
|
|
60
|
+
}
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runDoctor() {
|
|
65
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
66
|
+
const out = (m) => process.stdout.write(m + '\n')
|
|
67
|
+
|
|
68
|
+
out('')
|
|
69
|
+
out('Agnoclast doctor — checking your connection…')
|
|
70
|
+
const { token, source } = resolveToken()
|
|
71
|
+
out(` token source: ${source ?? 'NONE FOUND'}`)
|
|
72
|
+
out(` endpoint: ${base}`)
|
|
73
|
+
out('')
|
|
74
|
+
|
|
75
|
+
const r = await checkToken(token, base)
|
|
76
|
+
|
|
77
|
+
if (r.ok) {
|
|
78
|
+
out(' ✓ context — your token authenticates and Agnoclast returned your context.')
|
|
79
|
+
if (typeof r.projectCount === 'number') out(` You can currently see ${r.projectCount} project(s).`)
|
|
80
|
+
if (r.requestId) out(` (request id: ${r.requestId})`)
|
|
81
|
+
|
|
82
|
+
// A SECOND surface, because one endpoint answering is not "the system is healthy". /api/skills
|
|
83
|
+
// 409'd every multi-brain caller for three days while this command printed PASS on the strength
|
|
84
|
+
// of /api/mcp-context alone. See checkSkills' header for the full record.
|
|
85
|
+
const s = await checkSkills(token, base)
|
|
86
|
+
if (s.ok) {
|
|
87
|
+
const n = typeof s.skillCount === 'number'
|
|
88
|
+
? ` (${s.skillCount} org skill${s.skillCount === 1 ? '' : 's'} published)` : ''
|
|
89
|
+
out(` ✓ skills — the org-skill surface answers${n}.`)
|
|
90
|
+
} else {
|
|
91
|
+
// NOT a total failure — context works, so capture and retrieval are fine. But it must never
|
|
92
|
+
// again render as PASS, because org skills silently stop updating when this breaks.
|
|
93
|
+
out(` ✗ skills — ${s.diagnosis?.kind ?? 'error'}${s.status ? ` (HTTP ${s.status})` : ''}`)
|
|
94
|
+
out(` ${s.diagnosis?.message ?? 'unknown error'}`)
|
|
95
|
+
out('')
|
|
96
|
+
out(' ⚠ PARTIAL — your connection is fine and capture/retrieval work, but org-published')
|
|
97
|
+
out(' skills cannot be fetched, so they will silently stop updating (the client falls')
|
|
98
|
+
out(' back to its last-good cache). Report this rather than ignoring it.')
|
|
99
|
+
out('')
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
out('')
|
|
104
|
+
out(' ✓ PASS — every surface checked is answering.')
|
|
105
|
+
out('')
|
|
106
|
+
out(' If your AI still does not see Agnoclast, the server just is not loaded yet —')
|
|
107
|
+
out(' fully quit and reopen Claude Code (the MCP server starts on launch).')
|
|
108
|
+
out('')
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
out(` ✗ FAIL — ${r.diagnosis?.kind ?? 'error'}${r.status ? ` (HTTP ${r.status})` : ''}`)
|
|
113
|
+
out(` ${r.diagnosis?.message ?? 'unknown error'}`)
|
|
114
|
+
if (r.diagnosis?.retriable) out(' This looks transient — running `doctor` again shortly may succeed.')
|
|
115
|
+
out('')
|
|
116
|
+
return 1
|
|
117
|
+
}
|