@theronap/agnoclast-mcp 0.9.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +47 -0
  2. package/bin/cortex-mcp.mjs +223 -0
  3. package/lib/capture.mjs +470 -0
  4. package/lib/code_graph_cli.mjs +59 -0
  5. package/lib/context_log.mjs +92 -0
  6. package/lib/diagnose.mjs +360 -0
  7. package/lib/docs_scan.mjs +171 -0
  8. package/lib/doctor.mjs +117 -0
  9. package/lib/edge_extract.mjs +156 -0
  10. package/lib/editors/_fsutil.mjs +31 -0
  11. package/lib/editors/antigravity.mjs +130 -0
  12. package/lib/editors/claude.mjs +202 -0
  13. package/lib/editors/codex.mjs +111 -0
  14. package/lib/editors/cursor.mjs +77 -0
  15. package/lib/editors/index.mjs +42 -0
  16. package/lib/extract_typed.mjs +68 -0
  17. package/lib/graphify_sync.mjs +134 -0
  18. package/lib/grep_cli.mjs +82 -0
  19. package/lib/hydrate.mjs +181 -0
  20. package/lib/imessage_send.mjs +88 -0
  21. package/lib/ingest_folder.mjs +170 -0
  22. package/lib/install.mjs +163 -0
  23. package/lib/login.mjs +148 -0
  24. package/lib/managed.mjs +49 -0
  25. package/lib/migrate_key.mjs +139 -0
  26. package/lib/presence.mjs +226 -0
  27. package/lib/publish_targets.mjs +51 -0
  28. package/lib/red_link_triage.mjs +37 -0
  29. package/lib/redact.mjs +40 -0
  30. package/lib/rename_notice.mjs +31 -0
  31. package/lib/resolve.mjs +153 -0
  32. package/lib/server.mjs +2986 -0
  33. package/lib/session_key.mjs +37 -0
  34. package/lib/setup.mjs +215 -0
  35. package/lib/skills.mjs +374 -0
  36. package/lib/statusline.mjs +67 -0
  37. package/lib/uninstall.mjs +237 -0
  38. package/lib/use_brain.mjs +82 -0
  39. package/lib/with_token.mjs +66 -0
  40. package/package.json +36 -0
  41. package/skills/author-docs/SKILL.md +74 -0
  42. package/skills/context/SKILL.md +25 -0
  43. package/skills/log/SKILL.md +114 -0
  44. package/skills/walkthrough/SKILL.md +189 -0
@@ -0,0 +1,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
+ }