@theronap/cortex-mcp 0.9.137 → 0.9.138

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.
@@ -216,22 +216,96 @@ export function extractSession(transcript) {
216
216
  '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
217
217
  // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
218
218
  // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
219
- if (!acquireSummaryLock()) return null
219
+ if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
220
220
  try {
221
221
  const r = spawnSync(
222
222
  'claude',
223
223
  ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
224
224
  { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
225
225
  )
226
- if (r.status !== 0 || !r.stdout) return null
227
- return parseEdgeJson(r.stdout.trim())
228
- } catch {
229
- return null
226
+ const why = classifyEdgeFailure(r)
227
+ if (why) return edgeSkip(why.reason, why.detail)
228
+ const parsed = parseEdgeJson(r.stdout.trim())
229
+ if (!parsed) return edgeSkip('unparseable', 'the model returned output that is not the expected JSON')
230
+ return parsed
231
+ } catch (e) {
232
+ return edgeSkip('threw', e instanceof Error ? e.message : String(e))
230
233
  } finally {
231
234
  releaseSummaryLock()
232
235
  }
233
236
  }
234
237
 
238
+ // ── WHY THE EXTRACTION DID NOT RUN ───────────────────────────────────────────────────────────────
239
+ //
240
+ // Every failure path here returns null, and the caller correctly treats null as "ship the transcript
241
+ // tail and let the server summarize". That fallback is right. What was wrong is that FIVE different
242
+ // failures were indistinguishable, and one of them never fixes itself.
243
+ //
244
+ // 🔴 THE EVIDENCE. Found 2026-09-08: `claude --print` had been exiting 1 with
245
+ // "Failed to authenticate: OAuth session expired and could not be refreshed" — so edge extraction
246
+ // returned null for EVERY session, on every seat with an expired token. Nothing said so anywhere.
247
+ // It was invisible because the fallback works: 51 of 51 `claude-code` records over the preceding ten
248
+ // days carried a summary, because the SERVER wrote them. A green outcome over a dead mechanism.
249
+ //
250
+ // Two things were silently untrue for however long that lasted:
251
+ // 1. this module's own header — "the cloud then receives only the derived digest, never the raw
252
+ // transcript" — since the fallback ships the (redacted) tail instead;
253
+ // 2. anything downstream of the extraction, including ADR-0055's page proposals, which cannot
254
+ // exist if the call that would make them never returns.
255
+ //
256
+ // A busy lock and an expired credential are not the same event: the first resolves itself on the
257
+ // next session, the second needs a human to run `claude setup-token` and will otherwise never
258
+ // recover. Collapsing them into one silent `return null` is the same defect family as
259
+ // claimRecordForTriage's "probably in the future" — a message that names one cause for many.
260
+ function edgeSkip(reason, detail) {
261
+ // stderr, not stdout: stdout of a Stop hook is not read, and anything written there would land in
262
+ // the transcript of the NEXT capture. Prefixed so `cortex doctor` and a log grep can find it.
263
+ process.stderr.write(`cortex: edge extraction SKIPPED [${reason}] ${detail}\n`)
264
+ return null
265
+ }
266
+
267
+ /**
268
+ * PURE (unit-tested): classify a finished `claude --print` result, or null when it succeeded.
269
+ *
270
+ * Exported so the failure taxonomy is testable without spawning anything — the auth case in
271
+ * particular could not otherwise be covered, and it is the one that matters most.
272
+ */
273
+ export function classifyEdgeFailure(r) {
274
+ if (!r) return { reason: 'no-result', detail: 'spawn returned nothing' }
275
+ // spawn itself failed — almost always ENOENT, i.e. `claude` is not on the hook's PATH. A Stop hook
276
+ // runs with a login shell's PATH, which is not always the interactive one.
277
+ if (r.error) {
278
+ return r.error.code === 'ENOENT'
279
+ ? { reason: 'no-claude', detail: '`claude` is not on PATH for this hook; edge extraction cannot run here' }
280
+ : { reason: 'spawn-failed', detail: r.error.message }
281
+ }
282
+ // timeout: spawnSync kills the child, so status is null and signal is set.
283
+ if (r.signal) return { reason: 'timeout', detail: `killed by ${r.signal} after ${summaryTimeoutMs()}ms` }
284
+
285
+ const out = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
286
+ // ⚠ THE AUTH MESSAGE ARRIVES ON **STDOUT**, WITH EXIT 1 — not on stderr, which is where a reader
287
+ // would look for it. Verified against the live failure 2026-09-08:
288
+ // status 1, stderr "", stdout "Failed to authenticate: OAuth session expired and could not be
289
+ // refreshed". Matching only stderr would classify this as a plain non-zero exit and lose the one
290
+ // fact that tells a human what to do.
291
+ if (/oauth|authenticat|not logged in|401|unauthorized/i.test(out)) {
292
+ return {
293
+ reason: 'auth-expired',
294
+ detail: 'the subscription login for headless `claude --print` is dead. Run `claude setup-token`, '
295
+ + 'then set CLAUDE_CODE_OAUTH_TOKEN in ~/.claude/settings.json (env). '
296
+ + 'Until then EVERY session ships its transcript tail to the server instead of a local digest, '
297
+ + 'and no page proposals are made.',
298
+ }
299
+ }
300
+ if (r.status !== 0) return { reason: 'exit', detail: `claude exited ${r.status}: ${firstLine(out)}` }
301
+ if (!r.stdout) return { reason: 'no-output', detail: 'claude exited 0 with empty stdout' }
302
+ return null
303
+ }
304
+
305
+ function firstLine(s) {
306
+ return String(s ?? '').split('\n').map((l) => l.trim()).filter(Boolean)[0] ?? '(no output)'
307
+ }
308
+
235
309
  // Tolerant JSON extraction: the model may wrap output in prose / ```json fences, so grab the first
236
310
  // balanced-looking {...} span. Arrays pass through untouched — the SERVER validates them.
237
311
  export function parseEdgeJson(out) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.137",
3
+ "version": "0.9.138",
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": {