@theronap/cortex-mcp 0.9.141 → 0.9.143
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/bin/cortex-mcp.mjs +7 -0
- package/lib/capture.mjs +14 -0
- package/lib/doctor.mjs +32 -5
- package/lib/edge_extract.mjs +10 -0
- package/lib/obligations_render.mjs +39 -0
- package/lib/obligations_worker.mjs +284 -0
- package/lib/server.mjs +18 -21
- package/lib/surface.mjs +94 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -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
|
package/lib/doctor.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from 'os'
|
|
|
3
3
|
import { join } from 'path'
|
|
4
4
|
import { checkToken, checkSkills, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
5
5
|
import { renderRenameNotice } from './rename_notice.mjs'
|
|
6
|
+
import { detectSurface, renderSurface } from './surface.mjs'
|
|
6
7
|
|
|
7
8
|
// `npx @theronap/cortex-mcp doctor` — a live, one-command health check.
|
|
8
9
|
//
|
|
@@ -21,13 +22,27 @@ export const resolveToken = resolveTokenSource
|
|
|
21
22
|
// signal inside Claude Code itself (three-machine dry-run finding 2026-06-09: with no
|
|
22
23
|
// indicator, a user can't tell whether their sessions are flowing to the org).
|
|
23
24
|
// Always returns 0 — a status line must never break a session start.
|
|
25
|
+
//
|
|
26
|
+
// The surface line is printed from a `finally` so no branch below can skip it. That includes the
|
|
27
|
+
// early returns and the "connected" line, which a person in Claude's Home tab also sees: the token is
|
|
28
|
+
// fine there, and the app they are looking at still cannot use it. See surface.mjs.
|
|
24
29
|
export async function runStatus() {
|
|
25
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
26
30
|
const out = (m) => process.stdout.write(m + '\n')
|
|
31
|
+
try {
|
|
32
|
+
await statusLine(out)
|
|
33
|
+
} finally {
|
|
34
|
+
const surface = renderSurface(detectSurface(process.env), 'status')
|
|
35
|
+
if (surface) out(surface)
|
|
36
|
+
}
|
|
37
|
+
return 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function statusLine(out) {
|
|
41
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
27
42
|
const { token } = resolveToken()
|
|
28
43
|
if (!token) {
|
|
29
44
|
out('Agnoclast: NOT connected — no token found. Run: npx -y @theronap/cortex-mcp setup <token>')
|
|
30
|
-
return
|
|
45
|
+
return
|
|
31
46
|
}
|
|
32
47
|
try {
|
|
33
48
|
const r = await checkToken(token, base)
|
|
@@ -40,7 +55,7 @@ export async function runStatus() {
|
|
|
40
55
|
// warning that contradicts it.
|
|
41
56
|
if (r.captureNotice?.message) {
|
|
42
57
|
out(`Agnoclast: ⚠ ${r.captureNotice.message}`)
|
|
43
|
-
return
|
|
58
|
+
return
|
|
44
59
|
}
|
|
45
60
|
const n = typeof r.projectCount === 'number' ? ` · ${r.projectCount} project${r.projectCount === 1 ? '' : 's'} visible` : ''
|
|
46
61
|
out(`Agnoclast: connected — sessions on this machine are captured to your org${n}.`)
|
|
@@ -58,12 +73,24 @@ export async function runStatus() {
|
|
|
58
73
|
} catch (e) {
|
|
59
74
|
out(`Agnoclast: status check failed (${e?.message ?? String(e)}) — run doctor.`)
|
|
60
75
|
}
|
|
61
|
-
return 0
|
|
62
76
|
}
|
|
63
77
|
|
|
78
|
+
// The surface block goes LAST, from a `finally`, for the same reason as in runStatus: all three
|
|
79
|
+
// verdicts (PASS, PARTIAL, FAIL) can be printed to a person in Claude's Home tab, and PASS is the
|
|
80
|
+
// one that misled. It says "reopen Claude Code", and reopening the Claude app can put them straight
|
|
81
|
+
// back in Home. The exit code is unchanged: not being able to see the surface is not a failure.
|
|
64
82
|
export async function runDoctor() {
|
|
65
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
66
83
|
const out = (m) => process.stdout.write(m + '\n')
|
|
84
|
+
try {
|
|
85
|
+
return await doctorChecks(out)
|
|
86
|
+
} finally {
|
|
87
|
+
out(renderSurface(detectSurface(process.env), 'doctor'))
|
|
88
|
+
out('')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function doctorChecks(out) {
|
|
93
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
67
94
|
|
|
68
95
|
out('')
|
|
69
96
|
out('Agnoclast doctor — checking your connection…')
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -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,39 @@
|
|
|
1
|
+
// PURE: what my_obligations prints. Out of server.mjs so the labelling below can be tested — the tool
|
|
2
|
+
// handler itself is reachable by no test.
|
|
3
|
+
//
|
|
4
|
+
// 🔴 A PROPOSAL MUST NOT READ AS SOMETHING THE PERSON ENTERED. ADR-0059 extraction writes rows in state
|
|
5
|
+
// `proposed`, and listObligations returns the owner's own proposals alongside confirmed ones — by
|
|
6
|
+
// design, since a proposal nobody sees is a silent drop. Until 2026-09-10 this renderer printed them
|
|
7
|
+
// all under "N open:" with no mark, so two lines extracted from a lecture (one of them wrongly dated)
|
|
8
|
+
// looked exactly like obligations the person had written down. It is labelled here, and the header
|
|
9
|
+
// says what to do with a wrong one.
|
|
10
|
+
//
|
|
11
|
+
// `dueAt` arrives in the OWNER's zone with its offset (web/lib/engine/due_dates.ts), so slice(0, 10)
|
|
12
|
+
// below is the owner's calendar date and new Date() is still the exact instant.
|
|
13
|
+
export function renderObligations(obs, now = Date.now()) {
|
|
14
|
+
if (!obs?.length) return 'Nothing open.'
|
|
15
|
+
const proposed = obs.filter((o) => o.state === 'proposed').length
|
|
16
|
+
const lines = [
|
|
17
|
+
proposed
|
|
18
|
+
? `${obs.length} open — ${proposed} of them PROPOSED: extracted from a record, not entered by you. A wrong one: resolve_obligation with state "cancelled".`
|
|
19
|
+
: `${obs.length} open:`,
|
|
20
|
+
'',
|
|
21
|
+
]
|
|
22
|
+
for (const o of obs) {
|
|
23
|
+
const overdue = o.dueAt && new Date(o.dueAt).getTime() <= now
|
|
24
|
+
const when = o.dueAt ? `${overdue ? 'OVERDUE ' : 'due '}${o.dueAt.slice(0, 10)}` : 'no deadline'
|
|
25
|
+
lines.push(`${o.id}`)
|
|
26
|
+
lines.push(` ${o.subject} — ${when}${o.state === 'snoozed' ? ' (snooze expired)' : ''}${o.state === 'proposed' ? ' [PROPOSED]' : ''}`)
|
|
27
|
+
// ⚠ EVIDENCE IS RENDERED AS A QUESTION, NEVER AS A VERDICT. The phrasing is the feature:
|
|
28
|
+
// "is this done?" costs the reader a second, "you still owe this" about finished work costs
|
|
29
|
+
// the channel its credibility.
|
|
30
|
+
if (o.evidence?.length) {
|
|
31
|
+
lines.push(` ❓ ${o.evidence.length} record(s) suggest this may already be done — check, then resolve_obligation:`)
|
|
32
|
+
for (const e of o.evidence.slice(0, 3)) {
|
|
33
|
+
lines.push(` ${e.occurredAt.slice(0, 10)} ${e.title ?? '(untitled)'}${e.viaIdentifier ? ` [${e.viaIdentifier}]` : ''}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
lines.push('')
|
|
37
|
+
}
|
|
38
|
+
return lines.join('\n')
|
|
39
|
+
}
|
|
@@ -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,8 @@ 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'
|
|
18
|
+
import { renderObligations } from './obligations_render.mjs'
|
|
17
19
|
|
|
18
20
|
// Reactive red-link triage (Mechanism 2). On a read_page miss, ask the server whether the name is a
|
|
19
21
|
// tracked wanted page, whether a bare node exists for it, and whether it's a deliberately demoted page,
|
|
@@ -2122,27 +2124,7 @@ function renderNudge(payload) {
|
|
|
2122
2124
|
const out = await res.json().catch(() => null)
|
|
2123
2125
|
if (!res.ok) return toolError(`Could not list: ${out?.error ?? res.status}`)
|
|
2124
2126
|
const obs = out?.obligations ?? []
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
const now = Date.now()
|
|
2128
|
-
const lines = [`${obs.length} open:`, '']
|
|
2129
|
-
for (const o of obs) {
|
|
2130
|
-
const overdue = o.dueAt && new Date(o.dueAt).getTime() <= now
|
|
2131
|
-
const when = o.dueAt ? `${overdue ? 'OVERDUE ' : 'due '}${o.dueAt.slice(0, 10)}` : 'no deadline'
|
|
2132
|
-
lines.push(`${o.id}`)
|
|
2133
|
-
lines.push(` ${o.subject} \u2014 ${when}${o.state === 'snoozed' ? ' (snooze expired)' : ''}`)
|
|
2134
|
-
// \u26a0 EVIDENCE IS RENDERED AS A QUESTION, NEVER AS A VERDICT. The phrasing is the feature:
|
|
2135
|
-
// "is this done?" costs the reader a second, "you still owe this" about finished work costs
|
|
2136
|
-
// the channel its credibility.
|
|
2137
|
-
if (o.evidence?.length) {
|
|
2138
|
-
lines.push(` \u2753 ${o.evidence.length} record(s) suggest this may already be done \u2014 check, then resolve_obligation:`)
|
|
2139
|
-
for (const e of o.evidence.slice(0, 3)) {
|
|
2140
|
-
lines.push(` ${e.occurredAt.slice(0, 10)} ${e.title ?? '(untitled)'}${e.viaIdentifier ? ` [${e.viaIdentifier}]` : ''}`)
|
|
2141
|
-
}
|
|
2142
|
-
}
|
|
2143
|
-
lines.push('')
|
|
2144
|
-
}
|
|
2145
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
2127
|
+
return { content: [{ type: 'text', text: renderObligations(obs) }] }
|
|
2146
2128
|
},
|
|
2147
2129
|
)
|
|
2148
2130
|
|
|
@@ -2348,6 +2330,17 @@ function renderNudge(payload) {
|
|
|
2348
2330
|
},
|
|
2349
2331
|
)
|
|
2350
2332
|
|
|
2333
|
+
// ADR-0059 step 2 — what capture_record says about the background obligations read. ⚠ It must not
|
|
2334
|
+
// claim more than happened: the read has only STARTED, it can fail, and what it finds is a proposal,
|
|
2335
|
+
// not a calendar entry. 'disabled' is the person's own CORTEX_SUMMARIZE_DISABLED and needs no remark.
|
|
2336
|
+
const obligationCheckLine = (started, sealed) => {
|
|
2337
|
+
if (started === 'disabled') return null
|
|
2338
|
+
if (started === 'failed') return '⚠ Could not start the background check for deadlines — this record was NOT read for obligations.'
|
|
2339
|
+
return sealed
|
|
2340
|
+
? '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.'
|
|
2341
|
+
: '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.'
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2351
2344
|
server.registerTool(
|
|
2352
2345
|
'capture_record',
|
|
2353
2346
|
{
|
|
@@ -2421,6 +2414,8 @@ function renderNudge(payload) {
|
|
|
2421
2414
|
if (body.containerDeferred) {
|
|
2422
2415
|
l.push(`Container ${body.containerRecordId} is carried with it and applied when you materialise — you do not need to place it again.`)
|
|
2423
2416
|
}
|
|
2417
|
+
const check = obligationCheckLine(spawnObligationWorker({ target: { intakeItemId: body.intakeItemId }, text, title }), true)
|
|
2418
|
+
if (check) l.push(check)
|
|
2424
2419
|
return { content: [{ type: 'text', text: l.join('\n') }] }
|
|
2425
2420
|
}
|
|
2426
2421
|
const lines = [`Captured "${title}" as record ${body.id}.`]
|
|
@@ -2429,6 +2424,8 @@ function renderNudge(payload) {
|
|
|
2429
2424
|
if (body.routed_by?.length) lines.push(`Routes via: ${body.routed_by.join(', ')} — it reaches whatever page claims those.`)
|
|
2430
2425
|
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
2426
|
if (body.contained) lines.push('Placed inside the container you named.')
|
|
2427
|
+
const check = obligationCheckLine(spawnObligationWorker({ target: { recordId: body.id }, text, title }), false)
|
|
2428
|
+
if (check) lines.push(check)
|
|
2432
2429
|
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
2433
2430
|
},
|
|
2434
2431
|
)
|
package/lib/surface.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Which app is running `doctor` / `status` — and, when that cannot be told, the requirement, plainly.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS (T23, v1 scope lock, added 2026-09-09). A new seat lost about an hour to being in
|
|
4
|
+
// the Claude desktop app's Home tab instead of its Code tab. Everything Agnoclast installs lives in
|
|
5
|
+
// Claude Code's own config: the server in ~/.claude.json, the hooks in ~/.claude/settings.json. Home
|
|
6
|
+
// reads neither, so nothing connected and nothing said why. `doctor` and `status` both ran in that
|
|
7
|
+
// state and neither mentioned the one fact that mattered. `doctor` printed PASS, because the token
|
|
8
|
+
// was fine, then told the person to "reopen Claude Code", which put them back in Home.
|
|
9
|
+
//
|
|
10
|
+
// WHAT CAN BE DETECTED. Verified against the Claude Code 2.1.260 binary on 2026-09-10, not assumed:
|
|
11
|
+
// CLAUDECODE="1" Claude Code puts this in the environment of every hook command, MCP
|
|
12
|
+
// server and shell command it starts. Present means Claude Code started us.
|
|
13
|
+
// CLAUDE_CODE_ENTRYPOINT Claude Code sets it at startup when its launcher has not: 'cli', or
|
|
14
|
+
// 'sdk-cli' for `claude -p`. Launchers set their own: 'claude-desktop' (the
|
|
15
|
+
// desktop app's Code tab), 'claude-vscode', 'local-agent', 'remote', 'sdk-ts'.
|
|
16
|
+
//
|
|
17
|
+
// WHAT CANNOT: Home. Home never starts this process, so no process running `doctor` or `status` is
|
|
18
|
+
// ever "in Home". A person in Home who runs `doctor` does it from a terminal, and that terminal looks
|
|
19
|
+
// like every other terminal. So a missing signal means UNKNOWN, never "fine", and UNKNOWN prints the
|
|
20
|
+
// requirement. That is the T23 acceptance criterion: the message appears when the surface is absent,
|
|
21
|
+
// rather than merely being able to appear.
|
|
22
|
+
//
|
|
23
|
+
// ⚠ Only the entrypoints below count as a place Agnoclast is known to work. Claude Code also runs
|
|
24
|
+
// underneath other products ('local-agent', 'remote*', 'sdk-ts', ...), and whether those load the
|
|
25
|
+
// user's ~/.claude.json has not been verified. They get the requirement, not a ✓. A wrong ✓ hides the
|
|
26
|
+
// message from the one person who needed it. A wrong ⚠ costs a line that begins "If that's where
|
|
27
|
+
// you are".
|
|
28
|
+
//
|
|
29
|
+
// Deliberately NOT used, each for a reason:
|
|
30
|
+
// ~/.claude.json wiring `doctor` already reports it as "token source", and it was just as
|
|
31
|
+
// present in the Home incident. Wired is not a fact about where you are.
|
|
32
|
+
// /api/session-ping keyed by person + cwd, not by machine; says "some host started the
|
|
33
|
+
// server recently", not "the app you are looking at can"; and it adds a
|
|
34
|
+
// network round trip to a SessionStart hook already measured at 4.6s.
|
|
35
|
+
// ~/.cortex/presence.json written only after a SUCCESSFUL hydrate, so a missing file cannot tell
|
|
36
|
+
// "never used Code here" from "hydrate failed". It is history, not location.
|
|
37
|
+
// Claude.app installed says nothing about which tab is open.
|
|
38
|
+
//
|
|
39
|
+
// Pure: no I/O. The caller passes the environment in.
|
|
40
|
+
|
|
41
|
+
/** Entrypoints known to be Claude Code proper, i.e. Claude Code reading this user's own config. */
|
|
42
|
+
const CODE_ENTRYPOINTS = {
|
|
43
|
+
'cli': 'Claude Code in a terminal',
|
|
44
|
+
'sdk-cli': 'Claude Code in a terminal',
|
|
45
|
+
'claude-desktop': 'the Code tab of the Claude desktop app',
|
|
46
|
+
'claude-desktop-3p': 'the Code tab of the Claude desktop app',
|
|
47
|
+
'claude-vscode': 'the Claude Code editor extension',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ONE text, two renderings. `status` joins these into one line; `doctor` prints them wrapped. Keeping
|
|
51
|
+
// a single source matters: capture_status.mjs records two copies of one answer that had already
|
|
52
|
+
// drifted apart. Written for someone who has never heard of a terminal flag or a config file.
|
|
53
|
+
//
|
|
54
|
+
// "If you use Claude" is load-bearing. `install` also wires Codex, Cursor and Antigravity, so an
|
|
55
|
+
// unqualified "Agnoclast only works in Claude Code" would be false for those seats. "Home or Chat"
|
|
56
|
+
// names both labels: the desktop app's own policy code calls the surface "Chat", and the seat that
|
|
57
|
+
// prompted this called it "Home".
|
|
58
|
+
export const REQUIREMENT_LINES = [
|
|
59
|
+
'If you use Claude, Agnoclast only works in Claude Code: the Code tab of the Claude',
|
|
60
|
+
'desktop app, Claude Code in a terminal, or the Claude Code extension for VS Code or',
|
|
61
|
+
"JetBrains. The Claude app's Home or Chat tab can't start Agnoclast, so nothing",
|
|
62
|
+
"connects there and nothing tells you why. If that's where you are, switch to the",
|
|
63
|
+
'Code tab.',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {Record<string, string|undefined>} [env]
|
|
68
|
+
* @returns {{ surface: 'code', where: string, entrypoint: string|null } | { surface: 'unknown', entrypoint: string|null }}
|
|
69
|
+
*/
|
|
70
|
+
export function detectSurface(env = process.env) {
|
|
71
|
+
const entrypoint = env?.CLAUDE_CODE_ENTRYPOINT || null
|
|
72
|
+
// Exactly "1", which is the only value Claude Code writes. Stricter than Claude Code's own truthiness
|
|
73
|
+
// check on purpose, because a false positive here suppresses the requirement.
|
|
74
|
+
if (env?.CLAUDECODE !== '1') return { surface: 'unknown', entrypoint }
|
|
75
|
+
// A Claude Code old enough not to set an entrypoint was still Claude Code reading this user's config.
|
|
76
|
+
if (!entrypoint) return { surface: 'code', where: 'Claude Code', entrypoint: null }
|
|
77
|
+
const where = CODE_ENTRYPOINTS[entrypoint]
|
|
78
|
+
return where ? { surface: 'code', where, entrypoint } : { surface: 'unknown', entrypoint }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What to print about the surface. Returns '' when there is nothing to say.
|
|
83
|
+
* status: silent inside Claude Code. It runs as a SessionStart hook, its stdout lands in the model's
|
|
84
|
+
* context every session, and there the requirement is already met.
|
|
85
|
+
* doctor: always says something. A ✓ when the surface is known, the requirement when it is not.
|
|
86
|
+
* @param {ReturnType<typeof detectSurface>} detection
|
|
87
|
+
* @param {'doctor'|'status'} mode
|
|
88
|
+
*/
|
|
89
|
+
export function renderSurface(detection, mode) {
|
|
90
|
+
const known = detection?.surface === 'code'
|
|
91
|
+
if (mode === 'status') return known ? '' : `Agnoclast: ⚠ ${REQUIREMENT_LINES.join(' ')}`
|
|
92
|
+
if (known) return ` ✓ app — running inside ${detection.where}, where Agnoclast works.`
|
|
93
|
+
return REQUIREMENT_LINES.map((l, i) => (i === 0 ? ` ⚠ ${l}` : ` ${l}`)).join('\n')
|
|
94
|
+
}
|
package/package.json
CHANGED