@theronap/agnoclast-mcp 0.9.183 → 0.9.184

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.
@@ -78,14 +78,20 @@ export function formatNotice(signals) {
78
78
  // was its own. What moves an agent is certainty, a short list, and the reason stated. So the rows
79
79
  // shown are the ones whose files THIS SESSION CHANGED (`worked`), each with the file that proves it,
80
80
  // framed as the step before the answer; every other tier is one line of counts.
81
- const yours = offers.filter((o) => o.via_reason === 'matched' && (o.worked ?? []).length)
82
- const rest = offers.filter((o) => !yours.includes(o))
81
+ //
82
+ // ⭐ AND "PROVABLY" MEANS AUTHORED (later the same day). The first cut called a unit yours when it
83
+ // touched a file this session changed; on the session that built it that listed 105, including other
84
+ // sessions' PRs, because every PR touches the same shared files. Now only an arrival carrying this
85
+ // session's own commit, PR or release (`authored`, computed by the server) is listed; file overlap
86
+ // alone is counted in the line below and named for what it is.
87
+ const yours = offers.filter((o) => (o.authored ?? []).length)
88
+ const sameFiles = offers.filter((o) => !yours.includes(o) && o.via_reason === 'matched' && (o.worked ?? []).length)
89
+ const rest = offers.filter((o) => !yours.includes(o) && !sameFiles.includes(o))
83
90
  if (yours.length) {
84
91
  const n = yours.length
85
- lines.push(`⚡ ${n} arrival${n === 1 ? '' : 's'} came from work THIS SESSION did — you changed the files ${n === 1 ? 'it touches' : 'they touch'}. File ${n === 1 ? 'it' : 'them'} before anything else:`)
92
+ lines.push(`⚡ ${n} arrival${n === 1 ? ' is' : 's are'} THIS SESSION'S OWN WORK — ${n === 1 ? 'its' : 'their'} commit, PR or release is one you made. File ${n === 1 ? 'it' : 'them'} before anything else:`)
86
93
  for (const o of yours.slice(0, MAX_YOURS)) {
87
- const files = o.worked.slice(0, 2).map((f) => f.split('/').pop()).join(', ')
88
- lines.push(` ${o.intakeItemId} ${o.title ?? '(unreadable)'} · ${o.source} · ${o.hoursAgo}h — you changed ${files}`)
94
+ lines.push(` ${o.intakeItemId} ${o.title ?? '(unreadable)'} · ${o.source} · ${o.hoursAgo}h — ${o.authored[0]}`)
89
95
  }
90
96
  if (n > MAX_YOURS) lines.push(` … and ${n - MAX_YOURS} more that are yours`)
91
97
  lines.push(' `intake_look <id>` shows where one would go · `intake_materialize` files it · `not_mine` if one is not yours.')
@@ -93,6 +99,7 @@ export function formatNotice(signals) {
93
99
  const counts = { matched: 0, orphan: 0, declined: 0 }
94
100
  for (const o of rest) counts[o.via_reason] = (counts[o.via_reason] ?? 0) + 1
95
101
  const parts = []
102
+ if (sameFiles.length) parts.push(`${sameFiles.length} touching files you changed but not your own commits or PRs`)
96
103
  if (counts.matched) parts.push(`${counts.matched} loosely matching your work`)
97
104
  if (counts.orphan) parts.push(`${counts.orphan} matching no session`)
98
105
  if (counts.declined) parts.push(`${counts.declined} declined by other sessions`)
package/lib/server.mjs CHANGED
@@ -214,8 +214,10 @@ export async function runServer(version) {
214
214
  // because a brand-new session's transcript may not exist yet when this server starts; each
215
215
  // heartbeat retries until it appears, then the tracker reads only what was appended since.
216
216
  let workedFiles = null
217
+ let trackerArmed = false // set a few seconds after startup; see _armTracker below
217
218
  function currentWorkedFiles() {
218
219
  try {
220
+ if (!trackerArmed) return []
219
221
  if (!workedFiles) {
220
222
  const t = findTranscript(process.env.CLAUDE_CODE_SESSION_ID)
221
223
  if (!t) return []
@@ -226,6 +228,10 @@ export async function runServer(version) {
226
228
  return [] // attention data is never worth disturbing a session for
227
229
  }
228
230
  }
231
+ // Reads what currentWorkedFiles' poll collected; never re-reads the transcript itself.
232
+ function currentAuthored() {
233
+ try { return workedFiles ? workedFiles.authored(100) : [] } catch { return [] }
234
+ }
229
235
  async function pingSession() {
230
236
  try {
231
237
  await fetchCortex(`${BASE}/api/session-ping`, {
@@ -254,6 +260,10 @@ export async function runServer(version) {
254
260
  // directory, and every desktop-launched session launches from home — measured, all five
255
261
  // of Theron's live sessions — so it registered nothing. This is the one that works.
256
262
  workedFiles: currentWorkedFiles(),
263
+ // authored (0.9.184): what the session PRODUCED — commits, PRs, releases — from the same
264
+ // transcript read, which the line above has just advanced. This is what makes an arrival
265
+ // YOURS; a shared changed file is not (see authoredInCall in working_files.mjs).
266
+ authored: currentAuthored(),
257
267
  }),
258
268
  })
259
269
  } catch { /* best-effort heartbeat — never disrupt the session */ }
@@ -261,6 +271,12 @@ export async function runServer(version) {
261
271
  void pingSession()
262
272
  const _hb = setInterval(() => void pingSession(), 60_000)
263
273
  if (typeof _hb.unref === 'function') _hb.unref() // don't keep the process alive just for the heartbeat
274
+ // The transcript tracker's first poll is the expensive one — up to 64 MB of history for authorship,
275
+ // plus git probes — and it is synchronous. Run inside the startup heartbeat it competed with the MCP
276
+ // handshake, on a launch path already measured at 9–12 s under load against a 30 s connect timeout.
277
+ // So the startup heartbeat carries presence only, and this one, seconds later, carries the transcript.
278
+ const _armTracker = setTimeout(() => { trackerArmed = true; void pingSession() }, 8_000)
279
+ if (typeof _armTracker.unref === 'function') _armTracker.unref()
264
280
 
265
281
  // Cache context for 5 minutes so repeated tool calls don't re-fetch.
266
282
  // KEYED BY REPO since 2026-08-27: the context now carries a repo-scoped Gate 4 block, so the same
@@ -29,9 +29,15 @@ import { readdirSync, existsSync, statSync, openSync, readSync, closeSync } from
29
29
  import { homedir } from 'node:os'
30
30
  import { join, dirname } from 'node:path'
31
31
  import { execFileSync } from 'node:child_process'
32
+ import { createHash } from 'node:crypto'
32
33
  import { touchedPaths, repoFullNameFrom } from './capture.mjs'
33
34
 
34
35
  const FIRST_READ_TAIL = 2 * 1024 * 1024 // on first poll, only the recent end of a long transcript
36
+ const AUTHORED_HISTORY = 64 * 1024 * 1024 // ...but authorship reads this far back (see scanAuthoredHistory)
37
+ // Raw-line prefilters for the history scan, applied before JSON.parse. Loose on purpose: the strict,
38
+ // command-position test runs on the parsed command.
39
+ const AUTHORING_HINT = /\bgh pr |\bgit |\bnpm |\bpnpm |\byarn /
40
+ const TOOL_USE_ID = /"tool_use_id":"([^"]+)"/g
35
41
  const MAX_READ_PER_POLL = 4 * 1024 * 1024
36
42
  const MAX_DIR_PROBES_PER_POLL = 25 // each probe is a git subprocess; bound the heartbeat's cost
37
43
  const MAX_KEPT = 200
@@ -115,9 +121,14 @@ function writesIn(text, out) {
115
121
  * A plain `cat`, `grep` or `sed -n` of a path is a read and is not counted.
116
122
  */
117
123
  export function editedPathsInLine(line) {
118
- const out = []
119
124
  let d
120
- try { d = JSON.parse(line) } catch { return out }
125
+ try { d = JSON.parse(line) } catch { return [] }
126
+ return editedPathsIn(d)
127
+ }
128
+
129
+ // The parsed form, so the tracker parses each transcript line once for both of its readers.
130
+ function editedPathsIn(d) {
131
+ const out = []
121
132
  for (const b of d?.message?.content ?? []) {
122
133
  if (b?.type !== 'tool_use') continue
123
134
  const inp = b.input ?? {}
@@ -148,6 +159,206 @@ export function editedPathsInLine(line) {
148
159
  return [...new Set(out)]
149
160
  }
150
161
 
162
+ // ── WHAT THE SESSION PRODUCED — the authorship signal (2026-09-25) ──────────────────────────────────
163
+ //
164
+ // 🔴 CHANGING THE SAME FILE IS PROXIMITY, NOT AUTHORSHIP. `worked` above made an arrival "yours" when
165
+ // it touched a file this session changed. Measured the day it shipped, on the session that built it:
166
+ // the per-prompt block called 105 arrivals "work THIS SESSION did", including PR #1200 (Gmail agent
167
+ // tools) and #1194/#1195 — none of them its own. They matched because server.mjs and CHANGELOG.md are
168
+ // edited by nearly every session, so every PR touches them too.
169
+ //
170
+ // What makes an arrival this session's is that it carries something this session MADE: a commit it
171
+ // created, a PR it opened or merged, a version it published. Those are recorded here, as keys:
172
+ // pr:<owner>/<repo>#<n> subject:<16 hex> commit:<sha> release:<pkg>@<version>
173
+ //
174
+ // ⚠ READ FROM THE COMMAND *AND* ITS RESULT, PAIRED BY tool_use_id, because each half lies alone:
175
+ // - `a..b main -> origin/main` is printed by `git fetch` exactly as `git push` prints a push.
176
+ // - `https://github.com/o/r/pull/N` is printed by `gh pr view` exactly as `gh pr create` prints one.
177
+ // - `gh pr merge` prints almost nothing useful; the PR number is in its ARGUMENTS, including loops
178
+ // (`for n in 1087 1089; do gh pr merge $n …`), which is how several PRs were merged this week.
179
+ //
180
+ // ⚠ THE SUBJECT KEY EXISTS BECAUSE OUTPUT GETS CUT. Mined from a real transcript: most `git commit`
181
+ // calls were piped through `| tail -3`, so the `[branch sha]` line never reached the transcript. The
182
+ // message survives — the session wrote it in the command — and GitHub echoes it back as the push
183
+ // title ("Committed: <subject>", with " (#N)" appended on a squash merge). Hashed so that session
184
+ // rows hold no commit prose; the server computes the same hash from the arrival (settling_nudge.ts,
185
+ // kept in step by a parity test that imports this function).
186
+ const MIN_SUBJECT = 12
187
+
188
+ /** Canonical subject key, or null for one too generic to prove anything. Exported for the parity test. */
189
+ export function subjectKey(subject) {
190
+ if (typeof subject !== 'string') return null
191
+ const s = subject.split('\n')[0].replace(/\s+/g, ' ').trim().replace(/\s*\(#\d+\)$/, '')
192
+ // "Merge branch 'main' …" is written by git, not by the session, and is identical across sessions.
193
+ if (s.length < MIN_SUBJECT || /^merge\b/i.test(s)) return null
194
+ return `subject:${createHash('sha256').update(s).digest('hex').slice(0, 16)}`
195
+ }
196
+
197
+ // git's global options before the subcommand. A value is a SHELL WORD, quotes included: this session
198
+ // commits as `git -c user.name='Theron Peterson' commit`, and `\S+` stopped at the space — measured,
199
+ // 84 of 96 commit commands in one transcript yielded no subject until this was a word.
200
+ // ⚠ ONE CHARACTER OR ONE QUOTED RUN PER STEP, never `[^\\s'"]+` inside the `+`: that nesting is
201
+ // exponential on a failed match, and a first version of this line hung the tracker on a long command.
202
+ const WORD = `(?:[^\\s'"]|'[^']*'|"[^"]*")+`
203
+ const GIT_OPTS = `(?:\\s+(?:-[Cc]\\s+${WORD}|--no-pager|--git-dir=${WORD}|--work-tree=${WORD}))*`
204
+ // 🔴 AT COMMAND POSITION ONLY — the program must be INVOKED, not mentioned. Measured building this: the
205
+ // session's own transcript-mining scripts carried `gh pr create` inside a python heredoc and printed
206
+ // week-old PR URLs, and the first cut recorded all of them as this session's PRs. The arrivals notice
207
+ // learned the same lesson the same day: a parser reading the conversation it prints into cannot tell
208
+ // the world from its own echo. So a command counts only at the start of a line or after `&&` `||` `;`
209
+ // `|` `(` `$(` `{` or a `do`/`then`/`else`, optionally behind `timeout 540` and friends (how releases
210
+ // are run here) — never inside a quoted string — and it is matched against the
211
+ // shell text with heredoc BODIES removed (a commit message body is read separately, below).
212
+ const AT_CMD = String.raw`(?:^|[;&|({\n]|\$\(|\b(?:do|then|else)\b)\s*(?:[A-Za-z_]\w*=\S*\s+)*(?:(?:time|command|nice|env|timeout\s+\S+)\s+)*`
213
+ const GIT_COMMIT = new RegExp(`${AT_CMD}git${GIT_OPTS}\\s+commit\\b`)
214
+ const GIT_PUSH = new RegExp(`${AT_CMD}git${GIT_OPTS}\\s+push\\b`)
215
+ const GH_PR_CREATE = new RegExp(`${AT_CMD}gh\\s+pr\\s+create\\b`)
216
+ const GH_PR_MERGE = new RegExp(`${AT_CMD}gh\\s+pr\\s+merge\\b`)
217
+ const PUBLISH = new RegExp(`${AT_CMD}(?:npm|pnpm|yarn)(?:\\s+-{1,2}[\\w-]+)*\\s+(?:run\\s+release|publish)\\b`)
218
+ const AUTHORING_RES = [GIT_COMMIT, GIT_PUSH, GH_PR_CREATE, GH_PR_MERGE, PUBLISH]
219
+ /** Could this command author anything? Only such commands wait for their result. */
220
+ function isAuthoring(command) {
221
+ const code = maskQuotes(splitHeredocs(command).shell)
222
+ return AUTHORING_RES.some((re) => re.test(code))
223
+ }
224
+
225
+ // Quoted text is DATA to the shell, so it is data here: `echo 'next: git push, then npm publish'` has a
226
+ // `then` that is prose, and without this it read as the shell keyword that puts npm at command
227
+ // position. Lengths are kept so indexes found in the masked text still point into the original.
228
+ function maskQuotes(shell) {
229
+ return shell.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (q) => q[0] + ' '.repeat(Math.max(0, q.length - 2)) + q[q.length - 1])
230
+ }
231
+
232
+ const REPO = '[\\w.-]+\\/[\\w.-]+'
233
+ const pr = (repo, n) => `pr:${repo.toLowerCase()}#${Number(n)}`
234
+
235
+ const COMMIT_REFUSED = /nothing to commit|nothing added to commit|no changes added to commit|Aborting commit/i
236
+ const MERGE_REFUSED = /is not mergeable|not in the correct state|merge conflict|required status check|GraphQL: |no pull requests found|already merged|is closed/i
237
+ const URL_PRINTING_READ = new RegExp(`${AT_CMD}gh\\s+(?:pr\\s+(?:view|list|status|checks)|api|search|browse)\\b`)
238
+ // gh pr merge's options that take a VALUE, so the value is never mistaken for the PR number.
239
+ const MERGE_VALUE_FLAGS = new Set(['-R', '--repo', '-b', '--body', '-F', '--body-file', '-t', '--subject', '-A', '--author-email', '--match-head-commit'])
240
+
241
+ /**
242
+ * The PRs each `gh pr merge` invocation names, flags in any order: `gh pr merge 1082 --squash`,
243
+ * `gh pr merge --squash --delete-branch 1082 -R o/r` (Codex review: the second shape was missed), or a
244
+ * PR URL. A `$n` from a loop is left to the loop handler.
245
+ */
246
+ function mergeTargets(code) {
247
+ const out = []
248
+ for (const m of code.matchAll(new RegExp(`${AT_CMD}gh\\s+pr\\s+merge\\b([^;&|\\n]*)`, 'g'))) {
249
+ // A masked quote (`" "`) is ONE shell word, so it must be one token: `-t "my title" 1082`.
250
+ const toks = m[1].replace(/"\s*"|'\s*'/g, 'Q').trim().split(/\s+/).filter(Boolean)
251
+ for (let i = 0; i < toks.length; i++) {
252
+ const t = toks[i]
253
+ if (t.startsWith('-')) { if (MERGE_VALUE_FLAGS.has(t)) i++; continue }
254
+ const url = t.match(new RegExp(`^https://github\\.com/(${REPO})/pull/(\\d+)`))
255
+ if (url) { out.push({ repo: url[1], n: url[2] }); break }
256
+ if (/^\d+$/.test(t)) { out.push({ repo: null, n: t }); break }
257
+ break // the first positional is the PR; anything else (a branch name, `$n`) is not a number
258
+ }
259
+ }
260
+ return out
261
+ }
262
+
263
+ /** The text of a tool_result block, which arrives as a string or as a list of text parts. */
264
+ function resultText(content) {
265
+ if (typeof content === 'string') return content
266
+ if (Array.isArray(content)) return content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n')
267
+ return ''
268
+ }
269
+
270
+ /**
271
+ * What one command produced, judged from the command and its output together. Exported for the tests.
272
+ *
273
+ * `ctx.repoFor(n, dirHint)` resolves the repo of a `gh pr merge <n>` that did not name one — from the
274
+ * PRs this session created, else the directory the command ran in. Returning null drops the number:
275
+ * a PR number without its repo would match that number in every repo this person has.
276
+ */
277
+ export function authoredInCall(command, output, ctx = {}) {
278
+ if (typeof command !== 'string') return []
279
+ const out = new Set()
280
+ const text = resultText(output)
281
+ const { shell, bodies } = splitHeredocs(command)
282
+ // Detection runs on `code` (quotes masked); values — messages, flags, numbers — are read from `shell`.
283
+ const code = maskQuotes(shell)
284
+
285
+ // A commit git REFUSED made nothing, so its message proves nothing (Codex review, 2026-09-25). Empty
286
+ // output still counts: `-q` and `| tail` leave none, and that is the common case the subject key is for.
287
+ if (GIT_COMMIT.test(code) && !COMMIT_REFUSED.test(text)) {
288
+ // The message as the session wrote it: a heredoc fed to the commit line (`-F - <<'EOF'`, or
289
+ // `-m "$(cat <<'EOF'`), else an inline `-m "…"` / `--message "…"`.
290
+ for (const { owner, body } of bodies) {
291
+ if (!GIT_COMMIT.test(owner)) continue
292
+ const key = subjectKey(body.split('\n').find((l) => l.trim()) ?? '')
293
+ if (key) out.add(key)
294
+ }
295
+ const at = code.search(GIT_COMMIT)
296
+ const inline = shell.slice(at).match(/\s(?:-a?m\s*|--message(?:=|\s+))(["'])((?:\\.|(?!\1)[^\\])*)\1/)
297
+ if (inline && !inline[2].startsWith('$(')) {
298
+ const key = subjectKey(inline[2].replace(/\\(["'\\])/g, '$1'))
299
+ if (key) out.add(key)
300
+ }
301
+ for (const m of text.matchAll(/^\[[^\]\n]+\s([0-9a-f]{7,40})\]/gm)) out.add(`commit:${m[1]}`)
302
+ }
303
+
304
+ if (GIT_PUSH.test(code)) {
305
+ for (const m of text.matchAll(/^\s*[+ *!=-]?\s*[0-9a-f]{7,40}\.\.\.?([0-9a-f]{7,40})\s+(\S+)\s+->\s+(\S+)/gm)) {
306
+ const [, tip, src, dst] = m
307
+ // `main -> origin/main` is a FETCH line that shared the command (`git fetch && git push`). A push
308
+ // names the remote branch bare: `fix/x -> fix/x`, `HEAD -> fix/x`.
309
+ if (dst !== src && dst.endsWith(`/${src}`)) continue
310
+ out.add(`commit:${tip}`)
311
+ }
312
+ }
313
+
314
+ if (GH_PR_CREATE.test(code)) {
315
+ // gh prints the new PR's URL as its LAST line, so credit only as many trailing URLs as there were
316
+ // creates. Anything earlier in the output is some other command's text (a `cat`, a log) that
317
+ // happened to share the call.
318
+ const creates = [...code.matchAll(new RegExp(GH_PR_CREATE.source, 'g'))].length
319
+ // ...unless a read that ALSO prints PR URLs shared the call: `gh pr create … || gh pr view 1200`
320
+ // would otherwise credit #1200 when the create failed. Crediting nothing is the safe side — the PR's
321
+ // commits still carry this session's subject keys, and its merge its number.
322
+ const urls = URL_PRINTING_READ.test(code) ? []
323
+ : [...text.matchAll(new RegExp(`https://github\\.com/(${REPO})/pull/(\\d+)\\b`, 'g'))]
324
+ for (const m of urls.slice(-creates)) {
325
+ out.add(pr(m[1], m[2]))
326
+ ctx.onCreated?.(Number(m[2]), m[1])
327
+ }
328
+ }
329
+
330
+ // ⚠ "failed to run git" is NOT a refusal: gh prints it on SUCCESSFUL merges whenever `main` is checked
331
+ // out in another worktree — measured on #1082, which merged. Only gh's own refusals count as failure.
332
+ if (GH_PR_MERGE.test(code) && !MERGE_REFUSED.test(text)) {
333
+ const flag = shell.match(new RegExp(`\\s(?:-R|--repo)[\\s=]+(${REPO})`))?.[1] ?? null
334
+ const cds = [...shell.matchAll(/(?:\bcd\s+|\bgit\s+-C\s+)("[^"]+"|'[^']+'|[^\s;&|]+)/g)]
335
+ const dirHint = cds.length ? expandVarsFrom(shell, cds[cds.length - 1][1].replace(/^["']|["']$/g, '')) : null
336
+ const add = (repo, n) => {
337
+ const r = repo ?? flag ?? ctx.repoFor?.(Number(n), dirHint) ?? null
338
+ if (r) out.add(pr(r, n))
339
+ }
340
+ for (const t of mergeTargets(code)) add(t.repo, t.n)
341
+ for (const m of code.matchAll(/\bfor\s+(\w+)\s+in\s+([\d\s]+?)\s*;\s*do\b([\s\S]*?)\bdone\b/g)) {
342
+ if (!new RegExp(`\\bgh\\s+pr\\s+merge\\s+"?\\$\\{?${m[1]}\\}?\\b`).test(m[3])) continue
343
+ for (const n of m[2].trim().split(/\s+/)) add(null, n)
344
+ }
345
+ // gh's own confirmation, when the output survived — its SUCCESS phrasings only, since gh also names
346
+ // the PR in "Pull request o/r#12 is not mergeable": "✓ Squashed and merged pull request o/r#12 (…)",
347
+ // "✓ Pull request o/r#12 will be automatically merged via squash when all requirements are met".
348
+ for (const m of text.matchAll(new RegExp(`merged pull request (${REPO})?#(\\d+)`, 'gi'))) add(m[1] ?? null, m[2])
349
+ for (const m of text.matchAll(new RegExp(`pull request (${REPO})?#(\\d+) will be automatically merged`, 'gi'))) add(m[1] ?? null, m[2])
350
+ }
351
+
352
+ if (PUBLISH.test(code)) {
353
+ // npm's "+ name@1.2.3", and this repo's release script's per-name verification "OK name@1.2.3".
354
+ // Success lines only: a publish refused with a 409 prints neither, and must not claim the version.
355
+ for (const m of text.matchAll(/^\s*(?:\+|OK)\s+((?:@[\w.-]+\/)?[\w.-]+)@(\d+\.\d+\.\d+(?:-[\w.]+)?)\s*$/gm)) {
356
+ out.add(`release:${m[1].toLowerCase()}@${m[2]}`)
357
+ }
358
+ }
359
+ return [...out]
360
+ }
361
+
151
362
  /**
152
363
  * Find this session's transcript by its id alone. Scanning for `<id>.jsonl` avoids depending on how
153
364
  * Claude Code slugs the launch directory into a folder name — the id is unique, the slug rule is not
@@ -185,6 +396,65 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
185
396
  let carry = ''
186
397
  const repoOfDir = new Map() // dir -> { root, repo } | null
187
398
  const recent = new Map() // identifier -> true, insertion order = recency
399
+ const pending = new Map() // tool_use id -> authoring command, until its result arrives
400
+ const authoredKeys = new Map() // authorship key -> true, insertion order = recency
401
+ const createdPrs = new Map() // PR number -> repo, from this session's own `gh pr create`
402
+
403
+ const ctx = {
404
+ onCreated: (n, repo) => createdPrs.set(n, repo),
405
+ repoFor(n, dirHint) {
406
+ if (createdPrs.has(n)) return createdPrs.get(n)
407
+ if (!dirHint || !existsSync(dirHint)) return null
408
+ const root = resolveRoot(dirHint)
409
+ return root ? resolveRepo(root) : null
410
+ },
411
+ }
412
+
413
+ // A tool_use and its tool_result are separate transcript lines, often in separate polls, so the
414
+ // command waits here — only if it could author something, which keeps this to a handful of entries.
415
+ function collectAuthored(d) {
416
+ for (const b of d?.message?.content ?? []) {
417
+ if (b?.type === 'tool_use' && typeof b.input?.command === 'string' && isAuthoring(b.input.command)) {
418
+ pending.set(b.id, b.input.command)
419
+ if (pending.size > 200) pending.delete(pending.keys().next().value)
420
+ } else if (b?.type === 'tool_result' && pending.has(b.tool_use_id)) {
421
+ const cmd = pending.get(b.tool_use_id)
422
+ pending.delete(b.tool_use_id)
423
+ for (const k of authoredInCall(cmd, b.content, ctx)) {
424
+ authoredKeys.delete(k)
425
+ authoredKeys.set(k, true)
426
+ if (authoredKeys.size > MAX_KEPT) authoredKeys.delete(authoredKeys.keys().next().value)
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ // ⚠ AUTHORSHIP LOOKS FURTHER BACK THAN FILES DO. The files a session is working on are its recent
433
+ // ones, so their first read is the 2 MB tail. What it PRODUCED is different: a PR opened this morning
434
+ // still has arrivals settling tonight, and the tracker restarts with every MCP reconnect — which is
435
+ // after every release. Measured on a 42 MB transcript: its tail held none of the day's PRs. So the
436
+ // first poll also reads the history before the tail, for authorship only, with string filters ahead
437
+ // of any JSON.parse (measured ~150 ms for 42 MB; server.mjs keeps this off the startup path).
438
+ function scanAuthoredHistory(from, to) {
439
+ if (to <= from) return
440
+ let fd
441
+ try {
442
+ fd = openSync(transcriptPath, 'r')
443
+ const buf = Buffer.alloc(to - from)
444
+ readSync(fd, buf, 0, buf.length, from)
445
+ for (const line of buf.toString('utf8').split('\n')) {
446
+ const candidate = line.includes('"tool_use"') && AUTHORING_HINT.test(line)
447
+ const answer = !candidate && pending.size > 0 && line.includes('"tool_result"')
448
+ && [...line.matchAll(TOOL_USE_ID)].some((m) => pending.has(m[1]))
449
+ if (!candidate && !answer) continue
450
+ let d
451
+ try { d = JSON.parse(line) } catch { continue } // the slice's first line is partial
452
+ collectAuthored(d)
453
+ }
454
+ } catch { /* attention data: a failed history read leaves the tail's reading intact */ } finally {
455
+ if (fd !== undefined) closeSync(fd)
456
+ }
457
+ }
188
458
 
189
459
  function locate(p) {
190
460
  const dir = dirname(p)
@@ -199,6 +469,7 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
199
469
  if (offset < 0 || size < offset) { // first read, or the file was rewritten
200
470
  offset = Math.max(0, size - FIRST_READ_TAIL)
201
471
  carry = ''
472
+ scanAuthoredHistory(Math.max(0, size - AUTHORED_HISTORY), offset)
202
473
  }
203
474
  if (size > offset) {
204
475
  const len = Math.min(size - offset, MAX_READ_PER_POLL)
@@ -215,7 +486,13 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
215
486
  const lines = nl >= 0 ? text.slice(0, nl).split('\n') : []
216
487
 
217
488
  const touched = []
218
- for (const line of lines) if (line) touched.push(...editedPathsInLine(line))
489
+ for (const line of lines) {
490
+ if (!line) continue
491
+ let d
492
+ try { d = JSON.parse(line) } catch { continue }
493
+ touched.push(...editedPathsIn(d))
494
+ collectAuthored(d)
495
+ }
219
496
 
220
497
  let probes = 0
221
498
  for (const p of touched) {
@@ -245,5 +522,10 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
245
522
  return [...recent.keys()].reverse().slice(0, limit)
246
523
  }
247
524
 
248
- return { poll }
525
+ /** What this session produced, most recent first. Filled by poll(); reading it does not re-read the transcript. */
526
+ function authored(limit = 100) {
527
+ return [...authoredKeys.keys()].reverse().slice(0, limit)
528
+ }
529
+
530
+ return { poll, authored }
249
531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.183",
3
+ "version": "0.9.184",
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": {