@theronap/agnoclast-mcp 0.9.172 → 0.9.173

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/diagnose.mjs CHANGED
@@ -12,6 +12,8 @@
12
12
  import { readFileSync } from 'fs'
13
13
  import { homedir } from 'os'
14
14
  import { join } from 'path'
15
+ import { AsyncLocalStorage } from 'node:async_hooks'
16
+ import { spoolReport, flush as flushReportSpool, readEntries as readReportSpool, spoolDir as reportSpoolDir } from './report_spool.mjs'
15
17
 
16
18
  export const isUuid = (s) =>
17
19
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s ?? '')
@@ -281,9 +283,12 @@ export function setSessionKey(key) { SESSION_KEY = key || null }
281
283
  // blind. A one-shot hook process legitimately has no session and IS still cortex-mcp — it should
282
284
  // say so, and be visible as one, not disguise itself as a script.
283
285
  let CLIENT_ID = 'cortex-mcp/unknown'
286
+ let CLIENT_VERSION = null
284
287
  export function setClientVersion(version) {
285
288
  CLIENT_ID = `cortex-mcp/${version || 'unknown'}`
289
+ CLIENT_VERSION = version || null
286
290
  }
291
+ export const clientVersion = () => CLIENT_VERSION
287
292
 
288
293
  // ── Retries (D9, eng review 2026-09-11) ──────────────────────────────────────────────────────────
289
294
  //
@@ -402,7 +407,7 @@ export function mayRetry(klass, { idempotent, retryOnTimeout = true, requireRetr
402
407
  * baseDelayMs, maxDelayMs full-jitter backoff parameters
403
408
  * sleep, random, now injectable for tests
404
409
  */
405
- export async function fetchCortex(url, opts = {}, policy = {}) {
410
+ async function fetchCortexOnce(url, opts = {}, policy = {}) {
406
411
  const {
407
412
  retries = 2, baseDelayMs = 400, maxDelayMs = 8_000,
408
413
  retryOnTimeout = true, requireRetryAfter = false,
@@ -474,6 +479,147 @@ export async function fetchCortex(url, opts = {}, policy = {}) {
474
479
  }
475
480
  }
476
481
 
482
+ // ── ADR-0070 §1/§5: outage auto-file ─────────────────────────────────────────────────────────────
483
+ //
484
+ // An agent files only what the SERVER CANNOT SEE: failures that never reached the app. Everything the
485
+ // app answered in JSON, the server already knows about (Sentry, §7), and a 429 is our own limiter
486
+ // telling this seat to slow down — filing about it would be the seat arguing with the limiter.
487
+ //
488
+ // connect / DNS / timeout → outage (failure_class 'connect')
489
+ // non-JSON 4xx/5xx (not 401, not 429) → outage (failure_class 'infra' — firewall / protection)
490
+ // 503 → outage (failure_class 'refused', or 'infra' when non-JSON)
491
+ // 429, JSON app errors, 401 → never
492
+ //
493
+ // ⚠ STRUCTURED FIELDS ONLY. classify()'s message carries brain names and sample page titles, so it is
494
+ // never read here. The one body field taken is the machine `error` code of a JSON 503, and only when it
495
+ // is a bare snake_case token.
496
+ //
497
+ // The report POST itself passes `policy.skipAutofile`, so a failed report never files a report about
498
+ // itself; the spool flush POSTs the same way.
499
+
500
+ const toolContext = new AsyncLocalStorage()
501
+ /** Run `fn` with `tool` as the name any outage filed during it is attributed to (server.mjs wraps every tool). */
502
+ export const runWithTool = (tool, fn) => toolContext.run(tool, fn)
503
+
504
+ // For callers outside a tool call (hooks, doctor). Unknown paths are 'other' — the server coerces too.
505
+ const PATH_TOOLS = {
506
+ 'mcp-context': 'my_context', 'session-context': 'session_context', 'grep': 'grep', 'search': 'search_org',
507
+ 'brain/page': 'read_page', 'brain/author': 'author', 'brain/edit-page': 'edit_page', 'records/capture': 'capture_record',
508
+ 'intake/claim': 'intake_claim', 'intake/materialize': 'intake_materialize', 'staged': 'staged_records', 'brains': 'my_brains',
509
+ }
510
+
511
+ /** PURE. The tool an outage on `url` belongs to: the running tool if known, else a path mapping, else 'other'. */
512
+ export function toolForUrl(url, running = toolContext.getStore()) {
513
+ if (running) return running
514
+ let path = ''
515
+ try { path = new URL(url).pathname } catch { return 'other' }
516
+ const m = path.match(/\/api\/(.+?)\/?$/)
517
+ return (m && PATH_TOOLS[m[1]]) || 'other'
518
+ }
519
+
520
+ const ERROR_CODE_RE = /^[a-z_]{1,40}$/
521
+
522
+ /**
523
+ * PURE. Should this outcome be auto-filed, and with which fields? `outcome` is either
524
+ * { status, contentType, errorCode } for a response, or { thrownClass } for a thrown failure.
525
+ * Returns the structured report, or null.
526
+ */
527
+ export function outageReport(outcome, { tool, requestId = null, version = CLIENT_VERSION } = {}) {
528
+ const base = { kind: 'outage', tool: tool ?? 'other', request_id: requestId ?? null, client_version: version ?? null }
529
+ if (outcome.thrownClass) {
530
+ if (outcome.thrownClass === 'connect') return { ...base, status: null, error_code: null, failure_class: 'connect' }
531
+ if (outcome.thrownClass === 'timeout') return { ...base, status: null, error_code: 'timeout', failure_class: 'connect' }
532
+ return null
533
+ }
534
+ const { status, contentType } = outcome
535
+ if (!(status >= 400) || status === 429 || status === 401) return null
536
+ const isJson = String(contentType ?? '').includes('application/json')
537
+ if (!isJson) return { ...base, status, error_code: null, failure_class: 'infra' }
538
+ if (status === 503) {
539
+ const code = typeof outcome.errorCode === 'string' && ERROR_CODE_RE.test(outcome.errorCode) ? outcome.errorCode : null
540
+ return { ...base, status, error_code: code, failure_class: 'refused' }
541
+ }
542
+ return null
543
+ }
544
+
545
+ /** Auto-filing is off under `bun test` unless the test points the spool somewhere (never the real home). */
546
+ function autofileEnabled(env = process.env) {
547
+ if (env.CORTEX_AUTOFILE === '0') return false
548
+ return env.NODE_ENV !== 'test' || Boolean(env.CORTEX_REPORT_SPOOL_DIR)
549
+ }
550
+
551
+ const authOf = (opts) => {
552
+ const h = opts?.headers ?? {}
553
+ return h.Authorization ?? h.authorization ?? null
554
+ }
555
+
556
+ /** The API base a URL was sent to — everything before `/api/`. */
557
+ const baseOf = (url) => { const i = String(url).indexOf('/api/'); return i > 0 ? String(url).slice(0, i) : null }
558
+
559
+ export const BUG_REPORTS_PATH = '/api/ops/bug-reports'
560
+
561
+ /** POST one report body. Never auto-files, never retries a non-idempotent write it cannot see land. */
562
+ export function postBugReport(base, authorization, body) {
563
+ return fetchCortex(`${base}${BUG_REPORTS_PATH}`, {
564
+ method: 'POST',
565
+ headers: { Authorization: authorization, 'Content-Type': 'application/json' },
566
+ body: JSON.stringify(body),
567
+ }, { skipAutofile: true, retries: 0 })
568
+ }
569
+
570
+ let flushing = null
571
+ /** Non-blocking flush of the report spool after a 2xx. At most one at a time per process. */
572
+ function triggerFlush(url, authorization) {
573
+ if (flushing) return flushing
574
+ const base = baseOf(url)
575
+ if (!base) return null
576
+ try { if (!readReportSpool(reportSpoolDir()).length) return null } catch { return null }
577
+ flushing = flushReportSpool(async (body) => {
578
+ const r = await postBugReport(base, authorization, body)
579
+ try { await r.body?.cancel() } catch { /* already consumed */ }
580
+ return r
581
+ })
582
+ .catch(() => null)
583
+ .finally(() => { flushing = null })
584
+ return flushing
585
+ }
586
+ /** Tests: await the in-flight flush, if any. */
587
+ export const pendingFlush = () => flushing
588
+
589
+ /**
590
+ * fetch with the D9 retry policy (see fetchCortexOnce), plus the ADR-0070 outage hook. Returns the
591
+ * Response (possibly a non-OK one, after retries are exhausted or refused); throws a clear "Could not
592
+ * reach" error when no response was ever obtained.
593
+ *
594
+ * policy.skipAutofile never auto-file this call's failure, and never flush from it (the report POSTs).
595
+ */
596
+ export async function fetchCortex(url, opts = {}, policy = {}) {
597
+ const hook = !policy.skipAutofile && autofileEnabled() && authOf(opts)
598
+ let res
599
+ try {
600
+ res = await fetchCortexOnce(url, opts, policy)
601
+ } catch (e) {
602
+ if (hook && e?.retryClass) {
603
+ const r = outageReport({ thrownClass: e.retryClass }, { tool: toolForUrl(url) })
604
+ if (r) spoolReport(r)
605
+ }
606
+ throw e
607
+ }
608
+ if (!hook) return res
609
+ if (res.ok) { void triggerFlush(url, authOf(opts)); return res }
610
+ try {
611
+ const contentType = res.headers.get('content-type')
612
+ let errorCode = null
613
+ if (res.status === 503 && String(contentType ?? '').includes('application/json')) {
614
+ try { errorCode = JSON.parse(await res.clone().text())?.error ?? null } catch { /* not json after all */ }
615
+ }
616
+ const r = outageReport({ status: res.status, contentType, errorCode },
617
+ { tool: toolForUrl(url), requestId: res.headers.get('x-vercel-id') })
618
+ if (r) spoolReport(r)
619
+ } catch { /* filing must never change what the caller gets */ }
620
+ return res
621
+ }
622
+
477
623
  // Live health probe: hit /api/mcp-context with the token and classify the result.
478
624
  // Returns { ok, status, projectCount?, requestId?, diagnosis? }.
479
625
  export async function checkToken(token, base) {
package/lib/doctor.mjs CHANGED
@@ -5,6 +5,14 @@ import { checkToken, checkSkills, resolveBase, resolveTokenSource } from './diag
5
5
  import { renderRenameNotice } from './rename_notice.mjs'
6
6
  import { detectSurface, renderSurface } from './surface.mjs'
7
7
  import { queueDir, queueStatus, renderQueueNotice } from './capture_queue.mjs'
8
+ import { pendingNotice, renderReportNotice } from './report_spool.mjs'
9
+
10
+ /** ADR-0070 §5: issue reports filed in the BACKGROUND since the last session start. A background
11
+ * auto-file had no turn to tell anyone in, so this is where the person hears about it. Consumes the
12
+ * notice (each filing is reported once). Local, synchronous, never throws. */
13
+ function reportNotice(dir) {
14
+ try { return renderReportNotice(pendingNotice(dir ? { dir } : {})) } catch { return [] }
15
+ }
8
16
 
9
17
  /** The capture-queue lines, or [] — local, synchronous, and never throws (a status line must not break). */
10
18
  function queueNotice(dir) {
@@ -38,14 +46,17 @@ export const resolveToken = resolveTokenSource
38
46
  // succeeds — most of all when it does not. They are read FIRST, locally, so the happy line can stop
39
47
  // claiming "sessions on this machine are captured" while some demonstrably are not.
40
48
  //
41
- // `opts.queueDir` exists for tests only (bun's os.homedir() ignores HOME, so a test cannot redirect it).
49
+ // `opts.queueDir` / `opts.reportSpoolDir` exist for tests only (bun's os.homedir() ignores HOME, so a
50
+ // test cannot redirect it).
42
51
  export async function runStatus(opts = {}) {
43
52
  const out = (m) => process.stdout.write(m + '\n')
44
53
  const queueLines = queueNotice(opts.queueDir)
54
+ const reportLines = reportNotice(opts.reportSpoolDir)
45
55
  try {
46
56
  await statusLine(out, { capturesStuck: queueLines.length > 0 })
47
57
  } finally {
48
58
  for (const line of queueLines) out(line)
59
+ for (const line of reportLines) out(line)
49
60
  const surface = renderSurface(detectSurface(process.env), 'status')
50
61
  if (surface) out(surface)
51
62
  }
@@ -0,0 +1,71 @@
1
+ // ADR-0070 — the `report_issue` tool's logic, kept out of server.mjs so it can be exercised directly.
2
+ //
3
+ // The agent is the right reporter: it saw the tool, the status and the error code, where the person
4
+ // saw "it didn't work". But two of the four kinds are JUDGMENTS about the person's own data or
5
+ // experience, so they need the person's yes (Theron, 2026-09-24) — enforced here, not only asked for
6
+ // in the description.
7
+
8
+ import { postBugReport, clientVersion } from './diagnose.mjs'
9
+ import { spoolReport } from './report_spool.mjs'
10
+
11
+ export const REPORT_KINDS = ['outage', 'bug', 'data_wrong', 'confusing']
12
+ export const ASK_FIRST_KINDS = new Set(['bug', 'data_wrong', 'confusing'])
13
+ export const FREE_TEXT_MAX = 500
14
+
15
+ export const REPORT_ISSUE_DESCRIPTION =
16
+ 'File a structured issue report with the Agnoclast team. ' +
17
+ 'WHEN: an Agnoclast tool failed in a way that looks like the product\'s fault, returned data the user says is wrong, or behaved confusingly. ' +
18
+ 'OUTAGES (Agnoclast unreachable, timing out, 503, or blocked by infrastructure): file WITHOUT asking — most are already filed automatically. ' +
19
+ 'For data_wrong, bug or confusing: ASK THE USER FIRST, and set user_approved=true ONLY if they agreed; without it the report is refused. ' +
20
+ 'ALWAYS tell the user you filed it, in the same turn. ' +
21
+ 'NEVER put page content, record content, names, or error message text in ANY field — use the tool name, HTTP status and the machine error code (e.g. `brain_required`) only. `summary`/`expected` describe the behaviour in generic words ("search returned a page from the wrong brain"), never the data itself.'
22
+
23
+ /** PURE. Validate + shape the report. Returns { report } or { error }. */
24
+ export function buildReport(args, version = clientVersion()) {
25
+ const kind = args?.kind
26
+ if (!REPORT_KINDS.includes(kind)) return { error: `kind must be one of ${REPORT_KINDS.join(', ')}` }
27
+ const approved = args?.user_approved === true
28
+ if (ASK_FIRST_KINDS.has(kind) && !approved) {
29
+ return { error: `NOT FILED. A ${kind} report needs the user's agreement first: ask them, and call again with user_approved=true only if they said yes.` }
30
+ }
31
+ const status = Number.isInteger(args?.status) ? args.status : null
32
+ const code = typeof args?.error_code === 'string' && /^[a-z_]{1,40}$/.test(args.error_code) ? args.error_code : (args?.error_code ? 'other' : null)
33
+ const report = {
34
+ kind, tool: typeof args?.tool === 'string' && /^[a-z0-9_]{1,64}$/.test(args.tool) ? args.tool : 'other',
35
+ status, error_code: code, failure_class: null, request_id: null, client_version: version ?? null,
36
+ }
37
+ // Free text travels only on a user-approved judged kind. An outage is fields, never prose.
38
+ if (ASK_FIRST_KINDS.has(kind) && approved) {
39
+ report.user_approved = true
40
+ const cap = (s) => (typeof s === 'string' && s.trim() ? s.trim().slice(0, FREE_TEXT_MAX) : undefined)
41
+ if (cap(args.summary)) report.summary = cap(args.summary)
42
+ if (cap(args.expected)) report.expected = cap(args.expected)
43
+ }
44
+ return { report }
45
+ }
46
+
47
+ /**
48
+ * File it now; on failure spool it (it is then sent after the next successful call). Returns the tool
49
+ * result text and whether it is an error. `post` is injectable for tests.
50
+ */
51
+ export async function fileIssue(args, { base, token, version, post = postBugReport, spool = spoolReport } = {}) {
52
+ const built = buildReport(args, version)
53
+ if (built.error) return { isError: true, text: built.error }
54
+ const { report } = built
55
+ const body = { surface: 'agent', ...report }
56
+ let res = null
57
+ try { res = await post(base, `Bearer ${token}`, body) } catch { res = null }
58
+ if (res && res.status >= 200 && res.status < 300) {
59
+ let j = {}
60
+ try { j = await res.json() } catch { /* shape drift — still filed */ }
61
+ const count = Number(j?.count) > 1 ? ` It has now been reported ${j.count} times.` : ''
62
+ return { isError: false, text: `✓ Filed ${report.kind} report${j?.id ? ` ${j.id}` : ''} for \`${report.tool}\`.${count} Tell the user you filed it, now.` }
63
+ }
64
+ if (res && res.status >= 400 && res.status < 500 && res.status !== 429) {
65
+ return { isError: true, text: `NOT FILED — Agnoclast rejected the report (HTTP ${res.status}). Tell the user it could not be filed.` }
66
+ }
67
+ const saved = spool(report)
68
+ return saved
69
+ ? { isError: false, text: `Agnoclast could not take the report right now (${res ? `HTTP ${res.status}` : 'unreachable'}), so it was SAVED on this machine and will be sent automatically after the next successful call. Tell the user it is saved, not yet filed.` }
70
+ : { isError: true, text: 'NOT FILED — Agnoclast was unreachable and the report could not be saved locally either. Tell the user.' }
71
+ }
@@ -0,0 +1,176 @@
1
+ // ADR-0070 §5 — the local spool for bug reports an agent files, most of all the OUTAGE reports that
2
+ // fetchCortex files on its own when Agnoclast cannot be reached at all.
3
+ //
4
+ // 🔴 WHY A SPOOL. An outage report is, by construction, filed at the moment the API is unreachable, so
5
+ // the POST that would carry it fails for the same reason. It waits here and is sent after the next 2xx
6
+ // from any call (fetchCortex triggers flush()).
7
+ //
8
+ // STANDALONE ON PURPOSE. It borrows capture_queue.mjs's RULES — dir 0700, files 0600, every write
9
+ // tmp+rename, a drop ledger, nothing leaves silently — but not its code, which is bound to session
10
+ // generations and protects session capture. A defect here must never be able to cost a capture.
11
+ //
12
+ // Layout — ~/.cortex/report-spool/:
13
+ // <dedup key>.json one entry per failure SIGNATURE (kind, tool, status, error_code). A repeat
14
+ // increments `count` and moves `last_seen`, so a flapping API is one report.
15
+ // _dropped.json what the cap or a final server rejection forced out, for 7 days.
16
+ // _filed.json what flush() sent in the background, until the next session start reports it.
17
+ // _notice.json when that report was last made (pendingNotice's watermark).
18
+ //
19
+ // ⚠ STRUCTURED FIELDS ONLY. Nothing here ever holds an error MESSAGE — classify()'s text carries brain
20
+ // names and page titles. Only an interactive, user-approved report_issue may carry summary/expected.
21
+
22
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
23
+ import { homedir, tmpdir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import { createHash, randomBytes } from 'node:crypto'
26
+
27
+ export const SPOOL_MAX_ENTRIES = 50
28
+ export const DROPPED_WINDOW_DAYS = 7
29
+ const DAY_MS = 24 * 60 * 60_000
30
+ const LEDGER_MAX = 100
31
+
32
+ /** The spool directory. CORTEX_REPORT_SPOOL_DIR exists for tests (bun's os.homedir() ignores HOME), and
33
+ * under `bun test` without it the real home is never touched — a test must not consume a real notice. */
34
+ export function spoolDir(env = process.env) {
35
+ if (env.CORTEX_REPORT_SPOOL_DIR) return env.CORTEX_REPORT_SPOOL_DIR
36
+ if (env.NODE_ENV === 'test') return join(tmpdir(), `cortex-report-spool-test-${process.pid}`)
37
+ return join(homedir(), '.cortex', 'report-spool')
38
+ }
39
+
40
+ /** PURE. The dedup key: kind, tool, status, error_code — NOT version (one outage spans releases). */
41
+ export function reportKey({ kind, tool, status, error_code }) {
42
+ const sig = JSON.stringify([kind ?? null, tool ?? null, status ?? null, error_code ?? null])
43
+ return createHash('sha256').update(sig).digest('hex').slice(0, 32)
44
+ }
45
+
46
+ function atomicWrite(dir, name, value) {
47
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
48
+ const file = join(dir, name)
49
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`
50
+ writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 })
51
+ renameSync(tmp, file) // a concurrent reader never sees half a file
52
+ }
53
+
54
+ const readJson = (file, fallback) => { try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return fallback } }
55
+
56
+ /** Every readable entry. An unreadable one is removed AND recorded — never skipped forever. */
57
+ export function readEntries(dir = spoolDir()) {
58
+ let names
59
+ try { names = readdirSync(dir) } catch { return [] }
60
+ const out = []
61
+ for (const n of names) {
62
+ if (!n.endsWith('.json') || n.startsWith('_')) continue
63
+ const e = readJson(join(dir, n), null)
64
+ if (e && typeof e === 'object' && e.key) { out.push(e); continue }
65
+ try { unlinkSync(join(dir, n)) } catch { /* raced */ }
66
+ ledgerAppend(dir, '_dropped.json', { at: Date.now(), kind: null, tool: null, reason: 'unreadable spool file' })
67
+ }
68
+ return out
69
+ }
70
+
71
+ function ledgerRead(dir, name, now = Date.now()) {
72
+ const list = readJson(join(dir, name), [])
73
+ return Array.isArray(list) ? list.filter((d) => now - (d?.at ?? 0) <= DROPPED_WINDOW_DAYS * DAY_MS) : []
74
+ }
75
+
76
+ function ledgerAppend(dir, name, item, now = Date.now()) {
77
+ try { atomicWrite(dir, name, [...ledgerRead(dir, name, now), item].slice(-LEDGER_MAX)) } catch { /* never throw from a ledger */ }
78
+ }
79
+
80
+ export const readDropped = (dir = spoolDir(), now = Date.now()) => ledgerRead(dir, '_dropped.json', now)
81
+
82
+ const dropRow = (e, reason, at) => ({ at, kind: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1, reason })
83
+
84
+ /**
85
+ * Spool one report. A repeat of the same signature increments `count`; a NEW signature past the cap
86
+ * evicts the oldest (by last_seen) into the drop ledger. Returns the entry written, or null. Never throws.
87
+ */
88
+ export function spoolReport(report, { dir = spoolDir(), now = Date.now() } = {}) {
89
+ try {
90
+ const key = reportKey(report)
91
+ const cur = readJson(join(dir, `${key}.json`), null)
92
+ const entry = cur?.key
93
+ ? { ...cur, ...report, key, rev: randomBytes(6).toString('hex'), count: (cur.count ?? 1) + (report.count ?? 1), last_seen: now }
94
+ : { ...report, key, rev: randomBytes(6).toString('hex'), count: report.count ?? 1, first_seen: now, last_seen: now, attempts: 0 }
95
+ if (!cur?.key) {
96
+ const others = readEntries(dir).sort((a, b) => (a.last_seen ?? 0) - (b.last_seen ?? 0))
97
+ while (others.length >= SPOOL_MAX_ENTRIES) {
98
+ const old = others.shift()
99
+ try { unlinkSync(join(dir, `${old.key}.json`)) } catch { /* raced */ }
100
+ ledgerAppend(dir, '_dropped.json', dropRow(old, 'spool full', now), now)
101
+ }
102
+ }
103
+ atomicWrite(dir, `${key}.json`, entry)
104
+ return entry
105
+ } catch { return null }
106
+ }
107
+
108
+ /** PURE. The wire body for one entry — the §2 contract, and nothing else. */
109
+ export function wireBody(e) {
110
+ const body = {
111
+ surface: 'agent', kind: e.kind, tool: e.tool ?? 'other', status: e.status ?? null, error_code: e.error_code ?? null,
112
+ failure_class: e.failure_class ?? null, request_id: e.request_id ?? null, client_version: e.client_version ?? null,
113
+ count: e.count ?? 1,
114
+ }
115
+ if (e.user_approved === true) {
116
+ body.user_approved = true
117
+ if (e.summary) body.summary = e.summary
118
+ if (e.expected) body.expected = e.expected
119
+ }
120
+ return body
121
+ }
122
+
123
+ /**
124
+ * Send every entry via `postFn(body)` → { status } (or throws). 2xx removes it (any repeats spooled
125
+ * meanwhile stay); a final 4xx (not 429) moves it to the drop ledger; 429/5xx/network keep it with
126
+ * attempts+1. Returns { sent, kept, dropped }.
127
+ */
128
+ export async function flush(postFn, { dir = spoolDir(), now = Date.now } = {}) {
129
+ const out = { sent: 0, kept: 0, dropped: 0 }
130
+ for (const e of readEntries(dir)) {
131
+ let status = 0
132
+ try { status = Number((await postFn(wireBody(e)))?.status) || 0 } catch { status = 0 }
133
+ const file = join(dir, `${e.key}.json`)
134
+ const cur = readJson(file, null)
135
+ if (status >= 200 && status < 300) {
136
+ out.sent++
137
+ ledgerAppend(dir, '_filed.json', { at: now(), kind: e.kind, tool: e.tool, status: e.status ?? null, count: e.count ?? 1 }, now())
138
+ if (cur?.rev && cur.rev !== e.rev && (cur.count ?? 1) > (e.count ?? 1)) {
139
+ atomicWrite(dir, `${e.key}.json`, { ...cur, count: cur.count - (e.count ?? 1) })
140
+ } else { try { unlinkSync(file) } catch { /* raced */ } }
141
+ } else if (status >= 400 && status < 500 && status !== 429) {
142
+ out.dropped++
143
+ try { unlinkSync(file) } catch { /* raced */ }
144
+ ledgerAppend(dir, '_dropped.json', dropRow(e, `rejected ${status}`, now()), now())
145
+ } else {
146
+ out.kept++
147
+ if (cur?.key) atomicWrite(dir, `${e.key}.json`, { ...cur, attempts: (cur.attempts ?? 0) + 1 })
148
+ }
149
+ }
150
+ return out
151
+ }
152
+
153
+ /**
154
+ * What was filed in the BACKGROUND (and what was dropped) since the last notice, then move the
155
+ * watermark — so each is reported at exactly one session start. Returns { filed, dropped }.
156
+ */
157
+ export function pendingNotice({ dir = spoolDir(), now = Date.now() } = {}) {
158
+ try {
159
+ const since = Number(readJson(join(dir, '_notice.json'), {})?.at) || 0
160
+ const filed = ledgerRead(dir, '_filed.json', now).filter((x) => x.at > since)
161
+ const dropped = ledgerRead(dir, '_dropped.json', now).filter((x) => x.at > since)
162
+ if (filed.length || dropped.length) atomicWrite(dir, '_notice.json', { at: now })
163
+ return { filed, dropped }
164
+ } catch { return { filed: [], dropped: [] } }
165
+ }
166
+
167
+ /** PURE. The session-start lines for pendingNotice()'s result — empty when there is nothing to say. */
168
+ export function renderReportNotice({ filed = [], dropped = [] } = {}) {
169
+ const lines = []
170
+ const outages = filed.filter((x) => x.kind === 'outage').length
171
+ const other = filed.length - outages
172
+ if (outages) lines.push(`Agnoclast: ${outages} outage report(s) were filed automatically while you were away — tell the user.`)
173
+ if (other) lines.push(`Agnoclast: ${other} saved issue report(s) were sent once Agnoclast was reachable again — tell the user.`)
174
+ if (dropped.length) lines.push(`Agnoclast: ⚠ ${dropped.length} issue report(s) could not be sent and were dropped (${dropped[0].reason}).`)
175
+ return lines
176
+ }
package/lib/server.mjs CHANGED
@@ -5,7 +5,8 @@ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'fs'
5
5
  import { homedir } from 'os'
6
6
  import { join } from 'path'
7
7
  import { createHash, randomUUID } from 'crypto'
8
- import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion } from './diagnose.mjs'
8
+ import { fetchCortex, classify, resolveBase, resolveEnvToken, setSessionKey, setClientVersion, runWithTool } from './diagnose.mjs'
9
+ import { REPORT_KINDS, REPORT_ISSUE_DESCRIPTION, fileIssue } from './report_issue.mjs'
9
10
  import { folderClaimLine } from './folder_claims.mjs'
10
11
  import { resolveSessionKey, resolveLogSessionId } from './session_key.mjs'
11
12
  import { runSendImessage } from './imessage_send.mjs'
@@ -247,6 +248,11 @@ export async function runServer(version) {
247
248
  }
248
249
 
249
250
  const server = new McpServer({ name: 'cortex', version })
251
+ // ADR-0070: every tool runs with its NAME in async context, so an outage fetchCortex auto-files is
252
+ // attributed to the tool that hit it rather than guessed from a URL shared by several tools.
253
+ const registerTool = server.registerTool.bind(server)
254
+ server.registerTool = (name, config, handler) =>
255
+ registerTool(name, config, (...args) => runWithTool(name, () => handler(...args)))
250
256
 
251
257
 
252
258
  // ── THE PRE-CLAIM NUDGE, rendered ───────────────────────────────────────────────────────────────
@@ -362,8 +368,12 @@ function renderNudge(payload) {
362
368
  const body = await res.text()
363
369
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
364
370
  }
365
- const { context } = await res.json()
366
- return { content: [{ type: 'text', text: context }] }
371
+ const ctx = await res.json()
372
+ // ⭐ RENDER THE ARRIVALS DELTA HERE TOO. It was wired to grep / read_page / list_records only,
373
+ // so a session doing pipeline work never saw it — measured 2026-09-24, eighteen hours with a
374
+ // stale watermark while three arrivals landed unannounced. This is the tool an agent calls at
375
+ // session start and on every topic shift, which is when "mail arrived" is worth knowing.
376
+ return { content: [{ type: 'text', text: (ctx?.context ?? '') + renderArrival(ctx) }] }
367
377
  },
368
378
  )
369
379
 
@@ -855,8 +865,12 @@ function renderNudge(payload) {
855
865
  const body = await res.text()
856
866
  throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
857
867
  }
858
- const { context } = await res.json()
859
- return { content: [{ type: 'text', text: context }] }
868
+ const ctx = await res.json()
869
+ // ⭐ RENDER THE ARRIVALS DELTA HERE TOO. It was wired to grep / read_page / list_records only,
870
+ // so a session doing pipeline work never saw it — measured 2026-09-24, eighteen hours with a
871
+ // stale watermark while three arrivals landed unannounced. This is the tool an agent calls at
872
+ // session start and on every topic shift, which is when "mail arrived" is worth knowing.
873
+ return { content: [{ type: 'text', text: (ctx?.context ?? '') + renderArrival(ctx) }] }
860
874
  },
861
875
  )
862
876
 
@@ -4259,7 +4273,10 @@ function renderNudge(payload) {
4259
4273
 
4260
4274
  const records = out?.records ?? []
4261
4275
  if (records.length === 0) {
4262
- return { content: [{ type: 'text', text: 'Nothing waiting for a home.' }] }
4276
+ // ⚠ THE DELTA STILL SHIPS ON THE EMPTY PATH. "Nothing waiting for a home" and "nothing has
4277
+ // arrived" are different facts, and an empty digest is exactly when a silent counter reads
4278
+ // as all-clear.
4279
+ return { content: [{ type: 'text', text: 'Nothing waiting for a home.' + renderArrival(out) }] }
4263
4280
  }
4264
4281
  const lines = records.map((r) => {
4265
4282
  const homes = r.currentHomes?.length ? r.currentHomes.join(', ') : 'nothing'
@@ -4269,7 +4286,7 @@ function renderNudge(payload) {
4269
4286
  return {
4270
4287
  content: [{
4271
4288
  type: 'text',
4272
- text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs.`,
4289
+ text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs.` + renderArrival(out),
4273
4290
  }],
4274
4291
  }
4275
4292
  },
@@ -5177,5 +5194,28 @@ function renderNudge(payload) {
5177
5194
  },
5178
5195
  )
5179
5196
 
5197
+ // ADR-0070 — the agent files the bug report. Outages are filed without asking (and most already are,
5198
+ // by fetchCortex); the three JUDGED kinds need the user's yes, enforced in report_issue.mjs.
5199
+ server.registerTool(
5200
+ 'report_issue',
5201
+ {
5202
+ title: 'Report an Agnoclast issue',
5203
+ description: REPORT_ISSUE_DESCRIPTION,
5204
+ inputSchema: {
5205
+ kind: z.enum(REPORT_KINDS).describe('outage = Agnoclast unreachable/timing out/503/blocked; bug = a tool misbehaved; data_wrong = the user says the data shown is wrong; confusing = a tool\'s behaviour or wording confused you or the user'),
5206
+ tool: z.string().describe('the Agnoclast tool name involved, e.g. `grep` or `read_page`; `other` if none'),
5207
+ status: z.number().int().optional().describe('the HTTP status you saw, if any'),
5208
+ error_code: z.string().optional().describe('the machine error code only (e.g. `brain_required`) — NEVER the error message text'),
5209
+ summary: z.string().optional().describe('bug/data_wrong/confusing only, ≤500 chars: the behaviour in generic words. NO page content, record content, names or error text'),
5210
+ expected: z.string().optional().describe('bug/data_wrong/confusing only, ≤500 chars: what should have happened, in generic words'),
5211
+ user_approved: z.boolean().describe('true ONLY if you asked the user and they agreed to file this. Required true for bug, data_wrong and confusing; ignored for outage'),
5212
+ },
5213
+ },
5214
+ async (args) => {
5215
+ const r = await fileIssue(args, { base: BASE, token: TOKEN, version })
5216
+ return r.isError ? toolError(r.text) : { content: [{ type: 'text', text: r.text }] }
5217
+ },
5218
+ )
5219
+
5180
5220
  await server.connect(new StdioServerTransport())
5181
5221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.172",
3
+ "version": "0.9.173",
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": {