@theronap/cortex-mcp 0.9.163 → 0.9.164

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.163",
3
+ "version": "0.9.164",
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": {