@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.
- package/bin/cortex-mcp.mjs +7 -0
- package/lib/capture.mjs +14 -0
- package/lib/edge_extract.mjs +10 -0
- package/lib/obligations_worker.mjs +284 -0
- package/lib/server.mjs +16 -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/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,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