@theronap/cortex-mcp 0.9.141 → 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
@@ -345,6 +345,7 @@ const scheduledTaskFragment = (name) =>
345
345
  'One genuine finding outweighs an otherwise unremarkable run; summarize the FINDING, not the run.'
346
346
 
347
347
  export function extractSession(transcript) {
348
+ LAST_SKIP = null
348
349
  const raw = (transcript ?? '').trim()
349
350
  if (!raw || process.env.CORTEX_SUMMARIZE_DISABLED) return null
350
351
  const task = scheduledTaskName(raw)
@@ -433,7 +434,16 @@ export function extractSession(transcript) {
433
434
  // next session, the second needs a human to run `claude setup-token` and will otherwise never
434
435
  // recover. Collapsing them into one silent `return null` is the same defect family as
435
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
+
436
445
  function edgeSkip(reason, detail) {
446
+ LAST_SKIP = { reason, detail }
437
447
  // stderr, not stdout: stdout of a Stop hook is not read, and anything written there would land in
438
448
  // the transcript of the NEXT capture. Prefixed so `cortex doctor` and a log grep can find it.
439
449
  process.stderr.write(`cortex: edge extraction SKIPPED [${reason}] ${detail}\n`)
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.141",
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": {