@theronap/agnoclast-mcp 0.9.176 → 0.9.178

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.
@@ -112,7 +112,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
112
112
  ` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
113
113
  ` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
114
114
  ` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
115
- ` arrivals-notice hook pair: detect incoming-record offers (PostToolUse) and surface them (--emit, UserPromptSubmit)\n` +
115
+ ` arrivals-notice --emit: surface incoming-record offers to you (UserPromptSubmit hook)\n` +
116
116
  ` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
117
117
  ` migrate-key [--dry-run] move the MCP config key to its new name, allow rules first\n` +
118
118
  ` with-token -- <cmd> run <cmd> with your token in its environment (for cron/launchd wrappers)\n` +
@@ -221,16 +221,28 @@ if (cmd === 'login') {
221
221
  const { closeFetch } = await import('../lib/diagnose.mjs')
222
222
  await closeFetch()
223
223
  } else if (cmd === 'arrivals-notice') {
224
- // PostToolUse hook: surface the arrivals offer to the PERSON. The ⚡ block is appended to TOOL
225
- // RESULTS, which only the agent reads — and an audit on 2026-09-21 found the agent acting on it
226
- // once in a day. Pure text extraction from the payload it is handed: no network, no token, always
227
- // exit 0. It reports; it never blocks a tool call.
228
- // Two halves, one subcommand. `--emit` is the UserPromptSubmit side (drain + print); without it
229
- // this is the PostToolUse side (detect + spool, prints nothing). Neither can do the job alone:
230
- // PostToolUse sees tool output but its stdout is not injected; UserPromptSubmit is injected but
231
- // never sees tool output. See lib/arrivals_notice.mjs's header.
232
- const mod = await import('../lib/arrivals_notice.mjs')
233
- process.exitCode = rest.includes('--emit') ? await mod.runArrivalsDrain() : await mod.runArrivalsNotice()
224
+ // UserPromptSubmit hook: surface the arrivals offer to the PERSON. The signals are appended to
225
+ // TOOL RESULTS, which only the agent reads — and an audit on 2026-09-21 found the agent acting on
226
+ // it once in a day. One cheap authenticated GET, fail-open, always exit 0. It reports; it never
227
+ // blocks a prompt.
228
+ // ONE half now. `--emit` (UserPromptSubmit) ASKS THE SERVER for the two turn-time signals and
229
+ // prints them. The PostToolUse scraper + spool it replaced could never surface anything before the
230
+ // next UserPromptSubmit anyway, because PostToolUse stdout is not injected — so this delivers at
231
+ // the same moment, from the source. See lib/arrivals_notice.mjs's header.
232
+ // ⚠ WITHOUT --emit THIS IS A DELIBERATE NO-OP, not a fallthrough. That was the PostToolUse half,
233
+ // retired 2026-09-25 along with the spool. A seat whose settings.json still has that hook must do
234
+ // NOTHING here: the live path CONSUMES a watermark, so letting a stale PostToolUse call reach it
235
+ // would announce arrivals into a stdout that is never injected and leave the next prompt — the one
236
+ // the person actually reads — correctly reporting nothing. Deleting a hook from one machine's
237
+ // settings does not delete it from anyone else's, so the old invocation has to stay harmless.
238
+ if (rest.includes('--emit')) {
239
+ const mod = await import('../lib/arrivals_notice.mjs')
240
+ process.exitCode = await mod.runArrivalsDrain()
241
+ const { closeFetch } = await import('../lib/diagnose.mjs')
242
+ await closeFetch()
243
+ } else {
244
+ process.exitCode = 0
245
+ }
234
246
  } else if (cmd === 'hydrate') {
235
247
  // UserPromptSubmit hook (① discovery): hydrate the model with query-centered Agnoclast context on the
236
248
  // FIRST substantive turn, before it answers — then never again this session (topic-shift refresh stays
@@ -1,11 +1,10 @@
1
- // PostToolUse hook — make the arrivals offer visible to the PERSON, not just the agent.
1
+ // UserPromptSubmit hook — make the arrivals offer visible to the PERSON, not just the agent.
2
2
  //
3
- // 🔴 WHY THIS EXISTS, and it is not a nicety. The `⚡ ARRIVED WHILE YOU WERE WORKING` block and the
4
- // per-turn delta line are appended to TOOL RESULTS, which only the agent reads. Measured on
5
- // 2026-09-21 against a single session's own record: the block fired repeatedly through the day and
6
- // the agent acted on it ONCE, and only because the user asked it to prove the mechanism existed. An
7
- // audit of the four records it named found one that plainly belonged to that session's own work and
8
- // had simply been let pass.
3
+ // 🔴 WHY THIS EXISTS, and it is not a nicety. The per-turn delta line and the settling feed are
4
+ // appended to TOOL RESULTS, which only the agent reads. Measured on 2026-09-21 against a single
5
+ // session's own record: the block fired repeatedly through the day and the agent acted on it ONCE,
6
+ // and only because the user asked it to prove the mechanism existed. An audit of the four records
7
+ // it named found one that plainly belonged to that session's own work and had simply been let pass.
9
8
  //
10
9
  // The failure is structural rather than a lapse: the offer arrives MID-TASK, inside a result the
11
10
  // agent requested for an unrelated reason, and nothing forces a decision. A signal that only the
@@ -13,196 +12,110 @@
13
12
  // coverage. So this does not ask the agent to be more diligent; it removes the agent from the
14
13
  // delivery path.
15
14
  //
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.
15
+ // ⚠ IT IS NOT A GATE. Exit is always 0. A notifier that can break the thing it observes gets
16
+ // disabled, and then it observes nothing.
19
17
  //
20
- // 🔴 TWO EVENTS, BECAUSE NEITHER ONE CAN DO THIS ALONE. Measured 2026-09-21, after shipping a
21
- // version that could not work:
18
+ // 🔴 IT ASKS THE SERVER. IT USED TO SCRAPE ITS OWN CONVERSATION. (Rewritten 2026-09-25.)
22
19
  //
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
20
+ // The previous design was a two-hook round trip: PostToolUse read the rendered English off tool
21
+ // results with two regexes, spooled the matches to ~/.cortex/arrivals-spool, and this half drained
22
+ // the spool on the next prompt. The server had computed both numbers and written that text itself —
23
+ // the scraper existed only to read them back out of its own output.
25
24
  //
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.
25
+ // It failed in both directions:
31
26
  //
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.
27
+ // MISSED — the signal rode on Agnoclast TOOL RESULTS, so a turn of bash and edits produced none,
28
+ // and nothing arrived no matter how much had actually arrived.
29
+ // INVENTED — any text containing the pattern was scraped as if it were a signal. This agent's own
30
+ // Bash output, while quoting the regex to debug it, was ingested as arrivals.
34
31
  //
35
- // ⚠ NO NETWORK, NO TOKEN, NO DECRYPT. Everything printed is already in the payload the hook was
36
- // handed. That keeps it fast enough to run after every tool call and means it cannot leak anything
37
- // the agent was not already shown.
38
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
39
- import { homedir } from 'node:os'
40
- import { join } from 'node:path'
41
-
42
- const MATCHED = '⚡ ARRIVED WHILE YOU WERE WORKING'
43
- const DELTA_RE = /⚡ (\d+) new arrivals? in your pile/
44
- // The listing lines look like: - Title (email · 5.1h ago · id <uuid>) — …
45
- const ITEM_RE = /^\s*-\s+(.*?)\s+\((\S+)\s+·\s+([^·]+?)\s+·\s+id\s+([0-9a-f-]{36})\)/gmu
46
-
47
- function readStdin() {
48
- try { return readFileSync(0, 'utf8') } catch { return '' }
49
- }
50
-
51
- /** Every string in the payload, so this does not depend on which key holds the tool's text. */
52
- function* strings(v, depth = 0) {
53
- if (depth > 8 || v == null) return
54
- if (typeof v === 'string') { yield v; return }
55
- if (Array.isArray(v)) { for (const x of v) yield* strings(x, depth + 1); return }
56
- if (typeof v === 'object') { for (const x of Object.values(v)) yield* strings(x, depth + 1) }
57
- }
32
+ // A parser whose input is the conversation it is printing into cannot distinguish the world from
33
+ // its own echo. So it now asks the one party that knows.
34
+ //
35
+ // ⚠ AND IT COSTS NOTHING IN TIMING, which is the part that looks like a regression and is not.
36
+ // PostToolUse stdout is NOT injected into the conversation — that is measured, and it is why the
37
+ // spool existed at all. The spool could never surface anything before the next UserPromptSubmit.
38
+ // Asking at that same moment delivers at the identical time, from the source, with no state on disk.
39
+ //
40
+ // ⚠ THE NETWORK CALL IS NEW, AND DELIBERATE. The old header promised NO NETWORK, NO TOKEN, because
41
+ // it ran after EVERY TOOL CALL. That event is deleted. This runs once per prompt — the same budget
42
+ // SessionStart already spends — and fails open to silence.
43
+ //
44
+ // ⚠ THE SERVER OWNS THE DEDUPE NOW. arrivalDelta advances session_arrival_watermarks, announcing an
45
+ // arrival to a session exactly once, which is what the local `seen` files were approximating with
46
+ // filesystem state that no other party could see. A consequence worth stating: fetching CONSUMES
47
+ // the announcement, so this prints whatever it gets. Losing one notice is recoverable — the record
48
+ // is still unclaimed and the server will offer it again.
49
+ import { fetchCortex, resolveBase, resolveTokenSource } from './diagnose.mjs'
58
50
 
59
51
  /**
60
- * Pull the arrivals offer out of a PostToolUse payload.
52
+ * Render both turn-time signals for a person skimming their terminal.
61
53
  *
62
- * Exported for tests: the parsing is the part that can silently rot when the server changes the
63
- * block's wording, and a hook that stops matching fails SILENTLY — it just prints nothing forever,
64
- * which is indistinguishable from "nothing arrived".
54
+ * Deliberately shallow: names, ages and ids, never page contents. The settling half is owner-scoped
55
+ * at the query (private_intake_items joined to the caller's own account), so there is nobody to leak
56
+ * to — but this is a hook that prints unprompted, and a notice that dumps content trains its reader
57
+ * to skip it. Exported for the tests.
65
58
  */
66
- export function extractArrivals(payloadText) {
67
- let blob = payloadText
68
- try {
69
- const parsed = JSON.parse(payloadText)
70
- blob = [...strings(parsed)].join('\n')
71
- } catch {
72
- // Not JSON — scan the raw text. Fail open toward reporting rather than staying silent.
73
- }
74
- if (!blob) return null
75
-
76
- const deltaMatch = blob.match(DELTA_RE)
77
- const delta = deltaMatch ? Number(deltaMatch[1]) : 0
59
+ export function formatNotice(signals) {
60
+ const lines = []
61
+ const offers = signals?.settling?.offers ?? []
62
+ const total = Number(signals?.settling?.total ?? offers.length)
63
+ const delta = Number(signals?.arrival?.count ?? 0)
78
64
 
79
- const items = []
80
- if (blob.includes(MATCHED)) {
81
- // Only the section AFTER the marker, so an unrelated bulleted list earlier in a page read is
82
- // not mistaken for arrivals.
83
- const section = blob.slice(blob.indexOf(MATCHED))
84
- for (const m of section.matchAll(ITEM_RE)) {
85
- items.push({ title: m[1].trim(), source: m[2], age: m[3].trim(), id: m[4] })
86
- }
65
+ if (delta) {
66
+ lines.push(`⚡ ${delta} new arrival${delta === 1 ? '' : 's'} in the pile since this session was last told.`)
87
67
  }
88
- if (!delta && items.length === 0) return null
89
- return { delta, items }
90
- }
91
-
92
- /** Ids already announced, so the same arrival is not re-reported on every tool call until claimed. */
93
- function seenPath(sessionKey) {
94
- const dir = join(homedir(), '.cortex', 'arrivals-announced')
95
- try { mkdirSync(dir, { recursive: true }) } catch { /* best effort */ }
96
- return join(dir, `${(sessionKey || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
97
- }
98
-
99
- function loadSeen(p) {
100
- try { return new Set(JSON.parse(readFileSync(p, 'utf8'))) } catch { return new Set() }
101
- }
102
-
103
- function saveSeen(p, set) {
104
- // Bounded: a long session must not grow this file without limit.
105
- try { writeFileSync(p, JSON.stringify([...set].slice(-500))) } catch { /* best effort */ }
106
- }
107
-
108
- export function formatNotice(arrivals) {
109
- const lines = []
110
- if (arrivals.items.length) {
111
- lines.push(`⚡ ${arrivals.items.length} record${arrivals.items.length === 1 ? '' : 's'} offered to this session:`)
112
- for (const it of arrivals.items.slice(0, 6)) {
113
- lines.push(` · ${it.title.slice(0, 92)} (${it.source} · ${it.age})`)
114
- lines.push(` ${it.id}`)
68
+ 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.
71
+ //
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
+ for (const o of group) lines.push(` ${o.intakeItemId} ${o.source} · ${o.hoursAgo}h`)
115
87
  }
116
- if (arrivals.items.length > 6) lines.push(` … and ${arrivals.items.length - 6} more`)
117
- }
118
- if (arrivals.delta) {
119
- lines.push(`⚡ ${arrivals.delta} new arrival${arrivals.delta === 1 ? '' : 's'} in the pile since this session was last told.`)
88
+ // Only when the pile genuinely exceeds the server's scan window; `total` is counted before it.
89
+ if (total > offers.length) lines.push(` … and ${total - offers.length} beyond the scan window`)
90
+ lines.push(' (Claim + route them, or say they are not yours — they stay unclaimed until someone decides.)')
120
91
  }
121
- // Said plainly, because the whole point is that the person can tell whether anything happened.
122
- if (arrivals.items.length) lines.push(` (Claim + route them, or say they are not yours — they stay unclaimed until someone decides.)`)
123
92
  return lines.join('\n')
124
93
  }
125
94
 
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
- */
140
- export async function runArrivalsNotice() {
141
- try {
142
- const raw = readStdin()
143
- if (!raw) return 0
144
-
145
- let sessionKey = ''
146
- try { sessionKey = String(JSON.parse(raw)?.session_id ?? '') } catch { /* fine */ }
147
-
148
- const arrivals = extractArrivals(raw)
149
- if (!arrivals) return 0
150
-
151
- const p = seenPath(sessionKey)
152
- const seen = loadSeen(p)
153
- const fresh = arrivals.items.filter((it) => !seen.has(it.id))
154
-
155
- // Nothing new to say. The delta line is server-side watermarked and already fires once per
156
- // change, so it is reported whenever present.
157
- if (fresh.length === 0 && !arrivals.delta) return 0
158
-
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
-
174
- for (const it of fresh) seen.add(it.id)
175
- saveSeen(p, seen)
176
- return 0
177
- } catch {
178
- // ⚠ ALWAYS 0. This observes; it must never be the reason a tool call failed.
179
- return 0
180
- }
181
- }
182
-
183
95
  /**
184
- * UserPromptSubmit: DRAIN the spool and print. This is the half the person actually sees.
96
+ * UserPromptSubmit: ask, then print. This is the half the person actually sees.
185
97
  *
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.
98
+ * The session key is not read here: bin/cortex-mcp.mjs stamps it for every CONTEXT_HOOK_COMMAND, so
99
+ * fetchCortex already carries x-cortex-session-key. That also means this no longer touches stdin —
100
+ * which matters, because `hydrate` runs on the SAME event and consumes stdin itself, and two hooks
101
+ * racing to drain one pipe is a bug waiting on a scheduler.
190
102
  */
191
103
  export async function runArrivalsDrain() {
192
104
  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')
105
+ const { token } = resolveTokenSource()
106
+ if (!token) return 0
107
+ const base = resolveBase(process.env.CORTEX_URL)
108
+ const res = await fetchCortex(`${base}/api/session-signals`, {
109
+ headers: { Authorization: `Bearer ${token}` },
110
+ })
111
+ if (!res?.ok) return 0
112
+ const signals = await res.json().catch(() => null)
113
+ if (!signals) return 0
114
+ const out = formatNotice(signals)
115
+ if (out) process.stdout.write(out + '\n')
204
116
  return 0
205
117
  } catch {
118
+ // Fail open and silent: a prompt must never be blocked or slowed by its own notifier.
206
119
  return 0
207
120
  }
208
121
  }
@@ -105,22 +105,33 @@ export function mergeClaudeSettings(existing, spec) {
105
105
  }
106
106
  sgrp.hooks.push({ type: 'command', command: snapshotCmd })
107
107
 
108
- // PostToolUse — arrivals-notice.
108
+ // PostToolUse — arrivals-notice: RETIRED 2026-09-25. This block now REMOVES it and installs nothing.
109
109
  //
110
- // 🔴 THE ARRIVALS OFFER WAS INVISIBLE TO THE PERSON. `⚡ ARRIVED WHILE YOU WERE WORKING` and the
111
- // per-turn delta are appended to TOOL RESULTS, which only the agent reads. Measured 2026-09-21
112
- // against one session's own record: the block fired repeatedly through the day, the agent acted on
113
- // it once, and an audit of the records it named found one belonging to that session's own work
114
- // that had simply been let pass. A signal only the agent sees, and reliably scrolls past, LOOKS
115
- // like coverage while providing none.
110
+ // It existed because the arrivals signals are appended to TOOL RESULTS, which only the agent reads,
111
+ // and an audit on 2026-09-21 found the agent acting on them once in a day. The hook scraped those
112
+ // rendered results with two regexes and spooled the matches for the next prompt to drain.
116
113
  //
117
- // ⚠ SO THIS IS WIRED AS SOFTWARE, NOT LEFT TO INSTRUCTION. Asking the agent to relay arrivals is
118
- // asking it to be reliable at exactly the thing it was just measured failing at; a hook takes the
119
- // agent out of the delivery path entirely.
114
+ // Deleted because the scraper could not tell the world from its own echo. It MISSED every turn that
115
+ // called no Agnoclast tool (the signal rides on tool results), and it INVENTED arrivals from any
116
+ // text containing the pattern — including an agent's own Bash output quoting the regex to debug it.
117
+ // The UserPromptSubmit half below now ASKS THE SERVER, which is the only party that knows.
120
118
  //
121
- // ⚠ Matcher is '' (every tool). The block only appears on three surfaces, so the filtering is
122
- // server-side and this stays a cheap no-op elsewhere: it reads its stdin, finds no marker, exits 0.
119
+ // ⚠ AND IT COST NOTHING TO DELETE. PostToolUse stdout is not injected into the conversation — the
120
+ // measured fact that forced the spool to exist — so this hook could never surface anything before
121
+ // the next UserPromptSubmit. Asking there delivers at the identical moment.
122
+ //
123
+ // ⚠ THE FILTER STAYS, AND IS NOW THE POINT. Seats wired before today still have this hook. Left in
124
+ // place it would call a subcommand that is a deliberate no-op — harmless only because bin makes it
125
+ // one. Re-running setup RETIRES it, which is the only reach this package has into an existing
126
+ // settings.json.
123
127
  s.hooks.PostToolUse = Array.isArray(s.hooks.PostToolUse) ? s.hooks.PostToolUse : []
128
+ for (const pg of s.hooks.PostToolUse) {
129
+ if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
130
+ }
131
+ // Drop any group this emptied, so retirement does not leave a bare {matcher:'',hooks:[]} behind.
132
+ s.hooks.PostToolUse = s.hooks.PostToolUse.filter((g) => (g.hooks ?? []).length)
133
+ if (!s.hooks.PostToolUse.length) delete s.hooks.PostToolUse
134
+
124
135
  // 🔴 THIS HOOK DOES NOT FOLLOW `spec`, AND THE REASON IS A DEFECT THIS FILE SHIPPED ONCE.
125
136
  //
126
137
  // `spec` pins @stable, which is what desktop-wired seats deliberately follow. But a hook wired at
@@ -134,19 +145,15 @@ export function mergeClaudeSettings(existing, spec) {
134
145
  // WIRED AT A SPEC THAT CANNOT EXECUTE IT. A version floor is the narrowest way to hold that, and
135
146
  // npx resolves a semver range (verified 2026-09-21 against a real invocation, not assumed).
136
147
  //
148
+ // ⚠ FLOOR RAISED TO 0.9.177 ON 2026-09-25, and it is load-bearing rather than tidy. Below it,
149
+ // `--emit` drains a spool that nothing writes any more: it would exit 0, print nothing, and look
150
+ // exactly like a quiet queue. The floor is what makes that impossible instead of silent.
151
+ //
137
152
  // ⚠ And it uses the CANONICAL name. Every other command here carries `cortex-mcp` for
138
153
  // compatibility with frozen launchd/cron scripts on unreachable machines (ADR-0033) — but a
139
154
  // BRAND-NEW hook has no frozen scripts to be compatible with, so there is no reason to mint more
140
155
  // references to the retiring name. New wiring uses the name the product actually has.
141
- const ARRIVALS_MIN_VERSION = '0.9.162'
142
- const arrivalsCmd = markCommand(`npx -y @theronap/agnoclast-mcp@^${ARRIVALS_MIN_VERSION} arrivals-notice`)
143
- for (const pg of s.hooks.PostToolUse) {
144
- if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
145
- }
146
- let pgrp = s.hooks.PostToolUse.find((g) => (g.matcher ?? '') === '')
147
- if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PostToolUse.push(pgrp) }
148
- pgrp.hooks = pgrp.hooks ?? []
149
- pgrp.hooks.push({ type: 'command', command: arrivalsCmd })
156
+ const ARRIVALS_MIN_VERSION = '0.9.177'
150
157
 
151
158
  // UserPromptSubmit — hydrate (① discovery). Makes the FIRST context load of a session
152
159
  // non-discretionary: without it, hydration is a skill the agent may simply forget, and an agent
package/lib/server.mjs CHANGED
@@ -1428,6 +1428,197 @@ function renderNudge(payload) {
1428
1428
  },
1429
1429
  )
1430
1430
 
1431
+ // ── GMAIL WRITES — V5 / W5 of the Google write-scopes plan ───────────────────────────────────────
1432
+ //
1433
+ // Three tools over the W3 engine and its routes: change labels, save a reply draft, ask to send.
1434
+ // Modelled on search_spam_email and send_imessage, as W5 specifies, and they inherit that pair's two
1435
+ // habits — the 409 "which mailbox?" answer, and saying plainly what was NOT done.
1436
+ //
1437
+ // ⚠ THE OWNER CHECK IS THE SERVER'S, NOT OURS (review D14). Every route resolves the mailbox through
1438
+ // loadOwnerGmailConnectors + canAccessMailbox, so being able to READ an email record in a shared brain
1439
+ // never authorizes a write to it. These tools add no authorization of their own and must not imply any.
1440
+ //
1441
+ // ⚠ DARK UNTIL GOOGLE APPROVES THE WRITE SCOPES. In production GOOGLE_WRITE_SCOPES_LIVE is unset and
1442
+ // every Gmail connector is read-only, so these return the auth/upgrade path. That is correct behaviour,
1443
+ // not something to route around — they ship now so they are ready the day the scopes land.
1444
+ //
1445
+ // ⚠ NEVER SAY "NOTHING CHANGED" ON A FAILURE. modifyMessages chunks by BATCH_LIMIT and loops, and a
1446
+ // throw in a later chunk discards the results already collected — so a 5xx can follow real changes. V4
1447
+ // fixed exactly this lie in the web UI; the wording below is what keeps it fixed here.
1448
+
1449
+ /** ONE place a Gmail write failure becomes agent-readable text, so three tools cannot drift on it. */
1450
+ const gmailWriteError = (res, body, tool) => {
1451
+ const msg = typeof body?.error === 'string' ? body.error : null
1452
+ if (res.status === 409 && Array.isArray(body?.accounts) && body.accounts.length) {
1453
+ return toolError(`More than one connected Gmail account. Call ${tool} again with account: ${body.accounts.join(', ')}`)
1454
+ }
1455
+ if (res.status === 401 || res.status === 403) {
1456
+ return toolError(
1457
+ `${msg ?? 'Gmail refused that write.'} Gmail write access may not be granted on this account yet — ` +
1458
+ `the person can check Connectors in Agnoclast. Nothing was changed.`)
1459
+ }
1460
+ if (res.status >= 500) {
1461
+ return toolError(
1462
+ `${msg ?? `Agnoclast could not finish that (${res.status}).`} ⚠ Part of it MAY already have happened. ` +
1463
+ `Do not call ${tool} again — ask the person to check their mailbox first.`)
1464
+ }
1465
+ return toolError(msg ?? `${tool} failed (${res.status}).`)
1466
+ }
1467
+
1468
+ server.registerTool(
1469
+ 'gmail_modify',
1470
+ {
1471
+ title: 'Change labels on the user\'s own Gmail messages',
1472
+ description:
1473
+ 'Mark read or unread, archive, move back to the inbox, or add/remove a label on messages in the USER\'S OWN connected Gmail. Only the mailbox owner can act on a message — being able to read an email record in a shared brain does NOT authorize changing it, and the server enforces that. Identify messages with recordIds (Agnoclast email record ids) or messageIds (Gmail ids); they must all be in ONE mailbox. For add_label/remove_label pass labelId — one of the user\'s own labels (Label_…), or STARRED or IMPORTANT; call without labelId to get the list. THIS CHANGES THE USER\'S MAILBOX: confirm what they want before calling unless they just asked for it plainly. move_to_inbox is the undo for archive.',
1474
+ inputSchema: {
1475
+ action: z.enum(['mark_read', 'mark_unread', 'archive', 'move_to_inbox', 'add_label', 'remove_label'])
1476
+ .describe('what to do to every message named; move_to_inbox undoes archive'),
1477
+ recordIds: z.array(z.string()).optional().describe('Agnoclast email record ids, all in the same mailbox'),
1478
+ messageIds: z.array(z.string()).optional().describe('Gmail message ids, all in the same mailbox — an alternative to recordIds'),
1479
+ labelId: z.string().optional().describe('required for add_label / remove_label: one of the user\'s own labels (Label_…), or STARRED or IMPORTANT. Omit to be shown the available labels.'),
1480
+ account: z.string().email().optional().describe('which connected Gmail account, when the user has more than one'),
1481
+ },
1482
+ },
1483
+ async ({ action, recordIds, messageIds, labelId, account }) => {
1484
+ if (!recordIds?.length && !messageIds?.length) {
1485
+ return toolError('Name the messages to change: recordIds (Agnoclast email records) or messageIds (Gmail ids).')
1486
+ }
1487
+ // A label action with no id is not an error worth bouncing — answer it with the list, so the agent
1488
+ // can act on the reply instead of guessing an id or asking the person for one.
1489
+ if ((action === 'add_label' || action === 'remove_label') && !labelId) {
1490
+ const lr = await fetchCortex(`${BASE}/api/gmail/labels${account ? `?account=${encodeURIComponent(account)}` : ''}`,
1491
+ { headers: { Authorization: `Bearer ${TOKEN}` } })
1492
+ const lb = await lr.json().catch(() => null)
1493
+ if (!lr.ok) return gmailWriteError(lr, lb, 'gmail_modify')
1494
+ const labels = Array.isArray(lb?.labels) ? lb.labels : []
1495
+ if (!labels.length) return toolError(`${lb?.account ?? 'That mailbox'} has no labels you can add or remove. Nothing was changed.`)
1496
+ return toolError(
1497
+ `labelId is required for ${action}. Labels in ${lb.account}:\n` +
1498
+ labels.map((l) => `- ${l.name} → ${l.id}${l.type === 'system' ? ' (system)' : ''}`).join('\n') +
1499
+ `\nCall gmail_modify again with one of those ids. Nothing was changed.`)
1500
+ }
1501
+ const res = await fetchCortex(`${BASE}/api/gmail/messages/modify`, {
1502
+ method: 'POST',
1503
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1504
+ body: JSON.stringify({
1505
+ action,
1506
+ ...(labelId ? { labelId } : {}),
1507
+ ...(account ? { account } : {}),
1508
+ ...(recordIds?.length ? { recordIds } : { messageIds }),
1509
+ }),
1510
+ })
1511
+ const body = await res.json().catch(() => null)
1512
+ if (!res.ok) return gmailWriteError(res, body, 'gmail_modify')
1513
+ // Per-message results, because a batch can partly fail and the person needs to know which.
1514
+ const results = Array.isArray(body?.results) ? body.results : []
1515
+ const failed = results.filter((r) => r && r.ok === false)
1516
+ const changed = results.length - failed.length
1517
+ const lines = [`${action} in ${body.account}: ${changed} of ${results.length} message${results.length === 1 ? '' : 's'} changed.`]
1518
+ if (failed.length) {
1519
+ lines.push(`⚠ ${failed.length} did NOT change:`)
1520
+ for (const f of failed.slice(0, 10)) lines.push(`- ${f.id}: ${f.error ?? 'unknown error'}`)
1521
+ if (failed.length > 10) lines.push(`- …and ${failed.length - 10} more`)
1522
+ }
1523
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
1524
+ },
1525
+ )
1526
+
1527
+ server.registerTool(
1528
+ 'draft_email_reply',
1529
+ {
1530
+ title: 'Save a reply as a draft in the user\'s Gmail',
1531
+ description:
1532
+ 'Write a reply to one of the USER\'S OWN emails and save it as a DRAFT in their Gmail, threaded under the original. NOTHING IS SENT — the user reviews and sends it themselves from Gmail, so this is the safe way to help with a reply. Identify the email by recordId (an Agnoclast email record) or messageId (a Gmail id). Only the mailbox owner can draft against a message; the server enforces that. Use request_send_email instead when the user wants it actually sent.',
1533
+ inputSchema: {
1534
+ recordId: z.string().optional().describe('the Agnoclast email record to reply to'),
1535
+ messageId: z.string().optional().describe('the Gmail message id to reply to — an alternative to recordId'),
1536
+ text: z.string().min(1).describe('the reply body, as plain text'),
1537
+ account: z.string().email().optional().describe('which connected Gmail account, when the user has more than one'),
1538
+ },
1539
+ },
1540
+ async ({ recordId, messageId, text, account }) => {
1541
+ if (!recordId && !messageId) return toolError('Name the email to reply to: recordId or messageId.')
1542
+ const res = await fetchCortex(`${BASE}/api/gmail/drafts`, {
1543
+ method: 'POST',
1544
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1545
+ body: JSON.stringify({ ...(recordId ? { recordId } : { messageId }), text, ...(account ? { account } : {}) }),
1546
+ })
1547
+ const body = await res.json().catch(() => null)
1548
+ if (!res.ok) return gmailWriteError(res, body, 'draft_email_reply')
1549
+ return { content: [{ type: 'text', text:
1550
+ `Draft saved in ${body.account} — NOT sent. The user reviews and sends it from Gmail: ${body.openUrl}` }] }
1551
+ },
1552
+ )
1553
+
1554
+ // request_send_email — the one tool here that can put mail in front of a person's contacts.
1555
+ //
1556
+ // ⚠ D5: THIS POST IS NOT IDEMPOTENT AND MUST NEVER BE RETRIED. `idempotent: false` is passed
1557
+ // EXPLICITLY rather than relying on fetchCortex's GET/HEAD-only default, so that widening the default
1558
+ // later cannot silently make sending an email retriable. A 5xx here may mean the mail went out.
1559
+ //
1560
+ // ⚠ THE reviewUrl CONTRACT (the V5 hand-off documented in send-requests/route.ts). The route OMITS the
1561
+ // key when there is nothing to approve, so PRESENCE is the signal and `in` must not be used. Present →
1562
+ // pending, and the sentence below is relayed verbatim. ABSENT → the send rule allowed it and the mail
1563
+ // has ALREADY GONE; never invent a link or claim an approval is pending in that case.
1564
+ server.registerTool(
1565
+ 'request_send_email',
1566
+ {
1567
+ title: 'Ask to send an email from the user\'s Gmail',
1568
+ description:
1569
+ 'Send an email from the USER\'S OWN connected Gmail. By default this does NOT send immediately: it creates a request that WAITS for the user to approve it in Agnoclast, and it goes out at once only if their own send rule allows it. The message is frozen when the request is made, so write the final wording. Always show the user what you are about to send and get their agreement first. Returns either an approval link to relay to them, or confirmation that it already went. ⚠ NEVER call this twice for the same email — it is not idempotent, and a retry can send twice.',
1570
+ inputSchema: {
1571
+ to: z.array(z.string()).min(1).describe('recipient email addresses'),
1572
+ subject: z.string().min(1).describe('the subject line'),
1573
+ text: z.string().min(1).describe('the message body, as plain text'),
1574
+ cc: z.array(z.string()).optional().describe('cc addresses'),
1575
+ bcc: z.array(z.string()).optional().describe('bcc addresses'),
1576
+ replyToRecordId: z.string().optional().describe('reply in the thread of this Agnoclast email record'),
1577
+ replyToMessageId: z.string().optional().describe('reply in the thread of this Gmail message id'),
1578
+ account: z.string().email().optional().describe('which connected Gmail account to send from, when the user has more than one'),
1579
+ },
1580
+ },
1581
+ async ({ to, subject, text, cc, bcc, replyToRecordId, replyToMessageId, account }) => {
1582
+ let res
1583
+ try {
1584
+ res = await fetchCortex(`${BASE}/api/gmail/send-requests`, {
1585
+ method: 'POST',
1586
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1587
+ body: JSON.stringify({
1588
+ to, subject, text,
1589
+ ...(cc?.length ? { cc } : {}), ...(bcc?.length ? { bcc } : {}),
1590
+ ...(replyToRecordId ? { replyToRecordId } : {}),
1591
+ ...(replyToMessageId ? { replyToMessageId } : {}),
1592
+ ...(account ? { account } : {}),
1593
+ }),
1594
+ }, { idempotent: false, retries: 0 })
1595
+ } catch (e) {
1596
+ // No response at all: the request may still have reached the server. Say so and stop.
1597
+ return toolError(
1598
+ `Could not confirm the send request: ${e.message} ⚠ It may have been created anyway. ` +
1599
+ `Do NOT call request_send_email again — ask the user to check Email in Agnoclast.`)
1600
+ }
1601
+ const body = await res.json().catch(() => null)
1602
+ if (!res.ok) return gmailWriteError(res, body, 'request_send_email')
1603
+
1604
+ // PRESENCE, not `in` — the route omits the key rather than sending null (pinned by
1605
+ // src/send_request_verbs_test.ts). Absent means it already sent.
1606
+ const reviewUrl = body?.reviewUrl
1607
+ const acct = body?.request?.account ?? 'your Gmail'
1608
+ const n = body?.request?.recipientCount ?? to.length
1609
+ if (reviewUrl) {
1610
+ // Relayed VERBATIM — this exact sentence is the contract the create route records.
1611
+ return { content: [{ type: 'text', text:
1612
+ `Send request created from ${acct} to ${n} recipient${n === 1 ? '' : 's'}. It is NOT sent yet. ` +
1613
+ `Tell the user, in these words: It's waiting for your OK: ${reviewUrl}` }] }
1614
+ }
1615
+ const state = body?.request?.state ?? 'sent'
1616
+ return { content: [{ type: 'text', text:
1617
+ `Sent from ${acct} to ${n} recipient${n === 1 ? '' : 's'} — the user's send rule allowed it, so there is ` +
1618
+ `nothing to approve (state: ${state}). Do not offer an approval link; the email has already gone.` }] }
1619
+ },
1620
+ )
1621
+
1431
1622
  server.registerTool(
1432
1623
  'grep',
1433
1624
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.176",
3
+ "version": "0.9.178",
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": {