@theronap/agnoclast-mcp 0.9.169 → 0.9.171

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.
Files changed (2) hide show
  1. package/lib/server.mjs +169 -24
  2. package/package.json +1 -1
package/lib/server.mjs CHANGED
@@ -1116,6 +1116,11 @@ function renderNudge(payload) {
1116
1116
  if (preview && res.status === 404) {
1117
1117
  return toolError('This server cannot preview a materialization yet — nothing was written. Materialize without mode, or wait for the server to update.')
1118
1118
  }
1119
+ // Same redirect-not-dispatch rule as place_staged_record. A staged arrival needs no claim and
1120
+ // no unseal, so the fix is a different verb rather than a retry of this one.
1121
+ if (res.status === 404 && (await pileHolding(intakeItemId)) === 'staged') {
1122
+ return toolError(`${intakeItemId} is a STAGED arrival, not an intake unit — \`intake_materialize\` cannot act on it. Use \`place_staged_record\` with the pages it belongs to; staged arrivals take no lease, so there is nothing to claim or release. (\`staged_look\` reads it first, and carries the brain-ladder proposal.)`)
1123
+ }
1119
1124
  return toolError(body)
1120
1125
  }
1121
1126
  const body = await res.json()
@@ -1303,33 +1308,13 @@ function renderNudge(payload) {
1303
1308
  {
1304
1309
  title: 'Read an intake unit without claiming it',
1305
1310
  description:
1306
- 'Read a PENDING intake unit\'s FULL decrypted body without taking a lease — a read RECEIPT, not a claim (ADR-0035). Use it when the settling feed\'s headline + candidate identifiers cannot tell you whether the unit belongs to your current work; looking is non-exclusive (any number of sessions may inspect the same unit) and always logged, so look freely rather than guessing from the headline — diligence is subsidized here. After looking: claim it if it is yours to process, or simply move on — no release needed, you never held it. The response includes how many sessions have looked (`looks`/`distinctLookers`): several looks and no claim is a sign the unit needs the triage agent or the owner, not another look. Resolved units are refused — read those as records.',
1311
+ 'Read a PENDING intake unit\'s FULL decrypted body AND its `proposal` — the brain its signals point to, the pages it would attach to, the page that would govern it, the tier it would be born with, and `unclaimedIdentifiers` — without taking a lease. A read RECEIPT, not a claim (ADR-0035). The proposal rides with the look because they answer one question together: what is this, and where would it go — so you can tell whether a unit is yours to judge BEFORE leasing it. Use it when the settling feed\'s headline + candidate identifiers cannot tell you whether the unit belongs to your current work; looking is non-exclusive (any number of sessions may inspect the same unit) and always logged, so look freely rather than guessing from the headline — diligence is subsidized here. After looking: claim it if it is yours to process, or simply move on — no release needed, you never held it. The response includes how many sessions have looked (`looks`/`distinctLookers`): several looks and no claim is a sign the unit needs the triage agent or the owner, not another look. Resolved units are refused — read those as records.',
1307
1312
  inputSchema: {
1308
1313
  intakeItemId: z.string().describe('intake item uuid, from the settling feed (intake_changes) or intake_claim'),
1309
1314
  workingIdentifiers: z.array(z.string()).optional().describe('the identifiers your current work touches (repo:…, file:…, project:…) — stored on the receipt; feeds look→claim conversion analysis'),
1310
1315
  },
1311
1316
  },
1312
- async ({ intakeItemId, workingIdentifiers }) => {
1313
- let res
1314
- try {
1315
- res = await fetchCortex(`${BASE}/api/intake/look`, {
1316
- method: 'POST',
1317
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
1318
- body: JSON.stringify({ intakeItemId, workingIdentifiers: workingIdentifiers ?? [] }),
1319
- })
1320
- } catch (e) {
1321
- return toolError(`Could not look: ${e.message}`)
1322
- }
1323
- const out = await res.json().catch(() => null)
1324
- if (!res.ok) {
1325
- if (out?.error === 'not_lookable') {
1326
- return toolError(`Not lookable: the unit is ${out?.state ?? 'resolved'} — resolved units are read as records, not looks.`)
1327
- }
1328
- if (out?.error === 'not_found') return toolError('No such intake unit in your account.')
1329
- return toolError(`Could not look: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
1330
- }
1331
- return { content: [{ type: 'text', text: withAskNotice(JSON.stringify(out, null, 2)) }] }
1332
- },
1317
+ async ({ intakeItemId, workingIdentifiers }) => renderIntakeLook(intakeItemId, workingIdentifiers ?? []),
1333
1318
  )
1334
1319
 
1335
1320
  server.registerTool(
@@ -2180,7 +2165,24 @@ function renderNudge(payload) {
2180
2165
  // managed to list these rows for weeks without making one of them actionable.
2181
2166
  lines.push(` ${a.stagedId}`)
2182
2167
  lines.push(` ${a.title ?? '(no subject)'} · ${a.sourceType} · ${a.occurredAt?.slice(0, 10) ?? ''}`)
2183
- if (a.blockingIdentifiers?.length) {
2168
+ // 🔴 A QUESTION IS ALREADY OUT — say so FIRST, before any "held because" reason.
2169
+ //
2170
+ // Caught by using the feature once on real data, 2026-09-24, minutes after shipping it:
2171
+ // `staged_defer` wrote the question, `intake_cleanup_status` showed it to the owner, and
2172
+ // this listing rendered the arrival IDENTICALLY to three nobody had ever looked at. So the
2173
+ // next session would judge it from scratch and hit the same wall — which is the exact
2174
+ // failure the defer verb was built to end.
2175
+ //
2176
+ // ⚠ AND IT WAS IN THE API RESPONSE THE WHOLE TIME. `listStagedArrivals` returns
2177
+ // `deferredQuestion`, with an engine test asserting it. The test passed. What was missing
2178
+ // was a reader — the eighth instance of that shape this week, written into the very change
2179
+ // whose stated purpose was avoiding it. An engine-level test cannot catch it, because the
2180
+ // engine is not the surface anyone reads.
2181
+ if (a.deferredQuestion) {
2182
+ const asked = String(a.deferredQuestion.askedAt ?? '').slice(0, 10)
2183
+ lines.push(` ⏸ ASKED THE OWNER${asked ? ` on ${asked}` : ''} — do not re-judge this one: "${a.deferredQuestion.prompt}"`)
2184
+ lines.push(' Still pending on purpose: it needs placing once they answer. Their answer is in `intake_cleanup_status.openQuestions`.')
2185
+ } else if (a.blockingIdentifiers?.length) {
2184
2186
  lines.push(` blocked by: ${a.blockingIdentifiers.join(', ')}`)
2185
2187
  } else {
2186
2188
  lines.push(' held because no brain could be determined (no unknown identifiers)')
@@ -2216,7 +2218,52 @@ function renderNudge(payload) {
2216
2218
  stagedId: z.string().describe('staged arrival id, from `staged_records` or the session-start arrivals block'),
2217
2219
  },
2218
2220
  },
2219
- async ({ stagedId }) => {
2221
+ async ({ stagedId }) => renderStagedLook(stagedId),
2222
+ )
2223
+
2224
+ // ⭐ ONE LOOK VERB, WHICHEVER PILE THE ID CAME FROM (ADR-0066 §1, the interface half).
2225
+ //
2226
+ // The two arrival piles are different tables with different keys, different sealing and different
2227
+ // state models — collapsing the STORAGE is not on the table. What was actually costing decisions
2228
+ // is that an agent holding an id could not tell which pile it came from, and the wrong verb
2229
+ // answered "No such intake unit in your account." — a valid id, a real pending arrival, and an
2230
+ // error that reads as "this does not exist". This file already records the same confusion from the
2231
+ // other direction: "`intake_changes` returns ids from a different queue that `place` rejects.
2232
+ // Twice in twelve hours a staged arrival could only be placed because a human read an id off a
2233
+ // screen and pasted it."
2234
+ //
2235
+ // ⚠ READS DISPATCH, WRITES REDIRECT. Looking is free, non-exclusive and side-effect-free in both
2236
+ // piles, so resolving the id and answering the question the caller actually asked is pure gain.
2237
+ // The write verbs deliberately do NOT dispatch: `place_staged_record` and `intake_materialize`
2238
+ // have different semantics (one replays a body through ingest, one unseals and publishes), so
2239
+ // silently running the other one would be a different action than the caller requested. They name
2240
+ // the right verb and stop.
2241
+ function otherPileHint(id, pile) {
2242
+ return pile === 'staged'
2243
+ ? `That id is a STAGED arrival, not an intake unit. Staged verbs: \`staged_look\`, \`place_staged_record\`, \`discard_staged\`.`
2244
+ : `That id is an INTAKE unit, not a staged arrival. Intake verbs: \`intake_look\`, \`intake_claim\`, \`intake_materialize\`, \`intake_discard\`, \`intake_defer\`.`
2245
+ }
2246
+
2247
+ /** Does the OTHER pile hold this id? Only ever asked after the named pile said not-found. */
2248
+ async function pileHolding(id) {
2249
+ try {
2250
+ const r = await fetchCortex(`${BASE}/api/staged/look?stagedId=${encodeURIComponent(id)}`, {
2251
+ headers: { Authorization: `Bearer ${TOKEN}` },
2252
+ })
2253
+ if (r.ok) return 'staged'
2254
+ } catch { /* fall through — a probe must never fail the caller's own verb */ }
2255
+ try {
2256
+ const r = await fetchCortex(`${BASE}/api/intake/look`, {
2257
+ method: 'POST',
2258
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2259
+ body: JSON.stringify({ intakeItemId: id, workingIdentifiers: [] }),
2260
+ })
2261
+ if (r.ok) return 'intake'
2262
+ } catch { /* same */ }
2263
+ return null
2264
+ }
2265
+
2266
+ async function renderStagedLook(stagedId, { crossedFrom = null } = {}) {
2220
2267
  let res
2221
2268
  try {
2222
2269
  res = await fetchCortex(`${BASE}/api/staged/look?stagedId=${encodeURIComponent(stagedId)}`, {
@@ -2227,6 +2274,9 @@ function renderNudge(payload) {
2227
2274
  }
2228
2275
  const out = await res.json().catch(() => null)
2229
2276
  if (res.status === 404) {
2277
+ if (!crossedFrom && (await pileHolding(stagedId)) === 'intake') {
2278
+ return renderIntakeLook(stagedId, [], { crossedFrom: 'staged' })
2279
+ }
2230
2280
  return toolError(`No staged arrival ${stagedId} of yours. Staged rows are per-person and in no brain, so this means it is not yours or does not exist — the two are deliberately indistinguishable.`)
2231
2281
  }
2232
2282
  if (!res.ok) return toolError(`Could not read staged arrival ${stagedId}: ${out?.error ?? res.status}`)
@@ -2270,9 +2320,97 @@ function renderNudge(payload) {
2270
2320
  (l.summary ? `\n summary ${l.summary}\n` : '') +
2271
2321
  held +
2272
2322
  ladder +
2323
+ (crossedFrom ? `\n\nℹ Resolved by id: you asked with an intake verb and this id is a STAGED arrival, so the staged look is above. ${otherPileHint(stagedId, 'staged')}` : '') +
2273
2324
  `\n⚠ This is the ENVELOPE. The message body was never stored (bodyStored: ${l.bodyStored === true}), so you have seen who sent it and what it was called — not what it says. Judge accordingly, and do not report having read it.`,
2274
2325
  }],
2275
2326
  }
2327
+ }
2328
+
2329
+ async function renderIntakeLook(intakeItemId, workingIdentifiers = [], { crossedFrom = null } = {}) {
2330
+ let res
2331
+ try {
2332
+ res = await fetchCortex(`${BASE}/api/intake/look`, {
2333
+ method: 'POST',
2334
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2335
+ body: JSON.stringify({ intakeItemId, workingIdentifiers: workingIdentifiers ?? [] }),
2336
+ })
2337
+ } catch (e) {
2338
+ return toolError(`Could not look: ${e.message}`)
2339
+ }
2340
+ const out = await res.json().catch(() => null)
2341
+ if (!res.ok) {
2342
+ if (out?.error === 'not_lookable') {
2343
+ return toolError(`Not lookable: the unit is ${out?.state ?? 'resolved'} — resolved units are read as records, not looks.`)
2344
+ }
2345
+ if (out?.error === 'not_found') {
2346
+ // ⚠ ONE HOP ONLY — `crossedFrom` stops the two renderers bouncing an id nobody holds back
2347
+ // and forth. A genuinely unknown id must cost two probes and then stop.
2348
+ if (!crossedFrom && (await pileHolding(intakeItemId)) === 'staged') {
2349
+ return renderStagedLook(intakeItemId, { crossedFrom: 'intake' })
2350
+ }
2351
+ return toolError('No such intake unit in your account, and no staged arrival of yours has that id either — so it is not yours, or it does not exist.')
2352
+ }
2353
+ return toolError(`Could not look: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
2354
+ }
2355
+ const note = crossedFrom
2356
+ ? `ℹ Resolved by id: you asked with a staged verb and this id is an INTAKE unit, so the intake look is below. ${otherPileHint(intakeItemId, 'intake')}\n\n`
2357
+ : ''
2358
+ return { content: [{ type: 'text', text: withAskNotice(note + JSON.stringify(out, null, 2)) }] }
2359
+ }
2360
+
2361
+ // ⭐ THE THIRD UNIFORM AFFORDANCE (ADR-0066 §1). Intake has had `intake_defer` since ADR-0062;
2362
+ // staged had nothing, so an agent that correctly judged "this is not mine to decide" could only
2363
+ // walk away — and the queue could not tell that apart from "nobody has looked yet", which wants
2364
+ // the opposite response from the next reader.
2365
+ server.registerTool(
2366
+ 'staged_defer',
2367
+ {
2368
+ title: 'Hand an owner-only decision about a staged arrival upward',
2369
+ description:
2370
+ 'Record a question about a staged arrival that only its OWNER can answer, instead of leaving the arrival untouched. The staged twin of `intake_defer`. Use it when you have looked (`staged_look`) and the blocker is a decision rather than a lookup: which brain a message belongs to when several defensibly claim it (RUNG 4 says explicitly "do not pick"), or whether something is worth keeping at all. ' +
2371
+ '⚠ NOT A DISCARD AND NOT A PLACEMENT — nothing about the arrival changes. It stays `pending` and keeps its body; the question is what is new, and it lands in `intake_cleanup_status.openQuestions` where the owner answers it. So this is the SAFEST verb in the staged surface, and the right one whenever the alternative is guessing. ' +
2372
+ '⚠ ONE OPEN QUESTION PER ARRIVAL: deferring one that already has an open question returns that question rather than stacking a second. Two sessions hitting the same wall is evidence, not two questions. ' +
2373
+ 'A question is REQUIRED — a deferral with no question marks the arrival as handled while telling the owner nothing, which is the walking-away this verb exists to replace.',
2374
+ inputSchema: {
2375
+ stagedId: z.string().describe('staged arrival id, from `staged_records`, `staged_look` or the session-start arrivals block'),
2376
+ question: z.string().describe('what the owner needs to decide, in their terms — name the arrival and the actual fork, e.g. "Elder Walker conference email — Personal or Known By Name? Both brains have pages that could claim it."'),
2377
+ options: z.array(z.string()).optional().describe('the concrete choices when the decision is a pick rather than an open question — e.g. ["Personal","Known By Name","Discard — nothing to record"]. Rendered with the question, so the owner can answer with a choice instead of prose.'),
2378
+ },
2379
+ },
2380
+ async ({ stagedId, question, options }) => {
2381
+ let res
2382
+ try {
2383
+ res = await fetchCortex(`${BASE}/api/staged/defer`, {
2384
+ method: 'POST',
2385
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
2386
+ body: JSON.stringify({ stagedId, question, options }),
2387
+ })
2388
+ } catch (e) {
2389
+ return toolError(`Could not defer: ${e.message}`)
2390
+ }
2391
+ const out = await res.json().catch(() => null)
2392
+ if (!res.ok) {
2393
+ if (res.status === 404) {
2394
+ // Same cross-pile courtesy the look verbs give (#1175): an intake id here is the wrong
2395
+ // verb, not a missing arrival.
2396
+ if ((await pileHolding(stagedId)) === 'intake') {
2397
+ return toolError(`${stagedId} is an INTAKE unit, not a staged arrival — use \`intake_defer\` (it needs a claim first, unlike this verb).`)
2398
+ }
2399
+ return toolError(`No staged arrival ${stagedId} of yours. Staged rows are per-person, so this means it is not yours or does not exist.`)
2400
+ }
2401
+ if (out?.error === 'not_pending') {
2402
+ return toolError(`That arrival is already ${out?.detail ?? 'resolved'} — there is nothing left for the owner to decide. Deferring a placed or discarded arrival would queue a question about a decision already made.`)
2403
+ }
2404
+ return toolError(`Could not defer: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
2405
+ }
2406
+ return {
2407
+ content: [{
2408
+ type: 'text',
2409
+ text: out?.alreadyOpen
2410
+ ? `That arrival already had an open question (${out.questionId}) — returned rather than stacked a second. The owner sees it in \`intake_cleanup_status.openQuestions\`; it has not been asked twice.`
2411
+ : `Deferred to the owner. The arrival is untouched and still pending; question ${out?.questionId} is queued in \`intake_cleanup_status.openQuestions\`. Nothing was placed or discarded.`,
2412
+ }],
2413
+ }
2276
2414
  },
2277
2415
  )
2278
2416
 
@@ -2971,6 +3109,13 @@ function renderNudge(payload) {
2971
3109
  const out = await res.json().catch(() => null)
2972
3110
  if (!res.ok && res.status !== 207) {
2973
3111
  const detail = out?.detail ? `\n${out.detail}` : ''
3112
+ // ⚠ WRITES REDIRECT, THEY DO NOT DISPATCH. `place_staged_record` replays a stored body
3113
+ // through ingest; `intake_materialize` unseals and publishes. Running one because the caller
3114
+ // named the other would be a different action than they asked for — so name the verb and
3115
+ // stop. The look verbs dispatch instead, because looking is free and side-effect-free.
3116
+ if (res.status === 404 && ids.length === 1 && (await pileHolding(staged_id)) === 'intake') {
3117
+ return toolError(`${staged_id} is an INTAKE unit, not a staged arrival — \`place_staged_record\` cannot act on it. Claim it with \`intake_claim {intakeItemIds: ["${staged_id}"]}\` and then \`intake_materialize\`. (\`intake_look\` reads it first, free and without a lease.)`)
3118
+ }
2974
3119
  return toolError(`Could not place ${staged_id}: ${out?.error ?? res.status}${detail}`)
2975
3120
  }
2976
3121
  // 207 and the no-record branch both mean the content LANDED and the attach did not. Report
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.169",
3
+ "version": "0.9.171",
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": {