@theronap/cortex-mcp 0.9.142 → 0.9.144
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/edge_extract.mjs +3 -2
- package/lib/obligations_render.mjs +57 -0
- package/lib/server.mjs +45 -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…')
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -170,8 +170,9 @@ const PAGES_FRAGMENT =
|
|
|
170
170
|
const OBLIGATIONS_FRAGMENT =
|
|
171
171
|
'"obligations" (array of things someone must DO by a date, stated in this session. Each an object ' +
|
|
172
172
|
'with "subject" (what must be done, one short phrase), ' +
|
|
173
|
-
'"due_at" (
|
|
174
|
-
'date that was not
|
|
173
|
+
'"due_at" (a DATE, YYYY-MM-DD, when only a day is stated; a date-time, YYYY-MM-DDTHH:MM, ONLY when a ' +
|
|
174
|
+
'time of day is actually said; otherwise null — do NOT invent or infer a date or a time that was not ' +
|
|
175
|
+
'said. A day with no time is kept as a bare date and treated as all-day), ' +
|
|
175
176
|
'"evidence" (the sentence from the session that says so, copied EXACTLY and VERBATIM — it is ' +
|
|
176
177
|
'checked against the transcript and the whole item is discarded if it does not match), ' +
|
|
177
178
|
'"obligated_party" (exactly one of: "self" if the person whose session this is must do it; ' +
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
//
|
|
14
|
+
// ADR-0059 §5.2 (Theron, 2026-09-10): a deadline with no stated time is ALL DAY, starting 00:00 local.
|
|
15
|
+
// It prints "(all day)" instead of a time, reads DUE TODAY for the whole of its day, and turns OVERDUE
|
|
16
|
+
// only at `overdueAfter` (the next local midnight). Without `overdueAfter`, the old rule — overdue at
|
|
17
|
+
// dueAt — would call an all-day deadline OVERDUE from the first minute of its own day. A server older
|
|
18
|
+
// than these fields sends neither, and gets exactly the old output.
|
|
19
|
+
export function renderObligations(obs, now = Date.now()) {
|
|
20
|
+
if (!obs?.length) return 'Nothing open.'
|
|
21
|
+
const proposed = obs.filter((o) => o.state === 'proposed').length
|
|
22
|
+
const lines = [
|
|
23
|
+
proposed
|
|
24
|
+
? `${obs.length} open — ${proposed} of them PROPOSED: extracted from a record, not entered by you. A wrong one: resolve_obligation with state "cancelled".`
|
|
25
|
+
: `${obs.length} open:`,
|
|
26
|
+
'',
|
|
27
|
+
]
|
|
28
|
+
for (const o of obs) {
|
|
29
|
+
const when = describeDue(o, now)
|
|
30
|
+
lines.push(`${o.id}`)
|
|
31
|
+
lines.push(` ${o.subject} — ${when}${o.state === 'snoozed' ? ' (snooze expired)' : ''}${o.state === 'proposed' ? ' [PROPOSED]' : ''}`)
|
|
32
|
+
// ⚠ EVIDENCE IS RENDERED AS A QUESTION, NEVER AS A VERDICT. The phrasing is the feature:
|
|
33
|
+
// "is this done?" costs the reader a second, "you still owe this" about finished work costs
|
|
34
|
+
// the channel its credibility.
|
|
35
|
+
if (o.evidence?.length) {
|
|
36
|
+
lines.push(` ❓ ${o.evidence.length} record(s) suggest this may already be done — check, then resolve_obligation:`)
|
|
37
|
+
for (const e of o.evidence.slice(0, 3)) {
|
|
38
|
+
lines.push(` ${e.occurredAt.slice(0, 10)} ${e.title ?? '(untitled)'}${e.viaIdentifier ? ` [${e.viaIdentifier}]` : ''}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
lines.push('')
|
|
42
|
+
}
|
|
43
|
+
return lines.join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describeDue(o, now) {
|
|
47
|
+
if (!o.dueAt) return 'no deadline'
|
|
48
|
+
const day = o.dueAt.slice(0, 10)
|
|
49
|
+
const flagged = typeof o.dueAllDay === 'boolean' // absent = a server older than all-day deadlines
|
|
50
|
+
const at = new Date(o.dueAt).getTime()
|
|
51
|
+
const after = o.overdueAfter ? new Date(o.overdueAfter).getTime() : at
|
|
52
|
+
if (!flagged) return `${at <= now ? 'OVERDUE ' : 'due '}${day}`
|
|
53
|
+
const what = o.dueAllDay ? `${day} (all day)` : `${day} ${o.dueAt.slice(11, 16)}`
|
|
54
|
+
if (after <= now) return `OVERDUE ${what}`
|
|
55
|
+
if (o.dueAllDay && at <= now) return `DUE TODAY ${what}`
|
|
56
|
+
return `due ${what}`
|
|
57
|
+
}
|
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,
|
|
@@ -76,6 +77,28 @@ export const sectionCurrencyStamp = (s, day) => {
|
|
|
76
77
|
// invisible on exactly the sections that were healthy.
|
|
77
78
|
export const renderSection = (s, day) => `### ${s.heading}${sectionCurrencyStamp(s, day)}\n${s.body}`
|
|
78
79
|
|
|
80
|
+
// gate5_status's transport half, pulled out of the tool handler so it can be exercised directly rather
|
|
81
|
+
// than only string-matched (gate5_status_transport.drift.test.mjs). Takes the raw fetch Response from
|
|
82
|
+
// /api/gates/5/status; the registerTool callback's only remaining job is fetching it. Same three
|
|
83
|
+
// branches the drift test names: 404 (a deployment older than this tool), a generic non-ok response
|
|
84
|
+
// (classified and thrown, same as every other tool here), and 200 with or without a usable `text`.
|
|
85
|
+
export async function gate5StatusFromResponse(res) {
|
|
86
|
+
// A deployment older than this tool has no such route. That is a real state (the package and the
|
|
87
|
+
// web deploy move independently — #693) and it must read as "no reading", not as an error dump.
|
|
88
|
+
if (res.status === 404) {
|
|
89
|
+
return { content: [{ type: 'text', text: 'This tool is newer than the deployment it is talking to: /api/gates/5/status does not exist there yet, so NO reading was taken. This is not an all-clear. Re-check once the console has redeployed.' }] }
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
const body = await res.text()
|
|
93
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
94
|
+
}
|
|
95
|
+
const body = await res.json()
|
|
96
|
+
const text = typeof body?.text === 'string' && body.text.trim()
|
|
97
|
+
? body.text
|
|
98
|
+
: 'gate5_status received a response with no reading in it, so NO reading was taken. This is not an all-clear.'
|
|
99
|
+
return { content: [{ type: 'text', text }] }
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
// The Agnoclast MCP server (stdio). Serves the signed-in employee's scoped org
|
|
80
103
|
// context to their AI assistant. CORTEX_TOKEN identifies the user + org.
|
|
81
104
|
|
|
@@ -673,6 +696,27 @@ function renderNudge(payload) {
|
|
|
673
696
|
},
|
|
674
697
|
)
|
|
675
698
|
|
|
699
|
+
// ── gate5_status — v1 gate 5's operator-access alarm (scope lock D3) ─────────────────────────────
|
|
700
|
+
//
|
|
701
|
+
// THE VERDICT AND THE WORDS ARE RENDERED SERVER-SIDE, and that is the design rather than a shortcut.
|
|
702
|
+
// gate2_status and gate3_status build their prose here, in the client, and both ended up with wrong
|
|
703
|
+
// sentences frozen in published seats that could only be shimmed around from the route (see the
|
|
704
|
+
// notes above them). /api/gates/5/status returns the finished `text`, so its wording and verdict
|
|
705
|
+
// logic deploy with the web app. This handler only transports it, and its one rule is that "no
|
|
706
|
+
// reading" must never come out as silence or as an all-clear.
|
|
707
|
+
server.registerTool(
|
|
708
|
+
'gate5_status',
|
|
709
|
+
{
|
|
710
|
+
title: 'Gate 5 operator-access alarm (ops staff only)',
|
|
711
|
+
description: 'Read v1 gate 5\'s operator-access alarm: privileged database sessions (postgres / supabase_admin, sampled every 30s) that NO break-glass declaration covers, counted since a dated, attributed, expiring baseline (ADR-0060 option 1, accepted by Theron Peterson 2026-09-10). Aggregate-only — counts by likely source, the newest unexplained time and the window; never addresses, org names or session contents. OPS STAFF ONLY: the data is cross-org, so any other caller gets an operator-only answer, which is neither an alarm nor an all-clear. It also checks this app\'s OWN database connection: if the app itself is being recorded as operator access, the alarm is reported NOT ARMED rather than red or green. A failed read is reported as NO READING, never as clean. It decides nothing — gate 5 closes by Theron\'s decision, and what it cannot see is listed on [[Agnoclast v1 — the currency gate]].',
|
|
712
|
+
inputSchema: {},
|
|
713
|
+
},
|
|
714
|
+
async () => {
|
|
715
|
+
const res = await fetchCortex(`${BASE}/api/gates/5/status`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
716
|
+
return gate5StatusFromResponse(res)
|
|
717
|
+
},
|
|
718
|
+
)
|
|
719
|
+
|
|
676
720
|
server.registerTool(
|
|
677
721
|
'session_context',
|
|
678
722
|
{
|
|
@@ -2123,27 +2167,7 @@ function renderNudge(payload) {
|
|
|
2123
2167
|
const out = await res.json().catch(() => null)
|
|
2124
2168
|
if (!res.ok) return toolError(`Could not list: ${out?.error ?? res.status}`)
|
|
2125
2169
|
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') }] }
|
|
2170
|
+
return { content: [{ type: 'text', text: renderObligations(obs) }] }
|
|
2147
2171
|
},
|
|
2148
2172
|
)
|
|
2149
2173
|
|
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