@theronap/agnoclast-mcp 0.9.180 → 0.9.181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/server.mjs CHANGED
@@ -16,6 +16,7 @@ import { renderCaptureStatus } from './capture_status.mjs'
16
16
  import { renderTriage } from './red_link_triage.mjs'
17
17
  import { runCodeGraphQuery } from './code_graph_cli.mjs'
18
18
  import { repoFullNameFrom, workingFilesFrom } from './capture.mjs'
19
+ import { findTranscript, createWorkedFilesTracker } from './working_files.mjs'
19
20
  import { spawnObligationWorker } from './obligations_worker.mjs'
20
21
  import { renderObligations } from './obligations_render.mjs'
21
22
 
@@ -185,6 +186,25 @@ export async function runServer(version) {
185
186
  // server labels actor_source from this and watches for `mcp` + no session. Tying the two together
186
187
  // would make that pair impossible and blind the detector. See actorSourceFrom in auth_cloud.ts.
187
188
  setClientVersion(version)
189
+ // ADR-0066 §7 — the files this session WORKED ON, read from its own transcript.
190
+ //
191
+ // Keyed on CLAUDE_CODE_SESSION_ID, never SESSION_KEY: SESSION_KEY falls back to a random uuid on
192
+ // hosts that do not set the env var, and no transcript is named after a random uuid. Created LAZILY
193
+ // because a brand-new session's transcript may not exist yet when this server starts; each
194
+ // heartbeat retries until it appears, then the tracker reads only what was appended since.
195
+ let workedFiles = null
196
+ function currentWorkedFiles() {
197
+ try {
198
+ if (!workedFiles) {
199
+ const t = findTranscript(process.env.CLAUDE_CODE_SESSION_ID)
200
+ if (!t) return []
201
+ workedFiles = createWorkedFilesTracker(t)
202
+ }
203
+ return workedFiles.poll(40)
204
+ } catch {
205
+ return [] // attention data is never worth disturbing a session for
206
+ }
207
+ }
188
208
  async function pingSession() {
189
209
  try {
190
210
  await fetchCortex(`${BASE}/api/session-ping`, {
@@ -208,6 +228,11 @@ export async function runServer(version) {
208
228
  // of 217 arrivals "matched" on a live session — a pile with no bounded action in it.
209
229
  // Best-effort and bounded in workingFilesFrom; a non-repo cwd yields [] and is omitted.
210
230
  files: workingFilesFrom(process.cwd()).slice(0, 40),
231
+ // workedFiles (2026-09-25): the files the session actually touched, from its transcript,
232
+ // already qualified as file:<owner/name>:<path>. `files` above reads git in the LAUNCH
233
+ // directory, and every desktop-launched session launches from home — measured, all five
234
+ // of Theron's live sessions — so it registered nothing. This is the one that works.
235
+ workedFiles: currentWorkedFiles(),
211
236
  }),
212
237
  })
213
238
  } catch { /* best-effort heartbeat — never disrupt the session */ }
@@ -0,0 +1,144 @@
1
+ // ADR-0066 §7 — the FILES a session is working on, read from WHERE IT WORKED, not where it launched.
2
+ //
3
+ // 🔴 THIS IS THE SECOND TIME THIS EXACT MISTAKE HAS SHIPPED. 0.9.180 read git from the MCP server's
4
+ // process.cwd(). Measured the same day: all five of Theron's live sessions report cwd =
5
+ // /Users/theronpeterson — the desktop app launches every session from home, which is not a repo — so
6
+ // the feature registered NOTHING for any of them. capture.mjs documents the identical failure from
7
+ // 2026-08-03 ("still zero carried an identifier … because Claude Code sessions launch from wherever
8
+ // the terminal happened to be") and already fixed it the right way: read the transcript, because the
9
+ // transcript records the files the session actually read and wrote. This reuses that primitive.
10
+ //
11
+ // ⚠ INCREMENTAL, BECAUSE THE TRANSCRIPT IS LARGE AND THIS RUNS EVERY MINUTE. A long session's
12
+ // transcript measured 37.7 MB. The tracker remembers its byte offset and reads only what was
13
+ // appended since the last heartbeat; the first read starts from the tail, since recent work is what
14
+ // "working on" means.
15
+ //
16
+ // ⚠ ATTENTION DATA, NOT A CLEARANCE STAMP. capture.mjs uses the same paths to stamp REPOS onto records
17
+ // behind a MIN_FILES_FOR_STAMP security threshold, because a stamp grants other people read access.
18
+ // Nothing here grants anything: these identifiers only make this person's OWN sessions candidates for
19
+ // this person's OWN arrivals. So it takes every file touched rather than thresholding — and it
20
+ // deliberately does not modify touchedPaths, whose extraction is part of that security surface.
21
+ import { readdirSync, existsSync, statSync, openSync, readSync, closeSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { join, dirname } from 'node:path'
24
+ import { execFileSync } from 'node:child_process'
25
+ import { touchedPaths, repoFullNameFrom } from './capture.mjs'
26
+
27
+ const FIRST_READ_TAIL = 2 * 1024 * 1024 // on first poll, only the recent end of a long transcript
28
+ const MAX_READ_PER_POLL = 4 * 1024 * 1024
29
+ const MAX_DIR_PROBES_PER_POLL = 25 // each probe is a git subprocess; bound the heartbeat's cost
30
+ const MAX_KEPT = 200
31
+
32
+ // touchedPaths only recognises Bash paths under /Users, /home, /opt, /srv, /var — right for its
33
+ // security job, but git worktrees commonly live under /private/tmp or /tmp, and work there would be
34
+ // invisible. Widened HERE, for attention only, rather than in the shared extractor.
35
+ const EXTRA_BASH_PATHS = /\/(?:private|tmp)\/[^\s"'`;|&)<>]+/g
36
+
37
+ /** Paths one transcript line touched. Exported for the tests. */
38
+ export function pathsInLine(line) {
39
+ const out = touchedPaths(line)
40
+ let d
41
+ try { d = JSON.parse(line) } catch { return out }
42
+ for (const b of d?.message?.content ?? []) {
43
+ if (b?.type === 'tool_use' && typeof b.input?.command === 'string') {
44
+ for (const m of b.input.command.match(EXTRA_BASH_PATHS) ?? []) out.push(m)
45
+ }
46
+ }
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * Find this session's transcript by its id alone. Scanning for `<id>.jsonl` avoids depending on how
52
+ * Claude Code slugs the launch directory into a folder name — the id is unique, the slug rule is not
53
+ * ours to rely on.
54
+ */
55
+ export function findTranscript(sessionId, projectsDir = join(homedir(), '.claude', 'projects')) {
56
+ if (!sessionId) return null
57
+ try {
58
+ for (const d of readdirSync(projectsDir)) {
59
+ const p = join(projectsDir, d, `${sessionId}.jsonl`)
60
+ if (existsSync(p)) return p
61
+ }
62
+ } catch { /* no projects dir: a non-Claude-Code host */ }
63
+ return null
64
+ }
65
+
66
+ function gitRoot(dir) {
67
+ try {
68
+ return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
69
+ encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
70
+ }).trim() || null
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Tracks the files a session touches, reading its transcript incrementally.
78
+ * `poll()` returns fully-qualified identifiers, most recent first: `file:<owner/name>:<repo-relative>`.
79
+ */
80
+ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
81
+ const resolveRoot = opts.gitRoot ?? gitRoot
82
+ const resolveRepo = opts.repoFullNameFrom ?? repoFullNameFrom
83
+ let offset = -1
84
+ let carry = ''
85
+ const repoOfDir = new Map() // dir -> { root, repo } | null
86
+ const recent = new Map() // identifier -> true, insertion order = recency
87
+
88
+ function locate(p) {
89
+ const dir = dirname(p)
90
+ if (repoOfDir.has(dir)) return repoOfDir.get(dir)
91
+ return undefined
92
+ }
93
+
94
+ function poll(limit = 40) {
95
+ if (!transcriptPath) return []
96
+ let size
97
+ try { size = statSync(transcriptPath).size } catch { return [...recent.keys()].reverse().slice(0, limit) }
98
+ if (offset < 0 || size < offset) { // first read, or the file was rewritten
99
+ offset = Math.max(0, size - FIRST_READ_TAIL)
100
+ carry = ''
101
+ }
102
+ if (size > offset) {
103
+ const len = Math.min(size - offset, MAX_READ_PER_POLL)
104
+ const buf = Buffer.alloc(len)
105
+ let fd
106
+ try {
107
+ fd = openSync(transcriptPath, 'r')
108
+ readSync(fd, buf, 0, len, offset)
109
+ } catch { return [...recent.keys()].reverse().slice(0, limit) } finally { if (fd !== undefined) closeSync(fd) }
110
+ offset += len
111
+ const text = carry + buf.toString('utf8')
112
+ const nl = text.lastIndexOf('\n')
113
+ carry = nl >= 0 ? text.slice(nl + 1) : text
114
+ const lines = nl >= 0 ? text.slice(0, nl).split('\n') : []
115
+
116
+ const touched = []
117
+ for (const line of lines) if (line) touched.push(...pathsInLine(line))
118
+
119
+ let probes = 0
120
+ for (const p of touched) {
121
+ if (p.includes('/.git/') || p.endsWith('/')) continue
122
+ const dir = dirname(p)
123
+ if (!repoOfDir.has(dir)) {
124
+ if (probes >= MAX_DIR_PROBES_PER_POLL) continue
125
+ probes++
126
+ const root = existsSync(dir) ? resolveRoot(dir) : null
127
+ const repo = root ? resolveRepo(root) : null
128
+ repoOfDir.set(dir, root && repo ? { root, repo } : null)
129
+ }
130
+ const hit = locate(p)
131
+ if (!hit || !p.startsWith(hit.root + '/')) continue
132
+ const rel = p.slice(hit.root.length + 1)
133
+ if (!rel) continue
134
+ const id = `file:${hit.repo}:${rel}`
135
+ recent.delete(id)
136
+ recent.set(id, true)
137
+ if (recent.size > MAX_KEPT) recent.delete(recent.keys().next().value)
138
+ }
139
+ }
140
+ return [...recent.keys()].reverse().slice(0, limit)
141
+ }
142
+
143
+ return { poll }
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.180",
3
+ "version": "0.9.181",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {