@theronap/agnoclast-mcp 0.9.163 → 0.9.165

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.
@@ -85,7 +85,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
85
85
  ` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
86
86
  ` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
87
87
  ` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
88
- ` arrivals-notice PostToolUse hook: print incoming-record offers so the PERSON sees them too\n` +
88
+ ` arrivals-notice hook pair: detect incoming-record offers (PostToolUse) and surface them (--emit, UserPromptSubmit)\n` +
89
89
  ` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
90
90
  ` migrate-key [--dry-run] move the MCP config key to its new name, allow rules first\n` +
91
91
  ` with-token -- <cmd> run <cmd> with your token in its environment (for cron/launchd wrappers)\n` +
@@ -198,8 +198,12 @@ if (cmd === 'login') {
198
198
  // RESULTS, which only the agent reads — and an audit on 2026-09-21 found the agent acting on it
199
199
  // once in a day. Pure text extraction from the payload it is handed: no network, no token, always
200
200
  // exit 0. It reports; it never blocks a tool call.
201
- const { runArrivalsNotice } = await import('../lib/arrivals_notice.mjs')
202
- process.exitCode = await runArrivalsNotice()
201
+ // Two halves, one subcommand. `--emit` is the UserPromptSubmit side (drain + print); without it
202
+ // this is the PostToolUse side (detect + spool, prints nothing). Neither can do the job alone:
203
+ // PostToolUse sees tool output but its stdout is not injected; UserPromptSubmit is injected but
204
+ // never sees tool output. See lib/arrivals_notice.mjs's header.
205
+ const mod = await import('../lib/arrivals_notice.mjs')
206
+ process.exitCode = rest.includes('--emit') ? await mod.runArrivalsDrain() : await mod.runArrivalsNotice()
203
207
  } else if (cmd === 'hydrate') {
204
208
  // UserPromptSubmit hook (① discovery): hydrate the model with query-centered Agnoclast context on the
205
209
  // FIRST substantive turn, before it answers — then never again this session (topic-shift refresh stays
@@ -13,14 +13,29 @@
13
13
  // coverage. So this does not ask the agent to be more diligent; it removes the agent from the
14
14
  // delivery path.
15
15
  //
16
- // ⚠ IT IS NOT A GATE. Hook stdout is surfaced and exit is always 0: this reports, it never blocks a
17
- // tool call, and it never fails one. A notifier that can break the thing it observes gets disabled,
18
- // and then it observes nothing.
16
+ // ⚠ IT IS NOT A GATE. Exit is always 0: this reports, it never blocks a tool call, and it never
17
+ // fails one. A notifier that can break the thing it observes gets disabled, and then it observes
18
+ // nothing.
19
+ //
20
+ // 🔴 TWO EVENTS, BECAUSE NEITHER ONE CAN DO THIS ALONE. Measured 2026-09-21, after shipping a
21
+ // version that could not work:
22
+ //
23
+ // PostToolUse has the arrivals block — but its stdout is NOT injected into the conversation
24
+ // UserPromptSubmit IS injected (proven) — but never receives tool output at all
25
+ //
26
+ // The first version wired only PostToolUse and printed to stdout. It ran perfectly: correct parse,
27
+ // correct ids, state file written seconds after the block appeared. And NOBODY SAW ANY OF IT —
28
+ // which is precisely the failure this file exists to fix, reproduced by the fix. The assumption came
29
+ // from seeing SessionStart and UserPromptSubmit output every turn and generalising to all hooks;
30
+ // those two are context-INJECTING events by design.
31
+ //
32
+ // So: PostToolUse DETECTS and spools. UserPromptSubmit DRAINS and surfaces. Arrivals appear at the
33
+ // top of the person's next turn, which is when they are actually looking at the screen.
19
34
  //
20
35
  // ⚠ NO NETWORK, NO TOKEN, NO DECRYPT. Everything printed is already in the payload the hook was
21
36
  // handed. That keeps it fast enough to run after every tool call and means it cannot leak anything
22
37
  // the agent was not already shown.
23
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
38
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
24
39
  import { homedir } from 'node:os'
25
40
  import { join } from 'node:path'
26
41
 
@@ -108,6 +123,20 @@ export function formatNotice(arrivals) {
108
123
  return lines.join('\n')
109
124
  }
110
125
 
126
+ /** The handoff between the two hook events. One file per session, drained on the next prompt. */
127
+ function spoolPath(sessionKey) {
128
+ const dir = join(homedir(), '.cortex', 'arrivals-spool')
129
+ try { mkdirSync(dir, { recursive: true }) } catch { /* best effort */ }
130
+ return join(dir, `${(sessionKey || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
131
+ }
132
+
133
+ /**
134
+ * PostToolUse: detect, de-duplicate, SPOOL. Prints nothing — its stdout goes nowhere a person reads.
135
+ *
136
+ * ⚠ The spool ACCUMULATES across tool calls rather than overwriting. Several tool calls commonly
137
+ * land between two prompts, and only the first carries a given arrival; overwriting would mean the
138
+ * last tool call before the prompt decides what the person sees, which is usually nothing.
139
+ */
111
140
  export async function runArrivalsNotice() {
112
141
  try {
113
142
  const raw = readStdin()
@@ -127,7 +156,21 @@ export async function runArrivalsNotice() {
127
156
  // change, so it is reported whenever present.
128
157
  if (fresh.length === 0 && !arrivals.delta) return 0
129
158
 
130
- process.stdout.write(formatNotice({ delta: arrivals.delta, items: fresh }) + '\n')
159
+ // Merge into whatever is already spooled for this session.
160
+ const sp = spoolPath(sessionKey)
161
+ let pending = { delta: 0, items: [] }
162
+ try { pending = JSON.parse(readFileSync(sp, 'utf8')) } catch { /* first one this turn */ }
163
+ const byId = new Map((pending.items ?? []).map((it) => [it.id, it]))
164
+ for (const it of fresh) byId.set(it.id, it)
165
+ try {
166
+ writeFileSync(sp, JSON.stringify({
167
+ // The server watermarks the delta, so the freshest sighting is the right one — not a sum,
168
+ // which would double-count the same pile across two tool calls in one turn.
169
+ delta: arrivals.delta || pending.delta || 0,
170
+ items: [...byId.values()].slice(-40),
171
+ }))
172
+ } catch { /* best effort */ }
173
+
131
174
  for (const it of fresh) seen.add(it.id)
132
175
  saveSeen(p, seen)
133
176
  return 0
@@ -136,3 +179,30 @@ export async function runArrivalsNotice() {
136
179
  return 0
137
180
  }
138
181
  }
182
+
183
+ /**
184
+ * UserPromptSubmit: DRAIN the spool and print. This is the half the person actually sees.
185
+ *
186
+ * ⚠ CLEAR THE SPOOL BEFORE PRINTING FAILS, not after. A crash between print and clear would repeat
187
+ * the same arrivals on every subsequent prompt forever, and a notifier that nags is one that gets
188
+ * removed. Losing one notice is recoverable — the record is still unclaimed and will be offered
189
+ * again by the server.
190
+ */
191
+ export async function runArrivalsDrain() {
192
+ try {
193
+ const raw = readStdin()
194
+ let sessionKey = ''
195
+ try { sessionKey = String(JSON.parse(raw)?.session_id ?? '') } catch { /* fine */ }
196
+
197
+ const sp = spoolPath(sessionKey)
198
+ let pending = null
199
+ try { pending = JSON.parse(readFileSync(sp, 'utf8')) } catch { return 0 }
200
+ try { unlinkSync(sp) } catch { /* best effort */ }
201
+
202
+ if (!pending || (!pending.delta && !(pending.items ?? []).length)) return 0
203
+ process.stdout.write(formatNotice({ delta: pending.delta ?? 0, items: pending.items ?? [] }) + '\n')
204
+ return 0
205
+ } catch {
206
+ return 0
207
+ }
208
+ }
@@ -162,6 +162,24 @@ export function mergeClaudeSettings(existing, spec) {
162
162
  hgrp.hooks = hgrp.hooks ?? []
163
163
  hgrp.hooks.push({ type: 'command', command: hydrateCmd })
164
164
 
165
+ // UserPromptSubmit — arrivals-notice --emit, THE HALF THE PERSON ACTUALLY SEES.
166
+ //
167
+ // 🔴 THE POSTTOOLUSE HOOK ALONE COULD NOT WORK, and it shipped that way in 0.9.162/0.9.163.
168
+ // Measured 2026-09-21: it ran correctly — right parse, right ids, state file written seconds after
169
+ // the block appeared — and its output reached nobody, because PostToolUse stdout is NOT injected
170
+ // into the conversation. The assumption came from seeing SessionStart and UserPromptSubmit output
171
+ // every turn; those are context-INJECTING events by design and PostToolUse is not one.
172
+ //
173
+ // ⚠ So do not "move" it here — UserPromptSubmit never receives tool output, which is where the
174
+ // arrivals block lives. The two events hold complementary halves: PostToolUse DETECTS and spools
175
+ // to ~/.cortex/arrivals-spool, this one DRAINS and prints. Removing either leaves a hook that runs
176
+ // and reports nothing, which is the exact failure the feature exists to fix.
177
+ const arrivalsEmitCmd = markCommand(`npx -y @theronap/agnoclast-mcp@^${ARRIVALS_MIN_VERSION} arrivals-notice --emit`)
178
+ for (const ug of s.hooks.UserPromptSubmit ?? []) {
179
+ if (Array.isArray(ug.hooks)) ug.hooks = ug.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
180
+ }
181
+ hgrp.hooks.push({ type: 'command', command: arrivalsEmitCmd })
182
+
165
183
  // PreCompact — REMOVED, and this block is the migration that unwires existing seats.
166
184
  //
167
185
  // The "author now" reminder wrote to stdout on the assumption the harness surfaced it as context for
package/lib/server.mjs CHANGED
@@ -285,6 +285,43 @@ function renderArrival(payload) {
285
285
  ` — \`staged_records\` lists them. Not offered to you, just counted; \`ignore_arrivals\` if this is noise right now.`
286
286
  }
287
287
 
288
+ function renderSettling(payload) {
289
+ const feed = payload?.settling
290
+ const offers = Array.isArray(feed?.offers) ? feed.offers : []
291
+ if (!offers.length) return ''
292
+ const total = Number(feed?.total ?? offers.length)
293
+
294
+ const lines = offers.map((x) => {
295
+ const why = x.via?.length ? ` — matches ${x.via.map((v) => `\`${v}\``).join(', ')}` : ''
296
+ // Looks without claims is evidence about the UNIT, not about the sessions that walked away.
297
+ const looked = x.looks > 0 ? ` [looked at ${x.looks}x, still unclaimed]` : ''
298
+ return ` - ${x.source} · ${x.hoursAgo}h ago · ${x.intakeItemId}${why}${looked}`
299
+ })
300
+
301
+ // ⚠ SAY THE TRUE TOTAL, NOT THE PAGE SIZE. Measured 2026-09-22: one identifier matched 113 settling
302
+ // units against a cap of 12. A block that says "here are 12" when there are 113 is claiming
303
+ // certainty it does not have — and renders identically every turn, which teaches the reader that
304
+ // it never changes and can be skipped.
305
+ const more = total > offers.length
306
+ ? `\n— Showing ${offers.length} of ${total}. \`intake_changes\` lists the rest; this is a sample, not the queue.`
307
+ : ''
308
+
309
+ // ⚠ CONTENTION IS STATED, exactly as the record nudge states `k`. Measured the same day: THREE live
310
+ // sessions each matched the SAME 113 units on one identifier. Silent about that, each would claim
311
+ // the same units and learn about the race from a failed lease.
312
+ const maxHolders = offers.reduce((m, x) => Math.max(m, Number(x.holders ?? 1)), 1)
313
+ const who = maxHolders > 1
314
+ ? `\n— ⚠ ${maxHolders} live sessions of yours hold a matching identifier, so these are offered to all of them. \`intake_claim\` takes a lease — claim before you work, not after, or two sessions judge the same arrival.`
315
+ : `\n— No other live session of yours holds a matching identifier, so nobody else is looking at these.`
316
+
317
+ return `\n\n⚡ ARRIVALS SETTLING — yours alone, and no one else can see these yet:\n${lines.join('\n')}\n` +
318
+ `— These are unjudged ARRIVALS, not records. Since automatic materialization was deleted, nothing files them for you.\n` +
319
+ `— \`intake_look <id>\` reads the full body without claiming it — looking is free, non-exclusive and logged, so look rather than guess from the headline.\n` +
320
+ `— Then \`intake_claim\` and \`intake_materialize\` into the brain and pages it belongs to, or \`intake_discard\`, or \`intake_defer\` with a question when only the owner can decide.` +
321
+ more + who +
322
+ `\n— If none are yours, leave them: they stay settling for your other sessions, and become everyone's business once the window closes.`
323
+ }
324
+
288
325
  function renderNudge(payload) {
289
326
  const n = Array.isArray(payload?.nudge) ? payload.nudge : []
290
327
  if (!n.length) return ''
@@ -1389,7 +1426,7 @@ function renderNudge(payload) {
1389
1426
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
1390
1427
  }
1391
1428
  const payload = await res.json()
1392
- return { content: [{ type: 'text', text: formatGrepHits(payload, query) + renderArrival(payload) + renderNudge(payload) }] }
1429
+ return { content: [{ type: 'text', text: formatGrepHits(payload, query) + renderArrival(payload) + renderNudge(payload) + renderSettling(payload) }] }
1393
1430
  },
1394
1431
  )
1395
1432
 
@@ -1698,7 +1735,7 @@ function renderNudge(payload) {
1698
1735
  // `k` is stated rather than hidden. "Only you" and "you and two others" call for different
1699
1736
  // behaviour, and a nudge that claimed certainty it did not have is how an agent learns to stop
1700
1737
  // reading them.
1701
- const nudgeBlock = renderArrival(page) + renderNudge(page)
1738
+ const nudgeBlock = renderArrival(page) + renderNudge(page) + renderSettling(page)
1702
1739
 
1703
1740
  if (matches.length === 1) {
1704
1741
  return { content: [{ type: 'text', text: renderMatch(matches[0], false) + nudgeBlock }] }
@@ -3449,7 +3486,7 @@ function renderNudge(payload) {
3449
3486
  return toolError(`Could not list records: ${d.message}`)
3450
3487
  }
3451
3488
  const payload = await res.json()
3452
- return { content: [{ type: 'text', text: (payload?.text ?? '') + renderArrival(payload) + renderNudge(payload) }] }
3489
+ return { content: [{ type: 'text', text: (payload?.text ?? '') + renderArrival(payload) + renderNudge(payload) + renderSettling(payload) }] }
3453
3490
  },
3454
3491
  )
3455
3492
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.163",
3
+ "version": "0.9.165",
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": {