@theronap/agnoclast-mcp 0.9.181 → 0.9.182

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.
@@ -56,6 +56,10 @@ import { fetchCortex, resolveBase, resolveTokenSource } from './diagnose.mjs'
56
56
  * to — but this is a hook that prints unprompted, and a notice that dumps content trains its reader
57
57
  * to skip it. Exported for the tests.
58
58
  */
59
+ // Enough to hold a session's own recent output in full; small enough that the notice is READ rather
60
+ // than spilled to a file, which is what happened at 49 KB.
61
+ const MAX_YOURS = 25
62
+
59
63
  export function formatNotice(signals) {
60
64
  const lines = []
61
65
  const offers = signals?.settling?.offers ?? []
@@ -66,32 +70,34 @@ export function formatNotice(signals) {
66
70
  lines.push(`⚡ ${delta} new arrival${delta === 1 ? '' : 's'} in the pile since this session was last told.`)
67
71
  }
68
72
  if (offers.length) {
69
- lines.push(`⚡ ${total} arrival${total === 1 ? '' : 's'} asking THIS session to decide:`)
70
- // ⚠ EVERY OFFER, GROUPED BY CASCADE STAGE — changed 2026-09-25 from the first SIX.
73
+ // ⭐ LEAD WITH WHAT IS PROVABLY THIS SESSION'S, AND ONLY LIST THAT (2026-09-25).
71
74
  //
72
- // Six of a deterministically-ordered twelve showed the SAME handful every turn while the rest of
73
- // the pile stayed unreachable, and a session cannot claim what it is never shown. via_reason is
74
- // still carried through verbatim rather than flattened — a reader who learns the stated reason is
75
- // unreliable stops reading it — but it is printed ONCE PER GROUP instead of on every row, which
76
- // is what makes listing all of them fit at all.
77
- const STAGES = [
78
- ['matched', 'matches your work'],
79
- ['orphan', 'nothing matched them anywhere'],
80
- ['declined', 'every other matching session declined them'],
81
- ]
82
- for (const [key, why] of STAGES) {
83
- const group = offers.filter((o) => o.via_reason === key)
84
- if (!group.length) continue
85
- lines.push(` ${why} (${group.length}):`)
86
- // The title is what makes a row judgeable; an id names a unit without saying what it is.
87
- for (const o of group) {
88
- const name = o.title ? ` ${o.title}` : ''
89
- lines.push(` ${o.intakeItemId}${name} · ${o.source} · ${o.hoursAgo}h`)
75
+ // Measured on the session that built this, as the one receiving it: 6 bare ids — filed nothing; 205
76
+ // titled rows — filed nothing; 350 rows at 49 KB — the harness spilled it to a file and showed a 2 KB
77
+ // preview, so it was not even read. Filed immediately: the one record it had looked at and could see
78
+ // was its own. What moves an agent is certainty, a short list, and the reason stated. So the rows
79
+ // shown are the ones whose files THIS SESSION CHANGED (`worked`), each with the file that proves it,
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))
83
+ if (yours.length) {
84
+ 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:`)
86
+ 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}`)
90
89
  }
90
+ if (n > MAX_YOURS) lines.push(` … and ${n - MAX_YOURS} more that are yours`)
91
+ lines.push(' `intake_look <id>` shows where one would go · `intake_materialize` files it · `not_mine` if one is not yours.')
91
92
  }
92
- // Only when the pile genuinely exceeds the server's scan window; `total` is counted before it.
93
- if (total > offers.length) lines.push(` … and ${total - offers.length} beyond the scan window`)
94
- lines.push(' (Claim + route them, or say they are not yours — they stay unclaimed until someone decides.)')
93
+ const counts = { matched: 0, orphan: 0, declined: 0 }
94
+ for (const o of rest) counts[o.via_reason] = (counts[o.via_reason] ?? 0) + 1
95
+ const parts = []
96
+ if (counts.matched) parts.push(`${counts.matched} loosely matching your work`)
97
+ if (counts.orphan) parts.push(`${counts.orphan} matching no session`)
98
+ if (counts.declined) parts.push(`${counts.declined} declined by other sessions`)
99
+ if (total > offers.length) parts.push(`${total - offers.length} beyond the scan window`)
100
+ if (parts.length) lines.push(`⚡ Also settling: ${parts.join(' · ')} — not listed here; \`intake_changes\` shows them.`)
95
101
  }
96
102
  return lines.join('\n')
97
103
  }
@@ -13,6 +13,13 @@
13
13
  // appended since the last heartbeat; the first read starts from the tail, since recent work is what
14
14
  // "working on" means.
15
15
  //
16
+ // ⚠ CHANGED FILES ONLY — reading one is not working on it (2026-09-25, same day as the above). The
17
+ // first cut recorded every file the session READ as well, and a session investigating a bug reads
18
+ // dozens. Those holds then surface OTHER sessions' work as "yours, you worked on this file", which is
19
+ // certainty the signal does not have — and a nudge that claims certainty it lacks is how an agent
20
+ // learns to stop reading them. The existing rule for pages already draws this line: a deliberate
21
+ // open counts, a grep hit does not. For files, the floor is a change.
22
+ //
16
23
  // ⚠ ATTENTION DATA, NOT A CLEARANCE STAMP. capture.mjs uses the same paths to stamp REPOS onto records
17
24
  // behind a MIN_FILES_FOR_STAMP security threshold, because a stamp grants other people read access.
18
25
  // Nothing here grants anything: these identifiers only make this person's OWN sessions candidates for
@@ -47,6 +54,100 @@ export function pathsInLine(line) {
47
54
  return out
48
55
  }
49
56
 
57
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
58
+ const ABS_PATH = /\/(?:Users|home|opt|srv|var|private|tmp)\/[^\s"'`;|&)<>]+/g
59
+ // A command that writes through a script: every absolute path it names is a candidate target.
60
+ // Over-inclusive for a script that reads one file and writes another, which is the safe side here —
61
+ // the alternative is missing the edit, and the edit is the whole signal.
62
+ const SCRIPTED_WRITE = /open\([^)]*['"][wa]b?\+?['"]|writeFileSync|writeFile\(|write_text\(|Bun\.write\(|sed\s+(?:-\S+\s+)*-i\b|\bperl\s+-\S*i/
63
+ const REDIRECT_TARGET = /(?:^|[^<0-9&>])>>?\s*(['"]?)(\/[^\s'"`;|&)<>]+)\1/g
64
+ const TEE_TARGET = /\btee\s+(?:-a\s+)?(['"]?)(\/[^\s'"`;|&)<>]+)\1/g
65
+ const COPY_MOVE = /\b(?:cp|mv|install)\b[^;|&\n]*/g
66
+
67
+ // `W=/private/tmp/wt; cat > "$W/web/app.ts"` names its target only through the variable, and that is
68
+ // how a great deal of real editing is written. Resolve simple absolute assignments in the same command.
69
+ function expandVarsFrom(shell, text) {
70
+ const vars = {}
71
+ for (const m of shell.matchAll(/(?:^|[\s;&(])([A-Za-z_][A-Za-z0-9_]*)=(['"]?)(\/[^\s'"`;|&)<>]*)\2/g)) vars[m[1]] = m[3]
72
+ return text.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (all, name) => (name in vars ? vars[name] : all))
73
+ }
74
+
75
+ // Split a Bash command into the shell text and its heredoc bodies, remembering which command line fed
76
+ // each body. The distinction is the difference between content and code: the body of
77
+ // `cat > f <<EOF` is the file being written, and any path-looking text in it (a code comment giving an
78
+ // example command, say) is not a write; the body of `python3 - <<PY` IS the script doing the writing.
79
+ // Measured: scanning bodies indiscriminately reported `web/app.ts` — an example inside a comment.
80
+ const INTERPRETER = /\b(?:python3?|node|bun|bash|sh|zsh|ruby|perl)\b/
81
+ const SHELL_INTERPRETER = /\b(?:bash|sh|zsh)\b/
82
+ function splitHeredocs(cmd) {
83
+ const lines = cmd.split('\n')
84
+ const shell = []
85
+ const bodies = []
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const line = lines[i]
88
+ shell.push(line)
89
+ const m = line.match(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/)
90
+ if (!m) continue
91
+ const end = lines.findIndex((l, j) => j > i && l.trim() === m[2])
92
+ const stop = end < 0 ? lines.length : end
93
+ bodies.push({ owner: line, body: lines.slice(i + 1, stop).join('\n'), quoted: Boolean(m[1]) })
94
+ if (end >= 0) shell.push(lines[end])
95
+ i = stop
96
+ }
97
+ return { shell: shell.join('\n'), bodies }
98
+ }
99
+
100
+ function writesIn(text, out) {
101
+ if (SCRIPTED_WRITE.test(text)) out.push(...(text.match(ABS_PATH) ?? []))
102
+ for (const m of text.matchAll(REDIRECT_TARGET)) out.push(m[2])
103
+ for (const m of text.matchAll(TEE_TARGET)) out.push(m[2])
104
+ for (const seg of text.match(COPY_MOVE) ?? []) {
105
+ const paths = seg.match(ABS_PATH) ?? []
106
+ if (paths.length >= 2) out.push(paths[paths.length - 1])
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Paths one transcript line CHANGED. Exported for the tests.
112
+ *
113
+ * The edit tools name their target exactly. Bash is heuristic by necessity: a redirect or `tee`
114
+ * target, every path in a `sed -i` or a scripted write, and the destination of `cp`/`mv`/`install`.
115
+ * A plain `cat`, `grep` or `sed -n` of a path is a read and is not counted.
116
+ */
117
+ export function editedPathsInLine(line) {
118
+ const out = []
119
+ let d
120
+ try { d = JSON.parse(line) } catch { return out }
121
+ for (const b of d?.message?.content ?? []) {
122
+ if (b?.type !== 'tool_use') continue
123
+ const inp = b.input ?? {}
124
+ if (EDIT_TOOLS.has(b.name)) {
125
+ for (const k of ['file_path', 'notebook_path']) {
126
+ if (typeof inp[k] === 'string' && inp[k].startsWith('/')) out.push(inp[k])
127
+ }
128
+ continue
129
+ }
130
+ if (typeof inp.command !== 'string') continue
131
+ const { shell, bodies } = splitHeredocs(inp.command)
132
+ // Variables are resolved from the SHELL text only: an assignment inside a file's content is not
133
+ // an assignment the shell ever made.
134
+ const expand = (t) => expandVarsFrom(shell, t)
135
+ writesIn(expand(shell), out)
136
+ for (const { owner, body, quoted } of bodies) {
137
+ // Shell semantics, exactly: a QUOTED delimiter (<<'PY') hands the body over literally, so a `$W`
138
+ // inside it is text, not a path. Expanding it anyway turned an example in a code comment into a
139
+ // "changed" file that does not exist.
140
+ const text = quoted ? body : expand(body)
141
+ if (SHELL_INTERPRETER.test(owner)) writesIn(text, out) // a shell script: full rules
142
+ else if (INTERPRETER.test(owner)) { // python/node/…: its writes
143
+ if (SCRIPTED_WRITE.test(text)) out.push(...(text.match(ABS_PATH) ?? [])) // shell syntax is data
144
+ }
145
+ // else: content being written by cat/tee — its target is already on the owner line
146
+ }
147
+ }
148
+ return [...new Set(out)]
149
+ }
150
+
50
151
  /**
51
152
  * Find this session's transcript by its id alone. Scanning for `<id>.jsonl` avoids depending on how
52
153
  * Claude Code slugs the launch directory into a folder name — the id is unique, the slug rule is not
@@ -114,11 +215,15 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
114
215
  const lines = nl >= 0 ? text.slice(0, nl).split('\n') : []
115
216
 
116
217
  const touched = []
117
- for (const line of lines) if (line) touched.push(...pathsInLine(line))
218
+ for (const line of lines) if (line) touched.push(...editedPathsInLine(line))
118
219
 
119
220
  let probes = 0
120
221
  for (const p of touched) {
121
222
  if (p.includes('/.git/') || p.endsWith('/')) continue
223
+ // A directory is never an edit, and one without a trailing slash would pass the server's shape
224
+ // check and re-introduce exactly the breadth file-level holds exist to remove. Measured: `cd` into
225
+ // `web` and `packages/cortex-mcp` in a writing command reported both as changed "files".
226
+ try { if (statSync(p).isDirectory()) continue } catch { /* gone or never existed: judged below */ }
122
227
  const dir = dirname(p)
123
228
  if (!repoOfDir.has(dir)) {
124
229
  if (probes >= MAX_DIR_PROBES_PER_POLL) continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.181",
3
+ "version": "0.9.182",
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": {