@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
package/lib/capture.mjs
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { readFileSync, accessSync, constants, openSync, mkdirSync } from 'fs'
|
|
2
|
+
import { spawn, execFileSync } from 'child_process'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import { resolve, dirname, join } from 'path'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import { createHash } from 'crypto'
|
|
7
|
+
import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
8
|
+
import { extractSession } from './edge_extract.mjs'
|
|
9
|
+
import { extractTyped } from './extract_typed.mjs'
|
|
10
|
+
import { redactSecrets } from './redact.mjs'
|
|
11
|
+
|
|
12
|
+
// Fetch the node-type registry (global + this org's custom types) — the catalog the typed extractor
|
|
13
|
+
// produces against. Best-effort: null on any failure (typed extraction is then skipped, never blocks capture).
|
|
14
|
+
async function fetchRegistry(base, token) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetchCortex(`${base}/api/node-types`, { headers: { Authorization: `Bearer ${token}` } })
|
|
17
|
+
if (!res.ok) return null
|
|
18
|
+
const j = await res.json().catch(() => ({}))
|
|
19
|
+
return Array.isArray(j.types) ? j.types : null
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Project name from the hook cwd — cross-platform. Windows hooks send backslash paths,
|
|
26
|
+
// and splitting on '/' alone turned the WHOLE path into one garbage project slug
|
|
27
|
+
// ("c-users-webst-onedrive-…", three-machine dry-run finding 2026-06-09). A session run
|
|
28
|
+
// from the home directory itself is 'general', not a project named after the user.
|
|
29
|
+
export function projectFrom(cwd) {
|
|
30
|
+
if (!cwd) return 'general'
|
|
31
|
+
try {
|
|
32
|
+
if (resolve(String(cwd)) === homedir()) return 'general'
|
|
33
|
+
} catch { /* unresolvable path — fall through to basename */ }
|
|
34
|
+
const base = String(cwd).split(/[\\/]+/).filter((s) => s && !/^[A-Za-z]:$/.test(s)).pop()
|
|
35
|
+
return base ?? 'general'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Claude Code Stop hook → POSTs a session digest to Agnoclast cloud, which
|
|
39
|
+
// summarizes server-side and upserts ONE record per session. Node-native
|
|
40
|
+
// SHR-01/T6 — parse a git remote URL into GitHub 'owner/name', or null.
|
|
41
|
+
//
|
|
42
|
+
// Handles the four remote forms git emits: scp-like ssh (git@github.com:o/n.git), ssh://, https://,
|
|
43
|
+
// git://. The HOST CHECK IS LOAD-BEARING, not cosmetic: this string becomes a clearance key, and
|
|
44
|
+
// 'owner/name' on gitlab.com or a self-hosted forge would collide in the identifier namespace with an
|
|
45
|
+
// unrelated GitHub repo of the same name — granting its members read access to each other's sessions.
|
|
46
|
+
export function githubFullName(url) {
|
|
47
|
+
if (!url) return null
|
|
48
|
+
const m = String(url).trim()
|
|
49
|
+
.match(/^(?:git\+)?(?:https?:\/\/|ssh:\/\/|git:\/\/)?(?:[^@/]+@)?github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/i)
|
|
50
|
+
if (!m) return null
|
|
51
|
+
const owner = m[1].toLowerCase()
|
|
52
|
+
const name = m[2].toLowerCase()
|
|
53
|
+
if (!owner || !name || name.includes('/')) return null
|
|
54
|
+
return `${owner}/${name}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// SHR-01/T6 — the repo this session is working in, as 'owner/name', or null.
|
|
58
|
+
//
|
|
59
|
+
// D2, FAIL CLOSED: anything that is not unambiguously a GitHub worktree — no repo, no origin remote,
|
|
60
|
+
// a non-GitHub host, git not installed — returns null, and the session is then stamped with NO
|
|
61
|
+
// identifier and stays private. There is deliberately no fallback: a brain-level or hostname-level
|
|
62
|
+
// identifier would be held by every member of the org, so overlap would ALWAYS succeed. That is an
|
|
63
|
+
// accidental org-wide grant, i.e. the default-tier flip that was explicitly declined.
|
|
64
|
+
//
|
|
65
|
+
// `git config --get` is local and does no network I/O. Bounded and swallowed regardless: capture must
|
|
66
|
+
// never break a session, and a missing identifier is a private session, not a broken one.
|
|
67
|
+
export function repoFullNameFrom(cwd) {
|
|
68
|
+
if (!cwd) return null
|
|
69
|
+
try {
|
|
70
|
+
const url = execFileSync('git', ['-C', String(cwd), 'config', '--get', 'remote.origin.url'], {
|
|
71
|
+
encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
|
+
})
|
|
73
|
+
return githubFullName(url)
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── SHR-01/T9 — stamp the repos the session WORKED IN, not the one it was launched from ─────────
|
|
80
|
+
//
|
|
81
|
+
// WHY THIS EXISTS. `repoFullNameFrom(cwd)` above reads the session's LAUNCH directory. Measured
|
|
82
|
+
// 2026-08-03, four days after the stamp shipped: 3,598 → 3,663 claude-code records, and **still zero
|
|
83
|
+
// carried an identifier**. Not because the code was broken — because Claude Code sessions launch from
|
|
84
|
+
// wherever the terminal happened to be. Theron's launch from `~/Documents/brain` (a git repo with no
|
|
85
|
+
// remote), while every bit of the work happened in `~/dev/cortex-worktrees/*` on theronap/cortex.
|
|
86
|
+
// One session that day touched 319+ paths under those worktrees and was stamped with nothing.
|
|
87
|
+
//
|
|
88
|
+
// So the launch directory is the wrong question. What the clearance rule (ADR-0019) actually asks is
|
|
89
|
+
// whether two people *participated in the same work context* — and the transcript already records
|
|
90
|
+
// that directly, as the files the session read and wrote.
|
|
91
|
+
//
|
|
92
|
+
// ⚠ THE THRESHOLD IS A SECURITY KNOB, NOT A TUNING PARAMETER. Every repo stamped here becomes a key
|
|
93
|
+
// that lets that repo's other workers read this session's SCOPED records. Too low and glancing at one
|
|
94
|
+
// file in repo X hands X's members a session that was really about Y; too high and genuine work goes
|
|
95
|
+
// unstamped, which is the bug above. 3 sits in a wide empty gap — real work in a repo touches dozens
|
|
96
|
+
// to hundreds of files, a drive-by check touches one or two — so the rule rarely makes a close call.
|
|
97
|
+
//
|
|
98
|
+
// Chosen deliberately conservative because the direction matters: LOWERING this later takes effect
|
|
99
|
+
// immediately and safely, while RAISING it does NOT revoke stamps already written to
|
|
100
|
+
// records.event_identifiers. Start tight, loosen once there is real collaborator data.
|
|
101
|
+
export const MIN_FILES_FOR_STAMP = 3
|
|
102
|
+
|
|
103
|
+
// Bounds the clearance surface a single session can claim, and the work done to compute it.
|
|
104
|
+
export const MAX_STAMPED_REPOS = 5
|
|
105
|
+
const MAX_DIRS_PROBED = 25
|
|
106
|
+
|
|
107
|
+
// Absolute paths this session touched: file_path/path/notebook_path on any tool call, plus absolute
|
|
108
|
+
// paths appearing in Bash commands. Best-effort and total — a malformed transcript yields [], never
|
|
109
|
+
// throws, because capture must never break a session.
|
|
110
|
+
export function touchedPaths(transcript) {
|
|
111
|
+
const out = []
|
|
112
|
+
if (!transcript) return out
|
|
113
|
+
for (const line of String(transcript).split('\n')) {
|
|
114
|
+
if (!line) continue
|
|
115
|
+
let d
|
|
116
|
+
try { d = JSON.parse(line) } catch { continue }
|
|
117
|
+
const content = d?.message?.content
|
|
118
|
+
if (!Array.isArray(content)) continue
|
|
119
|
+
for (const b of content) {
|
|
120
|
+
if (b?.type !== 'tool_use') continue
|
|
121
|
+
const inp = b.input ?? {}
|
|
122
|
+
for (const k of ['file_path', 'path', 'notebook_path']) {
|
|
123
|
+
if (typeof inp[k] === 'string' && inp[k].startsWith('/')) out.push(inp[k])
|
|
124
|
+
}
|
|
125
|
+
if (typeof inp.command === 'string') {
|
|
126
|
+
// Non-repo matches (/tmp, /usr, …) simply resolve to no repo and cost one cached probe.
|
|
127
|
+
for (const m of inp.command.match(/\/(?:Users|home|opt|srv|var)\/[^\s"'`;|&)<>]+/g) ?? []) out.push(m)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The repos this session actually worked in. Always a SUPERSET of the launch-cwd behaviour: the
|
|
135
|
+
// launch repo is included unconditionally when it resolves, so nothing that stamps correctly today
|
|
136
|
+
// stops stamping. Everything else must clear MIN_FILES_FOR_STAMP.
|
|
137
|
+
export function repoFullNamesFrom(transcript, cwd) {
|
|
138
|
+
const launch = repoFullNameFrom(cwd)
|
|
139
|
+
const filesByRepo = new Map() // repo -> Set(distinct file paths)
|
|
140
|
+
const touchesByDir = new Map() // dir -> touch count
|
|
141
|
+
|
|
142
|
+
for (const p of touchedPaths(transcript)) {
|
|
143
|
+
const dir = dirname(p)
|
|
144
|
+
touchesByDir.set(dir, (touchesByDir.get(dir) ?? 0) + 1)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Probe the most-touched directories first, so a bounded budget still covers the real work.
|
|
148
|
+
const dirs = [...touchesByDir.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_DIRS_PROBED)
|
|
149
|
+
const repoOfDir = new Map()
|
|
150
|
+
for (const [dir] of dirs) {
|
|
151
|
+
if (!repoOfDir.has(dir)) repoOfDir.set(dir, repoFullNameFrom(dir))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const p of touchedPaths(transcript)) {
|
|
155
|
+
const repo = repoOfDir.get(dirname(p))
|
|
156
|
+
if (!repo) continue
|
|
157
|
+
if (!filesByRepo.has(repo)) filesByRepo.set(repo, new Set())
|
|
158
|
+
filesByRepo.get(repo).add(p)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const earned = [...filesByRepo.entries()]
|
|
162
|
+
.filter(([repo, files]) => files.size >= MIN_FILES_FOR_STAMP || repo === launch)
|
|
163
|
+
.sort((a, b) => b[1].size - a[1].size)
|
|
164
|
+
.map(([repo]) => repo)
|
|
165
|
+
|
|
166
|
+
const all = launch ? [launch, ...earned.filter((r) => r !== launch)] : earned
|
|
167
|
+
return all.slice(0, MAX_STAMPED_REPOS)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// (no bun, no repo clone). Always exits 0 — capture must never break a session.
|
|
171
|
+
|
|
172
|
+
function readStdin() {
|
|
173
|
+
try { return readFileSync(0, 'utf8') } catch { return '' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Claude transcripts are JSONL; pull human-readable text from the tail so the
|
|
177
|
+
// server has real content to summarize (not raw tool JSON). ALSO scan the FULL
|
|
178
|
+
// file for lifecycle-verb lines (§4b status recall — a "we're pausing X" said at
|
|
179
|
+
// minute 5 of a 2-hour session must reach the server's status prefilter even
|
|
180
|
+
// though only the tail ships) and append the flagged spans.
|
|
181
|
+
const LIFECYCLE_RE = /\b(paus\w*|resum\w*|shipp?\w*|kill\w*|cancel\w*|on hold|sunset\w*)\b/i
|
|
182
|
+
|
|
183
|
+
// Whether the Stop hook's transcript_path actually resolves to a READABLE file.
|
|
184
|
+
//
|
|
185
|
+
// This distinguishes "no transcript was ever written" from "transcript exists but yielded no prose" —
|
|
186
|
+
// transcriptTail() flattens both into the same empty string, which is exactly why the empty-session
|
|
187
|
+
// guard below could never see the first case.
|
|
188
|
+
export function transcriptReadable(path) {
|
|
189
|
+
if (!path) return false
|
|
190
|
+
try { accessSync(path, constants.R_OK); return true } catch { return false }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function transcriptTail(path) {
|
|
194
|
+
let raw = ''
|
|
195
|
+
try { raw = readFileSync(path, 'utf8') } catch { return '' }
|
|
196
|
+
const jsonLines = raw.split('\n').filter(Boolean)
|
|
197
|
+
const textOf = (line) => {
|
|
198
|
+
try {
|
|
199
|
+
const obj = JSON.parse(line)
|
|
200
|
+
const content = obj?.message?.content ?? obj?.content
|
|
201
|
+
if (typeof content === 'string') return [content]
|
|
202
|
+
if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text)
|
|
203
|
+
} catch { /* skip non-JSON */ }
|
|
204
|
+
return []
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const texts = jsonLines.slice(-60).flatMap(textOf)
|
|
208
|
+
const tail = texts.join('\n').slice(-6000)
|
|
209
|
+
|
|
210
|
+
// Full-session status-candidate spans (last 12 matching lines, capped).
|
|
211
|
+
const flagged = []
|
|
212
|
+
for (const line of jsonLines) {
|
|
213
|
+
for (const t of textOf(line)) {
|
|
214
|
+
for (const tl of t.split('\n')) {
|
|
215
|
+
if (LIFECYCLE_RE.test(tl)) flagged.push(tl.trim().slice(0, 200))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const spans = flagged.slice(-12).join('\n').slice(0, 1200)
|
|
220
|
+
return spans && !tail.includes(spans)
|
|
221
|
+
? `${tail}\n\n[status-candidate lines from the full session]\n${spans}`
|
|
222
|
+
: tail
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Stop-hook entry point. The Stop hook fires after EVERY assistant turn, and Claude Code BLOCKS the
|
|
226
|
+
// input box until this returns — so the heavy work (a synchronous `claude -p` edge summary, up to
|
|
227
|
+
// tens of seconds, and worse when it hangs under concurrent sessions) must NEVER run in-band here.
|
|
228
|
+
// Instead we read the (small) stdin payload, hand it to a DETACHED background worker, and return in
|
|
229
|
+
// ~milliseconds. The worker does the extraction + ingest out of band; the user's next prompt is never
|
|
230
|
+
// held. Set CORTEX_CAPTURE_SYNC=1 to force the old in-band behavior (tests / debugging).
|
|
231
|
+
export async function runCapture() {
|
|
232
|
+
// Recursion guard: edge extraction shells out to `claude --print` with CORTEX_SUMMARIZING=1. That
|
|
233
|
+
// headless session fires its own Stop hook → this same capture command. Without this guard it would
|
|
234
|
+
// recurse (and re-ingest the summarizer's prompt as a phantom session). Bail immediately.
|
|
235
|
+
if (process.env.CORTEX_SUMMARIZING) { process.stderr.write('cortex: summarizer subprocess, skipping\n'); return }
|
|
236
|
+
|
|
237
|
+
const stdinRaw = readStdin()
|
|
238
|
+
|
|
239
|
+
// Detach the slow work unless we ARE the detached worker (or a caller forced sync). A spawn failure
|
|
240
|
+
// falls through to the in-band path so a session is never dropped just because fork() failed.
|
|
241
|
+
if (!process.env.CORTEX_CAPTURE_DETACHED && !process.env.CORTEX_CAPTURE_SYNC) {
|
|
242
|
+
if (spawnDetachedWorker(stdinRaw)) return
|
|
243
|
+
}
|
|
244
|
+
await captureWork(stdinRaw)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Where a DETACHED worker's stdout/stderr go. Returns a writable fd, or 'ignore' if the log cannot be
|
|
248
|
+
// opened — a capture must never fail because a log file could not be created.
|
|
249
|
+
//
|
|
250
|
+
// WHY THIS EXISTS. The worker previously ran with `stdio: ['pipe', 'ignore', 'ignore']`, so every
|
|
251
|
+
// diagnostic capture prints — `no CORTEX_TOKEN`, `transcript_path unreadable`, `no-op session,
|
|
252
|
+
// skipping`, `ingest failed — <reason>` — was written to a discarded stream. In production that is
|
|
253
|
+
// EVERY capture: the sync path only runs under CORTEX_CAPTURE_SYNC, which nothing sets. So the
|
|
254
|
+
// messages carefully added at each failure branch have never once been readable by anyone.
|
|
255
|
+
//
|
|
256
|
+
// Measured 2026-08-19: session-record writes fell from 107/day (08-10) to 1/day (08-13) and stayed
|
|
257
|
+
// at 0-3/day for six days with no error surfacing anywhere. The failure was found only by forcing
|
|
258
|
+
// CORTEX_CAPTURE_SYNC=1 by hand and watching stderr — which is not a thing anyone will think to do
|
|
259
|
+
// about a subsystem that reports success. Append-only, one line per capture; the file is the only
|
|
260
|
+
// place a silent worker can leave a trace.
|
|
261
|
+
function captureLogFd() {
|
|
262
|
+
try {
|
|
263
|
+
const dir = join(homedir(), '.cortex')
|
|
264
|
+
mkdirSync(dir, { recursive: true })
|
|
265
|
+
return openSync(join(dir, 'capture.log'), 'a')
|
|
266
|
+
} catch {
|
|
267
|
+
return 'ignore'
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Re-invoke this same CLI as `capture` in a fully detached child (own session, output to capture.log) and
|
|
272
|
+
// feed it the hook payload on its stdin. Returns true if the child was launched (parent may return
|
|
273
|
+
// immediately), false if spawning failed (caller then does the work in-band). The child sees
|
|
274
|
+
// CORTEX_CAPTURE_DETACHED=1 so it runs captureWork() directly instead of forking again.
|
|
275
|
+
function spawnDetachedWorker(stdinRaw) {
|
|
276
|
+
try {
|
|
277
|
+
const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cortex-mcp.mjs')
|
|
278
|
+
const child = spawn(process.execPath, [bin, 'capture'], {
|
|
279
|
+
env: { ...process.env, CORTEX_CAPTURE_DETACHED: '1' },
|
|
280
|
+
detached: true,
|
|
281
|
+
stdio: ['pipe', captureLogFd(), captureLogFd()],
|
|
282
|
+
})
|
|
283
|
+
child.on('error', () => {}) // never let an async spawn error crash the hook
|
|
284
|
+
child.stdin.on('error', () => {})
|
|
285
|
+
child.stdin.end(stdinRaw)
|
|
286
|
+
child.unref()
|
|
287
|
+
return true
|
|
288
|
+
} catch {
|
|
289
|
+
return false
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function captureWork(stdinRaw) {
|
|
294
|
+
// Env first (explicit override / legacy inlined hooks), else the token wired into this machine's
|
|
295
|
+
// MCP config — so hook commands carry no secret (token-hygiene, 2026-07-02).
|
|
296
|
+
const token = resolveTokenSource().token
|
|
297
|
+
if (!token) { process.stderr.write('cortex: no CORTEX_TOKEN in env or wired config, skipping\n'); return }
|
|
298
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
299
|
+
|
|
300
|
+
let hook = {}
|
|
301
|
+
try { hook = JSON.parse(stdinRaw) } catch { /* no/invalid stdin */ }
|
|
302
|
+
const repo = projectFrom(hook.cwd)
|
|
303
|
+
// Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
|
|
304
|
+
// feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
|
|
305
|
+
// transcript (pasted key, a tool that read a config file, a token inlined in a hook cmd)
|
|
306
|
+
// must never be transmitted or stored. See redact.mjs.
|
|
307
|
+
const transcript = hook.transcript_path ? redactSecrets(transcriptTail(hook.transcript_path)) : ''
|
|
308
|
+
|
|
309
|
+
// A transcript_path that was SUPPLIED but is unreadable means the session ran with transcript writes
|
|
310
|
+
// disabled — `claude --print --no-session-persistence`, or the SDK's `persistSession:false`. Verified
|
|
311
|
+
// A/B 2026-08-18: with the flag, Claude Code writes no JSONL at all; without it, one appears.
|
|
312
|
+
//
|
|
313
|
+
// The guard below cannot catch that case, and never could: `session_id` is ALWAYS present in a Stop
|
|
314
|
+
// payload, so `!transcript && !hook.session_id` can only ever fire on the session_id half. Capture
|
|
315
|
+
// therefore sailed straight on and shipped `{...common, transcript: ''}` — a contentless record that
|
|
316
|
+
// ALSO lost its repo stamp (repoFullNamesFrom throws on the missing file and is swallowed), leaving it
|
|
317
|
+
// permanently private. That is worse than capturing nothing: it reports `cortex: captured`, and it
|
|
318
|
+
// inflates the very record count that capture health is judged by (there is no freshness monitor).
|
|
319
|
+
//
|
|
320
|
+
// Safe to skip rather than degrade, because capture is not one-shot: the Stop hook fires after EVERY
|
|
321
|
+
// assistant turn and the server upserts ONE record per session_id. A transcript that is merely
|
|
322
|
+
// unreadable for a moment is picked up by the next turn, so a transient miss self-heals; only a
|
|
323
|
+
// genuinely persistence-disabled session is dropped, and that session has no content to capture.
|
|
324
|
+
if (hook.transcript_path && !transcriptReadable(hook.transcript_path)) {
|
|
325
|
+
process.stderr.write('cortex: transcript_path unreadable (session persistence disabled?), skipping\n')
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
|
|
330
|
+
|
|
331
|
+
// T11: digest node refs surfaced to this session (stashed by the MCP server's my_context, keyed by
|
|
332
|
+
// cwd). Forwarded as hydratedFrom so the materializer excludes this session from the digests it
|
|
333
|
+
// consumed (feedback-loop guard). Best-effort + inert when absent (digest flag off / no my_context).
|
|
334
|
+
let hydratedFrom = []
|
|
335
|
+
try {
|
|
336
|
+
const key = createHash('sha1').update(hook.cwd || process.cwd()).digest('hex').slice(0, 16)
|
|
337
|
+
const parsed = JSON.parse(readFileSync(resolve(homedir(), '.cortex', 'brain-refs', `${key}.json`), 'utf8'))
|
|
338
|
+
if (Array.isArray(parsed.refs) && Date.now() - (parsed.ts ?? 0) < 12 * 3600 * 1000) hydratedFrom = parsed.refs
|
|
339
|
+
} catch { /* none — guard stays a no-op */ }
|
|
340
|
+
|
|
341
|
+
// Extract LOCALLY on the subscription (claude -p): summary + people + non-person entities. The
|
|
342
|
+
// cloud then receives only the derived digest, never the raw transcript or the metered API
|
|
343
|
+
// summarizer (which has been the silent point of failure). Fall back to shipping the transcript
|
|
344
|
+
// tail only if local extraction is unavailable (e.g. `claude` not on PATH) so we never drop a
|
|
345
|
+
// session. The server re-validates people/entities — the edge is not trusted.
|
|
346
|
+
// SHR-01/T6: the repo identifier is what lets a PEER read this session (see 0096). Omitted entirely
|
|
347
|
+
// when the cwd is not a GitHub worktree — the record still lands, it just stays private (D2).
|
|
348
|
+
const repoFullName = repoFullNameFrom(hook.cwd)
|
|
349
|
+
|
|
350
|
+
// SHR-01/T9: ...and the repos the session actually WORKED IN, which is usually not the same thing —
|
|
351
|
+
// the launch cwd stamped 0 of 3,663 sessions. Reads the RAW transcript, not the redacted tail above:
|
|
352
|
+
// the tail is truncated to ~6k chars and would miss most of the session's file paths. Nothing from
|
|
353
|
+
// this raw read is transmitted — only the derived 'owner/name' strings leave the machine.
|
|
354
|
+
let repoFullNames = repoFullName ? [repoFullName] : []
|
|
355
|
+
try {
|
|
356
|
+
if (hook.transcript_path) {
|
|
357
|
+
repoFullNames = repoFullNamesFrom(readFileSync(hook.transcript_path, 'utf8'), hook.cwd)
|
|
358
|
+
}
|
|
359
|
+
} catch { /* best-effort — a session with no derivable repo is private, not broken */ }
|
|
360
|
+
|
|
361
|
+
const common = {
|
|
362
|
+
source: 'claude-code',
|
|
363
|
+
project: repo,
|
|
364
|
+
sessionId: hook.session_id,
|
|
365
|
+
title: `Worked in ${repo}`,
|
|
366
|
+
payload: { session_id: hook.session_id, cwd: hook.cwd },
|
|
367
|
+
captureSource: 'hook', // T8: fallback writer — never clobbers a cortex-log ('skill') record
|
|
368
|
+
...(repoFullName ? { repoFullName } : {}), // back-compat: older servers read only this
|
|
369
|
+
...(repoFullNames.length ? { repoFullNames } : {}),
|
|
370
|
+
...(hydratedFrom.length ? { hydratedFrom } : {}),
|
|
371
|
+
}
|
|
372
|
+
const extracted = transcript ? extractSession(transcript) : null
|
|
373
|
+
if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write('cortex: no-op session, skipping\n'); return }
|
|
374
|
+
const ingestBody = extracted
|
|
375
|
+
? { ...common, summary: extracted.summary, people: extracted.people, entities: extracted.namedEntities }
|
|
376
|
+
: { ...common, transcript }
|
|
377
|
+
|
|
378
|
+
// Parallel typed extraction (OPT-IN via CORTEX_TYPED): registry-driven typed notes, sent ALONGSIDE the
|
|
379
|
+
// people/entities above. The server's typedNotes receiver persists them additively. Off by default so it
|
|
380
|
+
// never adds a 2nd `claude` call / latency until verified; flip to default once typed ≥ the blob path.
|
|
381
|
+
if (process.env.CORTEX_TYPED && transcript) {
|
|
382
|
+
try {
|
|
383
|
+
const registry = await fetchRegistry(base, token)
|
|
384
|
+
if (registry?.length) {
|
|
385
|
+
const typed = extractTyped(transcript, registry)
|
|
386
|
+
if (typed?.notes?.length) ingestBody.typedNotes = typed.notes
|
|
387
|
+
}
|
|
388
|
+
} catch { /* best-effort — never block capture */ }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Defined before the fetch so the throw path below can stamp its line the same way. Every line in
|
|
392
|
+
// capture.log carries an ISO timestamp and the session id, so a silent day can be reconstructed
|
|
393
|
+
// afterwards and matched against `records` / `page_revisions.session_key`.
|
|
394
|
+
const stamp = new Date().toISOString()
|
|
395
|
+
const sid = hook.session_id ?? '(no session_id)'
|
|
396
|
+
|
|
397
|
+
let res
|
|
398
|
+
try {
|
|
399
|
+
// Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
|
|
400
|
+
// makes a DETACHED worker self-terminate in ~20s instead of lingering on 3 unbounded attempts.
|
|
401
|
+
res = await fetchCortex(`${base}/api/ingest`, {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
404
|
+
body: JSON.stringify(ingestBody),
|
|
405
|
+
timeoutMs: 10_000,
|
|
406
|
+
}, { retries: 1 })
|
|
407
|
+
} catch (e) {
|
|
408
|
+
// Never break a session — just report and move on. This is where a 10s timeout against a slow
|
|
409
|
+
// /api/ingest lands, and with the old discarded stdio it was completely invisible: the session
|
|
410
|
+
// simply never appeared and nothing anywhere said why. The endpoint is measurably flaky — a
|
|
411
|
+
// SessionStart context fetch timed out at 09:54 and again at 11:59 on 2026-08-19 while direct
|
|
412
|
+
// curls answered in ~150ms, so intermittent timeouts here are a live hypothesis for the
|
|
413
|
+
// 2026-08-13 collapse, not a theoretical one.
|
|
414
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — ${e.message}\n`)
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// A 2xx FROM /api/ingest DOES NOT MEAN A RECORD EXISTS. The route answers `ok: true` on at least
|
|
419
|
+
// six outcomes and only one of them writes a session record:
|
|
420
|
+
//
|
|
421
|
+
// { ok: true, id, inserted, title } -> RECORDED (the only success)
|
|
422
|
+
// { ok: true, staged: true, id, reason } -> held in staged_records, NOT recorded
|
|
423
|
+
// { ok: true, skipped: '<why>' } -> no-op session / past the ingest horizon
|
|
424
|
+
// { ok: false, skipped: '<why>' } -> connector excluded from this brain
|
|
425
|
+
// { ok: true, discarded: true } -> tombstoned by private intake
|
|
426
|
+
// { ok: true, queued: true } -> accepted for later work
|
|
427
|
+
// { ok: true, via: 'private_intake', intakeItemId } -> an intake unit, not a session record
|
|
428
|
+
//
|
|
429
|
+
// The old line read `j.inserted ? 'captured' : 'updated'`, so EVERY one of the six non-writing
|
|
430
|
+
// outcomes printed "updated" — the word for a successful upsert. Worse, `.json().catch(() => ({}))`
|
|
431
|
+
// means an unparseable body also yields `{}` and therefore also printed "updated". Verified against
|
|
432
|
+
// prod 2026-08-19: a capture printed `cortex: updated "general" → general` for a session that has no
|
|
433
|
+
// row in `records` and none in `staged_records` either.
|
|
434
|
+
//
|
|
435
|
+
// NOTE `staged` CARRIES AN `id`, so testing for an id alone is not enough — that id is the
|
|
436
|
+
// staged_records row, not a record. This is the same false-success defect already fixed once in
|
|
437
|
+
// log_session ("`inserted` is merely falsy when nothing is recorded — an agent reported a session as
|
|
438
|
+
// saved when it was not"); the fix was applied there and not here. The server route already knew:
|
|
439
|
+
// its own comment at the no_route_for_source branch says "`{ok: true}` reads as success to
|
|
440
|
+
// everything that is not looking closely" and notes 84 rows accumulating behind that wording.
|
|
441
|
+
if (res.ok || res.status === 200) {
|
|
442
|
+
const raw = await res.text()
|
|
443
|
+
let j
|
|
444
|
+
try { j = JSON.parse(raw) } catch { j = null }
|
|
445
|
+
if (j && j.id && !j.staged) {
|
|
446
|
+
process.stderr.write(`cortex: ${stamp} ${sid} ${j.inserted ? 'captured' : 'updated'} "${j.title ?? repo}" → ${repo}\n`)
|
|
447
|
+
} else if (j && j.staged) {
|
|
448
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — staged (${j.reason ?? 'no reason given'}); staged session logs are not drainable by /api/staged/promote\n`)
|
|
449
|
+
} else if (j && j.skipped) {
|
|
450
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — server skipped: ${j.skipped}\n`)
|
|
451
|
+
} else if (j && j.discarded) {
|
|
452
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — discarded by private intake\n`)
|
|
453
|
+
} else if (j && j.queued) {
|
|
454
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — queued for later processing\n`)
|
|
455
|
+
} else if (j && j.intakeItemId) {
|
|
456
|
+
process.stderr.write(`cortex: ${stamp} ${sid} not a session record — filed as private intake unit ${j.intakeItemId}\n`)
|
|
457
|
+
} else {
|
|
458
|
+
// Unparseable or unrecognised 2xx. Deliberately NOT reported as success: an unknown shape is
|
|
459
|
+
// exactly the case the old code laundered into "updated".
|
|
460
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — unrecognised 2xx response: ${raw.slice(0, 200)}\n`)
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
464
|
+
process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — ingest failed: ${d.message}\n`)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// The post-capture edge-materialize batch (CORTEX_MATERIALIZE → runMaterialize) was EXCISED
|
|
468
|
+
// 2026-07-02 with the legacy materializer: its server pipeline deleted live-authored pages whose
|
|
469
|
+
// record-hashes drifted. Pages come from live authoring (the `author` tool + /log sweep) now.
|
|
470
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
import { existsSync } from 'fs'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
|
|
5
|
+
// Thin local wrapper around the `graphify` CLI's read-only query subcommands. Deliberately NOT a
|
|
6
|
+
// fetchCortex client like grep/read_page: this is LOCAL-MACHINE data (a tree-sitter AST graph of
|
|
7
|
+
// whatever repo the session's cwd happens to be in), not org-shared Agnoclast content, and it never
|
|
8
|
+
// becomes the wiki graph — see cortex-wiki-primary-spec (structural/extracted data is evidence,
|
|
9
|
+
// never auto-promoted into authored pages). No LLM, no network call; graphify already built the
|
|
10
|
+
// graph on disk, this just queries it.
|
|
11
|
+
|
|
12
|
+
function graphPath(cwd) {
|
|
13
|
+
return join(cwd, 'graphify-out', 'graph.json')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasGraphifyBinary() {
|
|
17
|
+
const r = spawnSync('graphify', ['--version'], { encoding: 'utf8', timeout: 10_000 })
|
|
18
|
+
return !r.error && r.status === 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pure-ish: build the argv for a given action, or return an error string if params are missing.
|
|
22
|
+
export function buildArgs({ action, question, from, to, node }) {
|
|
23
|
+
if (action === 'path') {
|
|
24
|
+
if (!from || !to) return { error: 'action:"path" requires both "from" and "to".' }
|
|
25
|
+
return { args: ['path', from, to] }
|
|
26
|
+
}
|
|
27
|
+
if (action === 'explain') {
|
|
28
|
+
if (!node) return { error: 'action:"explain" requires "node".' }
|
|
29
|
+
return { args: ['explain', node] }
|
|
30
|
+
}
|
|
31
|
+
if (!question) return { error: 'action:"query" requires "question".' }
|
|
32
|
+
return { args: ['query', question] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Effectful: run one of graphify's query/path/explain subcommands against the graph already built
|
|
36
|
+
// for `cwd`. Returns { ok, text } — never throws, always something readable to hand back to the model.
|
|
37
|
+
export function runCodeGraphQuery({ action, question, from, to, node }, cwd = process.cwd()) {
|
|
38
|
+
if (!existsSync(graphPath(cwd))) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
text: `No code graph found at ${graphPath(cwd)}. Run the graphify skill (\`/graphify .\`) in this repo first to build one.`,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!hasGraphifyBinary()) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
text: 'graphify CLI not found on PATH. Install it with `uv tool install graphifyy` (or `pipx install graphifyy`), then run the graphify skill to build a graph.',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const built = buildArgs({ action, question, from, to, node })
|
|
51
|
+
if (built.error) return { ok: false, text: built.error }
|
|
52
|
+
|
|
53
|
+
const r = spawnSync('graphify', built.args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 4 * 1024 * 1024 })
|
|
54
|
+
if (r.error) return { ok: false, text: `graphify failed to run: ${r.error.message}` }
|
|
55
|
+
const out = (r.stdout || '').trim()
|
|
56
|
+
const err = (r.stderr || '').trim()
|
|
57
|
+
if (r.status !== 0) return { ok: false, text: err || out || `graphify exited with status ${r.status}` }
|
|
58
|
+
return { ok: true, text: out || '(no results)' }
|
|
59
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
5
|
+
|
|
6
|
+
// The private third copy of this lived here until 2026-08-19. doctor.mjs:14 warned that a third
|
|
7
|
+
// copy is how a machine ends up connected to one command and 'no token found' to another; it also
|
|
8
|
+
// missed Codex entirely, so a Codex-only seat WAS in exactly that state. Shared resolver now.
|
|
9
|
+
const resolveToken = () => resolveTokenSource().token
|
|
10
|
+
|
|
11
|
+
function ensureDir(path) {
|
|
12
|
+
if (!existsSync(path)) mkdirSync(path, { recursive: true })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stamp(now = new Date()) {
|
|
16
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
17
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function snapshotDir() {
|
|
21
|
+
return join(homedir(), '.cortex', 'context-snapshots')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Keep only the most recent N timestamped snapshots; latest.md + index.jsonl are
|
|
25
|
+
// always preserved. Without this the per-session-start archives grow unbounded
|
|
26
|
+
// (2k+ files / ~40MB observed in the field). Best-effort: a prune failure must
|
|
27
|
+
// never break session start.
|
|
28
|
+
const SNAPSHOT_RETENTION = 50
|
|
29
|
+
|
|
30
|
+
function pruneSnapshots(dir, keep = SNAPSHOT_RETENTION) {
|
|
31
|
+
try {
|
|
32
|
+
const stamped = readdirSync(dir)
|
|
33
|
+
.filter((f) => /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.md$/.test(f))
|
|
34
|
+
.sort() // stamp() is zero-padded, so lexicographic order === chronological
|
|
35
|
+
for (const f of stamped.slice(0, Math.max(0, stamped.length - keep))) {
|
|
36
|
+
try { unlinkSync(join(dir, f)) } catch { /* ignore individual failures */ }
|
|
37
|
+
}
|
|
38
|
+
} catch { /* ignore — never break session start */ }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runSnapshotContext() {
|
|
42
|
+
const out = (m) => process.stdout.write(m + '\n')
|
|
43
|
+
const token = resolveToken()
|
|
44
|
+
if (!token) {
|
|
45
|
+
out('Agnoclast: context snapshot skipped — no token found.')
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
50
|
+
let res
|
|
51
|
+
try {
|
|
52
|
+
res = await fetchCortex(`${base}/api/mcp-context`, { headers: { Authorization: `Bearer ${token}` } })
|
|
53
|
+
} catch (e) {
|
|
54
|
+
out(`Agnoclast: context snapshot failed — ${e?.message ?? String(e)}`)
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text()
|
|
60
|
+
out(`Agnoclast: context snapshot failed — ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}`)
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const payload = await res.json().catch(() => ({}))
|
|
65
|
+
const context = typeof payload?.context === 'string' ? payload.context : ''
|
|
66
|
+
const dir = snapshotDir()
|
|
67
|
+
ensureDir(dir)
|
|
68
|
+
|
|
69
|
+
const capturedAt = new Date().toISOString()
|
|
70
|
+
const file = join(dir, `${stamp()}.md`)
|
|
71
|
+
const header = [
|
|
72
|
+
'# Agnoclast startup context snapshot',
|
|
73
|
+
`- Captured: ${capturedAt}`,
|
|
74
|
+
`- Source: ${base}/api/mcp-context`,
|
|
75
|
+
'',
|
|
76
|
+
].join('\n')
|
|
77
|
+
const text = `${header}${context}\n`
|
|
78
|
+
|
|
79
|
+
writeFileSync(file, text)
|
|
80
|
+
writeFileSync(join(dir, 'latest.md'), text)
|
|
81
|
+
writeFileSync(join(dir, 'index.jsonl'), JSON.stringify({
|
|
82
|
+
captured_at: capturedAt,
|
|
83
|
+
file,
|
|
84
|
+
base,
|
|
85
|
+
chars: context.length,
|
|
86
|
+
}) + '\n', { flag: 'a' })
|
|
87
|
+
|
|
88
|
+
pruneSnapshots(dir)
|
|
89
|
+
|
|
90
|
+
out(`Agnoclast: logged startup context → ${file}`)
|
|
91
|
+
return 0
|
|
92
|
+
}
|