@theronap/agnoclast-mcp 0.9.183 → 0.9.185

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,20 @@ 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
+ //
40
+ // ⚠ NO `\b`. In the raw JSON line a newline is the two characters `\` `n`, so `cd x\ngh pr create` reads
41
+ // `…ngh pr create` — `n` then `g`, no word boundary. The first version had `\bgh`, and every PR from a
42
+ // session that writes one command per line (the one that made #1200) was invisible; this session writes
43
+ // `&& gh …` and never noticed. A prefilter can only ever cost a parse, so it must never be the strict one.
44
+ const AUTHORING_HINT = /gh pr |git |npm |pnpm |yarn /
45
+ const TOOL_USE_ID = /"tool_use_id":"([^"]+)"/g
35
46
  const MAX_READ_PER_POLL = 4 * 1024 * 1024
36
47
  const MAX_DIR_PROBES_PER_POLL = 25 // each probe is a git subprocess; bound the heartbeat's cost
37
48
  const MAX_KEPT = 200
@@ -115,9 +126,14 @@ function writesIn(text, out) {
115
126
  * A plain `cat`, `grep` or `sed -n` of a path is a read and is not counted.
116
127
  */
117
128
  export function editedPathsInLine(line) {
118
- const out = []
119
129
  let d
120
- try { d = JSON.parse(line) } catch { return out }
130
+ try { d = JSON.parse(line) } catch { return [] }
131
+ return editedPathsIn(d)
132
+ }
133
+
134
+ // The parsed form, so the tracker parses each transcript line once for both of its readers.
135
+ function editedPathsIn(d) {
136
+ const out = []
121
137
  for (const b of d?.message?.content ?? []) {
122
138
  if (b?.type !== 'tool_use') continue
123
139
  const inp = b.input ?? {}
@@ -148,6 +164,206 @@ export function editedPathsInLine(line) {
148
164
  return [...new Set(out)]
149
165
  }
150
166
 
167
+ // ── WHAT THE SESSION PRODUCED — the authorship signal (2026-09-25) ──────────────────────────────────
168
+ //
169
+ // 🔴 CHANGING THE SAME FILE IS PROXIMITY, NOT AUTHORSHIP. `worked` above made an arrival "yours" when
170
+ // it touched a file this session changed. Measured the day it shipped, on the session that built it:
171
+ // the per-prompt block called 105 arrivals "work THIS SESSION did", including PR #1200 (Gmail agent
172
+ // tools) and #1194/#1195 — none of them its own. They matched because server.mjs and CHANGELOG.md are
173
+ // edited by nearly every session, so every PR touches them too.
174
+ //
175
+ // What makes an arrival this session's is that it carries something this session MADE: a commit it
176
+ // created, a PR it opened or merged, a version it published. Those are recorded here, as keys:
177
+ // pr:<owner>/<repo>#<n> subject:<16 hex> commit:<sha> release:<pkg>@<version>
178
+ //
179
+ // ⚠ READ FROM THE COMMAND *AND* ITS RESULT, PAIRED BY tool_use_id, because each half lies alone:
180
+ // - `a..b main -> origin/main` is printed by `git fetch` exactly as `git push` prints a push.
181
+ // - `https://github.com/o/r/pull/N` is printed by `gh pr view` exactly as `gh pr create` prints one.
182
+ // - `gh pr merge` prints almost nothing useful; the PR number is in its ARGUMENTS, including loops
183
+ // (`for n in 1087 1089; do gh pr merge $n …`), which is how several PRs were merged this week.
184
+ //
185
+ // ⚠ THE SUBJECT KEY EXISTS BECAUSE OUTPUT GETS CUT. Mined from a real transcript: most `git commit`
186
+ // calls were piped through `| tail -3`, so the `[branch sha]` line never reached the transcript. The
187
+ // message survives — the session wrote it in the command — and GitHub echoes it back as the push
188
+ // title ("Committed: <subject>", with " (#N)" appended on a squash merge). Hashed so that session
189
+ // rows hold no commit prose; the server computes the same hash from the arrival (settling_nudge.ts,
190
+ // kept in step by a parity test that imports this function).
191
+ const MIN_SUBJECT = 12
192
+
193
+ /** Canonical subject key, or null for one too generic to prove anything. Exported for the parity test. */
194
+ export function subjectKey(subject) {
195
+ if (typeof subject !== 'string') return null
196
+ const s = subject.split('\n')[0].replace(/\s+/g, ' ').trim().replace(/\s*\(#\d+\)$/, '')
197
+ // "Merge branch 'main' …" is written by git, not by the session, and is identical across sessions.
198
+ if (s.length < MIN_SUBJECT || /^merge\b/i.test(s)) return null
199
+ return `subject:${createHash('sha256').update(s).digest('hex').slice(0, 16)}`
200
+ }
201
+
202
+ // git's global options before the subcommand. A value is a SHELL WORD, quotes included: this session
203
+ // commits as `git -c user.name='Theron Peterson' commit`, and `\S+` stopped at the space — measured,
204
+ // 84 of 96 commit commands in one transcript yielded no subject until this was a word.
205
+ // ⚠ ONE CHARACTER OR ONE QUOTED RUN PER STEP, never `[^\\s'"]+` inside the `+`: that nesting is
206
+ // exponential on a failed match, and a first version of this line hung the tracker on a long command.
207
+ const WORD = `(?:[^\\s'"]|'[^']*'|"[^"]*")+`
208
+ const GIT_OPTS = `(?:\\s+(?:-[Cc]\\s+${WORD}|--no-pager|--git-dir=${WORD}|--work-tree=${WORD}))*`
209
+ // 🔴 AT COMMAND POSITION ONLY — the program must be INVOKED, not mentioned. Measured building this: the
210
+ // session's own transcript-mining scripts carried `gh pr create` inside a python heredoc and printed
211
+ // week-old PR URLs, and the first cut recorded all of them as this session's PRs. The arrivals notice
212
+ // learned the same lesson the same day: a parser reading the conversation it prints into cannot tell
213
+ // the world from its own echo. So a command counts only at the start of a line or after `&&` `||` `;`
214
+ // `|` `(` `$(` `{` or a `do`/`then`/`else`, optionally behind `timeout 540` and friends (how releases
215
+ // are run here) — never inside a quoted string — and it is matched against the
216
+ // shell text with heredoc BODIES removed (a commit message body is read separately, below).
217
+ 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+)*`
218
+ const GIT_COMMIT = new RegExp(`${AT_CMD}git${GIT_OPTS}\\s+commit\\b`)
219
+ const GIT_PUSH = new RegExp(`${AT_CMD}git${GIT_OPTS}\\s+push\\b`)
220
+ const GH_PR_CREATE = new RegExp(`${AT_CMD}gh\\s+pr\\s+create\\b`)
221
+ const GH_PR_MERGE = new RegExp(`${AT_CMD}gh\\s+pr\\s+merge\\b`)
222
+ const PUBLISH = new RegExp(`${AT_CMD}(?:npm|pnpm|yarn)(?:\\s+-{1,2}[\\w-]+)*\\s+(?:run\\s+release|publish)\\b`)
223
+ const AUTHORING_RES = [GIT_COMMIT, GIT_PUSH, GH_PR_CREATE, GH_PR_MERGE, PUBLISH]
224
+ /** Could this command author anything? Only such commands wait for their result. */
225
+ function isAuthoring(command) {
226
+ const code = maskQuotes(splitHeredocs(command).shell)
227
+ return AUTHORING_RES.some((re) => re.test(code))
228
+ }
229
+
230
+ // Quoted text is DATA to the shell, so it is data here: `echo 'next: git push, then npm publish'` has a
231
+ // `then` that is prose, and without this it read as the shell keyword that puts npm at command
232
+ // position. Lengths are kept so indexes found in the masked text still point into the original.
233
+ function maskQuotes(shell) {
234
+ return shell.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (q) => q[0] + ' '.repeat(Math.max(0, q.length - 2)) + q[q.length - 1])
235
+ }
236
+
237
+ const REPO = '[\\w.-]+\\/[\\w.-]+'
238
+ const pr = (repo, n) => `pr:${repo.toLowerCase()}#${Number(n)}`
239
+
240
+ const COMMIT_REFUSED = /nothing to commit|nothing added to commit|no changes added to commit|Aborting commit/i
241
+ 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
242
+ const URL_PRINTING_READ = new RegExp(`${AT_CMD}gh\\s+(?:pr\\s+(?:view|list|status|checks)|api|search|browse)\\b`)
243
+ // gh pr merge's options that take a VALUE, so the value is never mistaken for the PR number.
244
+ const MERGE_VALUE_FLAGS = new Set(['-R', '--repo', '-b', '--body', '-F', '--body-file', '-t', '--subject', '-A', '--author-email', '--match-head-commit'])
245
+
246
+ /**
247
+ * The PRs each `gh pr merge` invocation names, flags in any order: `gh pr merge 1082 --squash`,
248
+ * `gh pr merge --squash --delete-branch 1082 -R o/r` (Codex review: the second shape was missed), or a
249
+ * PR URL. A `$n` from a loop is left to the loop handler.
250
+ */
251
+ function mergeTargets(code) {
252
+ const out = []
253
+ for (const m of code.matchAll(new RegExp(`${AT_CMD}gh\\s+pr\\s+merge\\b([^;&|\\n]*)`, 'g'))) {
254
+ // A masked quote (`" "`) is ONE shell word, so it must be one token: `-t "my title" 1082`.
255
+ const toks = m[1].replace(/"\s*"|'\s*'/g, 'Q').trim().split(/\s+/).filter(Boolean)
256
+ for (let i = 0; i < toks.length; i++) {
257
+ const t = toks[i]
258
+ if (t.startsWith('-')) { if (MERGE_VALUE_FLAGS.has(t)) i++; continue }
259
+ const url = t.match(new RegExp(`^https://github\\.com/(${REPO})/pull/(\\d+)`))
260
+ if (url) { out.push({ repo: url[1], n: url[2] }); break }
261
+ if (/^\d+$/.test(t)) { out.push({ repo: null, n: t }); break }
262
+ break // the first positional is the PR; anything else (a branch name, `$n`) is not a number
263
+ }
264
+ }
265
+ return out
266
+ }
267
+
268
+ /** The text of a tool_result block, which arrives as a string or as a list of text parts. */
269
+ function resultText(content) {
270
+ if (typeof content === 'string') return content
271
+ if (Array.isArray(content)) return content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n')
272
+ return ''
273
+ }
274
+
275
+ /**
276
+ * What one command produced, judged from the command and its output together. Exported for the tests.
277
+ *
278
+ * `ctx.repoFor(n, dirHint)` resolves the repo of a `gh pr merge <n>` that did not name one — from the
279
+ * PRs this session created, else the directory the command ran in. Returning null drops the number:
280
+ * a PR number without its repo would match that number in every repo this person has.
281
+ */
282
+ export function authoredInCall(command, output, ctx = {}) {
283
+ if (typeof command !== 'string') return []
284
+ const out = new Set()
285
+ const text = resultText(output)
286
+ const { shell, bodies } = splitHeredocs(command)
287
+ // Detection runs on `code` (quotes masked); values — messages, flags, numbers — are read from `shell`.
288
+ const code = maskQuotes(shell)
289
+
290
+ // A commit git REFUSED made nothing, so its message proves nothing (Codex review, 2026-09-25). Empty
291
+ // output still counts: `-q` and `| tail` leave none, and that is the common case the subject key is for.
292
+ if (GIT_COMMIT.test(code) && !COMMIT_REFUSED.test(text)) {
293
+ // The message as the session wrote it: a heredoc fed to the commit line (`-F - <<'EOF'`, or
294
+ // `-m "$(cat <<'EOF'`), else an inline `-m "…"` / `--message "…"`.
295
+ for (const { owner, body } of bodies) {
296
+ if (!GIT_COMMIT.test(owner)) continue
297
+ const key = subjectKey(body.split('\n').find((l) => l.trim()) ?? '')
298
+ if (key) out.add(key)
299
+ }
300
+ const at = code.search(GIT_COMMIT)
301
+ const inline = shell.slice(at).match(/\s(?:-a?m\s*|--message(?:=|\s+))(["'])((?:\\.|(?!\1)[^\\])*)\1/)
302
+ if (inline && !inline[2].startsWith('$(')) {
303
+ const key = subjectKey(inline[2].replace(/\\(["'\\])/g, '$1'))
304
+ if (key) out.add(key)
305
+ }
306
+ for (const m of text.matchAll(/^\[[^\]\n]+\s([0-9a-f]{7,40})\]/gm)) out.add(`commit:${m[1]}`)
307
+ }
308
+
309
+ if (GIT_PUSH.test(code)) {
310
+ for (const m of text.matchAll(/^\s*[+ *!=-]?\s*[0-9a-f]{7,40}\.\.\.?([0-9a-f]{7,40})\s+(\S+)\s+->\s+(\S+)/gm)) {
311
+ const [, tip, src, dst] = m
312
+ // `main -> origin/main` is a FETCH line that shared the command (`git fetch && git push`). A push
313
+ // names the remote branch bare: `fix/x -> fix/x`, `HEAD -> fix/x`.
314
+ if (dst !== src && dst.endsWith(`/${src}`)) continue
315
+ out.add(`commit:${tip}`)
316
+ }
317
+ }
318
+
319
+ if (GH_PR_CREATE.test(code)) {
320
+ // gh prints the new PR's URL as its LAST line, so credit only as many trailing URLs as there were
321
+ // creates. Anything earlier in the output is some other command's text (a `cat`, a log) that
322
+ // happened to share the call.
323
+ const creates = [...code.matchAll(new RegExp(GH_PR_CREATE.source, 'g'))].length
324
+ // ...unless a read that ALSO prints PR URLs shared the call: `gh pr create … || gh pr view 1200`
325
+ // would otherwise credit #1200 when the create failed. Crediting nothing is the safe side — the PR's
326
+ // commits still carry this session's subject keys, and its merge its number.
327
+ const urls = URL_PRINTING_READ.test(code) ? []
328
+ : [...text.matchAll(new RegExp(`https://github\\.com/(${REPO})/pull/(\\d+)\\b`, 'g'))]
329
+ for (const m of urls.slice(-creates)) {
330
+ out.add(pr(m[1], m[2]))
331
+ ctx.onCreated?.(Number(m[2]), m[1])
332
+ }
333
+ }
334
+
335
+ // ⚠ "failed to run git" is NOT a refusal: gh prints it on SUCCESSFUL merges whenever `main` is checked
336
+ // out in another worktree — measured on #1082, which merged. Only gh's own refusals count as failure.
337
+ if (GH_PR_MERGE.test(code) && !MERGE_REFUSED.test(text)) {
338
+ const flag = shell.match(new RegExp(`\\s(?:-R|--repo)[\\s=]+(${REPO})`))?.[1] ?? null
339
+ const cds = [...shell.matchAll(/(?:\bcd\s+|\bgit\s+-C\s+)("[^"]+"|'[^']+'|[^\s;&|]+)/g)]
340
+ const dirHint = cds.length ? expandVarsFrom(shell, cds[cds.length - 1][1].replace(/^["']|["']$/g, '')) : null
341
+ const add = (repo, n) => {
342
+ const r = repo ?? flag ?? ctx.repoFor?.(Number(n), dirHint) ?? null
343
+ if (r) out.add(pr(r, n))
344
+ }
345
+ for (const t of mergeTargets(code)) add(t.repo, t.n)
346
+ for (const m of code.matchAll(/\bfor\s+(\w+)\s+in\s+([\d\s]+?)\s*;\s*do\b([\s\S]*?)\bdone\b/g)) {
347
+ if (!new RegExp(`\\bgh\\s+pr\\s+merge\\s+"?\\$\\{?${m[1]}\\}?\\b`).test(m[3])) continue
348
+ for (const n of m[2].trim().split(/\s+/)) add(null, n)
349
+ }
350
+ // gh's own confirmation, when the output survived — its SUCCESS phrasings only, since gh also names
351
+ // the PR in "Pull request o/r#12 is not mergeable": "✓ Squashed and merged pull request o/r#12 (…)",
352
+ // "✓ Pull request o/r#12 will be automatically merged via squash when all requirements are met".
353
+ for (const m of text.matchAll(new RegExp(`merged pull request (${REPO})?#(\\d+)`, 'gi'))) add(m[1] ?? null, m[2])
354
+ for (const m of text.matchAll(new RegExp(`pull request (${REPO})?#(\\d+) will be automatically merged`, 'gi'))) add(m[1] ?? null, m[2])
355
+ }
356
+
357
+ if (PUBLISH.test(code)) {
358
+ // npm's "+ name@1.2.3", and this repo's release script's per-name verification "OK name@1.2.3".
359
+ // Success lines only: a publish refused with a 409 prints neither, and must not claim the version.
360
+ for (const m of text.matchAll(/^\s*(?:\+|OK)\s+((?:@[\w.-]+\/)?[\w.-]+)@(\d+\.\d+\.\d+(?:-[\w.]+)?)\s*$/gm)) {
361
+ out.add(`release:${m[1].toLowerCase()}@${m[2]}`)
362
+ }
363
+ }
364
+ return [...out]
365
+ }
366
+
151
367
  /**
152
368
  * Find this session's transcript by its id alone. Scanning for `<id>.jsonl` avoids depending on how
153
369
  * Claude Code slugs the launch directory into a folder name — the id is unique, the slug rule is not
@@ -185,6 +401,65 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
185
401
  let carry = ''
186
402
  const repoOfDir = new Map() // dir -> { root, repo } | null
187
403
  const recent = new Map() // identifier -> true, insertion order = recency
404
+ const pending = new Map() // tool_use id -> authoring command, until its result arrives
405
+ const authoredKeys = new Map() // authorship key -> true, insertion order = recency
406
+ const createdPrs = new Map() // PR number -> repo, from this session's own `gh pr create`
407
+
408
+ const ctx = {
409
+ onCreated: (n, repo) => createdPrs.set(n, repo),
410
+ repoFor(n, dirHint) {
411
+ if (createdPrs.has(n)) return createdPrs.get(n)
412
+ if (!dirHint || !existsSync(dirHint)) return null
413
+ const root = resolveRoot(dirHint)
414
+ return root ? resolveRepo(root) : null
415
+ },
416
+ }
417
+
418
+ // A tool_use and its tool_result are separate transcript lines, often in separate polls, so the
419
+ // command waits here — only if it could author something, which keeps this to a handful of entries.
420
+ function collectAuthored(d) {
421
+ for (const b of d?.message?.content ?? []) {
422
+ if (b?.type === 'tool_use' && typeof b.input?.command === 'string' && isAuthoring(b.input.command)) {
423
+ pending.set(b.id, b.input.command)
424
+ if (pending.size > 200) pending.delete(pending.keys().next().value)
425
+ } else if (b?.type === 'tool_result' && pending.has(b.tool_use_id)) {
426
+ const cmd = pending.get(b.tool_use_id)
427
+ pending.delete(b.tool_use_id)
428
+ for (const k of authoredInCall(cmd, b.content, ctx)) {
429
+ authoredKeys.delete(k)
430
+ authoredKeys.set(k, true)
431
+ if (authoredKeys.size > MAX_KEPT) authoredKeys.delete(authoredKeys.keys().next().value)
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ // ⚠ AUTHORSHIP LOOKS FURTHER BACK THAN FILES DO. The files a session is working on are its recent
438
+ // ones, so their first read is the 2 MB tail. What it PRODUCED is different: a PR opened this morning
439
+ // still has arrivals settling tonight, and the tracker restarts with every MCP reconnect — which is
440
+ // after every release. Measured on a 42 MB transcript: its tail held none of the day's PRs. So the
441
+ // first poll also reads the history before the tail, for authorship only, with string filters ahead
442
+ // of any JSON.parse (measured ~150 ms for 42 MB; server.mjs keeps this off the startup path).
443
+ function scanAuthoredHistory(from, to) {
444
+ if (to <= from) return
445
+ let fd
446
+ try {
447
+ fd = openSync(transcriptPath, 'r')
448
+ const buf = Buffer.alloc(to - from)
449
+ readSync(fd, buf, 0, buf.length, from)
450
+ for (const line of buf.toString('utf8').split('\n')) {
451
+ const candidate = line.includes('"tool_use"') && AUTHORING_HINT.test(line)
452
+ const answer = !candidate && pending.size > 0 && line.includes('"tool_result"')
453
+ && [...line.matchAll(TOOL_USE_ID)].some((m) => pending.has(m[1]))
454
+ if (!candidate && !answer) continue
455
+ let d
456
+ try { d = JSON.parse(line) } catch { continue } // the slice's first line is partial
457
+ collectAuthored(d)
458
+ }
459
+ } catch { /* attention data: a failed history read leaves the tail's reading intact */ } finally {
460
+ if (fd !== undefined) closeSync(fd)
461
+ }
462
+ }
188
463
 
189
464
  function locate(p) {
190
465
  const dir = dirname(p)
@@ -199,6 +474,7 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
199
474
  if (offset < 0 || size < offset) { // first read, or the file was rewritten
200
475
  offset = Math.max(0, size - FIRST_READ_TAIL)
201
476
  carry = ''
477
+ scanAuthoredHistory(Math.max(0, size - AUTHORED_HISTORY), offset)
202
478
  }
203
479
  if (size > offset) {
204
480
  const len = Math.min(size - offset, MAX_READ_PER_POLL)
@@ -215,7 +491,13 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
215
491
  const lines = nl >= 0 ? text.slice(0, nl).split('\n') : []
216
492
 
217
493
  const touched = []
218
- for (const line of lines) if (line) touched.push(...editedPathsInLine(line))
494
+ for (const line of lines) {
495
+ if (!line) continue
496
+ let d
497
+ try { d = JSON.parse(line) } catch { continue }
498
+ touched.push(...editedPathsIn(d))
499
+ collectAuthored(d)
500
+ }
219
501
 
220
502
  let probes = 0
221
503
  for (const p of touched) {
@@ -245,5 +527,10 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
245
527
  return [...recent.keys()].reverse().slice(0, limit)
246
528
  }
247
529
 
248
- return { poll }
530
+ /** What this session produced, most recent first. Filled by poll(); reading it does not re-read the transcript. */
531
+ function authored(limit = 100) {
532
+ return [...authoredKeys.keys()].reverse().slice(0, limit)
533
+ }
534
+
535
+ return { poll, authored }
249
536
  }
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.185",
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": {