@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.
- package/lib/arrivals_notice.mjs +12 -5
- package/lib/server.mjs +16 -0
- package/lib/working_files.mjs +286 -4
- package/package.json +1 -1
package/lib/arrivals_notice.mjs
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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'}
|
|
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
|
-
|
|
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
|
package/lib/working_files.mjs
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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