@theronap/agnoclast-mcp 0.9.179 โ 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/capture.mjs +40 -0
- package/lib/server.mjs +31 -1
- package/lib/working_files.mjs +144 -0
- package/package.json +1 -1
package/lib/capture.mjs
CHANGED
|
@@ -80,6 +80,46 @@ export function repoFullNameFrom(cwd) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The FILES this session is working on, for ADR-0066 ยง7 session identifiers.
|
|
85
|
+
*
|
|
86
|
+
* ๐ด WHY: the session used to hold only `repo:<owner/name>`, while every record arrives carrying the
|
|
87
|
+
* exact paths it touched. Everything in a repo matches the repo, so on a live session 211 of 217
|
|
88
|
+
* arrivals came back "matched" โ a pile with no bounded action in it, which an agent then claims
|
|
89
|
+
* none of. Git already knows the answer precisely, so nothing new has to be plumbed or remembered.
|
|
90
|
+
*
|
|
91
|
+
* Two questions, unioned:
|
|
92
|
+
* working tree vs HEAD โ what is being edited right now
|
|
93
|
+
* base..HEAD โ what this branch has already committed
|
|
94
|
+
*
|
|
95
|
+
* The second is why this survives a commit: the moment work is committed the working tree goes
|
|
96
|
+
* clean, and a session that had just built something would stop being a candidate for the very
|
|
97
|
+
* records that build produces (its own PR, its own release mail).
|
|
98
|
+
*
|
|
99
|
+
* โ SCOPED TO THIS BRANCH ON PURPOSE. The base is merge-base(HEAD, origin), so a session sitting on
|
|
100
|
+
* an unmodified main reports NOTHING rather than the whole repo's recent history โ which would be
|
|
101
|
+
* other people's work, claimed as attention this session never paid.
|
|
102
|
+
*
|
|
103
|
+
* Best-effort and bounded like every other git read here: a non-repo cwd, a detached head or a
|
|
104
|
+
* missing origin yields [], and the caller simply omits the field.
|
|
105
|
+
*/
|
|
106
|
+
export function workingFilesFrom(cwd, max = 40) {
|
|
107
|
+
if (!cwd) return []
|
|
108
|
+
const run = (args) => {
|
|
109
|
+
try {
|
|
110
|
+
return execFileSync('git', ['-C', String(cwd), ...args], {
|
|
111
|
+
encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
112
|
+
}).split('\n').map((l) => l.trim()).filter(Boolean)
|
|
113
|
+
} catch {
|
|
114
|
+
return []
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const files = new Set(run(['diff', '--name-only', 'HEAD']))
|
|
118
|
+
const base = run(['merge-base', 'HEAD', 'origin/HEAD'])[0] || run(['merge-base', 'HEAD', 'origin/main'])[0]
|
|
119
|
+
if (base) for (const f of run(['diff', '--name-only', base, 'HEAD'])) files.add(f)
|
|
120
|
+
return [...files].slice(0, max)
|
|
121
|
+
}
|
|
122
|
+
|
|
83
123
|
// โโ SHR-01/T9 โ stamp the repos the session WORKED IN, not the one it was launched from โโโโโโโโโ
|
|
84
124
|
//
|
|
85
125
|
// WHY THIS EXISTS. `repoFullNameFrom(cwd)` above reads the session's LAUNCH directory. Measured
|
package/lib/server.mjs
CHANGED
|
@@ -15,7 +15,8 @@ import { buildProposal, renderProposal, withAskNotice } from './propose_obligati
|
|
|
15
15
|
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
|
-
import { repoFullNameFrom } from './capture.mjs'
|
|
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`, {
|
|
@@ -203,6 +223,16 @@ export async function runServer(version) {
|
|
|
203
223
|
cwd: process.cwd(),
|
|
204
224
|
mcpVersion: version,
|
|
205
225
|
repo: repoFullNameFrom(process.cwd()) ?? undefined,
|
|
226
|
+
// files (ADR-0066 ยง7, 2026-09-25): the exact paths this session is working on, so it can
|
|
227
|
+
// be told apart from every other session in the same repo. Holding only `repo:` made 211
|
|
228
|
+
// of 217 arrivals "matched" on a live session โ a pile with no bounded action in it.
|
|
229
|
+
// Best-effort and bounded in workingFilesFrom; a non-repo cwd yields [] and is omitted.
|
|
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(),
|
|
206
236
|
}),
|
|
207
237
|
})
|
|
208
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