@theronap/cortex-mcp 0.9.140 → 0.9.142

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.
@@ -152,6 +152,13 @@ if (cmd === 'login') {
152
152
  await runCapture()
153
153
  const { closeFetch } = await import('../lib/diagnose.mjs')
154
154
  await closeFetch()
155
+ } else if (cmd === 'extract-obligations') {
156
+ // ADR-0059 step 2: the detached worker capture_record launches (job on stdin), or by hand with
157
+ // --record/--intake <id> --file <path> [--dry-run] to read a record captured before this existed.
158
+ const { runObligationWorker } = await import('../lib/obligations_worker.mjs')
159
+ await runObligationWorker(process.argv.slice(3))
160
+ const { closeFetch } = await import('../lib/diagnose.mjs')
161
+ await closeFetch()
155
162
  } else if (cmd === 'resolve') {
156
163
  // Entity identity dedup: judge the server-flagged fuzzy duplicate pairs locally via `claude -p`.
157
164
  const { runResolve } = await import('../lib/resolve.mjs')
package/lib/capture.mjs CHANGED
@@ -314,6 +314,20 @@ async function captureWork(stdinRaw) {
314
314
  if (!token) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — no CORTEX_TOKEN in env or wired config\n`); return }
315
315
  const base = resolveBase(process.env.CORTEX_URL)
316
316
 
317
+ // ADR-0059 step 2 — retry obligations capture_record's worker parked while their sealed unit waited
318
+ // to be materialised (p90 ~1 day for a handoff). Here because this is the one thing that runs on
319
+ // every turn of every session on this machine; it costs one readdir when nothing is parked, and
320
+ // each entry carries its own backoff so a unit that waits a week is not posted every turn.
321
+ try {
322
+ const { flushParkedObligations, parkDir, postCandidates } = await import('./obligations_worker.mjs')
323
+ await flushParkedObligations({
324
+ dir: parkDir(),
325
+ post: (target, obligations) => postCandidates({ base, token, target, obligations, fetchImpl: fetchCortex }),
326
+ log: (line) => process.stderr.write(`cortex: ${stamp} obligations[flush] ${line}\n`),
327
+ now: () => Date.now(),
328
+ })
329
+ } catch { /* a parked retry must never cost a session its capture */ }
330
+
317
331
  const repo = projectFrom(hook.cwd)
318
332
  // Redact credential-shaped strings BEFORE anything leaves the machine — this `transcript`
319
333
  // feeds local edge/typed extraction AND the fallback POST to the org. A secret in a
@@ -396,6 +410,10 @@ async function captureWork(stdinRaw) {
396
410
  people: extracted.people,
397
411
  entities: extracted.namedEntities,
398
412
  ...(extracted.pages?.length ? { pages: extracted.pages } : {}),
413
+ // ADR-0059. Obligation candidates the extraction found AND verified verbatim against the
414
+ // transcript it was shown — the server cannot re-check the quote (it never receives the
415
+ // transcript), so these arrive already filtered. Omitted when empty, same as `pages`.
416
+ ...(extracted.obligations?.length ? { obligations: extracted.obligations } : {}),
399
417
  }
400
418
  : { ...common, transcript }
401
419
 
@@ -12,9 +12,31 @@ import { homedir } from 'os'
12
12
  // timeout + grace, or whose PID is dead) is reclaimed.
13
13
  const LOCK_PATH = join(homedir(), '.cortex', 'summarize.lock')
14
14
 
15
+ // 🔴 THE TIMEOUT MUST COVER THE TAIL OF A WIDE DISTRIBUTION, NOT ITS MIDDLE. Every full extraction
16
+ // timed 2026-09-10 on two real transcripts: 56, 68, 77, 86, 93, 102s — and one run that exceeded 150s
17
+ // and was killed. The old 45s default was below nearly all of them; a first raise to 150s still sat
18
+ // inside the tail. A killed call returns null, which skips EVERYTHING downstream: the summary, people,
19
+ // entities, ADR-0055's `pages`, and ADR-0059's `obligations`. The fallback then ships the transcript
20
+ // tail and the server writes a summary, so the record lands and nothing looks broken — a green outcome
21
+ // over a dead mechanism, which is this file's own recorded failure family (see edgeSkip's header).
22
+ //
23
+ // ⚠ LATENCY TRACKS OUTPUT, NOT INPUT — measured, and it is why length is a poor predictor. A plain
24
+ // one-key summary of the full 50k-char CSG transcript returns in ~12s. The five-key extraction of the
25
+ // same text takes 93s+. And ENT 425, three times LONGER than CSG, ran FASTER (77s) — because CSG names
26
+ // ~35 people in its team roster and each person is a six-field object. The cost is generating the JSON.
27
+ // So a record dense with names is slow regardless of its length, and no length-scaled timeout would
28
+ // have predicted it.
29
+ //
30
+ // ⚠ RAISING IT COSTS THE USER NOTHING. capture.mjs re-invokes itself as a fully DETACHED child
31
+ // (`detached: true`, `child.unref()`), so nobody waits on this call. The one real cost is the
32
+ // single-flight lock held longer, during which a concurrent session ships its tail instead — the
33
+ // existing graceful fallback. Too short loses the whole extraction; too long only degrades a neighbour
34
+ // to the fallback it already has. The stale-lock reclaim scales off this same value.
35
+ //
36
+ // ⚠ Its effect on capture_route (1 attachment in its first two days) is a candidate, NOT proven.
15
37
  function summaryTimeoutMs() {
16
38
  const n = Number(process.env.CORTEX_SUMMARY_TIMEOUT_MS)
17
- return Number.isFinite(n) && n > 0 ? n : 45_000
39
+ return Number.isFinite(n) && n > 0 ? n : 300_000
18
40
  }
19
41
 
20
42
  // Try to acquire the lock. Returns true if acquired (caller must call releaseSummaryLock in a finally),
@@ -134,6 +156,32 @@ const PAGES_FRAGMENT =
134
156
  'page. Most sessions belong on one to three pages. Empty array is correct for a session with no ' +
135
157
  'durable subject, and is much better than a loose guess.)'
136
158
 
159
+ // ── ADR-0059 — obligations ───────────────────────────────────────────────────────────────────────
160
+ //
161
+ // A DIFFERENT QUESTION FROM `pages`, AND DELIBERATELY NOT AS CONSERVATIVE. The pages prompt says an
162
+ // empty array is "much better than a loose guess", which is right for attachment — a missed page
163
+ // costs retrieval quality — and measured: capture_route produced 1 attachment across 10 sessions on
164
+ // a capable client (2026-09-09). A missed DEADLINE is a different cost, in the other direction, so
165
+ // this fragment asks for the same care about applicability without importing the same reluctance.
166
+ //
167
+ // ⚠ `evidence` IS VERIFIED HERE, NOT SERVER-SIDE (ADR-0059 §4.1). The cloud receives only the derived
168
+ // digest and never the transcript, so there is nothing server-side to compare a quote against. The
169
+ // check is `verbatimIn()` below, run against the SAME slice the model was shown.
170
+ const OBLIGATIONS_FRAGMENT =
171
+ '"obligations" (array of things someone must DO by a date, stated in this session. Each an object ' +
172
+ 'with "subject" (what must be done, one short phrase), ' +
173
+ '"due_at" (ISO 8601 date or datetime if one is stated, otherwise null — do NOT invent or infer a ' +
174
+ 'date that was not said), ' +
175
+ '"evidence" (the sentence from the session that says so, copied EXACTLY and VERBATIM — it is ' +
176
+ 'checked against the transcript and the whole item is discarded if it does not match), ' +
177
+ '"obligated_party" (exactly one of: "self" if the person whose session this is must do it; ' +
178
+ '"other" if someone else must; "none" if nobody must). ' +
179
+ 'The hard part is "none", so read it carefully: a real date about a real person is still "none" ' +
180
+ 'when nobody owes anything — "so-and-so\'s birthday is today" has every surface feature of a ' +
181
+ 'deadline and is not one. Historical and course-content dates are "none" too. ' +
182
+ 'Include an item when the session genuinely states an obligation, and do not stretch to find one; ' +
183
+ 'an empty array is correct for a session that contains no deadlines.)'
184
+
137
185
  const ENTITY_FRAGMENT =
138
186
  '"namedEntities" (array of important NON-PERSON things this content is about — concrete, named ' +
139
187
  'projects, processes, systems, products, documents, teams, tools, events, places, or topics. ' +
@@ -145,6 +193,108 @@ const ENTITY_FRAGMENT =
145
193
 
146
194
  // PURE. The scheduled task this transcript is an unattended run of, or null for a human session.
147
195
  // Claude Code wraps a scheduled run's instructions in <scheduled-task name="..." file="...">.
196
+ // ── THE SUMMARIZER'S WINDOW — the whole record, unless it is enormous ────────────────────────────
197
+ //
198
+ // 🔴 NO FIXED WINDOW WORKS, AND TWO REAL TRANSCRIPTS PROVED IT FROM OPPOSITE DIRECTIONS (2026-09-10).
199
+ //
200
+ // ENT 425 lecture (146,890 chars): the one assignment sits at char 147,121 — the LAST line. A head
201
+ // window cannot see it. A tail window can.
202
+ //
203
+ // CSG kickoff (50,241 chars): all three dated deadlines sit at chars 8,027–11,390 — the EARLY
204
+ // MIDDLE, in the "upcoming schedule" segment. A head window sees all three. A head+tail window,
205
+ // which is what this function did for about twenty minutes, sees NONE of them.
206
+ //
207
+ // Each placement fixed one transcript by breaking the other. The head-only slice this replaced was
208
+ // wrong; the head+tail "fix" was a second guess about where content lives, validated against n=1 and
209
+ // refuted by n=2. Where obligations appear depends on how a particular meeting was run, so any rule
210
+ // about which part to keep is a bet on the next transcript's structure.
211
+ //
212
+ // ✅ THE ACTUAL CONSTRAINT WAS NEVER THE MODEL. Measured: full text of the 146,890-char transcript ran
213
+ // in 77.0s, faster than the 12k-window runs of the same file (56–86s). Latency barely tracks length
214
+ // at this scale. And on CSG, full text found 2 of the 3 dated deadlines on BOTH runs, with correct ISO
215
+ // dates and zero false positives, where the head+tail window found 0 of 3.
216
+ //
217
+ // So the default is effectively "send everything". The head+tail fallback survives only for records
218
+ // so long they approach the model's context, where SOME window is unavoidable. At that size the
219
+ // placement bet returns, and it should be treated as a known-lossy last resort, not a design.
220
+ //
221
+ // ⚠ THIS REQUIRES THE PROMPT TO TRAVEL ON STDIN (see extractSession). Full text exceeds Linux's
222
+ // per-argument limit and Windows' command-line limit; as argv it would fail everywhere but macOS.
223
+ //
224
+ // ⚠ Env override CORTEX_SUMMARY_WINDOW is for MEASUREMENT. The default is what ships.
225
+ const SUMMARY_WINDOW = Number(process.env.CORTEX_SUMMARY_WINDOW) > 0 ? Number(process.env.CORTEX_SUMMARY_WINDOW) : 400_000
226
+ const ELISION = '\n\n[… middle of the session elided for length …]\n\n'
227
+
228
+ /**
229
+ * PURE, exported for tests. The text the summarizer actually sees — the whole record, unless it
230
+ * exceeds the budget, in which case both ends are kept around a MARKED elision.
231
+ *
232
+ * ⚠ WHATEVER THIS RETURNS IS ALSO THE CORPUS THE VERBATIM GATE CHECKS AGAINST (see `extractSession`).
233
+ * That coupling is required, not incidental: checking a quote against text the model was never shown
234
+ * would reject valid quotes, and checking against MORE than it was shown would accept a fabrication
235
+ * that happens to appear in the discarded middle. A quote spanning the elision fails closed, which is
236
+ * the correct outcome — those two passages were never adjacent.
237
+ */
238
+ export function summaryWindow(text, budget = SUMMARY_WINDOW) {
239
+ const s = String(text ?? '')
240
+ if (s.length <= budget) return s
241
+ const headBudget = Math.max(0, Math.floor((budget - ELISION.length) * 0.4))
242
+ const tailBudget = Math.max(0, budget - ELISION.length - headBudget)
243
+ return s.slice(0, headBudget) + ELISION + s.slice(s.length - tailBudget)
244
+ }
245
+
246
+ // ── ADR-0059 §4.1 — the verbatim gate ────────────────────────────────────────────────────────────
247
+
248
+ const OBLIGATION_PARTIES = new Set(['self', 'other', 'none'])
249
+
250
+ /** A guard against a runaway answer, not a target — the same framing as MAX_CAPTURE_PAGES. */
251
+ const MAX_OBLIGATIONS = 5
252
+
253
+ /**
254
+ * Is `needle` actually present in `haystack`, allowing only whitespace to differ?
255
+ *
256
+ * ⚠ WHITESPACE IS NORMALISED AND NOTHING ELSE IS. A transcript wraps lines; a model reproducing a
257
+ * sentence will not reproduce the wrapping. Collapsing runs of whitespace is the difference between
258
+ * a check that passes on real quotes and one that rejects nearly all of them. Case, punctuation and
259
+ * wording are NOT normalised — loosening those turns "is this sentence in the source" into "is this
260
+ * roughly like something in the source", which is the question this gate exists to refuse.
261
+ *
262
+ * 🔴 AND THE MINIMUM LENGTH IS LOAD-BEARING, NOT TIDINESS. Without it the gate is defeated by a
263
+ * one-word quote: `evidence: "Friday"` appears verbatim in almost any transcript that mentions a
264
+ * day, so a fabricated obligation would pass the check that exists to catch fabrication. A quote
265
+ * short enough to be accidentally present proves nothing about the claim attached to it.
266
+ */
267
+ export function verbatimIn(needle, haystack) {
268
+ const norm = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
269
+ const n = norm(needle)
270
+ if (n.length < 16) return false
271
+ return norm(haystack).includes(n)
272
+ }
273
+
274
+ /**
275
+ * Drop every obligation candidate that cannot be verified against the text the model was shown.
276
+ *
277
+ * Shape first, then the quote. A candidate missing a subject, missing evidence, or carrying an
278
+ * `obligated_party` outside the preset is malformed and goes — the server's CHECK constraint would
279
+ * reject it anyway, and failing here means the record still lands with its other keys intact.
280
+ */
281
+ export function keepVerifiableObligations(items, shown) {
282
+ if (!Array.isArray(items)) return []
283
+ const out = []
284
+ for (const it of items) {
285
+ if (!it || typeof it !== 'object') continue
286
+ const subject = typeof it.subject === 'string' ? it.subject.trim() : ''
287
+ const evidence = typeof it.evidence === 'string' ? it.evidence.trim() : ''
288
+ const party = typeof it.obligated_party === 'string' ? it.obligated_party.trim() : ''
289
+ if (!subject || !evidence || !OBLIGATION_PARTIES.has(party)) continue
290
+ if (!verbatimIn(evidence, shown)) continue
291
+ const due = typeof it.due_at === 'string' && it.due_at.trim() ? it.due_at.trim() : null
292
+ out.push({ subject: subject.slice(0, 200), due_at: due, evidence: evidence.slice(0, 500), obligated_party: party })
293
+ if (out.length >= MAX_OBLIGATIONS) break
294
+ }
295
+ return out
296
+ }
297
+
148
298
  export function scheduledTaskName(transcript) {
149
299
  const m = String(transcript ?? '').match(/<scheduled-task\s+[^>]*name="([^"]+)"/)
150
300
  return m ? m[1] : null
@@ -195,6 +345,7 @@ const scheduledTaskFragment = (name) =>
195
345
  'One genuine finding outweighs an otherwise unremarkable run; summarize the FINDING, not the run.'
196
346
 
197
347
  export function extractSession(transcript) {
348
+ LAST_SKIP = null
198
349
  const raw = (transcript ?? '').trim()
199
350
  if (!raw || process.env.CORTEX_SUMMARIZE_DISABLED) return null
200
351
  const task = scheduledTaskName(raw)
@@ -203,31 +354,57 @@ export function extractSession(transcript) {
203
354
  const text = task ? stripScheduledTaskBlocks(raw) : raw
204
355
  // A run whose entire transcript WAS the instruction block leaves nothing to judge. That is a
205
356
  // heartbeat by definition — skip without spending a `claude -p` call on it.
206
- if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [] }
357
+ if (task && !text) return { summary: 'NOOP', people: [], namedEntities: [], pages: [], obligations: [] }
358
+ // ONE window, named, used by both the prompt and the verbatim check — see `summaryWindow`.
359
+ const shown = summaryWindow(text)
360
+ // ⚠ THE OPENING FRAMING WAS "a Claude Code work session", AND IT CLASSIFIED A LECTURE AS NOOP.
361
+ // Measured 2026-09-10: the tail of the ENT 425 transcript came back `summary: "NOOP"` because the
362
+ // old no-op test — "no real work — greetings, no tasks" — reads "no tasks" as "no code was
363
+ // written". A NOOP here does not merely lose the summary: capture.mjs returns before ingest, so the
364
+ // record is never created at all.
365
+ //
366
+ // The new bar is "no DURABLE content", which still catches the case the rule exists for — a
367
+ // greeting, an interrupted session, a run that did nothing — and stops equating "nothing was built"
368
+ // with "nothing happened". Unattended scheduled runs are unaffected: `scheduledTaskFragment` is
369
+ // appended below and carries its own, stricter changed-vs-unchanged bar, which is where the
370
+ // heartbeat suppression that matters actually lives. ⚠ That fragment's own header records a NOOP
371
+ // wording change that killed a real finding on its first attempt — so this one is asserted by a
372
+ // negative control (a bare greeting must still NOOP), not just by the case it was written for.
207
373
  const prompt =
208
- 'You are processing a Claude Code work session for a knowledge base. Return ONLY minified JSON ' +
209
- '(no prose, no markdown fences) with EXACTLY these four keys:\n' +
210
- '"summary" (ONE concrete sentence under 20 words: what was worked on or decided. If the session ' +
211
- 'had no real work — greetings, no tasks set summary to exactly "NOOP"),\n' +
374
+ 'You are processing a captured record — a work session, a meeting, or a lecture transcript for a ' +
375
+ 'knowledge base. Return ONLY minified JSON (no prose, no markdown fences) with EXACTLY these five keys:\n' +
376
+ '"summary" (ONE concrete sentence under 20 words: what was worked on, decided, discussed or taught. ' +
377
+ 'If the record holds NO durable content at all only greetings, small talk, or a run that did ' +
378
+ 'nothing — set summary to exactly "NOOP". A lecture, meeting or discussion with real content is ' +
379
+ 'NOT a no-op merely because nothing was built),\n' +
212
380
  PEOPLE_FRAGMENT + ',\n' +
213
381
  ENTITY_FRAGMENT + ',\n' +
214
- PAGES_FRAGMENT +
382
+ PAGES_FRAGMENT + ',\n' +
383
+ OBLIGATIONS_FRAGMENT +
215
384
  (task ? scheduledTaskFragment(task) : '') +
216
- '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
385
+ '\n\n--- SESSION ---\n' + shown + '\n--- END ---'
217
386
  // Single-flight: if another session already has a summarizer running, skip (caller ships the tail;
218
387
  // server summarizes). Prevents the concurrent-`claude -p` stampede that deadlocks the OAuth refresh.
219
388
  if (!acquireSummaryLock()) return edgeSkip('busy', 'another session is summarizing; the tail ships instead')
220
389
  try {
390
+ // ⚠ THE PROMPT GOES ON STDIN, NOT ARGV — AND THE WINDOW BELOW DEPENDS ON IT. A command-line
391
+ // argument has an OS size limit: Linux caps a SINGLE argument at 131,072 bytes (MAX_ARG_STRLEN)
392
+ // and Windows caps the WHOLE command line at 32,767 characters. The full-text window can exceed
393
+ // both — ENT 425 alone is 146,890 characters. As argv it would work on macOS (ARG_MAX 1MB, the
394
+ // only machine that tests this) and fail with E2BIG everywhere else, green on the dev box and
395
+ // broken for the user. `claude --print` reads its prompt from stdin; verified live 2026-09-10.
221
396
  const r = spawnSync(
222
397
  'claude',
223
- ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
224
- { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
398
+ ['--print', '--model', process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5'],
399
+ { input: prompt, env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: summaryTimeoutMs(), maxBuffer: 4 * 1024 * 1024 },
225
400
  )
226
401
  const why = classifyEdgeFailure(r)
227
402
  if (why) return edgeSkip(why.reason, why.detail)
228
403
  const parsed = parseEdgeJson(r.stdout.trim())
229
404
  if (!parsed) return edgeSkip('unparseable', 'the model returned output that is not the expected JSON')
230
- return parsed
405
+ // ADR-0059 §4.1 — the verbatim gate. This is the ONLY place it can run: the server never receives
406
+ // the transcript, so a quote it cannot see is a quote it cannot check.
407
+ return { ...parsed, obligations: keepVerifiableObligations(parsed.obligations, shown) }
231
408
  } catch (e) {
232
409
  return edgeSkip('threw', e instanceof Error ? e.message : String(e))
233
410
  } finally {
@@ -257,7 +434,16 @@ export function extractSession(transcript) {
257
434
  // next session, the second needs a human to run `claude setup-token` and will otherwise never
258
435
  // recover. Collapsing them into one silent `return null` is the same defect family as
259
436
  // claimRecordForTriage's "probably in the future" — a message that names one cause for many.
437
+ // Why the most recent extractSession() in this process returned null, or null if it did not.
438
+ // extractSession's contract stays "value or null" — the Stop hook treats every null alike, correctly,
439
+ // because the next turn re-captures anyway. A ONE-SHOT caller cannot: a lecture handed to
440
+ // capture_record is extracted once, and "another session holds the lock" (retry in a minute) and
441
+ // "the credential expired" (stop) must not look the same to it. Read by obligations_worker.mjs.
442
+ let LAST_SKIP = null
443
+ export function lastEdgeSkip() { return LAST_SKIP }
444
+
260
445
  function edgeSkip(reason, detail) {
446
+ LAST_SKIP = { reason, detail }
261
447
  // stderr, not stdout: stdout of a Stop hook is not read, and anything written there would land in
262
448
  // the transcript of the NEXT capture. Prefixed so `cortex doctor` and a log grep can find it.
263
449
  process.stderr.write(`cortex: edge extraction SKIPPED [${reason}] ${detail}\n`)
@@ -343,6 +529,9 @@ export function parseEdgeJson(out) {
343
529
  // model returned and no matter what the prompt asked for. Adding a prompt fragment without
344
530
  // adding it here yields a feature that ships, runs, and does nothing.
345
531
  pages: Array.isArray(o.pages) ? o.pages : [],
532
+ // ADR-0059. Unverified at this point — parseEdgeJson has no transcript to check a quote
533
+ // against. `extractSession` filters these through `verbatimIn()` before returning.
534
+ obligations: Array.isArray(o.obligations) ? o.obligations : [],
346
535
  }
347
536
  } catch {
348
537
  return null
@@ -0,0 +1,284 @@
1
+ // ADR-0059 step 2 — obligations from things HANDED OVER, not only from sessions.
2
+ //
3
+ // capture_record files a transcript and returns at once. This worker then reads it for obligations
4
+ // the way the Stop hook reads a session (the same extractSession, the same verbatim gate), and posts
5
+ // what it finds to /api/obligations/candidates against the handle the capture returned.
6
+ //
7
+ // WHY DETACHED. Extraction runs 56–151s on a lecture (measured 2026-09-10). Blocking the tool call
8
+ // that long stalls the session, and Codex's default MCP tool timeout is 60s: a timed-out capture
9
+ // invites a retry, and every capture_record call mints a fresh unit, so a retry is a duplicate.
10
+ //
11
+ // 🔴 WHY IT PARKS. On a private-intake account the capture is SEALED and there is no record until
12
+ // someone materialises the unit — median 0 minutes for a handoff, but p90 ~1 day and max ~6 days on
13
+ // the seat this was built for. The server stores nothing for a sealed unit (a proposal's subject and
14
+ // quote ARE content, and sealed content stays sealed until the person materialises it), so it answers
15
+ // 409 and the candidates wait HERE, on the person's own machine — the machine the transcript came
16
+ // from — in ~/.cortex/obligations-pending/, mode 0600. Every Stop-hook capture calls
17
+ // flushParkedObligations, which retries what is due with backoff. A worker that slept in memory
18
+ // instead would lose them to the first reboot, which a six-day wait all but guarantees.
19
+ //
20
+ // Every outcome is one line in ~/.cortex/capture.log. A detached worker has nowhere else to speak,
21
+ // and this subsystem's history is silent success over broken parts (see capture.mjs captureLogFd).
22
+
23
+ import { spawn } from 'node:child_process'
24
+ import { mkdirSync, openSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
25
+ import { homedir } from 'node:os'
26
+ import { join, dirname } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ export const MAX_PARK_DAYS = 30
30
+ export const FIRST_RETRY_MS = 5 * 60_000
31
+ export const MAX_RETRY_MS = 6 * 60 * 60_000
32
+ export const BUSY_WAIT_MS = 30_000
33
+ export const BUSY_MAX_WAIT_MS = 20 * 60_000
34
+
35
+ export function parkDir(home = homedir()) {
36
+ return join(home, '.cortex', 'obligations-pending')
37
+ }
38
+
39
+ export function targetId(target) {
40
+ return target?.recordId ?? target?.intakeItemId ?? null
41
+ }
42
+
43
+ /** PURE. What the worker should do with a response from /api/obligations/candidates. */
44
+ export function classifyPost(status) {
45
+ if (status >= 200 && status < 300) return 'recorded'
46
+ // Not materialised yet. The one answer that means "same request, later".
47
+ if (status === 409) return 'pending'
48
+ // Not yours / discarded / malformed. A retry cannot change the answer.
49
+ if (status === 400 || status === 403 || status === 404 || status === 410) return 'drop'
50
+ // 401 is deliberately a retry: an expired credential is fixed by logging in again, and the
51
+ // candidates should still be there when that happens. So are 429, 5xx and a network failure.
52
+ return 'retry'
53
+ }
54
+
55
+ /** PURE. The next retry delay: 5 min, doubling, capped at 6 h. */
56
+ export function backoffMs(attempts) {
57
+ return Math.min(FIRST_RETRY_MS * 2 ** Math.max(0, attempts - 1), MAX_RETRY_MS)
58
+ }
59
+
60
+ /** POST the candidates. Never throws: a network failure is `retry`, like a 5xx. */
61
+ export async function postCandidates({ base, token, target, obligations, fetchImpl }) {
62
+ if (!token) return { action: 'retry', status: 0, body: { error: 'no CORTEX_TOKEN in env or wired config' } }
63
+ try {
64
+ const res = await fetchImpl(`${base}/api/obligations/candidates`, {
65
+ method: 'POST',
66
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
67
+ body: JSON.stringify({ ...target, obligations }),
68
+ })
69
+ const body = await res.json().catch(() => ({}))
70
+ return { action: classifyPost(res.status), status: res.status, body }
71
+ } catch (e) {
72
+ return { action: 'retry', status: 0, body: { error: e instanceof Error ? e.message : String(e) } }
73
+ }
74
+ }
75
+
76
+ function writeParked(dir, entry) {
77
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
78
+ const file = join(dir, `${targetId(entry.target)}.json`)
79
+ const tmp = `${file}.${process.pid}.tmp`
80
+ writeFileSync(tmp, JSON.stringify(entry), { mode: 0o600 })
81
+ renameSync(tmp, file) // atomic: a concurrent flush never reads half a file
82
+ }
83
+
84
+ /**
85
+ * Extract, then post — or park. Dependencies are injected so every branch is testable without
86
+ * spawning `claude` or touching the network.
87
+ *
88
+ * deps: { extract(text) → result|null, lastSkip() → {reason}|null, post(target, obligations) → {action,status,body},
89
+ * park(entry), log(line), sleep(ms), now() → ms }
90
+ */
91
+ export async function processJob(job, deps) {
92
+ const { extract, lastSkip, post, park, log, sleep, now } = deps
93
+ const giveUpAt = now() + BUSY_MAX_WAIT_MS
94
+ let result = extract(job.text)
95
+ // Another session holding the summarizer lock is the one failure worth waiting out: this record is
96
+ // read ONCE, so skipping it (right for the Stop hook, which re-captures next turn) would lose it.
97
+ while (!result && lastSkip()?.reason === 'busy' && now() < giveUpAt) {
98
+ await sleep(BUSY_WAIT_MS)
99
+ result = extract(job.text)
100
+ }
101
+ if (!result) {
102
+ const why = lastSkip()
103
+ log(`extraction FAILED [${why?.reason ?? 'unknown'}] — this record was NOT checked for obligations`)
104
+ return { outcome: 'extract-failed', reason: why?.reason ?? null }
105
+ }
106
+ const obligations = Array.isArray(result.obligations) ? result.obligations : []
107
+ if (obligations.length === 0) {
108
+ log('no obligations found')
109
+ return { outcome: 'none' }
110
+ }
111
+ const res = await post(job.target, obligations)
112
+ if (res.action === 'recorded') {
113
+ log(`recorded ${res.body?.proposed ?? '?'} of ${obligations.length} as proposals on record ${res.body?.recordId ?? '?'}`)
114
+ return { outcome: 'recorded', proposed: res.body?.proposed ?? null }
115
+ }
116
+ if (res.action === 'drop') {
117
+ log(`DROPPED ${obligations.length} — the server answered ${res.status} ${res.body?.error ?? ''}`.trim())
118
+ return { outcome: 'dropped', status: res.status }
119
+ }
120
+ const t = now()
121
+ park({ target: job.target, title: job.title ?? null, obligations, parkedAt: t, attempts: 1, nextAttemptAt: t + backoffMs(1) })
122
+ log(`parked ${obligations.length} — ${res.action === 'pending' ? 'the unit is not materialised yet' : `${res.status} ${res.body?.error ?? ''}`.trim()}; retried by later captures`)
123
+ return { outcome: 'parked', status: res.status }
124
+ }
125
+
126
+ /**
127
+ * Retry everything parked that is due. Called by every Stop-hook capture, so it must be cheap when
128
+ * there is nothing to do (one readdir) and must never throw.
129
+ *
130
+ * deps: { dir, post(target, obligations), log(line), now() → ms }
131
+ */
132
+ export async function flushParkedObligations(deps) {
133
+ const { dir, post, log, now } = deps
134
+ let names
135
+ try {
136
+ names = readdirSync(dir).filter((n) => n.endsWith('.json'))
137
+ } catch {
138
+ return { flushed: 0 } // no directory: nothing has ever been parked
139
+ }
140
+ let flushed = 0
141
+ for (const name of names) {
142
+ const file = join(dir, name)
143
+ let entry
144
+ try {
145
+ entry = JSON.parse(readFileSync(file, 'utf8'))
146
+ } catch {
147
+ try { unlinkSync(file) } catch { /* raced with another flush */ }
148
+ log(`discarded unreadable parked file ${name}`)
149
+ continue
150
+ }
151
+ const id = targetId(entry.target)
152
+ const t = now()
153
+ if (t - (entry.parkedAt ?? t) > MAX_PARK_DAYS * 24 * 60 * 60_000) {
154
+ try { unlinkSync(file) } catch { /* raced */ }
155
+ log(`GAVE UP on ${entry.obligations?.length ?? '?'} parked obligations for ${id} after ${MAX_PARK_DAYS} days — the unit never materialised`)
156
+ continue
157
+ }
158
+ if ((entry.nextAttemptAt ?? 0) > t) continue
159
+ const res = await post(entry.target, entry.obligations)
160
+ if (res.action === 'recorded' || res.action === 'drop') {
161
+ try { unlinkSync(file) } catch { /* raced with another flush; the write is idempotent */ }
162
+ flushed += 1
163
+ log(res.action === 'recorded'
164
+ ? `recorded ${res.body?.proposed ?? '?'} parked obligations for ${id} on attempt ${(entry.attempts ?? 0) + 1}`
165
+ : `DROPPED parked obligations for ${id} — the server answered ${res.status} ${res.body?.error ?? ''}`.trim())
166
+ continue
167
+ }
168
+ const attempts = (entry.attempts ?? 1) + 1
169
+ writeParked(dir, { ...entry, attempts, nextAttemptAt: t + backoffMs(attempts) })
170
+ }
171
+ return { flushed }
172
+ }
173
+
174
+ // ── process plumbing (not unit-tested; kept thin) ────────────────────────────────────────────────
175
+
176
+ function logFd() {
177
+ try {
178
+ const dir = join(homedir(), '.cortex')
179
+ mkdirSync(dir, { recursive: true })
180
+ return openSync(join(dir, 'capture.log'), 'a')
181
+ } catch {
182
+ return 'ignore'
183
+ }
184
+ }
185
+
186
+ function logger(target) {
187
+ const id = (targetId(target) ?? 'unknown').slice(0, 8)
188
+ return (line) => process.stderr.write(`cortex: ${new Date().toISOString()} obligations[${id}] ${line}\n`)
189
+ }
190
+
191
+ /**
192
+ * Launch the worker fully detached, the job on its stdin. Never throws: a capture that succeeded must
193
+ * not be reported as failed because a background read could not start.
194
+ *
195
+ * Returns 'started' | 'disabled' | 'failed' — three, not a boolean, because the caller REPORTS it:
196
+ * "disabled" is the person's own setting and needs no remark, "failed" means this record was not
197
+ * checked and the capture reply must say so.
198
+ */
199
+ export function spawnObligationWorker(job) {
200
+ if (process.env.CORTEX_SUMMARIZE_DISABLED) return 'disabled'
201
+ try {
202
+ const bin = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cortex-mcp.mjs')
203
+ const fd = logFd()
204
+ const child = spawn(process.execPath, [bin, 'extract-obligations'], {
205
+ env: { ...process.env },
206
+ detached: true,
207
+ stdio: ['pipe', fd, fd],
208
+ })
209
+ child.on('error', () => {})
210
+ child.stdin.on('error', () => {})
211
+ child.stdin.end(JSON.stringify(job))
212
+ child.unref()
213
+ return 'started'
214
+ } catch {
215
+ return 'failed'
216
+ }
217
+ }
218
+
219
+ async function realDeps(target) {
220
+ const { extractSession, lastEdgeSkip } = await import('./edge_extract.mjs')
221
+ const { fetchCortex, resolveBase, resolveTokenSource } = await import('./diagnose.mjs')
222
+ const base = resolveBase(process.env.CORTEX_URL)
223
+ const token = resolveTokenSource().token
224
+ return {
225
+ extract: extractSession,
226
+ lastSkip: lastEdgeSkip,
227
+ post: (t, obligations) => postCandidates({ base, token, target: t, obligations, fetchImpl: fetchCortex }),
228
+ park: (entry) => writeParked(parkDir(), entry),
229
+ log: logger(target),
230
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
231
+ now: () => Date.now(),
232
+ }
233
+ }
234
+
235
+ function readStdin() {
236
+ try { return readFileSync(0, 'utf8') } catch { return '' }
237
+ }
238
+
239
+ function flag(argv, name) {
240
+ const i = argv.indexOf(name)
241
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null
242
+ }
243
+
244
+ /**
245
+ * `cortex-mcp extract-obligations`
246
+ * (no flags) detached-worker mode: the job arrives as JSON on stdin
247
+ * --record <id> | --intake <id> foreground: read --file, extract, post, print the outcome
248
+ * --file <path> [--dry-run] --dry-run prints what WOULD be posted and posts nothing
249
+ *
250
+ * The foreground form is how records captured before this existed get read — ADR-0059 §7
251
+ * criterion 1 ("re-run the captured transcripts") needs exactly this and nothing else.
252
+ */
253
+ export async function runObligationWorker(argv = []) {
254
+ const file = flag(argv, '--file')
255
+ if (!file) {
256
+ let job
257
+ try { job = JSON.parse(readStdin()) } catch { job = null }
258
+ if (!job?.text || !targetId(job?.target)) {
259
+ process.stderr.write(`cortex: ${new Date().toISOString()} obligations[unknown] no job on stdin — nothing to do\n`)
260
+ return
261
+ }
262
+ await processJob(job, await realDeps(job.target))
263
+ return
264
+ }
265
+ const recordId = flag(argv, '--record')
266
+ const intakeItemId = flag(argv, '--intake')
267
+ if (Boolean(recordId) === Boolean(intakeItemId)) {
268
+ console.error('Pass exactly one of --record <id> or --intake <id>, with --file <path>.')
269
+ process.exitCode = 2
270
+ return
271
+ }
272
+ const target = recordId ? { recordId } : { intakeItemId }
273
+ const text = readFileSync(file, 'utf8')
274
+ const deps = await realDeps(target)
275
+ deps.log = (line) => console.log(line)
276
+ if (argv.includes('--dry-run')) {
277
+ const result = deps.extract(text)
278
+ if (!result) { console.log(`extraction FAILED [${deps.lastSkip()?.reason ?? 'unknown'}]`); process.exitCode = 1; return }
279
+ console.log(JSON.stringify(result.obligations ?? [], null, 2))
280
+ return
281
+ }
282
+ const out = await processJob({ target, text, title: null }, deps)
283
+ if (out.outcome === 'extract-failed' || out.outcome === 'dropped') process.exitCode = 1
284
+ }
package/lib/server.mjs CHANGED
@@ -14,6 +14,7 @@ import { renderCaptureStatus } from './capture_status.mjs'
14
14
  import { renderTriage } from './red_link_triage.mjs'
15
15
  import { runCodeGraphQuery } from './code_graph_cli.mjs'
16
16
  import { repoFullNameFrom } from './capture.mjs'
17
+ import { spawnObligationWorker } from './obligations_worker.mjs'
17
18
 
18
19
  // Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
19
20
  // tracked wanted page, whether a bare node exists for it, and whether it's a deliberately demoted page,
@@ -2348,6 +2349,17 @@ function renderNudge(payload) {
2348
2349
  },
2349
2350
  )
2350
2351
 
2352
+ // ADR-0059 step 2 — what capture_record says about the background obligations read. ⚠ It must not
2353
+ // claim more than happened: the read has only STARTED, it can fail, and what it finds is a proposal,
2354
+ // not a calendar entry. 'disabled' is the person's own CORTEX_SUMMARIZE_DISABLED and needs no remark.
2355
+ const obligationCheckLine = (started, sealed) => {
2356
+ if (started === 'disabled') return null
2357
+ if (started === 'failed') return '⚠ Could not start the background check for deadlines — this record was NOT read for obligations.'
2358
+ return sealed
2359
+ ? 'Reading it for deadlines in the background (a long transcript takes 1–3 min). Anything found waits on this machine and is attached as a proposal once you materialise it. Outcome in ~/.cortex/capture.log.'
2360
+ : 'Reading it for deadlines in the background (a long transcript takes 1–3 min). Anything found is stored as a proposal on this record. Outcome in ~/.cortex/capture.log.'
2361
+ }
2362
+
2351
2363
  server.registerTool(
2352
2364
  'capture_record',
2353
2365
  {
@@ -2421,6 +2433,8 @@ function renderNudge(payload) {
2421
2433
  if (body.containerDeferred) {
2422
2434
  l.push(`Container ${body.containerRecordId} is carried with it and applied when you materialise — you do not need to place it again.`)
2423
2435
  }
2436
+ const check = obligationCheckLine(spawnObligationWorker({ target: { intakeItemId: body.intakeItemId }, text, title }), true)
2437
+ if (check) l.push(check)
2424
2438
  return { content: [{ type: 'text', text: l.join('\n') }] }
2425
2439
  }
2426
2440
  const lines = [`Captured "${title}" as record ${body.id}.`]
@@ -2429,6 +2443,8 @@ function renderNudge(payload) {
2429
2443
  if (body.routed_by?.length) lines.push(`Routes via: ${body.routed_by.join(', ')} — it reaches whatever page claims those.`)
2430
2444
  else lines.push('⚠ NO IDENTIFIERS — this record reaches no page. Nobody will find it unless you give it one (identifiers) or attach it by hand.')
2431
2445
  if (body.contained) lines.push('Placed inside the container you named.')
2446
+ const check = obligationCheckLine(spawnObligationWorker({ target: { recordId: body.id }, text, title }), false)
2447
+ if (check) lines.push(check)
2432
2448
  return { content: [{ type: 'text', text: lines.join('\n') }] }
2433
2449
  },
2434
2450
  )
package/lib/setup.mjs CHANGED
@@ -168,6 +168,33 @@ export async function runSetup(argv, version) {
168
168
  log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
169
169
  }
170
170
 
171
+ // ── 3b. Stamp what installed this machine — ADR-0057 ──────────────────────────────────────────
172
+ // Nothing recorded which CLI last ran setup, and the desktop app needs exactly that to decide
173
+ // whether a seat's on-disk artifacts are stale.
174
+ //
175
+ // ⚠ WHY A STAMP AND NOT THE WIRED SPEC. The config holds a moving dist-tag (see pickSpec above:
176
+ // "never a version"), so it says nothing about WHEN this machine was installed or by what. The
177
+ // desktop's autoHealMcp() used to read a version out of it and, once this file started writing
178
+ // `@stable`, silently returned on every launch for three weeks — the skills/hooks refresh simply
179
+ // stopped. This file is the signal that replaces it.
180
+ //
181
+ // Written AFTER the artifacts it describes (config, hooks, skills) and BEFORE the token check on
182
+ // purpose: a bad token means the connection is wrong, not that the files failed to install.
183
+ // Claiming otherwise would leave a correctly-installed machine looking permanently stale.
184
+ //
185
+ // Fail-soft: a stamp we cannot write must never break an otherwise successful setup. A missing
186
+ // stamp reads as stale downstream, so the cost of failing here is one redundant heal, not a break.
187
+ try {
188
+ const cortexDir = join(homedir(), '.cortex')
189
+ if (!existsSync(cortexDir)) mkdirSync(cortexDir, { recursive: true })
190
+ writeFileSync(
191
+ join(cortexDir, 'setup.json'),
192
+ JSON.stringify({ version, spec, at: new Date().toISOString() }, null, 2),
193
+ )
194
+ } catch (e) {
195
+ log(` ⚠ could not record the setup stamp: ${e.message} (harmless; the app may re-run setup once)`)
196
+ }
197
+
171
198
  // ── 4. Self-verify — writing config proves "files written", NOT "connection works".
172
199
  // Actually call the API so a bad/expired token is caught HERE, not 40 minutes into debugging.
173
200
  log('')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.140",
3
+ "version": "0.9.142",
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": {