@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.
- package/bin/cortex-mcp.mjs +7 -0
- package/lib/capture.mjs +18 -0
- package/lib/edge_extract.mjs +200 -11
- package/lib/obligations_worker.mjs +284 -0
- package/lib/server.mjs +16 -0
- package/lib/setup.mjs +27 -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
|
|
@@ -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
|
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -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 :
|
|
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
|
|
209
|
-
'(no prose, no markdown fences) with EXACTLY these
|
|
210
|
-
'"summary" (ONE concrete sentence under 20 words: what was worked on
|
|
211
|
-
'
|
|
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' +
|
|
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'
|
|
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
|
-
|
|
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