@theronap/cortex-mcp 0.9.142 → 0.9.143
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/lib/doctor.mjs +32 -5
- package/lib/obligations_render.mjs +39 -0
- package/lib/server.mjs +2 -21
- package/lib/surface.mjs +94 -0
- package/package.json +1 -1
package/lib/doctor.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from 'os'
|
|
|
3
3
|
import { join } from 'path'
|
|
4
4
|
import { checkToken, checkSkills, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
5
5
|
import { renderRenameNotice } from './rename_notice.mjs'
|
|
6
|
+
import { detectSurface, renderSurface } from './surface.mjs'
|
|
6
7
|
|
|
7
8
|
// `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
|
|
8
9
|
//
|
|
@@ -21,13 +22,27 @@ export const resolveToken = resolveTokenSource
|
|
|
21
22
|
// signal inside Claude Code itself (three-machine dry-run finding 2026-06-09: with no
|
|
22
23
|
// indicator, a user can't tell whether their sessions are flowing to the org).
|
|
23
24
|
// Always returns 0 — a status line must never break a session start.
|
|
25
|
+
//
|
|
26
|
+
// The surface line is printed from a `finally` so no branch below can skip it. That includes the
|
|
27
|
+
// early returns and the "connected" line, which a person in Claude's Home tab also sees: the token is
|
|
28
|
+
// fine there, and the app they are looking at still cannot use it. See surface.mjs.
|
|
24
29
|
export async function runStatus() {
|
|
25
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
26
30
|
const out = (m) => process.stdout.write(m + '\n')
|
|
31
|
+
try {
|
|
32
|
+
await statusLine(out)
|
|
33
|
+
} finally {
|
|
34
|
+
const surface = renderSurface(detectSurface(process.env), 'status')
|
|
35
|
+
if (surface) out(surface)
|
|
36
|
+
}
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function statusLine(out) {
|
|
41
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
27
42
|
const { token } = resolveToken()
|
|
28
43
|
if (!token) {
|
|
29
44
|
out('Agnoclast: NOT connected — no token found. Run: npx -y @theronap/cortex-mcp setup <token>')
|
|
30
|
-
return
|
|
45
|
+
return
|
|
31
46
|
}
|
|
32
47
|
try {
|
|
33
48
|
const r = await checkToken(token, base)
|
|
@@ -40,7 +55,7 @@ export async function runStatus() {
|
|
|
40
55
|
// warning that contradicts it.
|
|
41
56
|
if (r.captureNotice?.message) {
|
|
42
57
|
out(`Agnoclast: ⚠ ${r.captureNotice.message}`)
|
|
43
|
-
return
|
|
58
|
+
return
|
|
44
59
|
}
|
|
45
60
|
const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
|
|
46
61
|
out(`Agnoclast: connected — sessions on this machine are captured to your org${n}.`)
|
|
@@ -58,12 +73,24 @@ export async function runStatus() {
|
|
|
58
73
|
} catch (e) {
|
|
59
74
|
out(`Agnoclast: status check failed (${e?.message ?? String(e)}) — run doctor.`)
|
|
60
75
|
}
|
|
61
|
-
return 0
|
|
62
76
|
}
|
|
63
77
|
|
|
78
|
+
// The surface block goes LAST, from a `finally`, for the same reason as in runStatus: all three
|
|
79
|
+
// verdicts (PASS, PARTIAL, FAIL) can be printed to a person in Claude's Home tab, and PASS is the
|
|
80
|
+
// one that misled. It says "reopen Claude Code", and reopening the Claude app can put them straight
|
|
81
|
+
// back in Home. The exit code is unchanged: not being able to see the surface is not a failure.
|
|
64
82
|
export async function runDoctor() {
|
|
65
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
66
83
|
const out = (m) => process.stdout.write(m + '\n')
|
|
84
|
+
try {
|
|
85
|
+
return await doctorChecks(out)
|
|
86
|
+
} finally {
|
|
87
|
+
out(renderSurface(detectSurface(process.env), 'doctor'))
|
|
88
|
+
out('')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function doctorChecks(out) {
|
|
93
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
67
94
|
|
|
68
95
|
out('')
|
|
69
96
|
out('Agnoclast doctor — checking your connection…')
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// PURE: what my_obligations prints. Out of server.mjs so the labelling below can be tested — the tool
|
|
2
|
+
// handler itself is reachable by no test.
|
|
3
|
+
//
|
|
4
|
+
// 🔴 A PROPOSAL MUST NOT READ AS SOMETHING THE PERSON ENTERED. ADR-0059 extraction writes rows in state
|
|
5
|
+
// `proposed`, and listObligations returns the owner's own proposals alongside confirmed ones — by
|
|
6
|
+
// design, since a proposal nobody sees is a silent drop. Until 2026-09-10 this renderer printed them
|
|
7
|
+
// all under "N open:" with no mark, so two lines extracted from a lecture (one of them wrongly dated)
|
|
8
|
+
// looked exactly like obligations the person had written down. It is labelled here, and the header
|
|
9
|
+
// says what to do with a wrong one.
|
|
10
|
+
//
|
|
11
|
+
// `dueAt` arrives in the OWNER's zone with its offset (web/lib/engine/due_dates.ts), so slice(0, 10)
|
|
12
|
+
// below is the owner's calendar date and new Date() is still the exact instant.
|
|
13
|
+
export function renderObligations(obs, now = Date.now()) {
|
|
14
|
+
if (!obs?.length) return 'Nothing open.'
|
|
15
|
+
const proposed = obs.filter((o) => o.state === 'proposed').length
|
|
16
|
+
const lines = [
|
|
17
|
+
proposed
|
|
18
|
+
? `${obs.length} open — ${proposed} of them PROPOSED: extracted from a record, not entered by you. A wrong one: resolve_obligation with state "cancelled".`
|
|
19
|
+
: `${obs.length} open:`,
|
|
20
|
+
'',
|
|
21
|
+
]
|
|
22
|
+
for (const o of obs) {
|
|
23
|
+
const overdue = o.dueAt && new Date(o.dueAt).getTime() <= now
|
|
24
|
+
const when = o.dueAt ? `${overdue ? 'OVERDUE ' : 'due '}${o.dueAt.slice(0, 10)}` : 'no deadline'
|
|
25
|
+
lines.push(`${o.id}`)
|
|
26
|
+
lines.push(` ${o.subject} — ${when}${o.state === 'snoozed' ? ' (snooze expired)' : ''}${o.state === 'proposed' ? ' [PROPOSED]' : ''}`)
|
|
27
|
+
// ⚠ EVIDENCE IS RENDERED AS A QUESTION, NEVER AS A VERDICT. The phrasing is the feature:
|
|
28
|
+
// "is this done?" costs the reader a second, "you still owe this" about finished work costs
|
|
29
|
+
// the channel its credibility.
|
|
30
|
+
if (o.evidence?.length) {
|
|
31
|
+
lines.push(` ❓ ${o.evidence.length} record(s) suggest this may already be done — check, then resolve_obligation:`)
|
|
32
|
+
for (const e of o.evidence.slice(0, 3)) {
|
|
33
|
+
lines.push(` ${e.occurredAt.slice(0, 10)} ${e.title ?? '(untitled)'}${e.viaIdentifier ? ` [${e.viaIdentifier}]` : ''}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
lines.push('')
|
|
37
|
+
}
|
|
38
|
+
return lines.join('\n')
|
|
39
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { renderTriage } from './red_link_triage.mjs'
|
|
|
15
15
|
import { runCodeGraphQuery } from './code_graph_cli.mjs'
|
|
16
16
|
import { repoFullNameFrom } from './capture.mjs'
|
|
17
17
|
import { spawnObligationWorker } from './obligations_worker.mjs'
|
|
18
|
+
import { renderObligations } from './obligations_render.mjs'
|
|
18
19
|
|
|
19
20
|
// Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
|
|
20
21
|
// tracked wanted page, whether a bare node exists for it, and whether it's a deliberately demoted page,
|
|
@@ -2123,27 +2124,7 @@ function renderNudge(payload) {
|
|
|
2123
2124
|
const out = await res.json().catch(() => null)
|
|
2124
2125
|
if (!res.ok) return toolError(`Could not list: ${out?.error ?? res.status}`)
|
|
2125
2126
|
const obs = out?.obligations ?? []
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
const now = Date.now()
|
|
2129
|
-
const lines = [`${obs.length} open:`, '']
|
|
2130
|
-
for (const o of obs) {
|
|
2131
|
-
const overdue = o.dueAt && new Date(o.dueAt).getTime() <= now
|
|
2132
|
-
const when = o.dueAt ? `${overdue ? 'OVERDUE ' : 'due '}${o.dueAt.slice(0, 10)}` : 'no deadline'
|
|
2133
|
-
lines.push(`${o.id}`)
|
|
2134
|
-
lines.push(` ${o.subject} \u2014 ${when}${o.state === 'snoozed' ? ' (snooze expired)' : ''}`)
|
|
2135
|
-
// \u26a0 EVIDENCE IS RENDERED AS A QUESTION, NEVER AS A VERDICT. The phrasing is the feature:
|
|
2136
|
-
// "is this done?" costs the reader a second, "you still owe this" about finished work costs
|
|
2137
|
-
// the channel its credibility.
|
|
2138
|
-
if (o.evidence?.length) {
|
|
2139
|
-
lines.push(` \u2753 ${o.evidence.length} record(s) suggest this may already be done \u2014 check, then resolve_obligation:`)
|
|
2140
|
-
for (const e of o.evidence.slice(0, 3)) {
|
|
2141
|
-
lines.push(` ${e.occurredAt.slice(0, 10)} ${e.title ?? '(untitled)'}${e.viaIdentifier ? ` [${e.viaIdentifier}]` : ''}`)
|
|
2142
|
-
}
|
|
2143
|
-
}
|
|
2144
|
-
lines.push('')
|
|
2145
|
-
}
|
|
2146
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
2127
|
+
return { content: [{ type: 'text', text: renderObligations(obs) }] }
|
|
2147
2128
|
},
|
|
2148
2129
|
)
|
|
2149
2130
|
|
package/lib/surface.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Which app is running `doctor` / `status` — and, when that cannot be told, the requirement, plainly.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS (T23, v1 scope lock, added 2026-09-09). A new seat lost about an hour to being in
|
|
4
|
+
// the Claude desktop app's Home tab instead of its Code tab. Everything Agnoclast installs lives in
|
|
5
|
+
// Claude Code's own config: the server in ~/.claude.json, the hooks in ~/.claude/settings.json. Home
|
|
6
|
+
// reads neither, so nothing connected and nothing said why. `doctor` and `status` both ran in that
|
|
7
|
+
// state and neither mentioned the one fact that mattered. `doctor` printed PASS, because the token
|
|
8
|
+
// was fine, then told the person to "reopen Claude Code", which put them back in Home.
|
|
9
|
+
//
|
|
10
|
+
// WHAT CAN BE DETECTED. Verified against the Claude Code 2.1.260 binary on 2026-09-10, not assumed:
|
|
11
|
+
// CLAUDECODE="1" Claude Code puts this in the environment of every hook command, MCP
|
|
12
|
+
// server and shell command it starts. Present means Claude Code started us.
|
|
13
|
+
// CLAUDE_CODE_ENTRYPOINT Claude Code sets it at startup when its launcher has not: 'cli', or
|
|
14
|
+
// 'sdk-cli' for `claude -p`. Launchers set their own: 'claude-desktop' (the
|
|
15
|
+
// desktop app's Code tab), 'claude-vscode', 'local-agent', 'remote', 'sdk-ts'.
|
|
16
|
+
//
|
|
17
|
+
// WHAT CANNOT: Home. Home never starts this process, so no process running `doctor` or `status` is
|
|
18
|
+
// ever "in Home". A person in Home who runs `doctor` does it from a terminal, and that terminal looks
|
|
19
|
+
// like every other terminal. So a missing signal means UNKNOWN, never "fine", and UNKNOWN prints the
|
|
20
|
+
// requirement. That is the T23 acceptance criterion: the message appears when the surface is absent,
|
|
21
|
+
// rather than merely being able to appear.
|
|
22
|
+
//
|
|
23
|
+
// ⚠ Only the entrypoints below count as a place Agnoclast is known to work. Claude Code also runs
|
|
24
|
+
// underneath other products ('local-agent', 'remote*', 'sdk-ts', ...), and whether those load the
|
|
25
|
+
// user's ~/.claude.json has not been verified. They get the requirement, not a ✓. A wrong ✓ hides the
|
|
26
|
+
// message from the one person who needed it. A wrong ⚠ costs a line that begins "If that's where
|
|
27
|
+
// you are".
|
|
28
|
+
//
|
|
29
|
+
// Deliberately NOT used, each for a reason:
|
|
30
|
+
// ~/.claude.json wiring `doctor` already reports it as "token source", and it was just as
|
|
31
|
+
// present in the Home incident. Wired is not a fact about where you are.
|
|
32
|
+
// /api/session-ping keyed by person + cwd, not by machine; says "some host started the
|
|
33
|
+
// server recently", not "the app you are looking at can"; and it adds a
|
|
34
|
+
// network round trip to a SessionStart hook already measured at 4.6s.
|
|
35
|
+
// ~/.cortex/presence.json written only after a SUCCESSFUL hydrate, so a missing file cannot tell
|
|
36
|
+
// "never used Code here" from "hydrate failed". It is history, not location.
|
|
37
|
+
// Claude.app installed says nothing about which tab is open.
|
|
38
|
+
//
|
|
39
|
+
// Pure: no I/O. The caller passes the environment in.
|
|
40
|
+
|
|
41
|
+
/** Entrypoints known to be Claude Code proper, i.e. Claude Code reading this user's own config. */
|
|
42
|
+
const CODE_ENTRYPOINTS = {
|
|
43
|
+
'cli': 'Claude Code in a terminal',
|
|
44
|
+
'sdk-cli': 'Claude Code in a terminal',
|
|
45
|
+
'claude-desktop': 'the Code tab of the Claude desktop app',
|
|
46
|
+
'claude-desktop-3p': 'the Code tab of the Claude desktop app',
|
|
47
|
+
'claude-vscode': 'the Claude Code editor extension',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ONE text, two renderings. `status` joins these into one line; `doctor` prints them wrapped. Keeping
|
|
51
|
+
// a single source matters: capture_status.mjs records two copies of one answer that had already
|
|
52
|
+
// drifted apart. Written for someone who has never heard of a terminal flag or a config file.
|
|
53
|
+
//
|
|
54
|
+
// "If you use Claude" is load-bearing. `install` also wires Codex, Cursor and Antigravity, so an
|
|
55
|
+
// unqualified "Agnoclast only works in Claude Code" would be false for those seats. "Home or Chat"
|
|
56
|
+
// names both labels: the desktop app's own policy code calls the surface "Chat", and the seat that
|
|
57
|
+
// prompted this called it "Home".
|
|
58
|
+
export const REQUIREMENT_LINES = [
|
|
59
|
+
'If you use Claude, Agnoclast only works in Claude Code: the Code tab of the Claude',
|
|
60
|
+
'desktop app, Claude Code in a terminal, or the Claude Code extension for VS Code or',
|
|
61
|
+
"JetBrains. The Claude app's Home or Chat tab can't start Agnoclast, so nothing",
|
|
62
|
+
"connects there and nothing tells you why. If that's where you are, switch to the",
|
|
63
|
+
'Code tab.',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {Record<string, string|undefined>} [env]
|
|
68
|
+
* @returns {{ surface: 'code', where: string, entrypoint: string|null } | { surface: 'unknown', entrypoint: string|null }}
|
|
69
|
+
*/
|
|
70
|
+
export function detectSurface(env = process.env) {
|
|
71
|
+
const entrypoint = env?.CLAUDE_CODE_ENTRYPOINT || null
|
|
72
|
+
// Exactly "1", which is the only value Claude Code writes. Stricter than Claude Code's own truthiness
|
|
73
|
+
// check on purpose, because a false positive here suppresses the requirement.
|
|
74
|
+
if (env?.CLAUDECODE !== '1') return { surface: 'unknown', entrypoint }
|
|
75
|
+
// A Claude Code old enough not to set an entrypoint was still Claude Code reading this user's config.
|
|
76
|
+
if (!entrypoint) return { surface: 'code', where: 'Claude Code', entrypoint: null }
|
|
77
|
+
const where = CODE_ENTRYPOINTS[entrypoint]
|
|
78
|
+
return where ? { surface: 'code', where, entrypoint } : { surface: 'unknown', entrypoint }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What to print about the surface. Returns '' when there is nothing to say.
|
|
83
|
+
* status: silent inside Claude Code. It runs as a SessionStart hook, its stdout lands in the model's
|
|
84
|
+
* context every session, and there the requirement is already met.
|
|
85
|
+
* doctor: always says something. A ✓ when the surface is known, the requirement when it is not.
|
|
86
|
+
* @param {ReturnType<typeof detectSurface>} detection
|
|
87
|
+
* @param {'doctor'|'status'} mode
|
|
88
|
+
*/
|
|
89
|
+
export function renderSurface(detection, mode) {
|
|
90
|
+
const known = detection?.surface === 'code'
|
|
91
|
+
if (mode === 'status') return known ? '' : `Agnoclast: ⚠ ${REQUIREMENT_LINES.join(' ')}`
|
|
92
|
+
if (known) return ` ✓ app — running inside ${detection.where}, where Agnoclast works.`
|
|
93
|
+
return REQUIREMENT_LINES.map((l, i) => (i === 0 ? ` ⚠ ${l}` : ` ${l}`)).join('\n')
|
|
94
|
+
}
|
package/package.json
CHANGED