@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,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
+ }
@@ -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
+ }