@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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { readJson, backupFile, ensureDir, writeJson } from './editors/_fsutil.mjs'
|
|
5
|
+
import { allowedToolsFor } from './editors/claude.mjs'
|
|
6
|
+
|
|
7
|
+
// The C2 key flip, made self-sufficient (ADR-0033 §5, T8).
|
|
8
|
+
//
|
|
9
|
+
// The plan was: C1 adds `mcp__agnoclast__*` to the permission allowlist, then C2 one release later
|
|
10
|
+
// flips `mcpServers.cortex` → `mcpServers.agnoclast`. An earlier draft claimed the release gap made
|
|
11
|
+
// the dangerous ordering "structurally impossible". IT DOES NOT. There is no forced upgrade
|
|
12
|
+
// sequence and — by this ADR's own decision — no version telemetry, so a seat that sits idle
|
|
13
|
+
// through C1 and then upgrades straight to a C2 build receives the flip having never received the
|
|
14
|
+
// allowlist. The gap helps the common case and guarantees nothing.
|
|
15
|
+
//
|
|
16
|
+
// So the ordering guarantee lives HERE, on the machine, not in the release cadence:
|
|
17
|
+
//
|
|
18
|
+
// 1. ensure the new namespace's allow rules are present (additive — nothing is removed)
|
|
19
|
+
// 2. RE-READ from disk and verify they actually landed
|
|
20
|
+
// 3. only then flip the config key
|
|
21
|
+
// 4. re-read and verify the flip landed
|
|
22
|
+
// 5. record it, so a re-run is a no-op
|
|
23
|
+
//
|
|
24
|
+
// If step 2 fails, the key is NOT flipped and the machine stays fully working on the old name. The
|
|
25
|
+
// failure this prevents is silent: renamed tools that fall outside the allowlist do not error, they
|
|
26
|
+
// prompt — which on an unattended seat (cron, scheduled agents, --print) is a hang or a skip.
|
|
27
|
+
//
|
|
28
|
+
// Note this is the REVERSE of the order editors/claude.mjs wire() uses, which writes .claude.json
|
|
29
|
+
// before settings.json. That is fine for a fresh install, where neither exists yet; it is exactly
|
|
30
|
+
// wrong for a migration, where the config key must move last.
|
|
31
|
+
|
|
32
|
+
export const OLD_KEY = 'cortex'
|
|
33
|
+
export const NEW_KEY = 'agnoclast'
|
|
34
|
+
|
|
35
|
+
/** Where the completed migration is recorded. Under ~/.cortex, which ADR-0033 D3 keeps. */
|
|
36
|
+
export const migrationStatePath = (home) => join(home, '.cortex', 'migration.json')
|
|
37
|
+
|
|
38
|
+
/** PURE. The mcpServers object with our entry moved to the new key, preserving the entry verbatim.
|
|
39
|
+
* Returns the same object reference semantics as the input (a copy), plus what it did. */
|
|
40
|
+
export function migratedMcpServers(servers) {
|
|
41
|
+
const out = { ...(servers ?? {}) }
|
|
42
|
+
if (out[NEW_KEY] && !out[OLD_KEY]) return { servers: out, moved: false, reason: 'already-new-key' }
|
|
43
|
+
if (!out[OLD_KEY]) return { servers: out, moved: false, reason: 'no-entry' }
|
|
44
|
+
// Carry the entry across untouched: command, args (the package spec) and env all survive, so a
|
|
45
|
+
// dogfooder pinned to @latest stays pinned and the token is not re-derived.
|
|
46
|
+
out[NEW_KEY] = out[OLD_KEY]
|
|
47
|
+
delete out[OLD_KEY]
|
|
48
|
+
return { servers: out, moved: true, reason: null }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** PURE. Allow rules the new namespace needs that this settings object does not yet carry. */
|
|
52
|
+
export function missingAllows(settings, required = allowedToolsFor(NEW_KEY)) {
|
|
53
|
+
const have = new Set(Array.isArray(settings?.permissions?.allow) ? settings.permissions.allow : [])
|
|
54
|
+
return required.filter((r) => !have.has(r))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** PURE. Settings with the new namespace's rules added. ADDITIVE — the old namespace's rules and
|
|
58
|
+
* every user-added rule survive (editors/claude.mjs:48 invariant), and `deny` is never touched. */
|
|
59
|
+
export function withNewAllows(settings, required = allowedToolsFor(NEW_KEY)) {
|
|
60
|
+
const s = settings && typeof settings === 'object' ? { ...settings } : {}
|
|
61
|
+
s.permissions = s.permissions && typeof s.permissions === 'object' ? { ...s.permissions } : {}
|
|
62
|
+
s.permissions.allow = Array.isArray(s.permissions.allow) ? [...s.permissions.allow] : []
|
|
63
|
+
for (const rule of required) if (!s.permissions.allow.includes(rule)) s.permissions.allow.push(rule)
|
|
64
|
+
return s
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Perform the guarded migration on one machine. Idempotent.
|
|
69
|
+
* Returns { status, ...detail } where status is one of:
|
|
70
|
+
* 'already' — the state file says this machine is done
|
|
71
|
+
* 'nothing-to-migrate' — no entry of ours under either key
|
|
72
|
+
* 'aborted' — the allowlist could not be verified; the key was NOT flipped
|
|
73
|
+
* 'migrated' — allows verified, key flipped, state recorded
|
|
74
|
+
*/
|
|
75
|
+
export function runKeyMigration({ home = homedir(), dryRun = false, io = {} } = {}) {
|
|
76
|
+
const { read = readJson, write = writeJson, backup = backupFile, mkdir = ensureDir, exists = existsSync } = io
|
|
77
|
+
const claudeJson = join(home, '.claude.json')
|
|
78
|
+
const settingsJson = join(home, '.claude', 'settings.json')
|
|
79
|
+
const statePath = migrationStatePath(home)
|
|
80
|
+
const required = allowedToolsFor(NEW_KEY)
|
|
81
|
+
|
|
82
|
+
if (exists(statePath)) {
|
|
83
|
+
try { if (read(statePath)?.to === NEW_KEY) return { status: 'already', statePath } } catch { /* re-run */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let cfg
|
|
87
|
+
try { cfg = read(claudeJson) } catch (e) { return { status: 'aborted', step: 'read-config', reason: e.message } }
|
|
88
|
+
const plan = migratedMcpServers(cfg?.mcpServers)
|
|
89
|
+
if (!plan.moved) return { status: 'nothing-to-migrate', reason: plan.reason }
|
|
90
|
+
|
|
91
|
+
// ── 1+2. Allowlist FIRST, then verify from disk. ───────────────────────────
|
|
92
|
+
let settings
|
|
93
|
+
try { settings = read(settingsJson) } catch (e) { return { status: 'aborted', step: 'read-settings', reason: e.message } }
|
|
94
|
+
const missing = missingAllows(settings, required)
|
|
95
|
+
if (dryRun) return { status: 'dry-run', wouldAdd: missing, wouldFlip: `${OLD_KEY} -> ${NEW_KEY}` }
|
|
96
|
+
|
|
97
|
+
if (missing.length) {
|
|
98
|
+
try {
|
|
99
|
+
backup(settingsJson)
|
|
100
|
+
mkdir(settingsJson)
|
|
101
|
+
write(settingsJson, withNewAllows(settings, required))
|
|
102
|
+
} catch (e) {
|
|
103
|
+
return { status: 'aborted', step: 'write-settings', reason: e.message, added: 0 }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Re-read from disk. Writing is not the same as landing: a full disk, a concurrent writer, or a
|
|
108
|
+
// permission problem can all produce a successful-looking write and an unchanged file.
|
|
109
|
+
let verify
|
|
110
|
+
try { verify = read(settingsJson) } catch (e) {
|
|
111
|
+
return { status: 'aborted', step: 'verify-settings', reason: e.message }
|
|
112
|
+
}
|
|
113
|
+
const stillMissing = missingAllows(verify, required)
|
|
114
|
+
if (stillMissing.length) {
|
|
115
|
+
return { status: 'aborted', step: 'verify-settings', reason: 'allow rules did not persist', stillMissing }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 3+4. Only now flip the key, then verify that too. ──────────────────────
|
|
119
|
+
try {
|
|
120
|
+
backup(claudeJson)
|
|
121
|
+
mkdir(claudeJson)
|
|
122
|
+
write(claudeJson, { ...cfg, mcpServers: plan.servers })
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { status: 'aborted', step: 'write-config', reason: e.message, allowsAdded: missing.length }
|
|
125
|
+
}
|
|
126
|
+
let cfgAfter
|
|
127
|
+
try { cfgAfter = read(claudeJson) } catch (e) {
|
|
128
|
+
return { status: 'aborted', step: 'verify-config', reason: e.message }
|
|
129
|
+
}
|
|
130
|
+
if (!cfgAfter?.mcpServers?.[NEW_KEY]) {
|
|
131
|
+
return { status: 'aborted', step: 'verify-config', reason: 'config key did not persist' }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 5. Record it. Best-effort: a missing state file only costs an idempotent re-run. ──
|
|
135
|
+
const record = { from: OLD_KEY, to: NEW_KEY, allowsAdded: missing.length }
|
|
136
|
+
try { mkdir(statePath); write(statePath, record) } catch { /* non-fatal */ }
|
|
137
|
+
|
|
138
|
+
return { status: 'migrated', ...record, statePath }
|
|
139
|
+
}
|
package/lib/presence.mjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The presence cue: the one place Agnoclast tells a human it is there.
|
|
2
|
+
//
|
|
3
|
+
// Every cue this product shipped before now reported the WRITE path — "connected", "logged
|
|
4
|
+
// context", "you owe the org a page". Nothing ever announced a READ, which is the only moment
|
|
5
|
+
// the thing is actually earning its keep. So the value was invisible by construction, and the
|
|
6
|
+
// first outside reviewer's blocker was, verbatim, "I don't fully understand how it's going to
|
|
7
|
+
// help yet."
|
|
8
|
+
//
|
|
9
|
+
// TWO RULES GOVERN THIS FILE.
|
|
10
|
+
//
|
|
11
|
+
// (1) THE MISS IS LOUDER THAN THE HIT. A cue that only lights up on success manufactures
|
|
12
|
+
// confidence, which is this codebase's documented recurring failure. The gate-6 miss on
|
|
13
|
+
// 2026-07-29 was exactly that shape: the agent hit the edge of the wiki, silently read the
|
|
14
|
+
// repo instead, and the answer still came back sounding authoritative. `renderMiss` exists
|
|
15
|
+
// to make that impossible to do quietly — and because hook stdout is injected into the
|
|
16
|
+
// model's context, it addresses the AGENT as well as the human. It is a guardrail wearing a
|
|
17
|
+
// receipt's clothes.
|
|
18
|
+
//
|
|
19
|
+
// (2) NEVER INVENT A NUMBER. Every field here is either supplied by the server or omitted.
|
|
20
|
+
// There is no inference, no "probably", no derived-looking count that came from parsing
|
|
21
|
+
// prose. A receipt that guesses is worse than no receipt, because it is a confident-looking
|
|
22
|
+
// claim about the one system whose whole job is not making those.
|
|
23
|
+
//
|
|
24
|
+
// Pure and side-effect free on purpose: no network, no fs, no clock. Callers pass everything in,
|
|
25
|
+
// which is what makes the three states unit-testable without a live org.
|
|
26
|
+
|
|
27
|
+
// Deliberately plain text, no ANSI. Hook stdout is both rendered to the user AND injected into
|
|
28
|
+
// model context; escape codes are noise in the second channel and can render literally in the
|
|
29
|
+
// first depending on the surface. Glyph weight carries the state instead of color.
|
|
30
|
+
const MARK_HIT = '◆'
|
|
31
|
+
const MARK_MISS = '◇'
|
|
32
|
+
const MARK_STALE = '◈'
|
|
33
|
+
|
|
34
|
+
// The blob. Claude's is a pulsing eight-spoke asterisk, so these are the same glyph at rising and
|
|
35
|
+
// falling stroke weight — read in sequence it breathes rather than spins. A spinner says "busy";
|
|
36
|
+
// a breath says "here", which is the thing we are actually trying to convey.
|
|
37
|
+
export const BLOB_FRAMES = ['✳', '✷', '✸', '✹', '✺', '✹', '✸', '✷']
|
|
38
|
+
|
|
39
|
+
export function blobFrame(tick) {
|
|
40
|
+
return BLOB_FRAMES[((tick % BLOB_FRAMES.length) + BLOB_FRAMES.length) % BLOB_FRAMES.length]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const WIDTH = 92
|
|
44
|
+
|
|
45
|
+
// Join names to fit one line, and say how many were dropped rather than trailing off. "+3 more" is
|
|
46
|
+
// a fact; a bare ellipsis is a shrug.
|
|
47
|
+
export function joinFit(names, budget) {
|
|
48
|
+
const list = (names ?? []).filter((n) => typeof n === 'string' && n.trim()).map((n) => n.trim())
|
|
49
|
+
if (list.length === 0) return ''
|
|
50
|
+
const out = []
|
|
51
|
+
let used = 0
|
|
52
|
+
for (const name of list) {
|
|
53
|
+
const cost = used === 0 ? name.length : name.length + 3
|
|
54
|
+
const remaining = list.length - out.length - 1
|
|
55
|
+
// Reserve room for the "+N more" tail so the fit never depends on it being empty.
|
|
56
|
+
const tail = remaining > 0 ? ` +${remaining} more`.length : 0
|
|
57
|
+
if (used + cost + tail > budget && out.length > 0) break
|
|
58
|
+
out.push(name)
|
|
59
|
+
used += cost
|
|
60
|
+
}
|
|
61
|
+
const dropped = list.length - out.length
|
|
62
|
+
return dropped > 0 ? `${out.join(' · ')} +${dropped} more` : out.join(' · ')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function line(text) {
|
|
66
|
+
return ` ${text}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Shorten a question to a nameable subject for the miss line. Kept local to the client — the server
|
|
70
|
+
// is never asked to echo the query back, so this adds no new data flow and nothing is stored. That
|
|
71
|
+
// matters: the raw query is customer content, and the standing position is that insight has to be
|
|
72
|
+
// derivable without reading it.
|
|
73
|
+
export function subjectOf(prompt, max = 48) {
|
|
74
|
+
const p = (prompt ?? '').trim().replace(/\s+/g, ' ')
|
|
75
|
+
if (!p) return ''
|
|
76
|
+
if (p.length <= max) return p
|
|
77
|
+
const cut = p.slice(0, max)
|
|
78
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
79
|
+
return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A read that landed. `unasked` is the Jarvis beat — something the query did not name that the
|
|
83
|
+
// graph surfaced anyway. It is the single most persuasive line this product can print, so it is
|
|
84
|
+
// also the one most worth refusing to fake: absent means absent.
|
|
85
|
+
export function renderHit({ pages = [], ms, unasked } = {}) {
|
|
86
|
+
const count = pages.length
|
|
87
|
+
const head = [`${MARK_HIT} agnoclast · read ${count} page${count === 1 ? '' : 's'}`]
|
|
88
|
+
if (Number.isFinite(ms)) head.push(`${Math.round(ms)}ms`)
|
|
89
|
+
const out = [head.join(' · ')]
|
|
90
|
+
const names = joinFit(pages, WIDTH - 2)
|
|
91
|
+
if (names) out.push(line(names))
|
|
92
|
+
if (unasked) out.push(line(`↳ unasked: ${unasked}`))
|
|
93
|
+
return out.join('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A read that found nothing. This is the load-bearing state.
|
|
97
|
+
//
|
|
98
|
+
// `subject` is what was looked for, `unabsorbed` is how many records have landed on the node since
|
|
99
|
+
// its page was last edited — high-and-recent means someone is actively working on a thing the wiki
|
|
100
|
+
// has not caught up to, which is a far more useful thing to tell a human than "no results".
|
|
101
|
+
//
|
|
102
|
+
// The closing directive is aimed at the model. It is the counter to the documented failure where
|
|
103
|
+
// the agent reached for the repo and reported nothing.
|
|
104
|
+
//
|
|
105
|
+
// ⚠ IT SAYS "SERVED", NOT "AUTHORED", AND THE DISTINCTION IS THE WHOLE POINT. An earlier revision
|
|
106
|
+
// read "nothing authored on X" — an assertion about the CORPUS that this client cannot possibly
|
|
107
|
+
// verify, and which is provably false today for two independent reasons:
|
|
108
|
+
// (1) #407 — `brainPages` is fetched inside the block pinned to the anchor brain
|
|
109
|
+
// (`runAsCloud(authId, fn, viewer.org_id)`), so a caller active in N brains only ever sees
|
|
110
|
+
// pages from one. Measured 2026-08-05: that is 5 brains for the operator, 2 each for two
|
|
111
|
+
// other people. Retrieval itself DOES span brains (`runAcrossBrains`), so the epicenter can
|
|
112
|
+
// be correct while the page lookup silently finds nothing.
|
|
113
|
+
// (2) `CORTEX_BRAIN_DIGEST` — when that flag is not 'on', `brainPages` is `[]` unconditionally.
|
|
114
|
+
// Either way `pages: []` means "none reached me", never "none exist". A receipt built on the rule
|
|
115
|
+
// NEVER INVENT A NUMBER had, in its single most load-bearing line, been inventing a fact.
|
|
116
|
+
export function renderMiss({ subject, unabsorbed } = {}) {
|
|
117
|
+
const out = [`${MARK_MISS} agnoclast · read 0 pages`]
|
|
118
|
+
const what = subject ? `nothing served for "${subject}"` : 'nothing served for this'
|
|
119
|
+
const tail =
|
|
120
|
+
Number.isFinite(unabsorbed) && unabsorbed > 0
|
|
121
|
+
? ` — ${unabsorbed} unabsorbed record${unabsorbed === 1 ? '' : 's'} on this node`
|
|
122
|
+
: ''
|
|
123
|
+
out.push(line(what + tail))
|
|
124
|
+
out.push(line('a gap in what was SERVED, not proof none exists — say so rather than substituting another source'))
|
|
125
|
+
return out.join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A page changed. Surfacing `why` and `rev` is what makes the write feel accountable instead of
|
|
129
|
+
// mysterious: the reader can see the reason and knows it is reversible without being told.
|
|
130
|
+
export function renderWrite({ section, page, why, rev } = {}) {
|
|
131
|
+
const target = section ? `wrote §${section} → ${page ?? 'page'}` : `wrote ${page ?? 'page'}`
|
|
132
|
+
const out = [`${MARK_HIT} agnoclast · ${target}`]
|
|
133
|
+
const detail = []
|
|
134
|
+
if (why) detail.push(`why: ${why}`)
|
|
135
|
+
if (rev != null) detail.push(`rev ${rev}`)
|
|
136
|
+
detail.push('reversible')
|
|
137
|
+
out.push(line(detail.join(' · ')))
|
|
138
|
+
return out.join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A read that landed on a page nothing has refreshed in a while. Distinct from a miss: the answer
|
|
142
|
+
// is real, but the reader should weigh it knowing new facts have arrived since. Time alone does not
|
|
143
|
+
// make a page wrong — unabsorbed facts do, which is why the count is the trigger, not the age.
|
|
144
|
+
export function renderStale({ pages = [], unabsorbed, ms } = {}) {
|
|
145
|
+
const count = pages.length
|
|
146
|
+
const head = [`${MARK_STALE} agnoclast · read ${count} page${count === 1 ? '' : 's'} · stale`]
|
|
147
|
+
if (Number.isFinite(ms)) head.push(`${Math.round(ms)}ms`)
|
|
148
|
+
const out = [head.join(' · ')]
|
|
149
|
+
const names = joinFit(pages, WIDTH - 2)
|
|
150
|
+
if (names) out.push(line(names))
|
|
151
|
+
if (Number.isFinite(unabsorbed) && unabsorbed > 0) {
|
|
152
|
+
out.push(line(`${unabsorbed} record${unabsorbed === 1 ? '' : 's'} landed since the last edit — treat as possibly behind`))
|
|
153
|
+
}
|
|
154
|
+
return out.join('\n')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A server too old to report counts. Context WAS served, so claiming a miss here would be a false
|
|
158
|
+
// negative — every bit as dishonest as a false positive, and far easier to ship by accident because
|
|
159
|
+
// `{...undefined}` silently produces an empty object that looks like a measured zero. "The server
|
|
160
|
+
// said zero pages" and "the server did not say" are different facts and must never collapse into one
|
|
161
|
+
// line. So: presence without arithmetic.
|
|
162
|
+
export function renderUncounted() {
|
|
163
|
+
return `${MARK_HIT} agnoclast · context loaded`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Ambient state ────────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
// The receipt is the moment; the blob is the presence. The statusline cannot call the org on every
|
|
168
|
+
// render (it redraws constantly, and this product is bought or rejected on per-operation cost), so
|
|
169
|
+
// hydration leaves a small breadcrumb behind and the statusline only ever reads the file.
|
|
170
|
+
//
|
|
171
|
+
// Deliberately NOT a cache of org data: it holds what THIS machine did and when, which is the one
|
|
172
|
+
// thing it can state without a round-trip and without going stale behind the reader's back.
|
|
173
|
+
|
|
174
|
+
const PRESENCE_FILE = 'presence.json'
|
|
175
|
+
|
|
176
|
+
export function presencePath(home) {
|
|
177
|
+
return joinPath(home, '.cortex', PRESENCE_FILE)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function joinPath(...parts) {
|
|
181
|
+
return parts.join('/').replace(/\/+/g, '/')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Pure: decide what the statusline should say, given the breadcrumb and how old it is. Extracted from
|
|
185
|
+
// all I/O so the decay boundaries are testable without touching a clock or a disk.
|
|
186
|
+
export function describePresence(state, nowMs, idleAfterMs = 45 * 60 * 1000) {
|
|
187
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return { status: 'unwired', text: 'agnoclast · not wired' }
|
|
188
|
+
const at = Date.parse(state.at ?? '')
|
|
189
|
+
if (!Number.isFinite(at)) return { status: 'unwired', text: 'agnoclast · not wired' }
|
|
190
|
+
|
|
191
|
+
const ageMs = nowMs - at
|
|
192
|
+
if (ageMs > idleAfterMs) return { status: 'idle', text: 'agnoclast · idle' }
|
|
193
|
+
|
|
194
|
+
// A server too old to report counts served real context with an empty `pages`. Reading that as a
|
|
195
|
+
// miss would republish, in the ambient line, exactly the false negative renderUncounted exists to
|
|
196
|
+
// prevent — caught here by a live run against prod, which is the only place the two differ.
|
|
197
|
+
if (state.kind === 'uncounted') return { status: 'live', text: 'agnoclast · context loaded' }
|
|
198
|
+
|
|
199
|
+
const pages = Array.isArray(state.pages) ? state.pages.length : 0
|
|
200
|
+
if (state.kind === 'miss' || pages === 0) return { status: 'miss', text: 'agnoclast · no pages served' }
|
|
201
|
+
return { status: 'live', text: `agnoclast · ${pages} page${pages === 1 ? '' : 's'}` }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The rendered statusline. `tick` advances the blob so it breathes as the conversation moves — the
|
|
205
|
+
// motion is tied to real activity rather than to a timer, which is both cheaper and more honest: it
|
|
206
|
+
// stops moving precisely when nothing is happening.
|
|
207
|
+
export function renderStatusline(state, nowMs, tick = 0) {
|
|
208
|
+
const { status, text } = describePresence(state, nowMs)
|
|
209
|
+
const glyph = status === 'live' ? blobFrame(tick) : status === 'miss' ? MARK_MISS : status === 'idle' ? '·' : '○'
|
|
210
|
+
return `${glyph} ${text}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Single entry point. Chooses the state from server-supplied meta ONLY — an absent field routes to
|
|
214
|
+
// the honest branch rather than an optimistic one. Returns '' when there is nothing truthful to
|
|
215
|
+
// say, because a receipt with nothing behind it is the exact failure this file exists to prevent.
|
|
216
|
+
export function formatReceipt(meta) {
|
|
217
|
+
// Arrays are objects and truthy, so a malformed payload would otherwise fall through and render a
|
|
218
|
+
// MISS — reporting a gap that was never measured. Junk in must produce silence, not a finding.
|
|
219
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return ''
|
|
220
|
+
if (meta.kind === 'write') return renderWrite(meta)
|
|
221
|
+
|
|
222
|
+
const pages = Array.isArray(meta.pages) ? meta.pages.filter(Boolean) : []
|
|
223
|
+
if (pages.length === 0) return renderMiss(meta)
|
|
224
|
+
if (Number.isFinite(meta.unabsorbed) && meta.unabsorbed > 0) return renderStale({ ...meta, pages })
|
|
225
|
+
return renderHit({ ...meta, pages })
|
|
226
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The names this package is published under, and how to derive each variant's package.json.
|
|
2
|
+
//
|
|
3
|
+
// ADR-0033 §3. Phase B publishes the SAME build under two names: `@theronap/agnoclast-mcp` going
|
|
4
|
+
// forward, and `@theronap/cortex-mcp` in perpetuity, because the old name is hardcoded in hook
|
|
5
|
+
// command strings and in generated launchd/cron scripts on machines we cannot reach. Generated
|
|
6
|
+
// scripts are frozen at write time (D9), so a script written last year still invokes the old name
|
|
7
|
+
// and no upgrade can repair it. Unpublishing would break those silently.
|
|
8
|
+
//
|
|
9
|
+
// "Byte-identical tarballs" is impossible — the name lives inside the tarball's own package.json —
|
|
10
|
+
// so the guarantee is weaker and more precise: both variants are packed from ONE source tree in ONE
|
|
11
|
+
// release step, and differ ONLY in the fields listed in VARIANT_FIELDS.
|
|
12
|
+
//
|
|
13
|
+
// The `bin` key differs too, and must. npm installs bins by key, so two packages both declaring
|
|
14
|
+
// `cortex-mcp` collide on any machine that resolves both. Each variant declares a bin named after
|
|
15
|
+
// itself. `npx -y <either name>` still works: with exactly one bin declared, npx runs it regardless
|
|
16
|
+
// of what the key is called.
|
|
17
|
+
|
|
18
|
+
/** The fields that may legitimately differ between variants. Anything else differing is a bug. */
|
|
19
|
+
export const VARIANT_FIELDS = ['name', 'bin']
|
|
20
|
+
|
|
21
|
+
/** Publish targets, primary first. Additive only: a name that has ever been published stays here. */
|
|
22
|
+
export const PUBLISH_TARGETS = [
|
|
23
|
+
{ name: '@theronap/agnoclast-mcp', bin: 'agnoclast-mcp', role: 'primary' },
|
|
24
|
+
{ name: '@theronap/cortex-mcp', bin: 'cortex-mcp', role: 'alias' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** The entry point every variant's bin points at. One file, many names. */
|
|
28
|
+
export const BIN_ENTRY = 'bin/cortex-mcp.mjs'
|
|
29
|
+
|
|
30
|
+
/** PURE. The package.json to publish for one target, derived from the source manifest. */
|
|
31
|
+
export function variantPackageJson(sourcePkg, target) {
|
|
32
|
+
if (!sourcePkg || typeof sourcePkg !== 'object') throw new TypeError('variantPackageJson: sourcePkg must be an object')
|
|
33
|
+
if (!target?.name || !target?.bin) throw new TypeError('variantPackageJson: target needs { name, bin }')
|
|
34
|
+
return { ...sourcePkg, name: target.name, bin: { [target.bin]: BIN_ENTRY } }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** PURE. Fields that differ between two manifests, so a release can assert only VARIANT_FIELDS do.
|
|
38
|
+
* Returns a sorted list of top-level keys whose JSON serialization differs. */
|
|
39
|
+
export function differingFields(a, b) {
|
|
40
|
+
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})])
|
|
41
|
+
const out = []
|
|
42
|
+
for (const k of keys) {
|
|
43
|
+
if (JSON.stringify(a?.[k]) !== JSON.stringify(b?.[k])) out.push(k)
|
|
44
|
+
}
|
|
45
|
+
return out.sort()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** PURE. True when two variants differ ONLY in the fields allowed to differ. */
|
|
49
|
+
export function variantsAreEquivalent(a, b) {
|
|
50
|
+
return differingFields(a, b).every((f) => VARIANT_FIELDS.includes(f))
|
|
51
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Red-link triage rendering (Mechanism 2) — the text an agent sees on a read_page miss.
|
|
2
|
+
//
|
|
3
|
+
// Standalone + dependency-free (like grep_cli.mjs) so it unit-tests without pulling the MCP SDK in.
|
|
4
|
+
// server.mjs owns the fetch; this owns the wording.
|
|
5
|
+
//
|
|
6
|
+
// The demoted arm is the one that matters. A superseded/historical page stays greppable
|
|
7
|
+
// (grep_brain_sections applies no validity filter) but read_page won't serve it (authored_page_tiers
|
|
8
|
+
// filters validity='current'), so an agent that greps a hit and then read_page's it lands here — and
|
|
9
|
+
// used to be told "no page yet, author it now". Authoring is pre-authorized, so the compliant next step
|
|
10
|
+
// was to overwrite a page a human deliberately retired, with nothing to catch it. Absence invites
|
|
11
|
+
// authoring; a demotion forbids it.
|
|
12
|
+
//
|
|
13
|
+
// Keyed off the ADDITIVE `t.demoted` flag, checked BEFORE category — never off a new category value. A
|
|
14
|
+
// server predating the flag omits it and every arm behaves exactly as before, so client and server can
|
|
15
|
+
// ship in either order.
|
|
16
|
+
|
|
17
|
+
// PURE: triage payload → miss text.
|
|
18
|
+
export function renderTriage(t, name) {
|
|
19
|
+
const refs = t.tracked
|
|
20
|
+
? ` It's referenced by ${t.refCount} page${t.refCount === 1 ? '' : 's'}${t.isSteward ? ' and is routed to YOU as its most-likely steward' : ''}.`
|
|
21
|
+
: ''
|
|
22
|
+
const aliasHint = `if it's really an existing page under another title, \`grep "${name}"\` to find it, then \`alias_page name="${name}" target_name="<that page>"\``
|
|
23
|
+
if (t.demoted) {
|
|
24
|
+
// KWA-28 — `red_link_targets` is a graph-side object and must carry an as-of. The server ALREADY
|
|
25
|
+
// sends `updatedAt` on this arm (the retirement's own timestamp) and this renderer was dropping it,
|
|
26
|
+
// so "deliberately retired" read as timeless. WHEN it was retired is the load-bearing fact here:
|
|
27
|
+
// the whole point of the arm is to stop an agent authoring over a human decision, and a decision
|
|
28
|
+
// from yesterday and one from eight months ago warrant different confidence about whether it still
|
|
29
|
+
// holds. Explicitly undated rather than silent when the server predates the field.
|
|
30
|
+
const when = t.updatedAt ? ` on ${String(t.updatedAt).slice(0, 10)}` : ' (retirement date not recorded)'
|
|
31
|
+
return `\n\n[[${name}]] EXISTS but is marked ${t.validity ?? 'superseded'} — it was authored and then deliberately retired${when}, so read_page (which serves only current pages) will not show it. It is NOT missing.${refs} Do NOT author over it: that would silently overwrite a decision someone made on purpose. Read it with \`page_history "${name}"\` then \`read_page "${name}"\` with a version. If it genuinely should be live again, revive it deliberately with \`set_page_validity\`.`
|
|
32
|
+
}
|
|
33
|
+
if (t.category === 'node') {
|
|
34
|
+
return `\n\n[[${name}]] is a wanted page — a ${t.isPerson ? 'person' : 'node'} exists but has no page yet.${refs} Either author it now with \`author\`, or ${aliasHint}.`
|
|
35
|
+
}
|
|
36
|
+
return `\n\n"${name}" isn't authored anywhere yet.${refs} Either author a new page with \`author\`, or ${aliasHint}.`
|
|
37
|
+
}
|
package/lib/redact.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// redact.mjs — strip high-confidence credential patterns from text BEFORE it leaves
|
|
2
|
+
// the machine (capture POSTs a transcript tail to the org). A leaked secret in a
|
|
3
|
+
// transcript — a user pastes an API key, a tool reads a config file holding one, or
|
|
4
|
+
// (the 2026-06-22 finding) a login token sits inlined in a hook command — must never
|
|
5
|
+
// be transmitted or stored. Conservative by design: targets known secret SHAPES so it
|
|
6
|
+
// won't mangle ordinary prose or record/UUID ids. Mirrored server-side in
|
|
7
|
+
// web/lib/engine/redact.ts — keep the two pattern lists in sync.
|
|
8
|
+
|
|
9
|
+
// [pattern, replacement]. Order matters: most specific Anthropic forms first so a
|
|
10
|
+
// login token reads as [REDACTED:anthropic-oauth], not the generic key bucket.
|
|
11
|
+
const PATTERNS = [
|
|
12
|
+
[/sk-ant-oat\d{2}-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic-oauth]'],
|
|
13
|
+
[/sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic-key]'],
|
|
14
|
+
[/sk-ant-[A-Za-z0-9_-]{20,}/g, '[REDACTED:anthropic]'],
|
|
15
|
+
[/sk-proj-[A-Za-z0-9_-]{20,}/g, '[REDACTED:openai]'],
|
|
16
|
+
[/sk-[A-Za-z0-9]{32,}/g, '[REDACTED:openai]'],
|
|
17
|
+
[/gh[pousr]_[A-Za-z0-9]{36,}/g, '[REDACTED:github]'],
|
|
18
|
+
[/github_pat_[A-Za-z0-9_]{22,}/g, '[REDACTED:github-pat]'],
|
|
19
|
+
[/AKIA[0-9A-Z]{16}/g, '[REDACTED:aws-akid]'],
|
|
20
|
+
[/AIza[0-9A-Za-z_-]{35}/g, '[REDACTED:google]'],
|
|
21
|
+
[/xox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack]'],
|
|
22
|
+
[/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[REDACTED:jwt]'],
|
|
23
|
+
[/(Bearer\s+)[A-Za-z0-9._-]{20,}/g, '$1[REDACTED]'],
|
|
24
|
+
// Agnoclast's own login token wherever it appears in KEY=value / KEY: value form (hooks.json,
|
|
25
|
+
// config.toml, shell commands — the 2026-07-02 finding: a grep of hooks.json put the live token
|
|
26
|
+
// in a transcript and nothing below caught it). Specific pattern first for the accurate label.
|
|
27
|
+
[/((?:AGNOCLAST|CORTEX)_TOKEN["']?\s*[=:]\s*["']?)[0-9a-fA-F][0-9a-fA-F-]{30,}/g, '$1[REDACTED:agnoclast-token]'],
|
|
28
|
+
// Generic secret-shaped assignment: an UPPER_SNAKE name ending in TOKEN/SECRET/PASSWORD/
|
|
29
|
+
// API_KEY/APIKEY assigned a ≥16-char value. Conservative: the name-suffix + length floor keep
|
|
30
|
+
// ordinary prose, short placeholders, and bare UUIDs (record ids) intact.
|
|
31
|
+
[/([A-Z][A-Z0-9_]{2,}(?:TOKEN|SECRET|PASSWORD|API_KEY|APIKEY)["']?\s*[=:]\s*["']?)[A-Za-z0-9._/+-]{16,}/g, '$1[REDACTED:env]'],
|
|
32
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED:private-key]'],
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export function redactSecrets(text) {
|
|
36
|
+
if (!text || typeof text !== 'string') return text
|
|
37
|
+
let out = text
|
|
38
|
+
for (const [re, repl] of PATTERNS) out = out.replace(re, repl)
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NEW_KEY, OLD_KEY } from './migrate_key.mjs'
|
|
2
|
+
|
|
3
|
+
// The C1 warning (ADR-0033 §4, T7).
|
|
4
|
+
//
|
|
5
|
+
// THIS WHOLE MODULE IS TEMPORARY. It ships in C1, one release ahead of the C2 key flip, and is
|
|
6
|
+
// DELETED a release after C2. If you are reading this and C2 shipped more than one release ago,
|
|
7
|
+
// delete the file, its test, and the two lines that call it in doctor.mjs.
|
|
8
|
+
//
|
|
9
|
+
// The decision it implements: warn once that the flip is coming, then it is the user's file and
|
|
10
|
+
// their call. No detection loop, no dismiss state, no expiry to guess. Earlier drafts proposed
|
|
11
|
+
// grepping every user's CLAUDE.md on a cadence and nagging until it changed; that was rejected —
|
|
12
|
+
// the package has never written CLAUDE.md and should not start, and a nag that cannot tell a fixed
|
|
13
|
+
// file from an unfixed one is wrong for some readers the entire time it runs.
|
|
14
|
+
//
|
|
15
|
+
// It is self-limiting without any state: the notice renders only while this machine is still on the
|
|
16
|
+
// OLD config key. Once `migrate-key` flips it, the condition is false and the message stops. That is
|
|
17
|
+
// "warn, then leave them alone" expressed as a condition rather than as a timer.
|
|
18
|
+
|
|
19
|
+
/** C1 sets this true. Until then the notice is inert, so building it now changes nothing for the
|
|
20
|
+
* people currently on @latest. Flipping this constant IS the C1 release. */
|
|
21
|
+
export const RENAME_NOTICE_ACTIVE = false
|
|
22
|
+
|
|
23
|
+
/** PURE. The one-line warning, or null when it should stay quiet.
|
|
24
|
+
* `key` is which config key answered on this machine ('cortex' | 'agnoclast' | null). */
|
|
25
|
+
export function renderRenameNotice(key, { active = RENAME_NOTICE_ACTIVE } = {}) {
|
|
26
|
+
if (!active) return null
|
|
27
|
+
if (key !== OLD_KEY) return null // already flipped, or nothing wired — nothing to warn about
|
|
28
|
+
return `Agnoclast: heads up — the MCP tools are being renamed from mcp__${OLD_KEY}__* to `
|
|
29
|
+
+ `mcp__${NEW_KEY}__*. If your CLAUDE.md names the old tools, update it when convenient. `
|
|
30
|
+
+ `Run \`npx -y @theronap/cortex-mcp migrate-key\` to switch now, or wait and it will happen for you.`
|
|
31
|
+
}
|